Skip to content

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

Open
hey-sion wants to merge 7 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round10_batch
Open

[volume-10] Batch 기반 주간·월간 랭킹 시스템 구현 - 박시온#416
hey-sion wants to merge 7 commits intoLoopers-dev-lab:hey-sionfrom
hey-sion:round10_batch

Conversation

@hey-sion
Copy link
Copy Markdown

@hey-sion hey-sion commented Apr 17, 2026

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


📌 Summary

  • 배경: Round 9에서 Kafka 이벤트 → Redis ZSET 기반 실시간 일간 랭킹을 구축했습니다. 하지만 주간/월간 랭킹은 7일/30일치 누적 집계가 필요하며, 매 이벤트마다 기간 누적을 실시간으로 유지하는 것은 비용과 복잡도가 과도합니다.
  • 목표: Spring Batch Chunk-Oriented Processing으로 product_metrics_daily(일별 집계)를 기반으로 주간(7일)/월간(30일) 롤링 윈도우 집계를 수행하고, 스냅샷 테이블(product_rank_snapshot)에 적재하여 API로 제공합니다.
  • 결과: 일별 메트릭 적재 → 배치 집계(GROUP BY + ROW_NUMBER TOP 100) → DELETE-INSERT 멱등성 → 랭킹 스냅샷 테이블 적재 → API rankingType 파라미터 확장(DAILY/WEEKLY/MONTHLY)의 전체 파이프라인을 구현했습니다.

🧭 Context & Decision

① product_metrics_daily — 테이블 추가 생성

기존 product_metrics는 상품당 1행으로 누적값만 저장하며, ProductMetricsReadModel(commerce-api)이 상품 목록 좋아요순 정렬에 사용 중입니다. 일별 구조로 변경하면 상품당 여러 행이 생겨 기존 JOIN이 깨집니다.

결정: 기존 product_metrics(누적)는 유지하고, product_metrics_daily를 별도 생성하여 일별 집계를 쌓습니다.

CREATE TABLE product_metrics_daily (
    product_id   BIGINT NOT NULL,
    metric_date  DATE   NOT NULL,
    view_count   BIGINT NOT NULL DEFAULT 0,
    like_count   BIGINT NOT NULL DEFAULT 0,   -- 순증감량 (음수 허용)
    order_line_count BIGINT NOT NULL DEFAULT 0,
    order_amount BIGINT NOT NULL DEFAULT 0,   -- price × quantity 누적
    PRIMARY KEY (metric_date, product_id)
);

설계 포인트:

  • PK 순서 (metric_date, product_id): 배치 SQL이 metric_date BETWEEN으로 기간 범위 조회하므로 metric_date가 선행 컬럼이어야 range scan 가능. 스트리머 upsert는 두 컬럼 모두 등호(=) 조건이라 PK 순서에 영향받지 않음
  • like_count 음수 허용: delta 방식. unlike 시 -1이 되어야 7일/30일 SUM 집계 시 Redis 일간 랭킹의 점수 차감 방식과 일치 (월요일 like(+1) → 화요일 unlike(-1) → 주간 SUM = 0)
  • order_amount 추가: 일간 랭킹과 동일한 score 공식(view×0.1 + like×0.2 + order_amount×0.7)을 주간/월간에도 적용하기 위함. order_amount 산출에 price×quantity가 필요하므로 PaymentCompletedEventHandlerorderedProducts를 데이터 소스로 사용하도록 변경.
    해당 변경으로 productIdorderedProducts에서 꺼낼 수 있으므로, payload의 불필요한 필드인 productIds를 제거함

③ Chunk-Oriented Processing — Reader에서 집계 완료

Job: rankingAggregationJob
  └─ aggregateRankingStep (Chunk, size=100)
       ├─ beforeStep: DELETE (rankingType + endDate 기준 기존 데이터 삭제)
       ├─ Reader:  JdbcCursorItemReader — GROUP BY + JOIN + ROW_NUMBER() TOP 100
       │           RowMapper에서 ProductRankSnapshot 엔티티 직접 생성
       └─ Writer:  JpaItemWriter (persist)

Processor를 생략한 이유: Reader의 집계 쿼리(GROUP BY + JOIN + ROW_NUMBER())가 모든 계산을 완료합니다. Processor가 담당할 역할이 단순 필드 매핑뿐이므로, RowMapper에서 엔티티를 직접 생성하여 Processor를 생략했습니다.

DB에서 GROUP BY로 합산하는 이유: 상품 100만개(대량의 데이터 가정) × 7일 = 700만건의 raw 데이터를 애플리케이션으로 읽어와 합산하는 것보다, DB에서 GROUP BY로 합산 후 최대 100건만 가져오는 것이 메모리와 네트워크 모두 효율적입니다.


④ 멱등성 — beforeStep DELETE + UNIQUE 제약

beforeStep: DELETE FROM product_rank_snapshot 
            WHERE ranking_type = :type AND rank_date = :endDate
Reader:     SELECT (집계 결과 TOP 100)
Writer:     INSERT (100건)

DELETE를 별도 Step으로 분리하지 않는 이유: DELETE Step 성공 → INSERT Step 중간 실패 → 재시작 시 DELETE Step이 스킵(이미 성공 상태)되어 부분 INSERT 위에 중복 INSERT가 발생할 수 있습니다. StepExecutionListener.beforeStep()에서 DELETE를 수행하면, Step 재시작 시에도 항상 DELETE가 먼저 실행되어 멱등성이 보장됩니다.

추가로 (ranking_type, rank_date, product_id) UNIQUE 제약을 안전망으로 설정했습니다.


⑤ Reader SQL 설계

SELECT *
FROM (
    SELECT
        pm.product_id, p.name, p.price, b.name AS brand_name,
        SUM(pm.view_count) AS total_view_count,
        SUM(pm.like_count) AS total_like_count,
        SUM(pm.order_line_count) AS total_order_line_count,
        SUM(pm.order_amount) AS total_order_amount,
        (SUM(pm.view_count) * ? + SUM(pm.like_count) * ? + SUM(pm.order_amount) * ?) AS score,
        ROW_NUMBER() OVER (
            ORDER BY score DESC, pm.product_id ASC
        ) AS rank_position
    FROM product_metrics_daily pm
    JOIN products p ON pm.product_id = p.id AND p.deleted_at IS NULL AND p.visibility = 'VISIBLE'
    JOIN brands b ON p.brand_id = b.id AND b.deleted_at IS NULL
    WHERE pm.metric_date BETWEEN ? AND ?
    GROUP BY pm.product_id, p.name, p.price, b.name
) ranked
WHERE rank_position <= 100
  • ROW_NUMBER() vs RANK(): RANK()는 동점 시 100건 초과 가능. ROW_NUMBER()로 정확히 100건 보장
  • 동점 시 product_id ASC: 결정적(deterministic) 정렬
  • 서브쿼리 필요한 이유: MySQL에서 윈도우 함수는 HAVING 이후에 평가되므로 HAVING rank <= 100이 동작하지 않음
  • JOIN 필터: p.deleted_at IS NULL, p.visibility = 'VISIBLE', b.deleted_at IS NULL로 삭제/비노출 상품 및 삭제된 브랜드 상품 제외

⑥ 랭킹 API 확장 — Facade 분기

GET /api/v1/rankings?rankingType=WEEKLY&date=20260414&size=20&page=1
                     ^^^^^^^^^^^^^^^^
                     신규 파라미터 (default: DAILY)
rankingType 데이터 소스 date 의미 date 기본값
DAILY (기본값) Redis ZSET 조회 대상 일자 오늘
WEEKLY product_rank_snapshot (DB) rank_date (배치 endDate) 가장 최근 rank_date
MONTHLY product_rank_snapshot (DB) rank_date (배치 endDate) 가장 최근 rank_date

RankingFacade에서 rankingType에 따라 분기합니다. DAILY는 기존 Redis 로직을 그대로 사용하고, WEEKLY/MONTHLY는 스냅샷 테이블에서 DB 조회합니다.


⑦ 롤링 윈도우 — 주간 정의

방식 배치 주기 장점 단점
고정 주차 (달력 기반) 주 1회 비용 절감 이번 주차 랭킹 불가, UX 부자연스러움
롤링 윈도우 (최근 7일) 매일 최신 데이터 제공 매일 배치 실행

롤링 윈도우 선택. 실제 이커머스에서 사용자는 "2주차 랭킹"이 아니라 "최근 일주일간 인기 상품"을 원합니다. 가능한 최신 데이터를 제공하는 것이 UX 관점에서 자연스럽습니다.

  • WEEKLY: endDate - 6일 ~ endDate (7일)
  • MONTHLY: endDate - 29일 ~ endDate (30일)
  • endDate 기본값: 어제 (배치 실행 시점 전날)

🔁 Flow Diagram

전체 데이터 흐름

[이벤트 발생: 좋아요/조회/주문]
       │
       ├──→ product_metrics (누적, 기존 유지)
       ├──→ product_metrics_daily (일별 집계, 신규)
       └──→ Redis ZSET (일간 실시간 랭킹, 기존 유지)

[매일 새벽 배치: rankingAggregationJob]
  product_metrics_daily
       │ GROUP BY + JOIN products/brands
       │ WHERE metric_date BETWEEN startDate AND endDate
       │ ROW_NUMBER() OVER (ORDER BY score DESC) <= 100
       ▼
  product_rank_snapshot (DELETE → INSERT, 멱등)

[API 조회]
  GET /api/v1/rankings?rankingType=DAILY   →  Redis ZSET (기존)
  GET /api/v1/rankings?rankingType=WEEKLY  →  product_rank_snapshot (DB)
  GET /api/v1/rankings?rankingType=MONTHLY →  product_rank_snapshot (DB)

배치 Job 실행 흐름

sequenceDiagram
    participant Scheduler as Cron / Manual
    participant Job as rankingAggregationJob
    participant Step as aggregateRankingStep
    participant DB as MySQL

    Scheduler->>Job: launch(rankingType=WEEKLY, endDate=2026-04-14)
    Job->>Step: execute

    Note over Step: beforeStep
    Step->>DB: DELETE FROM product_rank_snapshot<br/>WHERE ranking_type='WEEKLY' AND rank_date='2026-04-14'

    Note over Step: Reader (JdbcCursorItemReader)
    Step->>DB: SELECT ... FROM product_metrics_daily<br/>JOIN products, brands<br/>WHERE metric_date BETWEEN '2026-04-08' AND '2026-04-14'<br/>GROUP BY product_id ... TOP 100

    Note over Step: Writer (JpaItemWriter, chunk=100)
    Step->>DB: INSERT INTO product_rank_snapshot (100건)

    Step-->>Job: COMPLETED
Loading

🧪 Verification

배치 E2E 테스트 — RankingAggregationJobE2ETest

테스트 검증 내용
weeklyAggregation_aggregates7Days WEEKLY 7일 범위 집계, 상품별 총합/score/순위 정확성
monthlyAggregation_aggregates30Days MONTHLY 30일 범위 집계, 총합/score 정확성
weeklyAggregation_excludesDataOutsideWindow 8일 전 데이터 제외, 경계일(endDate-6) 포함 검증
monthlyAggregation_excludesDataOutsideWindow 31일 전 데이터 제외, rankingType/rankDate/productId 검증
reAggregation_replacesExistingSnapshots_noDuplicates 동일 파라미터 재실행 시 기존 스냅샷 대체, 중복 없음
excludesDeletedProducts 삭제 상품 제외 + 정상 상품 포함 동시 검증
excludesHiddenProducts 비노출(HIDDEN) 상품 제외
excludesProductsOfDeletedBrand 삭제된 브랜드의 상품 제외
tiedScore_orderedByProductIdAsc 동점 시 product_id 오름차순 정렬 검증
  • @TestPropertySource에 가중치(view=0.1, like=0.2, order=0.7) 명시하여 테스트 self-contained
  • score 검증은 isCloseTo(expected, within(0.01))로 부동소수점 안전하게 비교
  • findAll() 결과는 rankPosition 기준 정렬 후 검증 (JPA findAll 정렬 미보장)

스트리머 통합 테스트 — ProductMetricsDailyRepositoryTest

  • 같은 상품 + 같은 날짜 → ON DUPLICATE KEY UPDATE로 값 누적
  • 같은 상품 + 다른 날짜 → 별도 행 생성
  • decrement 시 음수 허용 (delta 방식)

API E2E 테스트 — RankingV1ApiE2ETest 확장

  • rankingType=WEEKLY → 스냅샷 데이터 반환
  • rankingType=MONTHLY → 스냅샷 데이터 반환
  • 기본값(파라미터 없음) → 기존 Redis 일간 랭킹 동작 (하위 호환)

⏭ 미구현 / 향후 과제

항목 내용
Redis Cache-Aside 주간/월간 조회 시 Redis 캐시 적용 (DB 부하 감소)
가중치 런타임 변경 현재 @Value로 빈 생성 시 1회 주입. DB 테이블 기반으로 런타임 변경 가능하도록 개선
배치 스케줄링 현재 수동 실행. Cron 또는 K8s CronJob으로 매일 새벽 자동 실행
배치 모니터링 실행 로그, 실패 알림, 처리 건수 추적

✅ Checklist

🧱 Spring Batch

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

🧩 Ranking API

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

✅ 변경 범위

새로 생성한 파일

모듈 파일 역할
commerce-streamer domain/ProductMetricsDaily.java 일별 메트릭 엔티티 (복합키)
commerce-streamer domain/ProductMetricsDailyId.java 복합키 클래스
commerce-streamer infrastructure/ProductMetricsDailyRepository.java ON DUPLICATE KEY UPDATE 네이티브 쿼리
modules/jpa domain/ranking/RankingType.java enum (DAILY, WEEKLY, MONTHLY)
modules/jpa domain/ranking/ProductRankSnapshot.java 랭킹 스냅샷 스냅샷 엔티티
modules/jpa infrastructure/ranking/ProductRankSnapshotJpaRepository.java JPA 레포지토리
commerce-batch batch/job/ranking/RankingAggregationJobConfig.java Job/Step/Reader/Writer/StepListener 설정
commerce-api domain/ranking/ProductRankSnapshotQueryRepository.java 스냅샷 조회 도메인 인터페이스
commerce-api infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java JPA 구현체 (페이지네이션)

수정한 파일

파일 변경 내용
PaymentCompletedEventHandler product_metrics_dailyorder_amount 적재 추가, orderedProducts 활용
ProductViewedEventHandler product_metrics_daily view_count 적재 추가
ProductLikedEventHandler product_metrics_daily like_count 적재 추가
ProductUnlikedEventHandler product_metrics_daily like_count 감소 적재 추가
PaymentCompletedEventPayload 미사용 필드(userId, productIds) 제거
ProductMetrics / ProductMetricsRepository 필드명 orderCountorderLineCount 변경
RankingV1Controller rankingType 파라미터 추가 (default: DAILY)
RankingFacade rankingType 분기 (DAILY→Redis, WEEKLY/MONTHLY→DB)

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

결제 완료 이벤트 페이로드를 재설계하여 개별 상품 정보를 포함하도록 변경하고, 다중 유형 스냅샷 기반 랭킹 시스템을 추가하며, 일일 상품 메트릭스 추적을 도입하고, 배치 기반 순위 집계 작업을 구현했다.

Changes

Cohort / File(s) Summary
결제 이벤트 페이로드 재설계
modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java, apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
userIdproductIds 필드를 제거하고 orderedProducts를 통해 개별 상품별 가격/수량 정보를 전달하도록 변경했다.
랭킹 시스템 멀티 타입 확장
modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java, modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java, modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java
RankingType 열거형(DAILY, WEEKLY, MONTHLY)과 스냅샷 저장 엔티티 ProductRankSnapshot을 추가하여 다중 주기별 순위 데이터 저장을 지원한다.
랭킹 조회 계층 확장
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java, apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
RankingFacade.getRankings 서명에 RankingType 파라미터를 추가하여 일별/스냅샷 기반 조회를 분기 처리한다. 스냅샷 조회 시 최신 순위 날짜를 동적으로 해석하고 오프셋 기반 페이징을 적용한다.
상품 메트릭스 구조 변경
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java, apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
orderCountorderLineCount로 이름 변경하여 주문 라인 단위 메트릭 추적으로 의미를 명확히 한다.
일일 상품 메트릭스 추가
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java, apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java
일별 조회/좋아요/주문 메트릭을 (metricDate, productId) 복합키로 저장하는 새 엔티티 계층을 도입한다. 각 핸들러가 이벤트 발생 시점의 일일 메트릭을 증분 업데이트하도록 한다.
이벤트 핸들러 일일 메트릭 기록
apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java, apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java, apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java, apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java
각 이벤트 핸들러가 집계 메트릭 업데이트와 동시에 일일 메트릭을 기록하도록 확장했다. Clock 의존성을 추가하여 테스트 가능성을 향상했다.
배치 기반 순위 집계
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java, apps/commerce-batch/src/main/resources/application.yml
RankingType별로 지정된 기간(1/7/30일)의 일일 메트릭을 집계하여 상위 100개 상품의 순위를 계산하고 ProductRankSnapshot에 저장하는 배치 작업을 추가했다. 가중치 설정(view: 0.1, like: 0.2, order: 0.7)을 YAML에 정의했다.
랭킹 및 메트릭 테스트 확장
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java, apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java, apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java
스냅샷 랭킹, 일일 메트릭 증감 동작, 배치 집계(시간 윈도우/중복 제거/삭제 상품 필터링/동점 처리)를 검증하는 테스트를 추가했다.
JPA 설정 변경
modules/jpa/src/main/resources/jpa.yml
local/test 프로필의 spring.jpa.hibernate.ddl-autonone에서 create로 변경했다.

Sequence Diagram(s)

sequenceDiagram
    participant Client as 클라이언트
    participant Controller as RankingV1Controller
    participant Facade as RankingFacade
    participant SnapshotRepo as ProductRankSnapshotQueryRepository
    participant DB as 데이터베이스
    
    Client->>Controller: GET /api/v1/rankings?rankingType=WEEKLY&date=2026-04-01
    Controller->>Controller: resolveDate(date, WEEKLY)
    Controller->>Facade: getRankings(2026-04-01, WEEKLY, page, size)
    
    Facade->>SnapshotRepo: findRankings(WEEKLY, 2026-04-01, offset, size)
    SnapshotRepo->>DB: SELECT * FROM product_rank_snapshot<br/>WHERE ranking_type='WEEKLY' AND rank_date='2026-04-01'<br/>ORDER BY rank_position ASC<br/>LIMIT size OFFSET offset
    DB-->>SnapshotRepo: ProductRankSnapshot 리스트
    SnapshotRepo-->>Facade: 조회 결과 반환
    
    Facade->>Facade: 각 스냅샷을 RankingProductInfo로 매핑
    Facade-->>Controller: RankingPageResult 반환
    Controller-->>Client: JSON 응답 (rank, score, productName 등)
    
    Note over Facade,DB: 날짜가 null인 경우:<br/>findLatestRankDate(WEEKLY)로<br/>최신 순위 날짜를 조회
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 구현 내용(배치 기반 주간·월간 랭킹)을 명확히 요약하며, 충분히 구체적이고 스캔 가능하다.
Description check ✅ Passed PR 설명이 요구되는 모든 섹션을 포함하며, 배경·목표·결과가 명확하고 기술 의사결정과 설계 내용이 상세히 기록되어 있다.

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

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

♻️ Duplicate comments (1)
apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java (1)

29-33: ⚠️ Potential issue | 🟡 Minor

ProductViewedEventHandler와 동일한 원자성·타임존 이슈가 적용된다.

집계/일별 두 decrement upsert의 트랜잭션 경계와 Clock 타임존 고정에 대해 루트 코멘트의 지적이 동일하게 해당된다. 추가로, 일별 테이블의 decrementLikeCount도 집계와 동일하게 음수 방지(GREATEST(..., 0)) 정책이 적용되어 있는지 확인 부탁한다. 일별 테이블은 "델타" 성격이라 음수 허용이 의도일 수 있으나(좋아요 취소가 이전 일자의 증가를 상쇄), 취소만 들어온 날의 일별 값이 0 아래로 내려가면 주간/월간 집계에서 의도치 않은 마이너스 기여가 발생할 수 있다. 정책을 문서화하고 테스트로 고정하는 편이 안전하다.

추가 테스트: 같은 날 like가 없는 상태에서 unlike만 수신했을 때 일별 like_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/application/handler/ProductUnlikedEventHandler.java`
around lines 29 - 33, ProductUnlikedEventHandler.handle currently calls
productMetricsRepository.decrementLikeCount and
productMetricsDailyRepository.decrementLikeCount separately and uses
LocalDate.now(clock) — make the two decrements atomic and timezone-consistent:
wrap both calls in a single transaction (e.g., annotate handle with
`@Transactional` or call a transactional service method) and ensure the same
injected Clock is used to compute LocalDate once at the start (store LocalDate
now = LocalDate.now(clock) and pass it to productMetricsDailyRepository). Also
verify/modify productMetricsDailyRepository.decrementLikeCount to guard against
negative counts (use database-side GREATEST(...,0) or equivalent) or document
the delta semantics and add a unit/integration test (e.g.,
ProductUnlikedEventHandlerTest_unlikeWithoutPriorLike_resultsInZeroDailyCount)
that asserts an unlike on a day with no prior like does not drive
daily/aggregate counts below zero.
🧹 Nitpick comments (20)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java (1)

14-48: @ModifyingclearAutomatically/flushAutomatically 지정을 검토한다

네 개의 네이티브 upsert 모두 @Modifying만 지정되어 있다. 현재 호출부(이벤트 핸들러)는 쓰기 직후 읽지 않으므로 당장은 문제없으나, 추후 동일 트랜잭션 내에서 엔티티 조회가 섞이면 1차 캐시가 최신값을 반영하지 못해 stale read가 발생할 수 있다. 네이티브 upsert는 특성상 영속성 컨텍스트에 반영되지 않으므로 clearAutomatically = true, flushAutomatically = true를 함께 지정하는 편이 안전하다.

♻️ 제안 diff
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`(value = """
             INSERT INTO product_metrics_daily ...
             """, nativeQuery = true)

**/*Repository*.java 가이드라인에서 "영속성 컨텍스트 오염 가능성을 점검한다"는 지침을 따른 제안이다.

🤖 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/ProductMetricsDailyRepository.java`
around lines 14 - 48, The native upsert methods (incrementLikeCount,
decrementLikeCount, incrementViewCount, incrementOrderLineCountAndAmount) use
`@Modifying` without clearing or flushing the persistence context, which can cause
stale reads if the same transaction later queries these entities; update each
`@Modifying` annotation to include clearAutomatically = true and
flushAutomatically = true so the persistence context is flushed and cleared
after the native query executes, ensuring subsequent reads see the upsert
results.
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java (1)

7-7: serialVersionUID 명시를 권장한다

Serializable을 구현하는 @IdClass 타입에서 serialVersionUID가 없으면 JVM/컴파일러별 자동 생성값에 의존하게 되어, 필드/애노테이션 변경이 있을 때 직렬화 호환성 경고 및 드물게 세션 복제·2차 캐시 직렬화 과정에서 잡기 어려운 이슈가 발생할 수 있다.

♻️ 제안 diff
 public class ProductMetricsDailyId implements Serializable {
+    private static final long serialVersionUID = 1L;
     private LocalDate metricDate;
     private Long productId;
🤖 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/ProductMetricsDailyId.java`
at line 7, ProductMetricsDailyId implements Serializable but lacks a
serialVersionUID; add a private static final long serialVersionUID field to the
ProductMetricsDailyId class (e.g. private static final long serialVersionUID =
1L;) to ensure stable serialization for this `@IdClass` type and avoid
JVM/compiler-dependent generated values when fields/annotations change.
modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java (1)

3-14: enum 값 자체에 days를 필드로 보유하는 방식을 검토한다

getDays()switch는 향후 QUARTERLY, YEARLY 등이 추가될 때 한 곳을 더 수정해야 하며, 값과 로직이 분리되어 있다. enum 생성자에서 days를 불변 필드로 보유하면 추가 케이스 누락 시 컴파일이 강제되지 않는 switch 대비 선언부에서 일원화된다.

♻️ 제안 diff
-public enum RankingType {
-    DAILY,
-    WEEKLY,
-    MONTHLY;
-
-    public int getDays() {
-        return switch (this) {
-            case DAILY -> 1;
-            case WEEKLY -> 7;
-            case MONTHLY -> 30;
-        };
-    }
-}
+public enum RankingType {
+    DAILY(1),
+    WEEKLY(7),
+    MONTHLY(30);
+
+    private final int days;
+
+    RankingType(int days) {
+        this.days = days;
+    }
+
+    public int getDays() {
+        return days;
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java` around
lines 3 - 14, The enum RankingType uses a switch in getDays(), separating data
from declaration; refactor RankingType to store a final int days field set via a
private constructor on each constant (e.g., DAILY(1), WEEKLY(7), MONTHLY(30))
and change getDays() to return this.days; update the enum constants to pass
their day values and remove the switch so adding new constants requires
supplying the days at declaration time.
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java (2)

141-156: 금액 산술은 long 기반으로 표현해야 실제 상품 가격 기준의 오버플로를 방지할 수 있다

int unitPrice = 3000; int quantity = 3; 이후 long orderAmount = unitPrice * quantity는 곱셈이 int에서 수행된 뒤 long으로 확장된다. 예제 값은 문제없지만 실제 상용 가격(고가 상품 x 수량)에서는 int 오버플로 후 음수가 long에 저장될 수 있다. 테스트는 프로덕션 코드의 계산 컨벤션을 암묵적으로 규정하는 역할도 하므로, 금액은 처음부터 long으로 표현하는 편이 안전하다.

♻️ 제안 diff
-        int unitPrice = 3000;
-        int quantity = 3;
-        long orderAmount = unitPrice * quantity;
+        long unitPrice = 3000L;
+        long quantity = 3L;
+        long orderAmount = unitPrice * quantity;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java`
around lines 141 - 156, Summary: Use long arithmetic for price/quantity to avoid
int overflow in ProductMetricsDailyRepositoryTest. In
ProductMetricsDailyRepositoryTest update the test variables so multiplication is
performed in long (e.g., change unitPrice and/or quantity to long literals or
cast before multiplication) and compute orderAmount as a long from the start
(replace int unitPrice, int quantity, long orderAmount = unitPrice * quantity
with long-aware declarations and calculation) so the test reflects the
production convention and avoids int overflow when calling
incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, orderAmount).

46-135: 경계·실패 케이스 보강을 권장한다

**/*Test*.java 가이드라인은 경계값/실패 케이스/예외 흐름 커버리지를 요구한다. 현재 테스트는 성공 누적 시나리오 위주로, 다음 케이스를 추가할 것을 권장한다.

  • 다른 productId x 같은 metricDate에서 별도 행으로 생성되고 상호 간섭이 없는지 (ODKU 키 동작 회귀 방지)
  • incrementOrderLineCountAndAmountorderAmount = 0 또는 매우 큰 값(Long.MAX_VALUE 근사)을 넣었을 때 컬럼 타입 한계 검증
  • 음수 누적 이후 incrementLikeCount로 0/양수 복귀 회귀 (이벤트 역전 시나리오)

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

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

In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java`
around lines 46 - 135, 테스트가 정상 누적 시나리오만 검증하고 경계·실패 케이스가 없어 요구사항을 충족하지 못하므로,
ProductMetricsDailyRepository 관련 테스트들에 경계/예외 케이스를 추가하세요: 다른 PRODUCT_ID에 대해 같은
날짜(TODAY)로 호출해 별도 행이 생성되고 서로 간섭하지 않는지 검증하는 케이스를 추가하고(incrementViewCount /
incrementLikeCount 사용), incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY,
amount)에 대해 amount=0 및 매우 큰 값(예: Long.MAX_VALUE 근사)으로 호출했을 때 orderAmount/컬럼 타입
한계를 검사하는 테스트를 추가하며, decrementLikeCount로 음수로 만든 후 incrementLikeCount로 0 또는 양수로
복귀하는 흐름(likeAndUnlike 역전 시나리오)을 assert하는 테스트를 각각 구현하세요; 각 테스트는 findMetrics(...)
호출로 결과를 조회하고 getViewCount/getLikeCount/getOrderLineCount/getOrderAmount 검증을
수행하도록 만드세요.
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java (1)

26-36: 기본값 초기화 방식을 DB 레벨에서도 명시하고, PK 컬럼 순서는 이미 최적화되어 있다.

현재 이 엔티티의 모든 쓰기 경로는 INSERT ... ON DUPLICATE KEY UPDATE 네이티브 쿼리를 통해 진행되므로, Long 래퍼 필드의 기본값(= 0L)은 Java 차원의 초기화일 뿐이며 JPA merge/리플렉션 경로에서의 NPE 위험은 실질적으로 없다. 다만 향후 JPA save 경로가 추가될 가능성에 대비하여, @ColumnDefault("0") 어노테이션을 컬럼에 함께 선언하면 DB 레벨에서도 기본값이 보장되어 방어적이다.

PK 컬럼 순서(metric_date, product_id)는 이미 올바르게 설계되어 있다. 배치 작업에서 WHERE metric_date BETWEEN ...로 범위 조회할 때 metric_date가 클러스터드 인덱스의 선두 컬럼이므로 효율적인 range scan이 가능하다.

🤖 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/ProductMetricsDaily.java`
around lines 26 - 36, Add DB-level default values for numeric metric columns in
ProductMetricsDaily by annotating each wrapper field (likeCount, viewCount,
orderLineCount, orderAmount) with `@ColumnDefault`("0") so the database guarantees
0 even if a JPA save/insert path is used later; update imports to include
org.hibernate.annotations.ColumnDefault and leave the existing Long types and
current PK ordering (metric_date, product_id) unchanged.
modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java (1)

14-19: 벌크 DELETE는 clearAutomatically = true 부여를 권장한다.

@Modifying JPQL 벌크 DELETE는 1차 캐시(영속성 컨텍스트)를 건너뛴다. 동일 트랜잭션 내에서 이후 ProductRankSnapshot을 로드/머지(예: JpaItemWriter가 동일 EntityManager를 공유하는 경로)하면 이미 삭제된 엔티티가 스테일 상태로 남아있을 위험이 있다. 배치 잡은 beforeStep에서 DELETE → reader/writer에서 INSERT 순이므로 현재 구조상 실질 충돌은 낮지만, 재사용 가능성과 회귀 안전성을 위해 clearAutomatically = true(+ 필요 시 flushAutomatically = true) 부여를 권장한다. 또한 호출 측에서 트랜잭션을 열고 있는지(배치 StepExecutionListenerbeforeStep은 기본적으로 스텝 트랜잭션 밖에서 동작) 확인이 필요하다.

추가 테스트: DELETE 후 동일 트랜잭션에서 (rankingType, rankDate)로 재조회 시 빈 결과가 반환되는지, 그리고 재집계 멱등성(같은 날짜 재실행 시 중복 없이 동일 결과) 검증.

♻️ 제안
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`("DELETE FROM ProductRankSnapshot p WHERE p.rankingType = :rankingType AND p.rankDate = :rankDate")
     void deleteByRankingTypeAndRankDate(
             `@Param`("rankingType") RankingType rankingType,
             `@Param`("rankDate") LocalDate rankDate
     );

RankingAggregationJobConfig에서 beforeStep DELETE가 어떤 트랜잭션 경계 안에서 실행되는지(별도 TransactionTemplate 사용 여부 포함) 확인 부탁한다.

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

In
`@modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java`
around lines 14 - 19, The JPQL bulk DELETE method deleteByRankingTypeAndRankDate
in ProductRankSnapshotJpaRepository should be annotated to avoid stale entities
in the persistence context: add clearAutomatically = true (and add
flushAutomatically = true if you want immediate flush) to the `@Modifying`
annotation so the EntityManager clears the 1st-level cache after the bulk
delete; also verify in RankingAggregationJobConfig whether the beforeStep DELETE
runs inside a transaction (or uses TransactionTemplate) and add/adjust
transaction boundaries as needed and add tests that after delete a subsequent
find by (rankingType, rankDate) in the same transaction returns empty and that
re-running aggregation is idempotent.
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)

135-144: orderedProducts 비어있는 경우의 이벤트 발행 정책을 재검토 부탁한다.

orderService.getOrderItems(...)가 어떤 이유로 빈 리스트를 반환할 경우(동시성 이슈, 보상 로직과의 경합 등), 빈 orderedProductsPAYMENT_COMPLETED 이벤트가 그대로 발행된다. 소비 측(PaymentCompletedEventHandler)은 빈 리스트를 무시하므로 기능상 문제는 없으나, 운영 관점에서는 "결제 성공 이벤트는 발행되었는데 메트릭이 전혀 오르지 않는" 관측 공백이 생긴다. 빈 리스트인 경우 경고 로깅을 남기거나, 방어적으로 발행을 스킵하고 메트릭/알림으로 상위에 신호를 주는 편을 권장한다.

또한 OrderedProduct.of(item.productId(), item.price(), item.quantity())가 이벤트 페이로드에 금액/수량을 싣게 되므로, 아웃박스 직렬화·로깅 레벨에서 가격 정보가 과도하게 기록되지 않는지(카프카 토픽 접근 권한, 로깅 마스킹) 운영 레벨 점검을 권장한다.

추가 테스트: orderItems가 빈 리스트일 때 이벤트 발행/로깅 동작 검증 테스트.

🤖 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/payment/PaymentFacade.java`
around lines 135 - 144, 현재 orderService.getOrderItems(...)가 빈 리스트를 반환하면
orderedProducts가 비어 있는 상태로 outboxEventPublisher.publish(...)가 호출되어 관측 공백이 생깁니다;
PaymentFacade에서 orderedProducts가 비어있는지 검사하여 빈 경우에는 outboxEventPublisher.publish을
건너뛰고 대신 processLogger.warn(...) 또는 logger.warn을 남기고(메시지에 payment.getId(),
payment.getOrderId() 포함) 모니터링용 카운터/메트릭을 증가시켜 알림을 보내도록 변경하세요(또는 비동기 재시도/재검증 로직을
호출하도록 방어적 처리를 추가). 변경 대상 식별자: orderService.getOrderItems, orderedProducts,
outboxEventPublisher.publish, PaymentCompletedEventPayload,
PaymentCompletedEventHandler; 또한 orderItems가 빈 리스트일 때 이벤트 발행/로깅 동작을 검증하는 단위 테스트를
추가하세요.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java (1)

7-10: 페이지네이션 파라미터 방어와 도메인 계약 명확화를 권장한다.

인터페이스 자체는 깔끔하나, offset/size가 음수거나 과도하게 클 때의 계약이 명시되어 있지 않다. 운영 관점에서는 Facade/구현체 어느 한쪽에서 반드시 상한(예: size <= 100)과 음수 차단을 강제해야 대량 스캔으로 인한 성능 저하를 방지할 수 있다. 또한 findLatestRankDate가 반환하는 날짜의 의미(가장 최근 집계 성공일 vs 오늘 포함 여부)를 javadoc으로 명시해두면 WEEKLY/MONTHLY 조회 시 "아직 배치가 돌지 않은 상태"의 처리(예: Optional.empty → 빈 응답)가 호출부에서 일관되게 이루어진다.

추가 테스트: offset < 0, size = 0, size > topN(=100) 입력 시의 동작 계약을 검증하는 테스트.

🤖 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/ProductRankSnapshotQueryRepository.java`
around lines 7 - 10, Add defensive pagination contract and clarify semantics:
update the ProductRankSnapshotQueryRepository interface by documenting (Javadoc)
that findRankings(RankingType, LocalDate, int offset, int size) requires offset
>= 0, size > 0 and size <= 100 (or configurable TOP_N) and that implementations
must validate/normalize inputs (throw IllegalArgumentException or clamp) to
prevent large scans; also document findLatestRankDate(RankingType) to specify
whether it returns the most recent successful aggregation date (excluding today
if batch not run) and when Optional.empty is returned. Then implement the
validation in the repository implementation(s) that back findRankings and add
unit tests covering offset < 0, size == 0, and size > 100 to assert the defined
behavior.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java (2)

6-6: 사용되지 않는 ProductRankSnapshotJpaRepository import가 포함되어 있다.

이 파일은 EntityManager만 사용하므로 ProductRankSnapshotJpaRepository import는 불필요하다. 정적 분석이 가리키는 항목은 아니지만, PR 병합 전 제거를 권장한다.

🤖 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/ProductRankSnapshotQueryRepositoryImpl.java`
at line 6, 파일 ProductRankSnapshotQueryRepositoryImpl에 불필요하게 포함된
ProductRankSnapshotJpaRepository import를 제거하세요; 이 클래스는 EntityManager만 사용하므로
ProductRankSnapshotJpaRepository 참조를 삭제(또는 불필요한 임포트 자동 정리 실행)하여 정리합니다.

35-42: SELECT MAX(...) 쿼리의 반환 처리 단순화를 권장한다.

SELECT MAX(p.rankDate)는 집계 쿼리이므로 항상 정확히 1개 행(값이 없을 때는 단일 null)을 반환한다. 따라서 result.isEmpty() 분기는 실제로 발생하지 않으며 getSingleResult()Optional.ofNullable(...) 조합이 의도를 더 명확히 드러낸다. 기능상 문제는 없어 필수는 아니다.

제안 수정안
-        List<LocalDate> result = entityManager.createQuery(
-                        "SELECT MAX(p.rankDate) FROM ProductRankSnapshot p " +
-                        "WHERE p.rankingType = :rankingType", LocalDate.class)
-                .setParameter("rankingType", rankingType)
-                .getResultList();
-        return result.isEmpty() ? Optional.empty() : Optional.ofNullable(result.get(0));
+        LocalDate result = entityManager.createQuery(
+                        "SELECT MAX(p.rankDate) FROM ProductRankSnapshot p " +
+                        "WHERE p.rankingType = :rankingType", LocalDate.class)
+                .setParameter("rankingType", rankingType)
+                .getSingleResult();
+        return Optional.ofNullable(result);
🤖 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/ProductRankSnapshotQueryRepositoryImpl.java`
around lines 35 - 42, The method findLatestRankDate currently executes
entityManager.createQuery(..., LocalDate.class).getResultList() and checks
isEmpty(), but since SELECT MAX(p.rankDate) always returns exactly one row
(possibly null) simplify by using getSingleResult() on the Query returned by
entityManager.createQuery and then wrap that result with
Optional.ofNullable(...); update the code in findLatestRankDate to call
getSingleResult() instead of getResultList() and return
Optional.ofNullable(theSingleResult) so intent is clearer.
modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java (1)

22-31: 스냅샷 조회 패턴 대비 인덱스 커버리지에 대한 운영상 의견을 남긴다.

uk_type_date_productidx_type_date_rank는 쓰기 중복 방지 및 페이지네이션 조회에 적절하다. 다만 상품 상세에서 최신 주·월간 순위를 역조회하려는 요구가 생기면 (product_id, ranking_type, rank_date) 보조 인덱스가 필요할 수 있으므로 API 추가 시점에 함께 고려한다. 스키마는 별도 마이그레이션 파이프라인으로 관리되는 점을 전제로 한다.

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

In
`@modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java`
around lines 22 - 31, 현재 인덱스 구성(UniqueConstraint uk_type_date_product, Index
idx_type_date_rank) 는 쓰기 중복 방지와 페이지네이션에 적절하지만, 상품 상세에서 최신 주/월간 순위를 역조회할 경우를 대비해
보조 인덱스가 필요합니다; 마이그레이션 시 product_rank_snapshot 테이블에 (product_id, ranking_type,
rank_date) 순서의 보조 인덱스 추가(또는 API 추가 시점에 함께 생성) 방안을 마련하고, 해당 인덱스 이름과 역할을 명시하여 스키마
마이그레이션 스크립트에 반영하세요.
apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java (1)

38-45: 라인 아이템마다 2회의 DB 왕복이 발생하여 주문 규모가 커질수록 처리 지연이 누적된다.

OrderedProduct마다 productMetricsRepository.incrementOrderLineCountproductMetricsDailyRepository.incrementOrderLineCountAndAmount가 동기 호출되어 N×2회의 라운드트립이 발생한다. 대형 주문/버스트 트래픽 시 컨슈머 지연과 리밸런싱 유발 가능성이 있다. 즉시 수정은 필수 아니나, 향후 배치 upsert(동일 (productId, today) 키에 대해 집계 후 1회 실행)로 모으는 것을 권장한다.

🤖 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/application/handler/PaymentCompletedEventHandler.java`
around lines 38 - 45, Per-order loop causes 2 DB roundtrips per OrderedProduct
(productMetricsRepository.incrementOrderLineCount and
productMetricsDailyRepository.incrementOrderLineCountAndAmount), so aggregate
updates by productId (and today) before persisting: build a map keyed by
product.getProductId() that sums quantity and amount (price*quantity), then
replace the per-item calls in PaymentCompletedEventHandler with two batched
operations—one to increment order line counts in productMetricsRepository with
aggregated counts and one to upsert/increment daily metrics in
productMetricsDailyRepository using the aggregated (productId, today) counts and
amounts—so each unique productId results in a single DB call instead of N×2
calls.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

32-38: rankingTypenull일 때의 방어 로직이 없어 NPE가 발생할 수 있다.

Controller에서 기본값 "DAILY"를 주고 있어 현재 경로에서는 문제가 없지만, 파사드는 다른 호출자(향후 내부 스케줄러·테스트 등)로부터 재사용될 가능성이 있으므로 방어가 필요하다. rankingType == null인 경우 즉시 CoreException으로 실패시키거나 DAILY로 폴백하는 것이 바람직하다. 추가 테스트로는 rankingType = null 전달 시 정의된 예외가 던져지는지를 검증한다.

🤖 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 32 - 38, Add a null-check for the rankingType parameter in
RankingFacade.getRankings and fail fast: if rankingType == null throw a
CoreException with a clear message (e.g., "rankingType must not be null")
instead of letting an NPE occur; update callers/tests to assert that passing
null to getRankings raises the CoreException and ensure existing flows still
call getDailyRankings(date, page, size) for RankingType.DAILY and
getSnapshotRankings(rankingType, date, page, size) otherwise.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)

44-52: null 반환으로 상태 신호를 전달하는 대신 의도를 명시적으로 드러내는 편이 좋다.

resolveDateDAILY가 아닐 때 null을 반환하고 Facade가 "null이면 최신 rank_date 조회" 규약으로 해석하는 암묵 계약은 유지 비용이 크다. 호출부에서 Optional<LocalDate>를 반환하거나, Facade의 getSnapshotRankingsLocalDate 없는 오버로드를 별도 제공하도록 리팩터링하면 경로 분기가 명확해진다. 현재 단계에서는 필수는 아니다.

🤖 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 44 - 52, The resolveDate method currently returns null for
non-DAILY ranking types which encodes a hidden contract consumed by
getSnapshotRankings; change resolveDate(LocalDate date, RankingType rankingType)
to return Optional<LocalDate> (or alternatively add an overload of
getSnapshotRankings that takes no date) so callers handle presence/absence
explicitly: update callers of resolveDate and the invocation of
getSnapshotRankings to branch on Optional.isPresent() (or call the new no-date
overload) instead of relying on null; reference symbols: resolveDate,
RankingType.DAILY, and getSnapshotRankings.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java (2)

82-89: @Bean 메서드에서 step-scope 대상에 null을 넘겨 호출하는 패턴은 표준적이지만, 의도를 드러내는 주석이 있으면 좋다.

aggregateRankingStep(null)deleteOldRankingStepListener(null, null) 호출은 @Configuration CGLIB 프록시가 실제 step-scoped 빈(프록시)을 해석해 주는 Spring Batch의 표준 패턴이다. 기능상 문제는 없으나, 신규 합류자 혼동을 막기 위해 "late-binding: StepScope proxy will resolve job parameters" 주석을 남기면 유지보수성이 향상된다.

🤖 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/RankingAggregationJobConfig.java`
around lines 82 - 89, Add an explicit clarifying comment where step-scoped beans
are invoked with null (e.g., the aggregateRankingStep(null) call inside
rankingAggregationJob and the deleteOldRankingStepListener(null, null) usages)
stating that passing null is intentional because Spring Batch uses a StepScope
proxy which will late-bind and resolve job parameters at runtime (suggested
text: "late-binding: StepScope proxy will resolve job parameters at runtime").
Place the comment immediately above or inline with the null-invocation to make
the intent clear to new contributors without changing behavior.

45-72: 가중치 동일 합산식을 두 번 기술하여 유지보수 과정에서 ORDER BY와 SELECT의 식이 어긋날 위험이 있다.

score 계산식이 SELECT와 ROW_NUMBER() OVER(ORDER BY ...) 양쪽에 중복되어 있고, 파라미터 바인딩도 6개로 분리되어 있다. 향후 가중치 항목(예: cart_count)을 추가할 때 한쪽만 바뀌면 정렬과 저장 스코어가 불일치해 순위가 틀어진다. 내부 서브쿼리에서 score를 컬럼으로 산출한 뒤 ROW_NUMBER() OVER (ORDER BY score DESC, product_id ASC)로 참조하도록 재작성하면 중복이 제거되고 파라미터도 3개로 줄어든다.

추가 제안: product_metrics_dailymetric_date 인덱스(및 월간 스캔 최적화를 위한 (metric_date, product_id) 복합 인덱스) 존재 여부를 점검한다. 30일 윈도 스캔이 풀스캔으로 동작하면 배치 시간이 선형 증가한다.

🤖 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/RankingAggregationJobConfig.java`
around lines 45 - 72, READER_SQL의 SELECT에서 score 식과 ROW_NUMBER() OVER(ORDER BY
...)에 동일한 가중치 합산식을 중복 사용하고 파라미터가 6개로 분리되어 있어 불일치 위험이 있으니, 내부 서브쿼리에서 (예: SELECT
... , (SUM(pm.view_count) * ? + SUM(pm.like_count) * ? + SUM(pm.order_amount) *
?) AS score) 형태로 score 컬럼을 먼저 계산한 뒤 외부에서 ROW_NUMBER() OVER(ORDER BY score DESC,
pm.product_id ASC)로 참조하도록 쿼리를 재작성하여 중복식을 제거하고 바인딩 파라미터를 3개로 줄이세요; 또한 쿼리 성능을 위해
product_metrics_daily의 metric_date (가능하면 (metric_date, product_id) 복합 인덱스) 존재
여부를 점검하세요.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)

260-409: 스냅샷 API 경계 케이스 커버리지를 한 가지 더 보강한다.

WEEKLY/MONTHLY 기본 경로·빈 결과·페이지네이션은 잘 다뤄졌으나, 다음 한 가지 보강을 권장한다: rankingType 파라미터에 잘못된 값(예: HOURLY)을 넘겼을 때의 응답 포맷 검증. 이는 RankingV1Controller의 enum 바인딩 실패 시 통합 에러 응답으로 노출되는지 회귀를 막기 위함이다. 또한 rankDate가 동일한 두 스냅샷에 대한 rankPosition 정렬의 결정성을 확인하는 케이스가 있으면 배치·API 간 정렬 계약이 고정된다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`
around lines 260 - 409, Add two E2E tests inside the GetSnapshotRankings nested
class: (1) a negative test that calls RANKING_ENDPOINT with an invalid
rankingType value (e.g. "?rankingType=HOURLY&size=10") and asserts the
controller returns the standardized error ApiResponse (HTTP 400/Bad Request and
the expected error body shape) to verify enum binding failure is surfaced as a
consistent error from RankingV1Controller, and (2) a determinism test that saves
two ProductRankSnapshot entries with the same rankDate and the same score but
different rankPosition using saveSnapshot(...) and asserts the returned list
from RANKING_ENDPOINT + "?rankingType=WEEKLY&size=10" is ordered
deterministically by rankPosition (e.g. lower rankPosition appears first); use
existing helpers (saveSnapshot, saveBrand, saveProduct, RANKING_ENDPOINT,
testRestTemplate) and mirror assertions style used in other tests.
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java (1)

177-279: 스냅샷 시나리오 단위 테스트는 적절하나 Mockito 의존성이 과도하다.

snapshotQueryRepository에 대한 when/then stubbing이 사실상 Facade 매핑 로직만 검증하는 구조가 되어 있어, 리포지토리 JPQL의 올바름은 별도 슬라이스 테스트나 E2E에 의존한다. 현재 E2E가 커버하므로 수용 가능하다. 경계 보강 제안: (1) productFacade가 스냅샷 경로에서 호출되지 않음을 verifyNoInteractions로 고정, (2) page < 1 또는 size <= 0에 대한 입력 검증 동작이 Facade·Controller 중 어디에 있는지 계약을 테스트로 고정한다.

🤖 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/ranking/RankingFacadeTest.java`
around lines 177 - 279, Add explicit interaction checks and input-validation
contract tests: in the SnapshotRankings tests add
verifyNoInteractions(productFacade) after each arrange/act to ensure
productFacade is not called when using snapshotQueryRepository (reference
SnapshotRankings and snapshotQueryRepository/productFacade), and add two unit
tests that call rankingFacade.getRankings with page < 1 and with size <= 0
asserting the agreed contract (e.g., expect IllegalArgumentException or a
specific validation exception); if the facade lacks input validation, implement
parameter checks in RankingFacade.getRankings to throw that exception for
invalid page/size so the new tests pass.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java (1)

62-62: 고정 END_DATE는 현재 유효하지만, 실제 운영 동작(어제 기준 집계)과의 괴리를 드러낼 테스트가 없다.

운영 관점에서 배치는 기본적으로 endDate = yesterday로 돌도록 설계되어 있다고 PR 설명에 기술되어 있으나, 본 테스트는 모든 케이스에서 고정된 2026-04-14만 사용한다. 파라미터 기본값 해석(예: 파라미터 누락 시 어제로 치환되는지, 시간대 계산이 서버 타임존에 좌우되지 않는지) 검증이 누락되어, 운영 환경 타임존 이슈가 테스트에서 포착되지 않는다.

최소한 endDate 파라미터를 생략하고 launchJob을 실행했을 때 기본값이 LocalDate.now().minusDays(1)(또는 구현 사양)로 치환되어 정상 동작하는지 확인하는 테스트를 한 건 추가할 것을 제안한다. 현재 테스트를 수정하는 것이 아니라 신규 케이스 추가만으로 충분하다.

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

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

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`
at line 62, Add a new test in RankingAggregationJobE2ETest that verifies the
default endDate behavior when the parameter is omitted: call launchJob (the same
method used by existing tests) without passing an endDate and assert the job
uses LocalDate.now().minusDays(1) (or the implementation's specified default) as
the effective end date (compare stored/queried aggregation boundaries or job
args), ensuring you account for server timezone by normalizing to LocalDate or
injecting a Clock if available; reference END_DATE only for existing fixed-case
tests and keep this new test separate to validate the implicit "yesterday"
default.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Line 30: The controller currently binds rankingType as the enum RankingType
which causes Spring to throw MethodArgumentTypeMismatchException (bypassing
ApiControllerAdvice) for invalid values; change the RankingV1Controller endpoint
to accept rankingType as String, validate/convert it to RankingType using domain
rules and throw new CoreException(ErrorType.BAD_REQUEST, ...) on invalid values,
or alternatively update ApiControllerAdvice to map
MethodArgumentTypeMismatchException -> CoreException so all enum binding
failures are normalized; add an E2E test that calls the endpoint with
rankingType=INVALID and asserts a 400 with the unified error body.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java`:
- Around line 148-169: Extract the anonymous StepExecutionListener into a
dedicated `@Component` class named DeleteOldRankingListener that implements
StepExecutionListener; inject ProductRankSnapshotJpaRepository and read job
parameters inside beforeStep (use stepExecution.getJobParameters() to obtain
rankingType and endDate), annotate the beforeStep method with `@Transactional`,
and update the deleteOldRankingStepListener bean to return/lookup this component
(or register it as the listener) so transactions run on a Spring-managed bean;
ensure you still call
productRankSnapshotJpaRepository.deleteByRankingTypeAndRankDate(rankingType,
actualEndDate) from the new beforeStep.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`:
- Around line 119-138: The teardown currently calls truncateExternalTables →
databaseCleanUp.truncateAllTables → cleanUpBatchMetadata inside `@AfterEach` so a
failure in an earlier step can skip later cleanup (causing
JobInstanceAlreadyCompleteException on subsequent runs) and SET
FOREIGN_KEY_CHECKS may not apply as expected due to session/transaction scope;
modify the `@AfterEach` to ensure each cleanup step runs independently by either
(a) wrapping each of truncateExternalTables, databaseCleanUp.truncateAllTables,
and cleanUpBatchMetadata in their own try/catch (or assertDoesNotThrow) so
failures don’t prevent the next step, or (b) perform a single transactional
block that disables FK checks once at the connection/session level and truncates
all external and batch tables together before re-enabling FK checks; update
truncateExternalTables and cleanUpBatchMetadata accordingly to use the chosen
approach and ensure FK toggling is applied at the correct scope.
- Around line 382-455: Add an assertion to each exclusion test
(excludesDeletedProducts, excludesHiddenProducts,
excludesProductsOfDeletedBrand) that the remaining ProductRankSnapshot has
rankPosition == 1 to verify re-ranking after exclusions; update the asserts
block in each test to check results.stream().anyMatch(r ->
r.getProductId().equals(1L) && r.getRankPosition()==1) (or equivalent) and leave
the existing noneMatch checks untouched—no changes to seeding are required
because the seeds already give the excluded product a higher metric.
- Around line 77-117: The test uses CREATE TABLE IF NOT EXISTS inside
createExternalTables() which can hide schema drift and causes cross-test
flakiness; change test setup to guarantee an exact schema by dropping and
recreating the tables each run (call DROP TABLE IF EXISTS for brands, products,
product_metrics_daily before creating them) or replace createExternalTables()
with a single shared `@Sql` script executed in `@BeforeEach` so the test has a
deterministic schema; ensure the setup includes the expected columns
(visibility, deleted_at, order_line_count) and the product_metrics_daily PRIMARY
KEY order (metric_date, product_id) so the test assertions match the real
schema.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java`:
- Around line 41-44: PaymentCompletedEventHandler 내부에서 OrderedProduct의
product.getPrice()와 product.getQuantity()가 null일 경우 자동 언박싱 NPE와 잠재적 산술 오버플로우가
발생하므로, product 처리 전에 null 가드를 추가해 price 또는 quantity가 null이면 해당 제품 항목을 스킵하고
productMetricsDailyRepository.incrementOrderLineCountAndAmount 호출을 하지 않도록 변경하고
WARN 로그를 남기세요; 또한 더 안전하게 계산하려면 quantity를 명시적으로 long으로 캐스트하고 orderAmount를 long으로
계산해 전달하도록 수정하고(price: product.getPrice().longValue(), quantity:
product.getQuantity().longValue()), 해당 동작에 대한 단위 테스트를 추가해 null price/quantity를
가진 OrderedProduct가 섞여 있어도 나머지 항목은 정상 처리되는지 검증하세요.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java`:
- Around line 29-33: The two repository calls in
ProductViewedEventHandler.handle are already covered by the transaction on
EventProcessingService.process, so do not add another transactional boundary;
instead ensure you leave the two calls to
productMetricsRepository.incrementViewCount and
productMetricsDailyRepository.incrementViewCount(metricDate) as-is under the
existing transaction. Replace the use of Clock.systemDefaultZone() with an
injected Clock tied to an explicit timezone (e.g.,
Clock.system(ZoneId.of("Asia/Seoul")) or a configurable bean) so metric_date is
deterministic across JVMs, and add a unit/integration test that injects a fixed
Clock at the day-boundary (e.g., UTC 23:59:59 / KST 00:00:00) to assert correct
metric_date computation for productMetricsDailyRepository.incrementViewCount.

In `@apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java`:
- Line 23: Add an explicit `@Column` declaration to the
ProductMetrics.orderLineCount field to match ProductMetricsDaily (use
`@Column`(name = "order_line_count", nullable = false) on the orderLineCount field
in class ProductMetrics) so the mapping is explicit and consistent; after
changing the entity, coordinate with the external migration pipeline to ensure a
migration/DDL is applied or a backfill from any existing order_count column is
included, confirm migration runs before app deployment, and verify no historical
metric data will be lost by the column rename.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java`:
- Around line 22-28: The current decrementLikeCount repository method inserts a
new row with like_count = -1 which can persist negative likes; change the
behavior so negative scores cannot be persisted by either: (A) modifying the
batch aggregation to clamp like totals with GREATEST(SUM(like_count), 0) when
writing to product_rank_snapshot, or (B) changing decrementLikeCount (the
repository method) to not INSERT when the row doesn't exist — replace the INSERT
... ON DUPLICATE KEY UPDATE pattern in decrementLikeCount with an UPDATE-only
statement (so no new row is created), or (C) migrate like handling to an
idempotent product_like_daily(user_id, product_id, metric_date) table and
compute deltas during aggregation; after choosing one approach, add a regression
E2E test in RankingAggregationJobE2ETest that ensures an arriving unlike before
a like does not produce a negative score in the final snapshot.

---

Duplicate comments:
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java`:
- Around line 29-33: ProductUnlikedEventHandler.handle currently calls
productMetricsRepository.decrementLikeCount and
productMetricsDailyRepository.decrementLikeCount separately and uses
LocalDate.now(clock) — make the two decrements atomic and timezone-consistent:
wrap both calls in a single transaction (e.g., annotate handle with
`@Transactional` or call a transactional service method) and ensure the same
injected Clock is used to compute LocalDate once at the start (store LocalDate
now = LocalDate.now(clock) and pass it to productMetricsDailyRepository). Also
verify/modify productMetricsDailyRepository.decrementLikeCount to guard against
negative counts (use database-side GREATEST(...,0) or equivalent) or document
the delta semantics and add a unit/integration test (e.g.,
ProductUnlikedEventHandlerTest_unlikeWithoutPriorLike_resultsInZeroDailyCount)
that asserts an unlike on a day with no prior like does not drive
daily/aggregate counts below zero.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java`:
- Around line 135-144: 현재 orderService.getOrderItems(...)가 빈 리스트를 반환하면
orderedProducts가 비어 있는 상태로 outboxEventPublisher.publish(...)가 호출되어 관측 공백이 생깁니다;
PaymentFacade에서 orderedProducts가 비어있는지 검사하여 빈 경우에는 outboxEventPublisher.publish을
건너뛰고 대신 processLogger.warn(...) 또는 logger.warn을 남기고(메시지에 payment.getId(),
payment.getOrderId() 포함) 모니터링용 카운터/메트릭을 증가시켜 알림을 보내도록 변경하세요(또는 비동기 재시도/재검증 로직을
호출하도록 방어적 처리를 추가). 변경 대상 식별자: orderService.getOrderItems, orderedProducts,
outboxEventPublisher.publish, PaymentCompletedEventPayload,
PaymentCompletedEventHandler; 또한 orderItems가 빈 리스트일 때 이벤트 발행/로깅 동작을 검증하는 단위 테스트를
추가하세요.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`:
- Around line 32-38: Add a null-check for the rankingType parameter in
RankingFacade.getRankings and fail fast: if rankingType == null throw a
CoreException with a clear message (e.g., "rankingType must not be null")
instead of letting an NPE occur; update callers/tests to assert that passing
null to getRankings raises the CoreException and ensure existing flows still
call getDailyRankings(date, page, size) for RankingType.DAILY and
getSnapshotRankings(rankingType, date, page, size) otherwise.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java`:
- Around line 7-10: Add defensive pagination contract and clarify semantics:
update the ProductRankSnapshotQueryRepository interface by documenting (Javadoc)
that findRankings(RankingType, LocalDate, int offset, int size) requires offset
>= 0, size > 0 and size <= 100 (or configurable TOP_N) and that implementations
must validate/normalize inputs (throw IllegalArgumentException or clamp) to
prevent large scans; also document findLatestRankDate(RankingType) to specify
whether it returns the most recent successful aggregation date (excluding today
if batch not run) and when Optional.empty is returned. Then implement the
validation in the repository implementation(s) that back findRankings and add
unit tests covering offset < 0, size == 0, and size > 100 to assert the defined
behavior.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java`:
- Line 6: 파일 ProductRankSnapshotQueryRepositoryImpl에 불필요하게 포함된
ProductRankSnapshotJpaRepository import를 제거하세요; 이 클래스는 EntityManager만 사용하므로
ProductRankSnapshotJpaRepository 참조를 삭제(또는 불필요한 임포트 자동 정리 실행)하여 정리합니다.
- Around line 35-42: The method findLatestRankDate currently executes
entityManager.createQuery(..., LocalDate.class).getResultList() and checks
isEmpty(), but since SELECT MAX(p.rankDate) always returns exactly one row
(possibly null) simplify by using getSingleResult() on the Query returned by
entityManager.createQuery and then wrap that result with
Optional.ofNullable(...); update the code in findLatestRankDate to call
getSingleResult() instead of getResultList() and return
Optional.ofNullable(theSingleResult) so intent is clearer.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 44-52: The resolveDate method currently returns null for non-DAILY
ranking types which encodes a hidden contract consumed by getSnapshotRankings;
change resolveDate(LocalDate date, RankingType rankingType) to return
Optional<LocalDate> (or alternatively add an overload of getSnapshotRankings
that takes no date) so callers handle presence/absence explicitly: update
callers of resolveDate and the invocation of getSnapshotRankings to branch on
Optional.isPresent() (or call the new no-date overload) instead of relying on
null; reference symbols: resolveDate, RankingType.DAILY, and
getSnapshotRankings.

In
`@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java`:
- Around line 177-279: Add explicit interaction checks and input-validation
contract tests: in the SnapshotRankings tests add
verifyNoInteractions(productFacade) after each arrange/act to ensure
productFacade is not called when using snapshotQueryRepository (reference
SnapshotRankings and snapshotQueryRepository/productFacade), and add two unit
tests that call rankingFacade.getRankings with page < 1 and with size <= 0
asserting the agreed contract (e.g., expect IllegalArgumentException or a
specific validation exception); if the facade lacks input validation, implement
parameter checks in RankingFacade.getRankings to throw that exception for
invalid page/size so the new tests pass.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`:
- Around line 260-409: Add two E2E tests inside the GetSnapshotRankings nested
class: (1) a negative test that calls RANKING_ENDPOINT with an invalid
rankingType value (e.g. "?rankingType=HOURLY&size=10") and asserts the
controller returns the standardized error ApiResponse (HTTP 400/Bad Request and
the expected error body shape) to verify enum binding failure is surfaced as a
consistent error from RankingV1Controller, and (2) a determinism test that saves
two ProductRankSnapshot entries with the same rankDate and the same score but
different rankPosition using saveSnapshot(...) and asserts the returned list
from RANKING_ENDPOINT + "?rankingType=WEEKLY&size=10" is ordered
deterministically by rankPosition (e.g. lower rankPosition appears first); use
existing helpers (saveSnapshot, saveBrand, saveProduct, RANKING_ENDPOINT,
testRestTemplate) and mirror assertions style used in other tests.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java`:
- Around line 82-89: Add an explicit clarifying comment where step-scoped beans
are invoked with null (e.g., the aggregateRankingStep(null) call inside
rankingAggregationJob and the deleteOldRankingStepListener(null, null) usages)
stating that passing null is intentional because Spring Batch uses a StepScope
proxy which will late-bind and resolve job parameters at runtime (suggested
text: "late-binding: StepScope proxy will resolve job parameters at runtime").
Place the comment immediately above or inline with the null-invocation to make
the intent clear to new contributors without changing behavior.
- Around line 45-72: READER_SQL의 SELECT에서 score 식과 ROW_NUMBER() OVER(ORDER BY
...)에 동일한 가중치 합산식을 중복 사용하고 파라미터가 6개로 분리되어 있어 불일치 위험이 있으니, 내부 서브쿼리에서 (예: SELECT
... , (SUM(pm.view_count) * ? + SUM(pm.like_count) * ? + SUM(pm.order_amount) *
?) AS score) 형태로 score 컬럼을 먼저 계산한 뒤 외부에서 ROW_NUMBER() OVER(ORDER BY score DESC,
pm.product_id ASC)로 참조하도록 쿼리를 재작성하여 중복식을 제거하고 바인딩 파라미터를 3개로 줄이세요; 또한 쿼리 성능을 위해
product_metrics_daily의 metric_date (가능하면 (metric_date, product_id) 복합 인덱스) 존재
여부를 점검하세요.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`:
- Line 62: Add a new test in RankingAggregationJobE2ETest that verifies the
default endDate behavior when the parameter is omitted: call launchJob (the same
method used by existing tests) without passing an endDate and assert the job
uses LocalDate.now().minusDays(1) (or the implementation's specified default) as
the effective end date (compare stored/queried aggregation boundaries or job
args), ensuring you account for server timezone by normalizing to LocalDate or
injecting a Clock if available; reference END_DATE only for existing fixed-case
tests and keep this new test separate to validate the implicit "yesterday"
default.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java`:
- Around line 38-45: Per-order loop causes 2 DB roundtrips per OrderedProduct
(productMetricsRepository.incrementOrderLineCount and
productMetricsDailyRepository.incrementOrderLineCountAndAmount), so aggregate
updates by productId (and today) before persisting: build a map keyed by
product.getProductId() that sums quantity and amount (price*quantity), then
replace the per-item calls in PaymentCompletedEventHandler with two batched
operations—one to increment order line counts in productMetricsRepository with
aggregated counts and one to upsert/increment daily metrics in
productMetricsDailyRepository using the aggregated (productId, today) counts and
amounts—so each unique productId results in a single DB call instead of N×2
calls.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java`:
- Around line 26-36: Add DB-level default values for numeric metric columns in
ProductMetricsDaily by annotating each wrapper field (likeCount, viewCount,
orderLineCount, orderAmount) with `@ColumnDefault`("0") so the database guarantees
0 even if a JPA save/insert path is used later; update imports to include
org.hibernate.annotations.ColumnDefault and leave the existing Long types and
current PK ordering (metric_date, product_id) unchanged.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java`:
- Line 7: ProductMetricsDailyId implements Serializable but lacks a
serialVersionUID; add a private static final long serialVersionUID field to the
ProductMetricsDailyId class (e.g. private static final long serialVersionUID =
1L;) to ensure stable serialization for this `@IdClass` type and avoid
JVM/compiler-dependent generated values when fields/annotations change.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java`:
- Around line 14-48: The native upsert methods (incrementLikeCount,
decrementLikeCount, incrementViewCount, incrementOrderLineCountAndAmount) use
`@Modifying` without clearing or flushing the persistence context, which can cause
stale reads if the same transaction later queries these entities; update each
`@Modifying` annotation to include clearAutomatically = true and
flushAutomatically = true so the persistence context is flushed and cleared
after the native query executes, ensuring subsequent reads see the upsert
results.

In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java`:
- Around line 141-156: Summary: Use long arithmetic for price/quantity to avoid
int overflow in ProductMetricsDailyRepositoryTest. In
ProductMetricsDailyRepositoryTest update the test variables so multiplication is
performed in long (e.g., change unitPrice and/or quantity to long literals or
cast before multiplication) and compute orderAmount as a long from the start
(replace int unitPrice, int quantity, long orderAmount = unitPrice * quantity
with long-aware declarations and calculation) so the test reflects the
production convention and avoids int overflow when calling
incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, orderAmount).
- Around line 46-135: 테스트가 정상 누적 시나리오만 검증하고 경계·실패 케이스가 없어 요구사항을 충족하지 못하므로,
ProductMetricsDailyRepository 관련 테스트들에 경계/예외 케이스를 추가하세요: 다른 PRODUCT_ID에 대해 같은
날짜(TODAY)로 호출해 별도 행이 생성되고 서로 간섭하지 않는지 검증하는 케이스를 추가하고(incrementViewCount /
incrementLikeCount 사용), incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY,
amount)에 대해 amount=0 및 매우 큰 값(예: Long.MAX_VALUE 근사)으로 호출했을 때 orderAmount/컬럼 타입
한계를 검사하는 테스트를 추가하며, decrementLikeCount로 음수로 만든 후 incrementLikeCount로 0 또는 양수로
복귀하는 흐름(likeAndUnlike 역전 시나리오)을 assert하는 테스트를 각각 구현하세요; 각 테스트는 findMetrics(...)
호출로 결과를 조회하고 getViewCount/getLikeCount/getOrderLineCount/getOrderAmount 검증을
수행하도록 만드세요.

In
`@modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java`:
- Around line 22-31: 현재 인덱스 구성(UniqueConstraint uk_type_date_product, Index
idx_type_date_rank) 는 쓰기 중복 방지와 페이지네이션에 적절하지만, 상품 상세에서 최신 주/월간 순위를 역조회할 경우를 대비해
보조 인덱스가 필요합니다; 마이그레이션 시 product_rank_snapshot 테이블에 (product_id, ranking_type,
rank_date) 순서의 보조 인덱스 추가(또는 API 추가 시점에 함께 생성) 방안을 마련하고, 해당 인덱스 이름과 역할을 명시하여 스키마
마이그레이션 스크립트에 반영하세요.

In `@modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java`:
- Around line 3-14: The enum RankingType uses a switch in getDays(), separating
data from declaration; refactor RankingType to store a final int days field set
via a private constructor on each constant (e.g., DAILY(1), WEEKLY(7),
MONTHLY(30)) and change getDays() to return this.days; update the enum constants
to pass their day values and remove the switch so adding new constants requires
supplying the days at declaration time.

In
`@modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java`:
- Around line 14-19: The JPQL bulk DELETE method deleteByRankingTypeAndRankDate
in ProductRankSnapshotJpaRepository should be annotated to avoid stale entities
in the persistence context: add clearAutomatically = true (and add
flushAutomatically = true if you want immediate flush) to the `@Modifying`
annotation so the EntityManager clears the 1st-level cache after the bulk
delete; also verify in RankingAggregationJobConfig whether the beforeStep DELETE
runs inside a transaction (or uses TransactionTemplate) and add/adjust
transaction boundaries as needed and add tests that after delete a subsequent
find by (rankingType, rankDate) in the same transaction returns empty and that
re-running aggregation is idempotent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d423d32d-a84f-4872-9305-d58d9f0505ac

📥 Commits

Reviewing files that changed from the base of the PR and between 681c61f and d36da68.

📒 Files selected for processing (27)
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
  • apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java
  • modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java
  • modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java
  • modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java
  • modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java
  • modules/jpa/src/main/resources/jpa.yml
💤 Files with no reviewable changes (2)
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java

@GetMapping
public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date,
@RequestParam(defaultValue = "DAILY") RankingType rankingType,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

rankingType 입력이 잘못된 경우 표준 에러 포맷을 벗어난다.

enum 바인딩 실패 시 Spring이 MethodArgumentTypeMismatchException을 던지고 ApiControllerAdvice에서 CoreException 기반의 통일된 응답 포맷 대신 기본 500/400 포맷이 반환될 수 있다. 운영 관점에서 클라이언트가 에러 코드를 일관되게 처리하지 못하면 장애 재현·디버깅이 어렵다.

수정안: String으로 받은 뒤 도메인 규칙으로 검증하여 CoreException(ErrorType.BAD_REQUEST, ...)로 변환하거나, @ControllerAdvice에 타입 미스매치 → CoreException 매핑을 추가한다. 추가 테스트: rankingType=INVALID 요청 시 400과 표준 에러 바디가 반환되는지 E2E로 검증한다.

As per coding guidelines, "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format".

🤖 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`
at line 30, The controller currently binds rankingType as the enum RankingType
which causes Spring to throw MethodArgumentTypeMismatchException (bypassing
ApiControllerAdvice) for invalid values; change the RankingV1Controller endpoint
to accept rankingType as String, validate/convert it to RankingType using domain
rules and throw new CoreException(ErrorType.BAD_REQUEST, ...) on invalid values,
or alternatively update ApiControllerAdvice to map
MethodArgumentTypeMismatchException -> CoreException so all enum binding
failures are normalized; add an E2E test that calls the endpoint with
rankingType=INVALID and asserts a 400 with the unified error body.

Comment on lines +148 to +169
@StepScope
@Bean
public StepExecutionListener deleteOldRankingStepListener(
@Value("#{jobParameters['rankingType']}") String rankingTypeStr,
@Value("#{jobParameters['endDate']}") LocalDate endDate
) {
return new StepExecutionListener() {
@Override
@Transactional
public void beforeStep(StepExecution stepExecution) {
RankingType rankingType = RankingType.valueOf(rankingTypeStr);
LocalDate actualEndDate = endDate != null ? endDate : LocalDate.now().minusDays(1);
log.info("기존 랭킹 데이터 삭제: rankingType={}, rankDate={}", rankingType, actualEndDate);
productRankSnapshotJpaRepository.deleteByRankingTypeAndRankDate(rankingType, actualEndDate);
}

@Override
public ExitStatus afterStep(StepExecution stepExecution) {
return stepExecution.getExitStatus();
}
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern $'@Bean
public StepExecutionListener $_($$$) {
  $$$
}'
rg -nP -C3 '@Transactional' apps/commerce-batch/src/main/java

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

Length of output: 1138


🏁 Script executed:

#!/bin/bash

# Check the structure of batch module and locate application layer
find apps/commerce-batch -type f -name "*.java" | grep -E "(Service|Application)" | head -20

# Check RankingAggregationJobConfig location and surrounding classes
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java | head -50

# Search for `@Transactional` usage in batch module
echo "=== `@Transactional` in batch module ==="
rg -n "@Transactional" apps/commerce-batch/src --type java

# Check if there's an application layer service for ranking in batch
echo "=== Looking for ranking-related services ==="
rg -n "Ranking.*Service\|RankingApplication" apps/commerce-batch/src --type java

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

Length of output: 3184


🏁 Script executed:

#!/bin/bash

# Check directory structure of commerce-batch
echo "=== Commerce-batch module structure ==="
find apps/commerce-batch/src/main/java -type d | sort

# Check if there's any application layer in batch
echo "=== Looking for application layer in batch ==="
ls -la apps/commerce-batch/src/main/java/com/loopers/batch/ 2>/dev/null || echo "Directory listing..."
find apps/commerce-batch/src/main/java -name "*Application.java" -o -name "*ApplicationService.java" -o -name "*Service.java" | head -10

# Check module definition and architecture
echo "=== Check commerce-batch pom.xml ==="
cat apps/commerce-batch/pom.xml | grep -A5 -B5 "artifactId\|name" | head -20

# Look at other batch job configurations to compare patterns
echo "=== Other batch job configurations ==="
find apps/commerce-batch/src/main/java -name "*JobConfig.java" -type f

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

Length of output: 1273


🏁 Script executed:

#!/bin/bash

# Check DemoJobConfig for comparison
echo "=== DemoJobConfig content ==="
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java | head -100

# Check if DemoJobConfig uses `@Transactional`
rg -n "@Transactional" apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/ --type java

# Look for batch test files to understand the transaction testing
echo "=== Batch tests related to ranking ==="
find apps/commerce-batch/src/test -name "*Ranking*" -type f

# Check infrastructure layer references to see how transactions are typically handled
echo "=== ProductRankSnapshotJpaRepository location ==="
find . -name "ProductRankSnapshotJpaRepository.java" -type f

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

Length of output: 2739


🏁 Script executed:

#!/bin/bash

# Check the E2E test for ranking
echo "=== RankingAggregationJobE2ETest ==="
cat -n apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java | head -150

# Check ProductRankSnapshotJpaRepository
echo "=== ProductRankSnapshotJpaRepository ==="
cat -n modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java

# Check if `@Modifying` is present
rg -n "@Modifying\|deleteByRankingTypeAndRankDate" modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ --type java -A2

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

Length of output: 8276


🏁 Script executed:

#!/bin/bash

# Get the rest of the test file
echo "=== Full RankingAggregationJobE2ETest ==="
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java

# Search for other StepExecutionListener implementations
echo -e "\n=== StepExecutionListener implementations ==="
rg -n "implements StepExecutionListener\|extends.*StepExecutionListener" --type java

# Check batch listener classes
echo -e "\n=== Batch listener files ==="
ls -la apps/commerce-batch/src/main/java/com/loopers/batch/listener/
cat apps/commerce-batch/src/main/java/com/loopers/batch/listener/*.java | head -100

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

Length of output: 36378


🏁 Script executed:

#!/bin/bash

# Search for tests that verify transaction rollback or exception handling
echo "=== Tests with 'rollback', 'exception', 'fail' keywords in ranking ==="
grep -rn "rollback\|exception\|fail\|Exception\|@Test.*[Ee]xception" apps/commerce-batch/src/test/java/com/loopers/job/ranking/ --include="*.java"

# Check if there are any tests that verify deletion failure scenarios
echo -e "\n=== Search for step failure or retry tests ==="
grep -rn "StepExecution\|beforeStep\|afterStep\|failure\|error" apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java | head -20

# Look for Transactional test configuration
echo -e "\n=== Search for `@Transactional` in batch tests ==="
rg -n "@Transactional" apps/commerce-batch/src/test --type java

# Check Spring version to understand AOP behavior
echo -e "\n=== Check Spring Boot version ==="
cd apps/commerce-batch && cat pom.xml | grep -A2 -B2 "spring-boot\|spring.version" | head -20

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

Length of output: 1801


🏁 Script executed:

#!/bin/bash

# Find pom.xml files
echo "=== Finding pom.xml files ==="
find . -name "pom.xml" -type f | head -5

# Check root pom for Spring version
echo -e "\n=== Root pom version ==="
cat pom.xml | grep -A5 "spring-boot-starter-parent" | head -10

# Check the actual RankingAggregationJobConfig more thoroughly
echo -e "\n=== Full RankingAggregationJobConfig (critical parts) ==="
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java | sed -n '140,180p'

# Search for any error handling around the delete operation
echo -e "\n=== Error handling context around deletion ==="
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java | sed -n '145,175p'

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

Length of output: 3639


익명 내부 클래스의 @Transactional은 작동하지만, 별도 클래스로 추출하여 운영 안정성을 높여야 한다.

현재 코드에서 StepExecutionListener를 익명 클래스로 반환하며 beforeStep@Transactional을 선언했다. 테스트("reAggregation_replacesExistingSnapshots_noDuplicates")가 통과하는 것으로 보아 트랜잭션이 실제로 적용되고 있으나, Spring의 공식 문서에서는 익명 내부 클래스 메서드에 @Transactional을 적용하는 것을 권장하지 않는다. 또한 현재 테스트는 정상 흐름만 검증하고 예외 발생 시 롤백 동작을 확인하지 않는다.

운영상 우려: deleteByRankingTypeAndRankDate 실행 중 예외가 발생하면 트랜잭션 롤백 여부가 불명확해지며, 부분 삭제 후 재시도 시 기존 레코드와의 UNIQUE 제약 조건 충돌이 재발할 수 있다.

수정안: @Component로 선언된 별도의 클래스(DeleteOldRankingListener)로 추출하고, 명시적으로 @Transactional을 선언한다. 추가 테스트를 작성하여 beforeStep 실행 중 런타임 예외 발생 시 롤백이 정상 동작하고, Step 재시도 시 중복 없이 재집계되는지 검증한다.

🤖 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/RankingAggregationJobConfig.java`
around lines 148 - 169, Extract the anonymous StepExecutionListener into a
dedicated `@Component` class named DeleteOldRankingListener that implements
StepExecutionListener; inject ProductRankSnapshotJpaRepository and read job
parameters inside beforeStep (use stepExecution.getJobParameters() to obtain
rankingType and endDate), annotate the beforeStep method with `@Transactional`,
and update the deleteOldRankingStepListener bean to return/lookup this component
(or register it as the listener) so transactions run on a Spring-managed bean;
ensure you still call
productRankSnapshotJpaRepository.deleteByRankingTypeAndRankDate(rankingType,
actualEndDate) from the new beforeStep.

Comment on lines +77 to +117
private void createExternalTables() {
transactionTemplate.executeWithoutResult(status -> {
entityManager.createNativeQuery("""
CREATE TABLE IF NOT EXISTS brands (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description VARCHAR(255),
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
deleted_at DATETIME(6)
)
""").executeUpdate();

entityManager.createNativeQuery("""
CREATE TABLE IF NOT EXISTS products (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
brand_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description VARCHAR(255),
price INT NOT NULL,
stock_quantity INT NOT NULL DEFAULT 0,
visibility VARCHAR(20) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
deleted_at DATETIME(6)
)
""").executeUpdate();

entityManager.createNativeQuery("""
CREATE TABLE IF NOT EXISTS product_metrics_daily (
product_id BIGINT NOT NULL,
metric_date DATE NOT NULL,
view_count BIGINT NOT NULL DEFAULT 0,
like_count BIGINT NOT NULL DEFAULT 0,
order_line_count BIGINT NOT NULL DEFAULT 0,
order_amount BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (metric_date, product_id)
)
""").executeUpdate();
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

CREATE TABLE IF NOT EXISTS는 스키마 드리프트를 은폐할 수 있다.

운영 관점에서, 다른 모듈(예: commerce-api)의 JPA 엔티티 또는 마이그레이션 스크립트가 먼저 brands/products/product_metrics_daily를 생성해둔 상태라면, 이 테스트의 IF NOT EXISTS는 조용히 스킵된다. 그 결과 본 테스트가 가정하는 컬럼 집합(예: visibility, deleted_at, order_line_count)이 실제 운영 스키마와 어긋나더라도 테스트가 통과해 장애를 뒤늦게 포착하게 된다. 또한 테스트 클래스가 테이블을 생성만 하고 DROP하지 않으므로, 다른 테스트 클래스가 다른 스키마 정의로 같은 이름의 테이블을 쓰는 경우 실행 순서에 따른 플래키 문제도 발생할 수 있다.

운영상 안전을 위해 (1) @BeforeEach에서 매번 DROP TABLE IF EXISTSCREATE TABLE로 다시 만들거나, (2) 아예 공용 테스트 SQL 스크립트(@Sql)를 단일 진실 공급원으로 두어 모든 배치/스트리머/ API 테스트가 같은 스키마를 공유하도록 정리할 것을 제안한다. 추가 테스트로는 product_metrics_daily의 PK 순서(metric_date, product_id)가 실제 리더 쿼리 기준과 일치하는지 명시적으로 확인하는 케이스(예: 동일 일자의 대량 데이터 삽입 후 기대 정렬 확인)가 있으면 좋겠다.

♻️ 수정 예시
     private void createExternalTables() {
         transactionTemplate.executeWithoutResult(status -> {
+            entityManager.createNativeQuery("DROP TABLE IF EXISTS product_metrics_daily").executeUpdate();
+            entityManager.createNativeQuery("DROP TABLE IF EXISTS products").executeUpdate();
+            entityManager.createNativeQuery("DROP TABLE IF EXISTS brands").executeUpdate();
             entityManager.createNativeQuery("""
-                    CREATE TABLE IF NOT EXISTS brands (
+                    CREATE TABLE brands (

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

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

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`
around lines 77 - 117, The test uses CREATE TABLE IF NOT EXISTS inside
createExternalTables() which can hide schema drift and causes cross-test
flakiness; change test setup to guarantee an exact schema by dropping and
recreating the tables each run (call DROP TABLE IF EXISTS for brands, products,
product_metrics_daily before creating them) or replace createExternalTables()
with a single shared `@Sql` script executed in `@BeforeEach` so the test has a
deterministic schema; ensure the setup includes the expected columns
(visibility, deleted_at, order_line_count) and the product_metrics_daily PRIMARY
KEY order (metric_date, product_id) so the test assertions match the real
schema.

Comment on lines +119 to +138
private void truncateExternalTables() {
transactionTemplate.executeWithoutResult(status -> {
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE product_metrics_daily").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE products").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE brands").executeUpdate();
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
});
}

private void cleanUpBatchMetadata() {
transactionTemplate.executeWithoutResult(status -> {
entityManager.createNativeQuery("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT").executeUpdate();
entityManager.createNativeQuery("DELETE FROM BATCH_STEP_EXECUTION").executeUpdate();
entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT").executeUpdate();
entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION_PARAMS").executeUpdate();
entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION").executeUpdate();
entityManager.createNativeQuery("DELETE FROM BATCH_JOB_INSTANCE").executeUpdate();
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

외부 테이블 → 배치 메타데이터 순서의 정리 로직에 누락 케이스가 존재한다.

운영 관점에서 @AfterEach는 성공 경로에서만 실행되며, setUp()에서 테이블 생성이 실패하거나 launchJob 도중 예외가 전파되어도 동일하게 호출되지만, 현재 순서(truncateExternalTablesdatabaseCleanUp.truncateAllTablescleanUpBatchMetadata)는 앞 단계가 실패하면 뒤 단계가 실행되지 않는다. 특히 cleanUpBatchMetadata가 누락되면 다음 테스트가 동일 JobParameters로 재실행될 때 JobInstanceAlreadyCompleteException으로 폭주한다. 또한 SET FOREIGN_KEY_CHECKS는 세션 스코프이므로 트랜잭션 범위에 따라 효과가 예상과 다를 수 있다.

운영 안정성을 위해 각 단계를 독립적으로 try/catch 또는 assertDoesNotThrow로 분리하거나, @AfterEach를 단일 트랜잭션 안에서 FK 체크 해제 후 모든 테이블을 한 번에 TRUNCATE 하도록 묶는 것을 제안한다. 추가 테스트로는 테스트가 의도적으로 실패했을 때 다음 테스트가 영향 없이 동작하는지 확인하는 격리 회귀 테스트(파라미터화된 실패 시나리오)를 추가할 것을 권장한다.

♻️ 수정 예시
     `@AfterEach`
     void tearDown() {
-        truncateExternalTables();
-        databaseCleanUp.truncateAllTables();
-        cleanUpBatchMetadata();
+        // 앞 단계 실패가 뒷 단계(특히 BATCH_* 초기화)를 막지 않도록 독립 실행한다.
+        try { truncateExternalTables(); } catch (Exception ignored) {}
+        try { databaseCleanUp.truncateAllTables(); } catch (Exception ignored) {}
+        cleanUpBatchMetadata();
     }

As per coding guidelines (**/*Test*.java: "테스트 데이터 준비/정리 전략을 점검한다").

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

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`
around lines 119 - 138, The teardown currently calls truncateExternalTables →
databaseCleanUp.truncateAllTables → cleanUpBatchMetadata inside `@AfterEach` so a
failure in an earlier step can skip later cleanup (causing
JobInstanceAlreadyCompleteException on subsequent runs) and SET
FOREIGN_KEY_CHECKS may not apply as expected due to session/transaction scope;
modify the `@AfterEach` to ensure each cleanup step runs independently by either
(a) wrapping each of truncateExternalTables, databaseCleanUp.truncateAllTables,
and cleanUpBatchMetadata in their own try/catch (or assertDoesNotThrow) so
failures don’t prevent the next step, or (b) perform a single transactional
block that disables FK checks once at the connection/session level and truncates
all external and batch tables together before re-enabling FK checks; update
truncateExternalTables and cleanUpBatchMetadata accordingly to use the chosen
approach and ensure FK toggling is applied at the correct scope.

Comment on lines +382 to +455
@DisplayName("삭제된 상품은 랭킹에서 제외된다")
@Test
void excludesDeletedProducts() throws Exception {
// arrange
seedTestDataWithDeletedProduct();

// act
var jobExecution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder()
.addString("rankingType", "WEEKLY")
.addLocalDate("endDate", END_DATE)
.toJobParameters()
);

// assert
assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());

List<ProductRankSnapshot> results = productRankSnapshotJpaRepository.findAll();
assertAll(
() -> assertThat(results).hasSize(1),
() -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)),
() -> assertThat(results).noneMatch(r -> r.getProductId().equals(99L))
);
}

@DisplayName("비노출(HIDDEN) 상품은 랭킹에서 제외된다")
@Test
void excludesHiddenProducts() throws Exception {
// arrange
seedTestDataWithHiddenProduct();

// act
var jobExecution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder()
.addString("rankingType", "WEEKLY")
.addLocalDate("endDate", END_DATE)
.toJobParameters()
);

// assert
assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());

List<ProductRankSnapshot> results = productRankSnapshotJpaRepository.findAll();
assertAll(
() -> assertThat(results).hasSize(1),
() -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)),
() -> assertThat(results).noneMatch(r -> r.getProductId().equals(98L))
);
}

@DisplayName("삭제된 브랜드의 상품은 랭킹에서 제외된다")
@Test
void excludesProductsOfDeletedBrand() throws Exception {
// arrange
seedTestDataWithDeletedBrand();

// act
var jobExecution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder()
.addString("rankingType", "WEEKLY")
.addLocalDate("endDate", END_DATE)
.toJobParameters()
);

// assert
assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());

List<ProductRankSnapshot> results = productRankSnapshotJpaRepository.findAll();
assertAll(
() -> assertThat(results).hasSize(1),
() -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)),
() -> assertThat(results).noneMatch(r -> r.getProductId().equals(97L))
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

제외 케이스 검증에서 rankPosition 재정렬 여부를 함께 확인하는 것이 좋다.

운영 관점에서 삭제/비노출/삭제된 브랜드 상품이 단순히 집계에서 빠지는지만 검증하면, 리더 쿼리의 ROW_NUMBER()가 이 제외 조건과 같은 WHERE에서 실행되지 않는 잠재 버그(예: 서브쿼리 단계에서 필터가 누락되어 rankPosition이 2부터 시작하는 케이스)를 놓칠 수 있다. 현재 단정은 hasSize(1)anyMatch/noneMatch만 있어, 남은 1건의 rankPosition이 1인지 확인하지 않는다.

세 테스트(excludesDeletedProducts, excludesHiddenProducts, excludesProductsOfDeletedBrand)에 rankPosition == 1 단정을 추가하고, 제외 대상 상품에 더 높은 메트릭을 심어 두어 “제외되지 않았다면 1등이었을 상품”이 실제로 1등 자리에서 배제되는지까지 검증할 것을 권장한다. 현재 시드가 이미 제외 상품에 더 높은 값을 넣고 있으므로 단정만 추가하면 충분하다.

♻️ 수정 예시 (excludesDeletedProducts 기준, 나머지 두 테스트에도 동일 적용)
         List<ProductRankSnapshot> results = productRankSnapshotJpaRepository.findAll();
         assertAll(
                 () -> assertThat(results).hasSize(1),
                 () -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)),
-                () -> assertThat(results).noneMatch(r -> r.getProductId().equals(99L))
+                () -> assertThat(results).noneMatch(r -> r.getProductId().equals(99L)),
+                () -> assertThat(results.get(0).getRankPosition()).isEqualTo(1)
         );

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

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

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java`
around lines 382 - 455, Add an assertion to each exclusion test
(excludesDeletedProducts, excludesHiddenProducts,
excludesProductsOfDeletedBrand) that the remaining ProductRankSnapshot has
rankPosition == 1 to verify re-ranking after exclusions; update the asserts
block in each test to check results.stream().anyMatch(r ->
r.getProductId().equals(1L) && r.getRankPosition()==1) (or equivalent) and leave
the existing noneMatch checks untouched—no changes to seeding are required
because the seeds already give the excluded product a higher metric.

Comment on lines +41 to +44
long orderAmount = product.getPrice() * product.getQuantity();
productMetricsDailyRepository.incrementOrderLineCountAndAmount(
product.getProductId(), today, orderAmount
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

price/quantity null 입력 시 NPE 및 int 오버플로 위험이 존재한다.

OrderedProduct.price(Long)와 quantity(Integer)는 박싱 타입이므로 업스트림에서 null로 전달될 경우 자동 언박싱 NPE가 발생하여 이벤트 처리 파이프라인이 멈출 수 있다. 또한 getQuantity()Integer지만 곱셈 전 long으로 승격되므로 현재는 안전하나, 향후 priceint로 변경되면 int * int 오버플로가 숨겨진다. 운영 관점에서는 결제 스트리머가 한 건의 잘못된 페이로드로 재시도 루프에 빠지는 것이 치명적이다.

수정안: 필수 필드 null 가드를 추가하고 명시적으로 long으로 캐스팅하며, 누락된 페이로드에 대한 WARN 로그를 남긴다. 테스트로는 price/quantity가 null인 OrderedProduct가 섞여도 나머지 항목이 정상 처리되는지(또는 전체가 skip되는지 정책에 맞춰) 검증하는 케이스를 추가한다.

제안 수정안
-            long orderAmount = product.getPrice() * product.getQuantity();
+            if (product.getPrice() == null || product.getQuantity() == null) {
+                log.warn("결제 완료 이벤트 페이로드에 price/quantity 누락: productId={}", product.getProductId());
+                continue;
+            }
+            long orderAmount = product.getPrice() * (long) product.getQuantity();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
long orderAmount = product.getPrice() * product.getQuantity();
productMetricsDailyRepository.incrementOrderLineCountAndAmount(
product.getProductId(), today, orderAmount
);
if (product.getPrice() == null || product.getQuantity() == null) {
log.warn("결제 완료 이벤트 페이로드에 price/quantity 누락: productId={}", product.getProductId());
continue;
}
long orderAmount = product.getPrice() * (long) product.getQuantity();
productMetricsDailyRepository.incrementOrderLineCountAndAmount(
product.getProductId(), today, orderAmount
);
🤖 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/application/handler/PaymentCompletedEventHandler.java`
around lines 41 - 44, PaymentCompletedEventHandler 내부에서 OrderedProduct의
product.getPrice()와 product.getQuantity()가 null일 경우 자동 언박싱 NPE와 잠재적 산술 오버플로우가
발생하므로, product 처리 전에 null 가드를 추가해 price 또는 quantity가 null이면 해당 제품 항목을 스킵하고
productMetricsDailyRepository.incrementOrderLineCountAndAmount 호출을 하지 않도록 변경하고
WARN 로그를 남기세요; 또한 더 안전하게 계산하려면 quantity를 명시적으로 long으로 캐스트하고 orderAmount를 long으로
계산해 전달하도록 수정하고(price: product.getPrice().longValue(), quantity:
product.getQuantity().longValue()), 해당 동작에 대한 단위 테스트를 추가해 null price/quantity를
가진 OrderedProduct가 섞여 있어도 나머지 항목은 정상 처리되는지 검증하세요.

Comment on lines 29 to 33
public void handle(Event<ProductViewedEventPayload> event) {
productMetricsRepository.incrementViewCount(event.getPayload().getProductId());
Long productId = event.getPayload().getProductId();
productMetricsRepository.incrementViewCount(productId);
productMetricsDailyRepository.incrementViewCount(productId, LocalDate.now(clock));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# 1. ProductViewedEventHandler 파일 확인
fd -t f ProductViewedEventHandler.java

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

Length of output: 184


🏁 Script executed:

# 2. TimeConfig 파일 찾기
fd -t f -i TimeConfig.java

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

Length of output: 223


🏁 Script executed:

# 3. EventHandler 인터페이스 및 디스패처 찾기
fd -t f -i "EventHandler*.java" | head -20

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

Length of output: 1102


🏁 Script executed:

# 4. 저장소 구현 찾기
fd -t f -i "*MetricsRepository*.java"

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

Length of output: 388


🏁 Script executed:

# 저장소 찾기 (올바른 glob 패턴)
fd -t f --glob "*MetricsRepository*.java"

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

Length of output: 178


🏁 Script executed:

# ProductViewedEventHandler 전체 내용
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java

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

Length of output: 1591


🏁 Script executed:

# TimeConfig 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/config/TimeConfig.java

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

Length of output: 486


🏁 Script executed:

# EventHandler 인터페이스 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/EventHandler.java

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

Length of output: 393


🏁 Script executed:

# ProductMetricsRepository 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java

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

Length of output: 2180


🏁 Script executed:

# ProductMetricsDailyRepository 찾기 및 확인
fd -t f --glob "*ProductMetricsDailyRepository*.java"

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

Length of output: 286


🏁 Script executed:

# EventHandler 디스패처 찾기
rg -t java "EventHandler" --glob "*Dispatcher*" -l

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

Length of output: 233


🏁 Script executed:

# EventHandler 호출처 찾기 (일반적인 패턴)
rg -t java "\.handle\(" apps/commerce-streamer/src --glob "*[Cc]onsumer*" --glob "*[Dd]ispatcher*" -l

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

Length of output: 67


🏁 Script executed:

# ProductMetricsDailyRepository 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java

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

Length of output: 2833


🏁 Script executed:

# EventHandler 호출 지점 더 넓게 검색
rg -t java "\.handle\(" apps/commerce-streamer/src/main --with-filename | head -20

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

Length of output: 212


🏁 Script executed:

# ProductViewedEventHandler 테스트 파일 찾기
fd -t f --glob "*ProductViewedEventHandler*Test*"

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

Length of output: 67


🏁 Script executed:

# 전체 handler 구현 패턴 확인 (다른 handler들이 `@Transactional을` 사용하는지)
rg -t java "@Transactional" apps/commerce-streamer/src/main/java/com/loopers/application/handler/ -A 5 | head -50

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

Length of output: 67


🏁 Script executed:

# EventProcessingService 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/EventProcessingService.java

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

Length of output: 1629


🏁 Script executed:

# 다른 핸들러들도 `@Transactional이` 없는지 확인
rg -t java "public void handle" apps/commerce-streamer/src/main/java/com/loopers/application/handler/ -B 5 | grep -E "(class|@Transactional|public void handle)" | head -30

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

Length of output: 1621


두 저장소 호출이 이미 트랜잭션 내에서 실행된다.

EventProcessingService.process() 메서드가 @Transactional로 선언되어 있고(라인 23), 이 메서드 내에서 handler.handle(event)가 호출되므로(라인 35), ProductViewedEventHandler.handle()의 두 저장소 호출은 이미 동일한 트랜잭션 경계 내에서 실행된다. 따라서 incrementViewCount()incrementViewCount(metricDate) 중 하나가 실패하면 둘 다 롤백되며, 트랜잭션 경계를 별도로 추가할 필요는 없다.

다만 Clock.systemDefaultZone()을 사용함에 따라 스트리머 JVM의 로컬 타임존에 일별 버킷이 종속되는 점은 여전히 운영 리스크다. 일일 스냅샷이 metric_date에 의존하고 배치 작업의 타임존이 다르면 일별 집계가 의도한 경계와 어긋날 수 있다. Clock.system(ZoneId.of("Asia/Seoul"))로 명시적 존을 고정하거나, 운영 문서에 타임존을 명시할 것을 권장한다.

추가 테스트:

  • 고정 Clock (예: UTC 23:59:59 / KST 00:00:00)으로 day-boundary 이벤트가 의도한 metric_date로 집계되는지 검증하는 테스트.
🤖 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/application/handler/ProductViewedEventHandler.java`
around lines 29 - 33, The two repository calls in
ProductViewedEventHandler.handle are already covered by the transaction on
EventProcessingService.process, so do not add another transactional boundary;
instead ensure you leave the two calls to
productMetricsRepository.incrementViewCount and
productMetricsDailyRepository.incrementViewCount(metricDate) as-is under the
existing transaction. Replace the use of Clock.systemDefaultZone() with an
injected Clock tied to an explicit timezone (e.g.,
Clock.system(ZoneId.of("Asia/Seoul")) or a configurable bean) so metric_date is
deterministic across JVMs, and add a unit/integration test that injects a fixed
Clock at the day-boundary (e.g., UTC 23:59:59 / KST 00:00:00) to assert correct
metric_date computation for productMetricsDailyRepository.incrementViewCount.


@Column(nullable = false)
private Long orderCount = 0L;
private Long orderLineCount = 0L;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 리포지토리/코드 내 잔존 order_count 사용처 확인
rg -nP -C2 'order_count|orderCount' --type=java --type=sql --type=yaml --type=yml
# 마이그레이션성 SQL 파일 잔존 여부
fd -e sql

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

Length of output: 112


🏁 Script executed:

# ProductMetrics.java 파일 찾기
fd -i 'ProductMetrics.java'

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

Length of output: 160


🏁 Script executed:

# 마이그레이션 관련 디렉토리 및 파일 탐색
find . -type d -iname '*migration*' -o -type d -iname '*db*' | head -20
fd -e sql -e ddl | head -30

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

Length of output: 67


🏁 Script executed:

# order_count / orderCount 검색 (더 정확한 패턴)
rg 'order_count|orderCount' --type java -n
rg 'order_count|orderCount' --type sql -n

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

Length of output: 233


🏁 Script executed:

# ProductMetrics와 관련된 모든 파일 검색
fd ProductMetrics

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

Length of output: 708


🏁 Script executed:

# ProductMetrics.java 내용 읽기
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java

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

Length of output: 802


🏁 Script executed:

# ProductMetricsReadModel.java 내용 읽기
cat -n apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java

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

Length of output: 882


🏁 Script executed:

# order_count 또는 orderCount 검색 (type 필터 제거)
rg 'order_count|orderCount' -n -C2

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

Length of output: 67


🏁 Script executed:

# 마이그레이션 관련 파일 검색 - 구체적 경로들 확인
find . -path "*/migration*" -o -path "*/sql*" -o -path "*/ddl*" 2>/dev/null | grep -E "\.(sql|ddl|yaml|yml)$"
ls -la apps/commerce-streamer/src/main/resources/ 2>/dev/null

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

Length of output: 260


🏁 Script executed:

# ProductMetricsDailyRepository 등 다른 관련 파일에서 order 관련 컬럼 사용 여부 확인
cat apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
cat apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java

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

Length of output: 2839


🏁 Script executed:

# 전체 리포지토리에서 "product_metrics" 테이블 참조 확인
rg 'product_metrics|ProductMetrics' -n | head -50

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

Length of output: 9001


🏁 Script executed:

# git 이력으로 이전 필드명 확인
git log -p --all -S 'orderCount' -- '*.java' | head -100

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

Length of output: 67


ProductMetrics 필드의 @Column 선언을 명시적으로 지정하고 외부 마이그레이션 확인이 필요하다

orderLineCount 필드에 @Column(name = "order_line_count")를 명시적으로 선언해야 한다. ProductMetricsDaily에서는 이미 명시적으로 선언되어 있지만, ProductMetrics에서는 @Column(nullable = false) 만 사용 중으로 일관성이 없다. Spring의 기본 naming strategy가 orderLineCountorder_line_count로 변환하므로 현재는 의도한 대로 동작하지만, 스키마 관리의 명확성을 위해 두 클래스 모두 동일하게 명시해야 한다.

// ProductMetrics 수정 필요
`@Column`(name = "order_line_count", nullable = false)
private Long orderLineCount = 0L;

더불어 이 프로젝트가 외부 마이그레이션 파이프라인으로 스키마를 관리하므로, 다음 사항을 배포 전 확인하라:

  • 기존 order_count 컬럼에서 order_line_count 컬럼으로의 데이터 이관(백필) DDL 포함 여부
  • 마이그레이션과 애플리케이션 배포의 순서 (마이그레이션 선행 필수)
  • 컬럼명 변경으로 인한 기존 누적값 손실 가능성 검토
🤖 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/ProductMetrics.java`
at line 23, Add an explicit `@Column` declaration to the
ProductMetrics.orderLineCount field to match ProductMetricsDaily (use
`@Column`(name = "order_line_count", nullable = false) on the orderLineCount field
in class ProductMetrics) so the mapping is explicit and consistent; after
changing the entity, coordinate with the external migration pipeline to ensure a
migration/DDL is applied or a backfill from any existing order_count column is
included, confirm migration runs before app deployment, and verify no historical
metric data will be lost by the column rename.

Comment on lines +22 to +28
@Modifying
@Query(value = """
INSERT INTO product_metrics_daily (product_id, metric_date, like_count, view_count, order_line_count, order_amount)
VALUES (:productId, :metricDate, -1, 0, 0, 0)
ON DUPLICATE KEY UPDATE like_count = like_count - 1
""", nativeQuery = true)
void decrementLikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

decrementLikeCount가 신규 행을 -1로 생성하는 동작은 운영 관점에서 주의가 필요하다

이벤트 스트림에서 unlikelike보다 먼저 도달하는 순서 역전이나 at-least-once에 따른 중복 unlike가 발생하면 like_count가 음수 상태로 product_metrics_daily에 영속되고, 이후 배치가 해당 날짜 구간을 product_rank_snapshot에 집계할 때 음수 기여분이 그대로 스냅샷에 남는다. 일별 Redis ZSET 방식과 달리 스냅샷은 TTL로 자동 회복되지 않으므로, 운영 중 영구적인 스코어 왜곡 가능성이 있다.

최소한 다음 중 하나를 검토하라.

  • 배치 집계 SQL에서 GREATEST(SUM(like_count), 0) 형태로 하한 클램핑
  • decrementLikeCount 시 기존 행이 없으면 INSERT를 스킵하도록 구조 변경(ON DUPLICATE KEY UPDATE만 유효한 update 쿼리로 치환)
  • 또는 like 이벤트는 delta 방식 대신 멱등 테이블(product_like_daily(product_id, user_id, metric_date))로 관리 후 집계

추가 테스트 제안: "unlike 이벤트가 like보다 먼저 도착한 경우, 배치 집계 결과의 score/likeCount가 음수로 반영되지 않는다"는 회귀 테스트를 RankingAggregationJobE2ETest에 추가한다.

🤖 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/ProductMetricsDailyRepository.java`
around lines 22 - 28, The current decrementLikeCount repository method inserts a
new row with like_count = -1 which can persist negative likes; change the
behavior so negative scores cannot be persisted by either: (A) modifying the
batch aggregation to clamp like totals with GREATEST(SUM(like_count), 0) when
writing to product_rank_snapshot, or (B) changing decrementLikeCount (the
repository method) to not INSERT when the row doesn't exist — replace the INSERT
... ON DUPLICATE KEY UPDATE pattern in decrementLikeCount with an UPDATE-only
statement (so no new row is created), or (C) migrate like handling to an
idempotent product_like_daily(user_id, product_id, metric_date) table and
compute deltas during aggregation; after choosing one approach, add a regression
E2E test in RankingAggregationJobE2ETest that ensures an arriving unlike before
a like does not produce a negative score in the final snapshot.

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