Skip to content

[7팀 - 김현우] Round10 제출#202

Open
UjiinEatingTangerines wants to merge 7 commits into
Loopers-dev-lab:UjiinEatingTangerinesfrom
UjiinEatingTangerines:round10
Open

[7팀 - 김현우] Round10 제출#202
UjiinEatingTangerines wants to merge 7 commits into
Loopers-dev-lab:UjiinEatingTangerinesfrom
UjiinEatingTangerines:round10

Conversation

@UjiinEatingTangerines

@UjiinEatingTangerines UjiinEatingTangerines commented Apr 17, 2026

Copy link
Copy Markdown

Round 10 — Spring Batch 기반 주·월간 랭킹 + Ranking API 기간 확장

📌 Summary

  • 배경: Round 9 에서 Redis ZSET 으로 실시간 일간 랭킹을 구축했지만, product_metrics 는 상품별 누적 테이블이라 "일별 원천" 이 없고 Redis TTL 은 2일이라 주/월간 집계에 쓸 수 없다. 실시간 경로에서 대용량 집계를 수행하면 DB/Redis 부하로 서비스 전체가 흔들릴 위험도 있다.
  • 목표: Spring Batch Chunk-Oriented 처리로 일별 데이터 7일치(주간)·한 달치(월간)를 집계해 Materialized View 에 TOP 100 을 적재하고, /api/v1/rankingsperiod 파라미터를 추가해 일간/주간/월간 랭킹을 조회 소스를 달리해서 제공한다.
  • 결과: 주/월간 Spring Batch Job 2개 + MV 테이블 2개 + period 분기 API 를 완성했다. 단위/통합 테스트 43개 + Docker 조건부 E2E 13개로 체크리스트 4개 항목을 모두 회귀 고정했다.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약
    • product_metricsUNIQUE(product_id) 누적 테이블 → 하루치 스냅샷을 뽑을 수 없다
    • Redis ZSET 은 TTL 2일 → 7일치 롤업 불가
    • Outbox 패턴은 R7 부터 commerce-api 측에 이미 적용되어 있어, 이벤트 전달 신뢰성은 해결된 상태
  • 문제/리스크
    • "지난 주 TOP 100" 같은 복잡 집계를 실시간으로 계산하면 DB·Redis 부하로 전체 서비스가 흔들린다
    • 배치 재실행 시 MV 가 누적/중복 적재되면 안 된다 (멱등성)
    • 가중치 정책(streamer 와 batch) 이 어긋나면 실시간 ZSET 과 배치 MV 점수의 의미가 일관되지 않는다
  • 성공 기준
    • 퀘스트 체크리스트 4개 항목 전부 충족: 파라미터 기반 Job / Chunk-Oriented / MV 설계·적재 / API 기간 분기
    • requestDate 재실행 멱등성
    • 윤년 2월·주말 경계 등 시간 경계 케이스까지 테스트로 고정

선택지와 결정

  • 고려한 대안구현 계획서 §1.1 대안 비교
    • A: streamer 컨슈머에서 product_metrics_daily동기 upsert (단일 @Transactional 로 cumulative 와 함께 커밋)
    • B: streamer 가 자체 outbox 에 "DailyAggregationNeeded" 를 쌓고 별도 컨슈머가 daily 처리
    • C: commerce-api 가 "DailyMetricEvent" 별도 토픽으로 발행
    • D: 별도 일배치가 Redis 또는 다른 소스에서 daily 를 재구성
  • 최종 결정: A (동기 upsert)
  • 트레이드오프
    • 핸들러 트랜잭션이 1 row insert 만큼 길어지지만, cumulative ↔ daily 정합성이 단일 TX 로 보장되고 폴링 지연이 없다
    • B/C 는 추가 인프라(outbox 테이블·릴레이·전용 컨슈머·토픽) 가 필요하지만 얻는 이점이 현재 부하 수준에서는 미미하다
    • D 는 "전일 분만" 집계 가능 → 주간 랭킹이 항상 하루 이상 지연되어 적시성이 낮다
  • 추후 개선 여지
    • streamer 핸들러 부하가 실제 병목으로 드러나면 대안 B 로 전환 가능
    • 가중치 정책은 현재 streamer/batch 각자 동일 상수를 들고 있으므로, 설정 단일화가 필요해지면 공용 모듈 승격 검토

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈
    • commerce-streamer: 이벤트 소비 시 daily upsert 추가
    • commerce-batch: 주/월간 Aggregation Job 2개 신설
    • commerce-api: Ranking API 에 period 파라미터 추가 + MV 조회 경로
  • 신규 추가
    • 테이블: product_metrics_daily, mv_product_rank_weekly, mv_product_rank_monthly
    • Batch Job: weeklyRankingAggregationJob, monthlyRankingAggregationJob
    • 도메인: RankingPeriod, PeriodicRankingRepository, WeeklyPeriod, MonthlyPeriod, BatchRankingScorePolicy
  • 제거/대체
    • 없음. 기존 product_metrics 누적 테이블과 Redis ZSET 일간 경로, R7 Outbox 파이프라인은 그대로 유지

주요 컴포넌트 책임

  • ProductMetricsEventHandler: Kafka 이벤트 1건을 단일 @Transactional 안에서 event_handled 멱등 등록 → 누적 product_metrics save → daily upsert (신규) → Redis ZSET ZINCRBY 의 4단계로 처리. 기존 registerEvent·isStale 가드가 daily 쪽 중복·역전도 자동으로 막는다.
  • WeeklyRankingAggregationJobConfig / MonthlyRankingAggregationJobConfig: @ConditionalOnProperty(spring.batch.job.name) 로 Job 단위 활성화. Step 1 (Tasklet purge) → Step 2 (Chunk: Reader-Processor-Writer) 의 2단 Step 구조로 멱등 재실행 보장.
  • JdbcCursorItemReader: SQL 에서 가중치 곱 · GROUP BY · ORDER BY score DESC, product_id ASC LIMIT 100 을 한 번에 수행하여 TOP 100 만 스트리밍. Paging 대신 Cursor 를 선택한 이유는 단일 실행으로 일관된 스냅샷을 얻고 GROUP BY + 오프셋 페이징의 드리프트 리스크를 피하기 위함.
  • WeeklyRankingProcessor / MonthlyRankingProcessor: @StepScope + AtomicInteger 로 rank_position 을 1..100 으로 단조 부여.
  • PeriodicRankingRepository: offset/limit 호출 규약을 JPA PageRequest 로 안전 변환하고 MV 엔티티를 RankedProduct 로 매핑.
  • RankingService.getTopRankings(period, ...): when(period) 로 DAILY → Redis ZSET, WEEKLY/MONTHLY → MV 테이블 분기. 날짜 정규화(WEEKLY→ISO 월요일, MONTHLY→yyyy-MM) 를 캡슐화.

🔁 Flow Diagram

Main Flow

굵은 글씨 구간(R10) 만 이번 라운드 변경. 앞단(R7 Outbox 체인) 과 뒷단(API) 은 기존 코드를 그대로 활용한다.

sequenceDiagram
  autonumber
  participant Client
  participant API as commerce-api
  participant Outbox as outbox_event
  participant Relay as OutboxRelayPublisher
  participant Kafka
  participant Streamer as commerce-streamer
  participant DailyTbl as product_metrics_daily
  participant Redis as Redis ZSET
  participant Batch as commerce-batch
  participant MV as mv_product_rank (weekly/monthly)

  Note over API,Relay: R7 Outbox — 본 라운드 무변경
  API->>Outbox: INSERT (비즈니스 TX 와 동일 트랜잭션)
  Relay->>Outbox: SELECT WHERE published_at IS NULL
  Relay->>Kafka: kafkaTemplate.send(payload)

  Note over Kafka,Redis: R10 — streamer 에 daily upsert 추가
  Kafka->>Streamer: consume (at-least-once)
  Streamer->>Streamer: registerEvent(eventId) · isStale 가드
  Streamer->>DailyTbl: upsert (likes/views/sales delta)
  Streamer->>Redis: ZINCRBY (일간)

  Note over Batch,MV: R10 — Weekly/Monthly Aggregation Job
  Batch->>DailyTbl: GROUP BY + weighted score + LIMIT 100
  Batch->>MV: DELETE (purge) + INSERT TOP100 (idempotent)

  Client->>API: GET /api/v1/rankings?period=...&date=yyyyMMdd
  alt period=DAILY
    API->>Redis: ZREVRANGE (기존 경로)
  else period=WEEKLY or MONTHLY
    API->>MV: findBy period_start / year_month_val (신규 경로)
  end
  API-->>Client: RankingPageInfo
Loading

📝 Review Points — 사전 논의 기록

리뷰 전 세 가지 설계 이슈를 사전에 확인했다. 각 포인트의 결론이 코드/문서 어디에 반영되어 있는지 링크로 정리한다.

RP-1. streamer 가 product_metricsproduct_metrics_daily 두 테이블에 동시에 쓰는지

"upsert면 product_metrics, product_metrics_daily 모두 실행된다는 의미야?"

  • 결론: 그렇다. 단일 @Transactional 안에서 두 테이블 + Redis ZSET 에 순차적으로 쓴다. JPA 쪽 두 테이블은 동일 트랜잭션으로 원자 커밋되므로 SUM(daily.likes_count) = cumulative.likes_count 불변조건이 깨질 수 없다.
  • 확인 링크:

RP-2. 대안 B (streamer 자체 outbox) 를 YAGNI 로 거절한 근거

"2안의 채택여부의 YAGNI 가 무슨 뜻이야?"

  • 결론: "You Aren't Gonna Need It" — 현재 핸들러 부하가 문제로 드러나지 않았고, 분산 트랜잭션 요구도 없는 상태에서 streamer 전용 outbox 테이블·릴레이·별도 컨슈머를 미리 만들면 복잡도만 증가하고 디버깅·설계 변경 비용이 커진다. 향후 부하가 실제 관찰되면 그때 도입한다.
  • 확인 링크:

RP-3. 프로젝트에 Outbox 패턴은 이미 적용되어 있음 (R7 도입) — 본 라운드에서 건드리지 않는다

"그럼 지금 프로젝트에는 outbox 패턴이 적용되어 있지 않은거야?"

  • 결론: commerce-api 측에 Round 7 부터 적용되어 있다. @EventListener 로 도메인 이벤트를 outbox_event 에 INSERT → @Scheduled Relay 가 Kafka 로 발행 → published_at 마킹. 본 라운드는 producer 측 outbox 무변경이며, Kafka 가 at-least-once 로 재전송하더라도 기존 registerEvent 가드가 daily upsert 도 자동으로 차단한다.
  • 확인 링크:

✅ Checklist 달성 근거

퀘스트 체크리스트 달성 위치
Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다 WeeklyRankingAggregationJobConfig + MonthlyRankingAggregationJobConfig@ConditionalOnProperty(spring.batch.job.name) + @Value("#{jobParameters['requestDate']}")
Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 구현 .chunk<Row, Entity>(50, tx).reader().processor().writer() + 보조 Step 은 Tasklet(purge)
집계 결과를 저장할 Materialized View 설계 및 적재 mv_product_rank_weekly / mv_product_rank_monthly (UNIQUE 2종 + INDEX) + JpaItemWriter 로 TOP 100 INSERT
API 가 일간/주간/월간 랭킹을 적절한 데이터 소스로 제공 RankingService.getTopRankings(period, ...)when(period) 분기: DAILY→Redis, WEEKLY/MONTHLY→MV

🧪 Testing

  • 자동화된 단위/통합 테스트: 스트리머 (daily upsert 7), 배치 단위(Period 경계 12, Processor 9, Purge 6), API (Service 12, Facade 3, Controller 4, PeriodicRepository 7) → 총 60개 통과
  • Docker 조건부 E2E: Weekly 6 + Monthly 7 = 13개 시나리오 (@EnabledIf(isDockerAvailable) 로 로컬 Docker 없으면 스킵, CI/로컬 Docker 환경에서 자동 실행)
  • 수동 검증용 .http: /.http/ranking/getRankings.http 에 10종 호출 예제 (경계 케이스 포함)

🔧 운영 메모

  • ddl-auto: create 는 local/test 전용. 운영에서는 수동 DDL 필요구현 계획서 Phase 1.2 / Phase 2.2 / Phase 2.3 에 DDL 전문 기재.
  • 스케줄: weeklyRankingAggregationJob 매일 새벽 1회, monthlyRankingAggregationJob 매월 1일 새벽 1회 권장.
  • 실행 커맨드:
    java -jar commerce-batch.jar \
      --job.name=weeklyRankingAggregationJob \
      --requestDate=20260413

📦 커밋 구성 (7개, 기능별 분리)

# 타입 요약
1 docs Round 10 퀘스트 스펙 + Spring Batch 기반 주·월간 랭킹 구현 계획서 추가
2 feat streamer 가 Kafka 이벤트 소비 시 product_metrics_daily 일별 스냅샷에도 upsert
3 feat 주·월간 TOP 100 을 저장할 Materialized View 엔티티 + 기간별 조회 Repository
4 feat 주간 랭킹 Spring Batch Job (Tasklet purge → Chunk aggregate TOP 100)
5 feat 월간 랭킹 Spring Batch Job (윤년/평년 2월·월말 경계 포함)
6 feat Ranking API 에 period 파라미터 추가 — DAILY=Redis / WEEKLY·MONTHLY=MV 분기
7 chore .http/ranking/getRankings.http 에 기간별 호출 예제·경계 케이스 추가

변경 목적

기존 Redis ZSET(TTL 2일) 기반 일일 랭킹 시스템의 한계를 극복하기 위해 Spring Batch로 product_metrics_daily를 원본으로 주간/월간 TOP100을 Materialized View에 사전 계산하여 저장하고, API에 period 파라미터를 추가해 DAILY(Redis) / WEEKLY·MONTHLY(MV) 데이터를 분기 제공한다.

핵심 변경점

  • commerce-streamer: Kafka 이벤트 처리 시 product_metrics_daily에 동기 upsert 추가 (KST 정규화, 기존 가드 적용)
  • commerce-batch: weeklyRankingAggregationJob, monthlyRankingAggregationJob 신규 구현 (Tasklet 기반 purge + JdbcCursorItemReader로 TOP100 스트리밍)
  • commerce-api: RankingPeriod enum 도입 및 period 파라미터 추가, PeriodicRankingRepository로 MV 조회 분기
  • DB & 문서: product_metrics_daily, mv_product_rank_weekly, mv_product_rank_monthly 테이블 신규 추가, Round 10 quest 및 설계 문서 포함

리스크/주의사항

배치 실행을 위한 스케줄 운영이 필수적입니다(주간: 매일 01:00, 월간: 매월 1일 01:00). 현재 동기 upsert 방식을 선택했는데, 추후 Kafka 부하 증가 시 비동기 방식으로 전환 가능한 설계입니다. 기존 DAILY 랭킹(Redis)과의 API 호환성은 defaultValue="DAILY"로 유지됩니다.

테스트/검증 방법

단위/통합 테스트 60개, Docker 조건부 E2E 테스트 13개가 추가되었으며, 멱등성(재실행 동일 결과), 시간 경계(윤년 2월 29일, 월말/월초, 주 경계), 점수 동등 시 product_id 오름차순 정렬 등을 검증합니다. 배치 실행 예: java -jar commerce-batch.jar --job.name=weeklyRankingAggregationJob --requestDate=20260413

- 퀘스트 문서(`round_10_quest`, `collect_stack_zip`)를 `docs/round10/quest` 에 보관하여 이후 PR 리뷰어가 요구 사항을 동일 경로에서 확인할 수 있도록 한다
- 6개 Phase 로 구성한 구현 계획서(`implementation_plan.md`)를 추가하여 `product_metrics_daily` 도입, MV 설계, Chunk-Oriented 배치, API 확장의 의사결정 맥락과 리스크를 사전에 문서화한다
…reamer 를 확장

- 기존 누적 `product_metrics` 와 별개로 `(product_id, metric_date)` UNIQUE 키 기반 일별 스냅샷을 MySQL 네이티브 `INSERT ... ON DUPLICATE KEY UPDATE` 로 누적하여 주·월간 집계 배치의 원천 데이터를 마련한다
- 동일 `@Transactional` 안에서 cumulative 저장 직후 daily upsert 를 호출하므로 두 테이블 간 정합성이 단일 트랜잭션으로 보장되며, 기존 `registerEvent(eventId)` 및 `isStale(event)` 가드가 자동으로 daily 쪽 중복·역전도 차단한다
- `occurredAt` 을 `withZoneSameInstant(Asia/Seoul)` 로 정규화하여 UTC 경계 이벤트가 KST 기준 올바른 날짜로 분류되도록 하고, 주간 경계 계산 시 직관성을 유지한다
- 이벤트 타입 매핑(LIKE/VIEW/ORDER 각각 delta 컬럼), 같은 날짜 누적, 다른 날짜 분리, 중복 eventId 차단, stale 차단, KST 정규화 7가지 시나리오를 `ProductMetricsEventHandlerDailyTest` 로 회귀 고정한다
- 쓰기 경로(commerce-batch)에는 `WeeklyProductRankModel`/`MonthlyProductRankModel` 을 두어 배치가 `mv_product_rank_weekly`/`mv_product_rank_monthly` 에 TOP100 을 적재할 수 있도록 한다
- 읽기 경로(commerce-api)에는 동일 테이블을 `@Immutable` 로 매핑하여 UPDATE 가 발생하지 않도록 하드닝하고, 향후 API 가 안전하게 조회 전용으로만 접근하도록 한다
- MV 스키마에는 `(period_start, product_id)` 와 `(period_start, rank_position)` 두 개의 UNIQUE 제약과 `(period_start, rank_position)` INDEX 를 두어 기간 내 상품·순위 중복을 원천 차단하고 랭킹 조회를 빠르게 유지한다
- `PeriodicRankingRepository` 도메인 인터페이스와 `PeriodicRankingRepositoryImpl` 을 도입해 offset/limit 호출 규약을 Pageable 로 안전하게 변환하고, 엔티티를 기존 `RankedProduct` 로 일관되게 매핑한다
…ly 에 적재하는 Spring Batch Job 을 추가

- `weeklyRankingAggregationJob` 을 `@ConditionalOnProperty(spring.batch.job.name)` 로 등록하여 `--job.name=weeklyRankingAggregationJob --requestDate=yyyyMMdd` 파라미터 기반 실행을 지원한다
- Step 1 은 Tasklet 기반 purge 로 해당 주의 기존 MV 행을 선제 삭제하여 `requestDate` 재실행 시에도 동일한 결과를 보장한다 (idempotency)
- Step 2 는 Chunk-Oriented 처리(chunk size 50) 로 구성하며 `JdbcCursorItemReader` 가 SQL 단에서 GROUP BY · 가중치 적용 · `ORDER BY score DESC, product_id ASC LIMIT 100` 을 한 번에 수행해 메모리 부담 없이 TOP100 만 스트리밍한다
- Processor 는 `@StepScope + AtomicInteger` 로 rank_position 을 1부터 단조 증가하게 부여하고, Writer 는 `JpaItemWriter` 로 `WeeklyProductRankModel` 을 배치 INSERT 한다
- `WeeklyPeriod` 는 `WeekFields.ISO` 로 월~일 경계를 계산하여 JVM 기본 로케일에 의존하지 않도록 하드닝한다
- 단위 테스트(Period 경계, Processor rank 부여, Purge 대상 날짜) 와 Docker 조건부 E2E 테스트(파라미터 실패, 정상 적재, 재실행 멱등, 경계 외 무시, 타이브레이크) 로 체크리스트를 회귀 고정한다
…Spring Batch Job 을 추가

- `monthlyRankingAggregationJob` 을 주간 Job 과 동일한 `@ConditionalOnProperty(spring.batch.job.name)` + Tasklet(purge) → Chunk(aggregate) 구조로 구성하여 `--job.name=monthlyRankingAggregationJob --requestDate=yyyyMMdd` 로 동작한다
- `MonthlyPeriod` 는 `YearMonth.atEndOfMonth()` 기반으로 월 경계를 계산해 윤년 2월(29일)과 평년 2월(28일), 12월→1월 넘어가지 않는 케이스를 JDK 가 자동 처리하도록 맡긴다
- 월간 purge 는 `year_month_val` (CHAR(7) 'YYYY-MM') 단일 키로 수행하여 인덱스를 그대로 활용하고 재실행 멱등성을 유지한다
- Phase 3 의 주간 Job 과 구조가 거의 동일하지만, 지금 추상화하면 OCP 보다 오히려 변경 비용이 커질 수 있어 공통 추상화를 도입하지 않고 병렬 구조로 유지한다 (YAGNI)
- 단위 테스트(윤년·평년 경계, Processor rank 부여, Purge 대상 yearMonth) 와 Docker 조건부 E2E 테스트(파라미터 실패, 정상 적재, 재실행 멱등, 월 경계 외 무시, 윤년 2월 29일 포함, 타이브레이크) 로 회귀 고정한다
…회하도록 확장

- `RankingPeriod` 열거형(DAILY/WEEKLY/MONTHLY) 과 `@RequestParam(defaultValue = "DAILY") period` 도입으로 기존 클라이언트가 `period` 를 보내지 않아도 일간 랭킹으로 동작하여 하위 호환을 유지한다
- `RankingService.getTopRankings(period, ...)` 에서 `when(period)` 로 분기하여 DAILY 는 기존 Redis ZSET 경로, WEEKLY/MONTHLY 는 `PeriodicRankingRepository` 를 통한 MV 테이블 경로로 위임한다 (조회 형태에 따라 적절한 데이터 소스 선택)
- 입력 `date` 는 세 경로 모두 `yyyyMMdd` 로 동일하게 받고, WEEKLY 는 `WeekFields.ISO` 로 해당 주 월요일, MONTHLY 는 `YearMonth.from(date)` 로 `yyyy-MM` 으로 내부 정규화하여 클라이언트가 어느 요일·어느 날짜를 보내도 같은 결과를 반환한다
- `RankingService`/`RankingFacade`/`RankingV1Controller` 테스트를 Nested 구조로 재구성하여 DAILY(Redis)·WEEKLY(Monday 정규화·MV 위임)·MONTHLY(yyyy-MM 변환·0-padding) 각 경로의 계약을 명시적으로 고정한다
- 잘못된 period 값은 Spring MVC 의 기본 enum 바인딩 실패로 400 BadRequest 로 처리되어 별도 핸들러를 두지 않는다 (YAGNI)
- 일간 기본값 호출, period=DAILY 명시, 페이지 이동, 주 중간/끝 요일 정규화 확인, 월 1일/중순, 윤년 2월 29일 등 10개 케이스를 `.http/ranking/getRankings.http` 에 정리하여 로컬 스모크 테스트 시 참고 경로로 사용할 수 있도록 한다
@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

랭킹 시스템을 기존 일일 실시간 기반(Redis ZSET)에서 주간·월간 집계 기반(배치 작업으로 생성된 Materialized View)으로 확장했다. 컨트롤러에 period 파라미터를 추가하고, 기간에 따라 서로 다른 데이터 소스와 조회 경로를 라우팅하도록 서비스 계층을 재구성했다. 스트리머에서 일일 메트릭을 수집하고, 배치 모듈에서 주간·월간 집계를 수행하여 MV 테이블에 저장하는 완전한 파이프라인을 구축했다.

Changes

Cohort / File(s) Summary
HTTP 테스트 요청
.http/ranking/getRankings.http
기간 파라미터 구조로 개선되어 DAILY/WEEKLY/MONTHLY 요청 케이스를 체계적으로 관리하고, 경계 값(주 경계, 윤년 2월29일 등)을 포함한 테스트 커버리지 추가.
API 계층 - 기간 인식 확장
RankingV1Controller.kt, RankingFacade.kt
컨트롤러에 period: RankingPeriod 파라미터 추가(기본값 DAILY)하고, Facade 메서드 시그니처 변경으로 기간 정보 전달.
도메인 모델 - 기간 열거형 및 저장소
RankingPeriod.kt, PeriodicRankingRepository.kt
DAILY/WEEKLY/MONTHLY 상수와 주간/월간 랭킹 조회 계약(인터페이스) 추가로 기간별 데이터 소스 추상화.
서비스 계층 - 라우팅 로직
RankingService.kt
getTopRankings(period, date, ...) 메서드가 기간에 따라 Redis(DAILY) 또는 MV 저장소(WEEKLY/MONTHLY)로 라우팅하도록 재구성.
인프라 - 주간 랭킹 조회
WeeklyProductRankModel.kt, WeeklyProductRankJpaRepository.kt, PeriodicRankingRepositoryImpl.kt
ISO 주 기준 MV 테이블 매핑 및 Spring Data JPA 저장소, 구현체로 주간 랭킹 조회 경로 완성.
인프라 - 월간 랭킹 조회
MonthlyProductRankModel.kt, MonthlyProductRankJpaRepository.kt
yyyy-MM 형식의 월간 MV 테이블 매핑 및 저장소로 월간 랭킹 조회 경로 완성.
배치 - 주간 집계 작업
WeeklyRankingAggregationJobConfig.kt, WeeklyRankingProcessor.kt, WeeklyRankingPurgeTasklet.kt, WeeklyAggregationRow.kt
daily 메트릭을 ISO 주 기준으로 집계하여 MV 테이블에 적재하는 Spring Batch Job 구현.
배치 - 월간 집계 작업
MonthlyRankingAggregationJobConfig.kt, MonthlyRankingProcessor.kt, MonthlyRankingPurgeTasklet.kt, MonthlyAggregationRow.kt
daily 메트릭을 yyyy-MM 기준으로 집계하여 MV 테이블에 적재하는 Spring Batch Job 구현.
배치 - 기간 추상화
WeeklyPeriod.kt, MonthlyPeriod.kt, BatchRankingScorePolicy.kt
주간/월간 기간 계산과 가중치 정책 재사용으로 배치 로직 일관성 유지.
스트리머 - 일일 메트릭 수집
ProductMetricsDailyModel.kt, ProductMetricsDailyJpaRepository.kt, ProductMetricsEventHandler.kt
이벤트 기반 일일 메트릭(좋아요, 조회, 판매) 집계로 배치 입력 데이터 공급.
단위/통합 테스트
RankingFacadeTest.kt, RankingServiceTest.kt, RankingV1ControllerTest.kt, PeriodicRankingRepositoryImplTest.kt, 배치 테스트들
기간별 라우팅, 저장소 위임, 배치 처리 흐름을 검증하는 테스트 추가.
배치 E2E 테스트
WeeklyRankingAggregationJobE2ETest.kt, MonthlyRankingAggregationJobE2ETest.kt
Testcontainers MySQL 기반 전체 배치 파이프라인 검증(데이터 순서, 경계값, 멱등성).
기간 계산 테스트
WeeklyPeriodTest.kt, MonthlyPeriodTest.kt
ISO 주 정규화, 월간 범위 계산 정확성 검증(월 경계, 윤년 처리).
문서
docs/round10/quest/collect_stack_zip, docs/round10/quest/round_10_quest
배치 처리 개념, Materialized View 전략, 운영 고려사항 설명 및 구현 요구사항 정의.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Controller as RankingV1Controller
    participant Facade as RankingFacade
    participant Service as RankingService
    participant RedisRepo as RankingRepository<br/>(Redis ZSET)
    participant MVRepo as PeriodicRankingRepository<br/>(MV Tables)
    
    rect rgba(100, 150, 200, 0.5)
        Note over Client,MVRepo: Daily Rankings Flow
        Client->>Controller: GET /api/v1/rankings<br/>period=DAILY, date=20260413
        Controller->>Facade: getRankings(DAILY, date, page, size)
        Facade->>Service: getTopRankings(DAILY, date, page, size)
        Service->>RedisRepo: getDailyTopRankings(date, page, size)
        RedisRepo-->>Service: List<RankedProduct>
        Service-->>Facade: RankingPage
        Facade-->>Controller: RankingPageInfo
        Controller-->>Client: 200 OK
    end
    
    rect rgba(150, 100, 200, 0.5)
        Note over Client,MVRepo: Weekly Rankings Flow
        Client->>Controller: GET /api/v1/rankings<br/>period=WEEKLY, date=20260415
        Controller->>Facade: getRankings(WEEKLY, date, page, size)
        Facade->>Service: getTopRankings(WEEKLY, date, page, size)
        Service->>Service: 계산: ISO 주 Monday
        Service->>MVRepo: findTopWeekly(periodStart, offset, limit)
        MVRepo->>MVRepo: WeeklyProductRankJpaRepository 위임
        MVRepo-->>Service: List<RankedProduct>
        Service-->>Facade: RankingPage
        Facade-->>Controller: RankingPageInfo
        Controller-->>Client: 200 OK
    end
    
    rect rgba(200, 150, 100, 0.5)
        Note over Client,MVRepo: Monthly Rankings Flow
        Client->>Controller: GET /api/v1/rankings<br/>period=MONTHLY, date=20260415
        Controller->>Facade: getRankings(MONTHLY, date, page, size)
        Facade->>Service: getTopRankings(MONTHLY, date, page, size)
        Service->>Service: 계산: yyyy-MM 포맷
        Service->>MVRepo: findTopMonthly(yearMonthVal, offset, limit)
        MVRepo->>MVRepo: MonthlyProductRankJpaRepository 위임
        MVRepo-->>Service: List<RankedProduct>
        Service-->>Facade: RankingPage
        Facade-->>Controller: RankingPageInfo
        Controller-->>Client: 200 OK
    end
Loading
sequenceDiagram
    actor Streamer as Event Stream
    participant EventHandler as ProductMetricsEventHandler
    participant MetricsRepo as ProductMetricsDailyJpaRepository
    actor BatchScheduler
    participant BatchJob as WeeklyRankingAggregationJob<br/>MonthlyRankingAggregationJob
    participant Reader as JdbcCursorItemReader
    participant Processor as Processor<br/>(Weekly/Monthly)
    participant Writer as JpaItemWriter
    participant MVTable as MV Tables<br/>(weekly/monthly)
    
    rect rgba(100, 200, 150, 0.5)
        Note over Streamer,MetricsRepo: Streamer Phase - Daily Metrics Collection
        Streamer->>EventHandler: handle(CatalogEventMessage)
        EventHandler->>MetricsRepo: upsert(productId, metricDate, deltas)
        MetricsRepo-->>EventHandler: 성공
    end
    
    rect rgba(150, 150, 200, 0.5)
        Note over BatchScheduler,MVTable: Batch Phase - Weekly Aggregation
        BatchScheduler->>BatchJob: launch(requestDate)
        BatchJob->>BatchJob: Purge Step: 기존 주간 데이터 삭제
        BatchJob->>Reader: 주간 범위 daily metrics 읽기
        Reader-->>BatchJob: ProductMetricsDaily 리스트
        BatchJob->>Processor: process(row) 순회
        Processor->>Processor: rank position 부여, score 계산
        Processor-->>BatchJob: WeeklyProductRankModel
        BatchJob->>Writer: 배치 쓰기
        Writer->>MVTable: 주간 MV 테이블 적재
    end
    
    rect rgba(200, 180, 100, 0.5)
        Note over BatchScheduler,MVTable: Batch Phase - Monthly Aggregation
        BatchScheduler->>BatchJob: launch(requestDate)
        BatchJob->>BatchJob: Purge Step: 기존 월간 데이터 삭제
        BatchJob->>Reader: 월간 범위 daily metrics 읽기
        Reader-->>BatchJob: ProductMetricsDaily 리스트
        BatchJob->>Processor: process(row) 순회
        Processor->>Processor: rank position 부여, score 계산
        Processor-->>BatchJob: MonthlyProductRankModel
        BatchJob->>Writer: 배치 쓰기
        Writer->>MVTable: 월간 MV 테이블 적재
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

판단 근거:

  • 변경 범위: 3개 모듈(commerce-api, commerce-batch, commerce-streamer)에 걸쳐 27개 파일 신규 추가, 13개 파일 수정
  • 로직 밀도: 기간별 라우팅, 배치 Job 설정, 기간 정규화(ISO 주, yyyy-MM), 집계 스코어 계산 등 여러 도메인 로직
  • 변경 다양성: 저장소 인터페이스, JPA 엔티티, Spring Batch 설정, 배치 처리기, 기간 계산 로직, E2E 테스트 등 이질적 변경
  • 상호작용 복잡도: 일일(Redis) → 주간/월간(배치+MV) 경로 분기, 기간 정규화 로직 검증, 배치 멱등성 및 경계값 처리 필요

운영 관점 주요 검토 항목:

  1. 배치 작업 재실행 시 기존 데이터 완전 삭제(Purge) → 부분 실패 시 복구 불가능 위험. 재실행 정책 및 감시 메커니즘 확인 필요.
  2. ISO 주 정규화 로직의 시간대 독립성 검증. 자정 경계 테스트 케이스 부족 가능성.
  3. 월간 집계의 leap year(2월 29일) 처리 정확성. 연도 경계(12월→1월) 처리 검증.
  4. MV 저장소 쿼리 성능(Top-N 제한, 인덱스 활용) 검증 부재. 페이지네이션 offset/limit 계산 로직 정확성.
  5. 배치 청크 크기, 동시성, 타임아웃 설정 등 운영 파라미터 기본값 검증.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning 제목이 팀명과 라운드 명시만 있고 실제 변경 내용을 구체적으로 설명하지 않아 매우 모호하다. 제목을 'Spring Batch 기반 주·월간 랭킹 집계 및 Ranking API 기간 확장' 또는 '[Round10] Materialized View 기반 주·월간 랭킹 배치 추가' 형태로 변경하여 핵심 변경사항을 명확히 한다.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed 저장소 템플릿의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 완벽하게 제공했으며, 추가로 Review Points와 Checklist 달성 근거까지 상세히 기재했다.

✏️ 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.

@UjiinEatingTangerines UjiinEatingTangerines changed the title Round10 [7팀 - 김현우] Round10 제출 Apr 17, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Caution

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

⚠️ Outside diff range comments (2)
apps/commerce-streamer/src/main/kotlin/com/loopers/application/metrics/ProductMetricsEventHandler.kt (1)

30-51: ⚠️ Potential issue | 🟠 Major

isStale 가드가 일별 집계까지 차단하는 의미를 점검할 것을 권고한다.

운영 관점에서 ProductMetricsModel.isStale(event) 은 누적 지표의 역전을 막기 위한 가드인데, 이 분기에서 return 하면 같은 트랜잭션의 upsertDailyMetrics 까지 스킵된다.

  • 누적 지표(product_metrics)는 "최종 상태" 라서 stale 이벤트를 무시하는 것이 맞다.
  • 그러나 일별 집계(product_metrics_daily)는 "해당 날짜에 발생한 이벤트의 합" 이다. 카프카 재처리/파티션 간 순서 역전으로 과거 시점 이벤트가 늦게 도착하면, 그 날짜의 일별 수치가 영구 누락되어 주·월간 MV 까지 오차가 전파된다.

두 지표의 의도가 다르므로, 일별 upsert 는 isStale 가드와 독립적으로 실행하는 쪽이 안전하다. registerEvent 만 통과하면 멱등성은 보장된다.

🛠️ 제안 수정안
     if (metrics.isStale(event)) {
-        return
+        // 누적 지표만 스킵하고 일별 집계·랭킹 스코어는 계속 진행
+        upsertDailyMetrics(event)
+        val scoreIncrement = rankingScorePolicy.calculateIncrement(event.eventType, event.delta)
+        val key = RankingKeyGenerator.dailyKey(event.occurredAt.toLocalDate())
+        rankingRepository.incrementScore(key, event.productId, scoreIncrement)
+        return
     }

     metrics.apply(event)
     productMetricsJpaRepository.save(metrics)

     upsertDailyMetrics(event)

(또는 isStale 가드를 호출 부위에서 분리하여 각 적재 경로가 자기 책임만 갖도록 리팩터링.)

추가 테스트 권고:

  • 과거 시점 occurredAt 이벤트가 isStale 로 판정되어도 product_metrics_daily 가 해당 날짜에 정확히 누적되는지.
  • 동일 이벤트 재처리(중복 eventId) 시 product_metrics_daily 가 두 번 누적되지 않는지.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/application/metrics/ProductMetricsEventHandler.kt`
around lines 30 - 51, The isStale check currently prevents both cumulative and
daily upserts; change ProductMetricsEventHandler.handle so that after passing
registerEvent(event.eventId) you always call upsertDailyMetrics(event) (so daily
aggregates are updated even for stale/corrected past events), and only then
perform the isStale(event) guard to decide whether to apply and save to the
cumulative ProductMetricsModel and run ranking updates; keep registerEvent as
the idempotency gate and leave productMetricsJpaRepository.findByProductId/
ProductMetricsModel.apply/save and rankingRepository.incrementScore logic
unchanged.
apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.kt (1)

71-82: ⚠️ Potential issue | 🟡 Minor

defaultsToPage1Size20 테스트가 실제로 기본값을 검증하지 못한다.

현재 구현은 controller.getRankings(period = RankingPeriod.DAILY, date = "20260410", size = 20, page = 1) 처럼 모든 파라미터를 명시적으로 전달하고 있어, 컨트롤러 시그니처의 기본값(@RequestParam(defaultValue = ...) 혹은 Kotlin 기본 인자)이 빠지거나 바뀌어도 본 테스트는 그대로 통과한다. 즉 테스트 이름과 실제 검증 범위가 불일치하므로, 운영 관점에서 API 기본값이 조용히 회귀할 위험이 있다. 컨트롤러 단위 테스트가 Bean Validation/바인딩까지 커버하기 어렵다면 MockMvc 기반 통합 테스트로 보강하거나, 본 테스트 케이스는 의미를 반영해 이름/검증을 재조정하는 편이 좋다.

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

In
`@apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.kt`
around lines 71 - 82, The test defaultsToPage1Size20 is asserting default
pagination but calls controller.getRankings with explicit page and size, so
change the test to exercise the controller's defaulting: either call
controller.getRankings omitting the page and size arguments (so Kotlin default
params or `@RequestParam` defaults are used) and update the mocked interaction on
rankingFacade.getRankings to expect page=1,size=20, or convert this unit test
into a MockMvc-based test that performs an HTTP GET to the rankings endpoint
without page and size query params and verifies the response.data.page==1 and
response.data.size==20; locate the test method defaultsToPage1Size20, the
controller.getRankings call, and the rankingFacade.getRankings mock to update
them accordingly.
♻️ Duplicate comments (1)
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt (1)

84-108: ⚠️ Potential issue | 🟠 Major

Weekly 와 동일한 Step 재실행 데이터 오염 위험이 존재한다.

WeeklyRankingAggregationJobConfig 에서 지적한 "purge allowStartIfComplete(true) + cursor reader saveState(true) 기본값 + aggregate step 기본값" 조합 이슈가 여기서도 동일하게 재현된다. 동일하게 reader 에 .saveState(false) + aggregate step 에 allowStartIfComplete(true) 적용을 권고한다. 추가 테스트 역시 MonthlyRankingAggregationJobE2ETest 에 동일 JobInstance 의 restart 회귀 테스트를 포함하는 것을 권고한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt`
around lines 84 - 108, The aggregate job repeats the same restart/state
corruption risk as Weekly: set the JdbcCursorItemReader used in
monthlyRankingAggregateStep (monthlyRankingItemReader) to .saveState(false) and
mark the aggregate step built in monthlyRankingAggregateStep with
.allowStartIfComplete(true) (in addition to the existing purge step setting in
monthlyRankingPurgeStep); update the reader configuration where
monthlyRankingItemReader is defined and add a restart regression test in
MonthlyRankingAggregationJobE2ETest that restarts the same JobInstance to verify
correct behavior.
🧹 Nitpick comments (25)
apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/MonthlyProductRankJpaRepository.kt (1)

15-17: @Modifying 의 영속성 컨텍스트 동기화 옵션 검토가 필요하다.

벌크 DELETE 는 영속성 컨텍스트를 우회하므로, 동일 트랜잭션 내에서 이후 MonthlyProductRankModel 을 저장/조회하는 흐름이 추가될 경우 stale 엔티티로 인한 정합성 이슈가 발생할 수 있다. 현재는 purge Tasklet 단독 호출이므로 문제 없으나, 향후 확장 대비로 clearAutomatically = true 명시를 권장한다. 반환값이 Int 이므로 0 반환 시 로그/알람 수준도 함께 운영 문서에 고정해두면 재실행 멱등성 검증이 쉬워진다. 추가 테스트로는 "이미 purge 된 월 재실행 시 0 반환 & 이후 Insert 정상 동작" 케이스를 배치 E2E 에 포함시키는 것을 권장한다.

♻️ 제안 수정
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true)
     `@Query`("DELETE FROM MonthlyProductRankModel m WHERE m.yearMonthVal = :yearMonthVal")
     fun deleteByYearMonthVal(`@Param`("yearMonthVal") yearMonthVal: String): Int

As per coding guidelines("영속성 컨텍스트 오염 가능성을 점검한다").

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/MonthlyProductRankJpaRepository.kt`
around lines 15 - 17, The bulk DELETE method deleteByYearMonthVal currently uses
`@Modifying` but does not specify persistence-context sync; update the `@Modifying`
on MonthlyProductRankJpaRepository::deleteByYearMonthVal to include
clearAutomatically = true so the EntityManager clears stale
MonthlyProductRankModel instances after the bulk operation, and ensure callers
handle the Int return (0-case) with a logged/alarmed path documented for
idempotency checks and add an E2E test that re-runs purge for an already-purged
month verifying 0 is returned and subsequent inserts work.
apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/MonthlyPeriodTest.kt (1)

9-89: 경계값 커버리지가 충분하다.

윤년/평년 2월, 1일·말일·12월 스필오버, 0-padding 까지 포함되어 운영상 회귀 방지 관점에서 충분하다. 추가로 고려할 만한 포인트는 다음과 같으나, 필수는 아니다.

  • LocalDate.MIN/LocalDate.MAX 같은 극단값은 실제 requestDate 범위를 벗어나므로 불필요하다.
  • 호출부(MonthlyRankingPurgeTaskletTest)가 BASIC_ISO_DATE 파싱까지 검증한다면 해당 파일에서 KST 경계 시나리오(예: 202601012026-01)를 1건 더 고정해두면 시간대 회귀를 잡기 쉽다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/MonthlyPeriodTest.kt`
around lines 9 - 89, Tests already cover key boundaries; optionally add one
KST-boundary case: add a test that parses a BASIC_ISO_DATE string like
"20260101" (use DateTimeFormatter.BASIC_ISO_DATE and Seoul/KST zone handling
where relevant) and pass the resulting LocalDate into MonthlyPeriod.of, then
assert period.yearMonthVal == "2026-01"; if parsing is already exercised in
MonthlyRankingPurgeTaskletTest, add the check there instead to ensure the
BASIC_ISO_DATE→LocalDate→MonthlyPeriod conversion handles KST boundary
correctly.
apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/WeeklyProductRankJpaRepository.kt (1)

16-18: @ModifyingclearAutomatically/flushAutomatically 누락 시 영속성 컨텍스트 오염 가능성이 있다.

벌크 delete 는 1차 캐시를 우회하기 때문에, 동일 트랜잭션 내에서 이후 WeeklyProductRankModel 을 조회/flush 하면 삭제된 엔티티가 캐시에 남아 재출현하거나 write/read 불일치가 발생할 수 있다. 운영 관점에서는 purge Tasklet 이후 동일 트랜잭션에서 Writer 가 insert 하는 구조라 현재는 문제가 없어 보이지만, 향후 Tasklet/Step 전이가 바뀌면 재현이 어려운 멱등성 결함으로 이어질 수 있다.

♻️ 제안 수정
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`("DELETE FROM WeeklyProductRankModel w WHERE w.periodStart = :periodStart")
     fun deleteByPeriodStart(`@Param`("periodStart") periodStart: LocalDate): Int

추가로 purge → aggregate 재실행 시나리오(동일 requestDate 2회 실행)에 대한 멱등성 통합 테스트가 MonthlyProductRankJpaRepository 와 동일 기준으로 양쪽에 모두 존재하는지 확인한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/WeeklyProductRankJpaRepository.kt`
around lines 16 - 18, The bulk delete in WeeklyProductRankJpaRepository's
deleteByPeriodStart bypasses the persistence context; update the `@Modifying`
annotation on the deleteByPeriodStart(method) to include clearAutomatically =
true and flushAutomatically = true so the first-level cache is flushed/cleared
after the query to avoid stale entities, and add an idempotency integration test
(matching the MonthlyProductRankJpaRepository approach) that runs purge →
aggregate twice for the same requestDate to verify no reappearing rows or
write/read inconsistencies.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyAggregationRow.kt (1)

10-16: 음수 집계값 방어가 필요한지 확인한다.

totalLikes/totalViews/totalSalesLong 으로 받는데, 일일 스냅샷의 delta 누적 특성상 이론적으로는 0 이상이어야 한다. Reader SQL 이 SUM(...)COALESCE(..., 0) 처리만 하고 필터링을 하지 않는다면, 데이터 오염 시 음수가 넘어와 Processor 의 score 계산이 왜곡되고 MV 순위가 뒤집힐 수 있다. 운영 관점에서 경계값 방어를 위해 (1) Reader SQL 에서 GREATEST(..., 0) 또는 HAVING 필터, 혹은 (2) Processor 에서 음수 감지 시 경고 로그 + 0 으로 클램핑 중 한 가지 적용을 고려한다. 경계값 테스트(음수/0/최대 누적)도 Processor 단위 테스트에 추가한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyAggregationRow.kt`
around lines 10 - 16, The WeeklyAggregationRow fields
totalLikes/totalViews/totalSales should be defended against negative values;
update the consumer/Processor that consumes WeeklyAggregationRow (or the place
constructing WeeklyAggregationRow) to detect any negative
totalLikes/totalViews/totalSales, log a warning including productId and
offending fields, and clamp those fields to 0 before any score calculation (or
alternatively add a SQL-side guard such as GREATEST(...,0) in the Reader). Also
add unit tests for the Processor to cover negative, zero, and large accumulation
cases to ensure score calculation is stable.
apps/commerce-api/src/test/kotlin/com/loopers/application/ranking/RankingFacadeTest.kt (1)

33-100: WEEKLY/MONTHLY 분기 테스트가 누락되었다.

본 PR의 핵심 변경점은 period 파라미터 도입과 그에 따른 데이터 소스 분기(Redis DAILY vs MV WEEKLY/MONTHLY)다. 그러나 본 테스트는 세 케이스 모두 RankingPeriod.DAILY 만 검증하여 실제 라우팅 결함이 들어와도 잡히지 않는다. 운영 관점에서 잘못된 period 전달 시 사용자가 엉뚱한 기간의 랭킹을 보게 되므로, 최소한 WEEKLY/MONTHLY 각각에 대해 rankingService.getTopRankings(period, ...) 가 전달받은 period 로 호출되는지 검증하는 테스트 추가를 권장한다.

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

In
`@apps/commerce-api/src/test/kotlin/com/loopers/application/ranking/RankingFacadeTest.kt`
around lines 33 - 100, The tests only assert behavior for RankingPeriod.DAILY;
add tests for WEEKLY and MONTHLY to ensure the period is forwarded to
rankingService.getTopRankings and proper branch behavior occurs: create two new
test methods (e.g., returnsWeeklyRankings and returnsMonthlyRankings) that call
rankingFacade.getRankings with RankingPeriod.WEEKLY and RankingPeriod.MONTHLY,
mock rankingService.getTopRankings(period, ...) to expect the respective period
and return a simple RankingPage, mock productService.findAllByIds and
brandService.findAllByIds similarly, then assert that rankingService was called
with the same period and the returned result content/totalElements match;
reference rankingFacade.getRankings, rankingService.getTopRankings, and
RankingPeriod.WEEKLY/RankingPeriod.MONTHLY when adding the tests.
apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/BatchRankingScorePolicy.kt (1)

15-20: streamer 의 RankingScorePolicy 와 이원화된 가중치 소스는 장기적으로 드리프트 위험이 있다.

주석에 명시된 대로 RankingKeyGenerator 중복 정의 선례를 따른 의도된 중복이나, 운영 관점에서 streamer 와 batch 의 application.yml 이 서로 다르게 바뀌면 실시간 ZSET 점수와 MV 점수 의미가 어긋나 사용자가 보는 랭킹이 period 전환 시 급격히 달라질 수 있다. 단기적으로는 (1) 두 모듈이 공통 application-ranking.yml 또는 공통 모듈의 properties 를 import 하도록 묶어두고, (2) 두 가중치 합이 의도한 스케일(예: 1.0) 에서 벗어나면 기동 실패시키는 @PostConstruct 검증을 추가하는 것을 권장한다. 가중치 불일치 회귀를 잡는 통합 테스트도 함께 도입한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/BatchRankingScorePolicy.kt`
around lines 15 - 20, BatchRankingScorePolicy currently defines local weights
independent from streamer which risks drift; fix by moving the weights into a
shared configuration (e.g., common application-ranking.yml or a shared
properties/config class) and have BatchRankingScorePolicy inject that shared
config instead of standalone `@Value` fields; additionally add a `@PostConstruct`
validation method in BatchRankingScorePolicy that checks (viewWeight +
likeWeight + orderWeight) is within an acceptable tolerance of the intended
scale (e.g., 1.0) and throw IllegalStateException to fail startup if it isn’t;
reference the BatchRankingScorePolicy class and the existing RankingKeyGenerator
duplication pattern when consolidating properties so both streamer and batch
import the same source.
apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerTest.kt (1)

24-33: 일일 upsert 동작이 검증되지 않는다.

productMetricsDailyJpaRepositoryrelaxed = true 로만 주입하고 실제 호출 검증(verify)이 빠져 있어, 동기 upsert 경로가 호출되는지/호출되지 않는지(멱등성 경로 포함)를 본 테스트만으로는 회귀 감지할 수 없다. 운영 관점에서 일일 스냅샷이 누락/중복되면 주·월간 MV 집계가 틀어질 위험이 크므로, 본 파일에서도 최소한 upsert 호출 횟수/stale·중복 가드 시 미호출을 검증하거나, 별도 ProductMetricsEventHandlerDailyTest 가 해당 케이스를 모두 커버하고 있음을 명확히 해두는 것이 안전하다.

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

In
`@apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerTest.kt`
around lines 24 - 33, The test currently injects
productMetricsDailyJpaRepository as a relaxed mock but never verifies calls, so
add explicit verify assertions (using mockk.verify) on
productMetricsDailyJpaRepository.upsert(...) to assert the daily upsert path is
invoked exactly when expected and not invoked for stale/duplicate events; update
the existing ProductMetricsEventHandler test that exercises
ProductMetricsEventHandler.handle(...) (or the handler method used) to assert
positive case call count and negative case zero calls, or create a dedicated
ProductMetricsEventHandlerDailyTest that covers new-day upsert, idempotent
duplicate, and stale-guard scenarios referencing
productMetricsDailyJpaRepository and the
rankingScorePolicy/productMetricsEventHandler instances.
apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyPeriod.kt (1)

12-21: 불변 값 객체로서 구현은 타당하나, 시간대 정규화 전제를 문서화할 필요가 있다.

date 가 이미 KST 기준 LocalDate 임을 전제로 Monday 정렬을 수행하는데, 호출측에서 UTC LocalDate 를 넘기면 주 경계가 하루 밀릴 수 있다. streamer 의 upsert 가 Asia/Seoul 로 정규화된다는 PR 의 전제에 의존하므로, 본 VO 가 "KST 기준 LocalDate 만 허용" 임을 KDoc 에 명시하거나, 팩토리 시그니처를 of(date: LocalDate, zone: ZoneId = KST) 형태로 방어적으로 바꾸는 것을 고려한다.

또한 data class 사용은 VO 로서 적절하지만, 향후 프레임워크 리플렉션(JPA/Jackson 등) 대상이 될 가능성이 생기면 일반 class + 명시적 equals/hashCode 로 전환 기준을 재검토한다. As per coding guidelines, "JPA 엔티티에 data class 사용은 원칙적으로 지양" — 현 시점에서는 엔티티가 아니므로 적용 대상 아님을 확인한다.

경계값 테스트에 (1) UTC 자정 직후 KST 날짜 유입 케이스, (2) ISO 주 54주 경계(예: 2026-W53 → 2027-W01) 가 포함되어 있는지 함께 점검한다.

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

In `@apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyPeriod.kt`
around lines 12 - 21, WeeklyPeriod.of assumes the incoming LocalDate is already
normalized to KST which can shift week boundaries if callers pass UTC dates;
either add KDoc to WeeklyPeriod/of explicitly stating "accepts KST-normalized
LocalDate only" or make the factory defensive by changing the signature to
accept a ZoneId (e.g., of(date: LocalDate, zone: ZoneId =
ZoneId.of("Asia/Seoul"))) and compute the ISO-week Monday using the provided
zone, and add unit tests for the UTC-midnight→KST offset case and ISO week-year
boundary cases (e.g., 2026-W53→2027-W01) to validate behavior.
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt (1)

17-19: @Transactional(readOnly=true) 범위가 과할 수 있으나, 실제로는 대부분 DB 커넥션이 필요하다.

RankingPeriod.DAILY 경로는 rankingRepository.getTopN/getTotalCount()로 Redis ZSET 조회만 수행하므로 DB 트랜잭션이 필요 없다. 그러나 RankingFacade.getRankings()rankingPage.entries가 비어있지 않으면 productService.findAllByIds()brandService.findAllByIds()를 호출하므로, 실무에서는 대부분의 DAILY 요청도 DB 커넥션을 필요로 한다.

커넥션 풀 사용률 개선을 원한다면 다음 중 한 가지를 고려한다.

  • RankingService의 개별 메서드(DAILY/WEEKLY/MONTHLY)에만 필요한 @Transactional(readOnly=true)를 적용하되, productService.findAllByIds() 호출 지점부터는 별도의 트랜잭션으로 분리한다.
  • 혹은 RankingFacade에서 period에 따라 분기한 뒤, entries 조회와 데이터 보강을 분리하여 DAILY 경로는 트랜잭션 없이 시작하고, MV 조회가 필요한 경우만 트랜잭션을 명시적으로 적용한다.

DAILY/WEEKLY/MONTHLY 각각에 대해 실제로 DB 커넥션이 획득/해제되는 패턴을 검증하는 테스트를 추가한다.

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt`
around lines 17 - 19, 현재 RankingFacade.getRankings에 클래스/메서드 수준으로
`@Transactional`(readOnly = true)를 적용해 대부분의 요청에서 불필요하게 DB 커넥션이 잡히고 있으므로, 트랜잭션 범위를
좁히도록 변경하세요: RankingService의 기간별 메서드(DAILY/WEEKLY/MONTHLY) 각각에만 readOnly 트랜잭션을
적용해 Redis 조회만 하는 경로는 트랜잭션 없이 동작하게 하거나, RankingFacade.getRankings에서 period로 분기한 뒤
rankingService.getTopRankings로 entries를 가져오는 부분은 트랜잭션 없이 수행하고 entries가 비어있지 않아
productService.findAllByIds 및 brandService.findAllByIds 호출이 필요한 경우에만 별도의
`@Transactional`(readOnly = true) 블록(또는 트랜잭션용 메서드)으로 감싸 DB 커넥션을 얻도록 변경하고,
DAILY/WEEKLY/MONTHLY별로 실제로 DB 커넥션이 획득/해제되는지 검증하는 테스트를 추가하세요.
apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessorTest.kt (2)

9-26: !! 사용 근거를 주석 또는 헬퍼로 정리할 것을 권고한다.

result!!.rankPosition 패턴이 테스트 전반에서 반복된다. WeeklyRankingProcessor.process 가 계약상 non-null 을 반환한다면 시그니처를 WeeklyProductRankModel(non-null)로 선언해 호출부의 !! 자체를 제거하거나, 테스트 쪽에서 assertThat(result).isNotNullrequireNotNull(result) 로 표현하는 것이 가독성 측면에서 명확하다. 현재 수준에서 기능 문제는 없다.

As per coding guidelines: "null-safety를 최우선으로 점검하고, '!!'는 불가피한 경우에만 허용하며 근거를 요구한다."

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

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessorTest.kt`
around lines 9 - 26, 테스트에서 반복되는 `result!!` 사용을 제거하세요: 두 가지 중 하나를 선택해 적용하면 됩니다 —
(1) 만약 `WeeklyRankingProcessor.process` 계약상 null을 반환하지 않으면 함수 시그니처를 non-nullable
타입(예: `WeeklyProductRankModel`)으로 변경하여 호출부의 `!!`를 삭제하거나, (2) 테스트에선
`assertThat(result).isNotNull` 바로 다음에 `requireNotNull(result)`를 호출해 이후 `result`를
non-null로 취급하도록 명시적으로 변환하세요; 관련 위치는 테스트의 `firstCallAssignsRankOne` 내
`processor.process(row)` 호출과 결과 검증 코드, 그리고 `WeeklyRankingProcessor.process` 선언부를
수정하면 됩니다.

42-52: ISO 주 경계 케이스 보강을 권고한다.

현재는 화·수·금요일만 검증한다. ISO 주 기준의 양 끝(월요일, 일요일)과 연도 경계(예: 2025-12-29 월 → 2025-W01 주차 시작, 2026-01-04 일 → 같은 주의 일요일) 테스트를 추가하면 WeeklyPeriod.of 로케일 의존성 회귀를 더 확실히 막을 수 있다.

🧪 추가 테스트 예시
`@Test`
fun mondayInputKeepsSameWeekBoundary() {
    val processor = WeeklyRankingProcessor(requestDate = "20260413") // Mon
    val r = processor.process(row(1L, 1.0))!!
    assertThat(r.periodStart).isEqualTo(java.time.LocalDate.of(2026, 4, 13))
    assertThat(r.periodEnd).isEqualTo(java.time.LocalDate.of(2026, 4, 19))
}

`@Test`
fun sundayInputKeepsSameWeekBoundary() {
    val processor = WeeklyRankingProcessor(requestDate = "20260419") // Sun
    val r = processor.process(row(1L, 1.0))!!
    assertThat(r.periodStart).isEqualTo(java.time.LocalDate.of(2026, 4, 13))
    assertThat(r.periodEnd).isEqualTo(java.time.LocalDate.of(2026, 4, 19))
}

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

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

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessorTest.kt`
around lines 42 - 52, Add boundary unit tests for WeeklyRankingProcessor to
cover ISO-week edge cases: create tests that call
WeeklyRankingProcessor(requestDate = "...") and processor.process(row(...)) for
a Monday input ("20260413") and a Sunday input ("20260419") asserting
periodStart == 2026-04-13 and periodEnd == 2026-04-19; also add year-boundary
cases such as requestDate "20251229" (expect week start 2025-12-29 / end
2026-01-04) and "20260104" (same week) to ensure WeeklyPeriod.of /
WeeklyRankingProcessor logic is locale-independent and handles ISO week
transitions correctly.
apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyProductRankModel.kt (1)

21-27: 중복 인덱스 제거 권고

uk_mpw_start_rank 유니크 제약이 (period_start, rank_position) 로 이미 B-Tree 인덱스를 자동 생성하는데, 같은 컬럼 조합으로 idx_mpw_start_rank 를 별도로 선언했다. MySQL/InnoDB 기준 동일한 구성의 보조 인덱스가 이중으로 유지되어 쓰기 시 불필요한 인덱스 갱신 비용이 발생하고 purge 배치 재실행 시 삭제 비용도 증가한다. MV 재적재가 주기적으로 반복되는 특성을 고려하면 운영상 낭비다.

♻️ 제안 수정
     name = "mv_product_rank_weekly",
-    indexes = [
-        Index(name = "idx_mpw_start_rank", columnList = "period_start, rank_position"),
-    ],
     uniqueConstraints = [
         UniqueConstraint(name = "uk_mpw_start_product", columnNames = ["period_start", "product_id"]),
         UniqueConstraint(name = "uk_mpw_start_rank", columnNames = ["period_start", "rank_position"]),
     ],

추가 테스트: MV 조회 경로(findByPeriodStartOrderByRankPositionAsc)가 유니크 인덱스만으로도 의도한 실행 계획(range scan on (period_start, rank_position))을 타는지 EXPLAIN 으로 확인하는 단계를 E2E 테스트 또는 수동 검증 체크리스트에 포함할 것을 제안한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyProductRankModel.kt`
around lines 21 - 27, The DDL declares a duplicate index: the unique constraint
uk_mpw_start_rank (columns period_start, rank_position) already creates a B-Tree
index, so remove the separately declared idx_mpw_start_rank to avoid redundant
write/delete overhead; edit WeeklyProductRankModel.kt to delete the Index(name =
"idx_mpw_start_rank", columnList = "period_start, rank_position") entry and keep
the UniqueConstraint(name = "uk_mpw_start_rank", columnNames = ["period_start",
"rank_position"]); after the change, verify the MV query path
findByPeriodStartOrderByRankPositionAsc still uses the intended range scan
(EXPLAIN) and add that check to E2E/manual validation.
apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTaskletTest.kt (1)

14-53: 실패 경로 테스트 누락

현재 세 케이스 모두 deleteByPeriodStart0 을 반환하는 정상 경로만 검증한다. 운영에서 아래 경우가 실제로 발생하거나 회귀 가능성이 있다.

  • requestDate 파라미터가 누락되거나 BASIC_ISO_DATE 형식을 벗어난 문자열(예: "2026-04-17", 빈 문자열)로 주어졌을 때의 동작. 현재 구현은 LocalDate.parse 에서 DateTimeParseException 이 던져지는데, 이것이 Job 실패로 정상 전파되어야 운영자가 재실행 파라미터를 바로 교정할 수 있다.
  • 재실행(idempotent) 검증: 동일 requestDate 로 두 번 execute 호출해도 동일한 periodStart 로 각각 1회씩 삭제 호출이 일어남을 검증하는 테스트.

운영 관점에서 "잘못된 파라미터로 배치가 조용히 성공하는" 리스크를 차단하기 위해 아래 케이스 추가를 권장한다.

♻️ 제안 수정
+    `@DisplayName`("잘못된 형식의 requestDate 가 주어지면 DateTimeParseException 으로 실패한다")
+    `@Test`
+    fun invalidRequestDateFormatFails() {
+        val repository = mockk<WeeklyProductRankJpaRepository>(relaxed = true)
+        val tasklet = WeeklyRankingPurgeTasklet(repository, requestDate = "2026-04-17")
+
+        assertThatThrownBy {
+            tasklet.execute(mockk(relaxed = true), mockk<ChunkContext>(relaxed = true))
+        }.isInstanceOf(java.time.format.DateTimeParseException::class.java)
+        verify(exactly = 0) { repository.deleteByPeriodStart(any()) }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTaskletTest.kt`
around lines 14 - 53, Add failure-path and idempotency tests for
WeeklyRankingPurgeTasklet: add tests that call WeeklyRankingPurgeTasklet.execute
with invalid requestDate values (e.g., empty string, "2026-04-17") and assert
that LocalDate.parse throws DateTimeParseException (or that the exception
propagates from execute) instead of silently succeeding; and add a test that
calls execute twice with the same valid requestDate and verifies
repository.deleteByPeriodStart(LocalDate.of(...)) is invoked twice (once per
execute). Locate usages of WeeklyRankingPurgeTasklet, requestDate constructor
argument, execute(...) and repository.deleteByPeriodStart(...) to implement
these cases.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTasklet.kt (1)

35-45: 삭제 건수 0 인 경우 관찰성 보강 권고

현재는 deleted 건수를 info 로만 남긴다. 운영 관점에서 다음 두 시나리오를 구분하기 어렵다.

  • 첫 실행이라 원래 MV 에 해당 주가 없음 → deleted=0 이 정상
  • 스케줄 충돌·잘못된 requestDate 로 다른 주가 삭제되었거나, 재실행인데도 이전 row 가 남아있지 않음 → deleted=0 이 이상 신호

최소한 deleted > 0 인 경우(재실행 경로)와 deleted == 0 인 경우(신규 적재 경로)를 구분해 로그 레벨/메시지를 달리하거나, 메트릭(Micrometer counter) 으로 배출해 대시보드에서 추이를 관찰할 수 있도록 개선을 권장한다. 또한 jobParameters 가 강제 unique(예: run.id) 가 아닌 경우 Spring Batch 가 중복 실행을 거부할 수 있으므로 운영 런북에 --requestDate + run.id 파라미터 가이드를 명시해 두는 것이 안전하다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTasklet.kt`
around lines 35 - 45, The execute method in WeeklyRankingPurgeTasklet currently
always logs deleted rows at info level; change it to distinguish deleted == 0 vs
> 0 by: after calling
weeklyProductRankJpaRepository.deleteByPeriodStart(period.start) check deleted
and (a) if deleted > 0 log info with period/start/end/requestDate and increment
or record a Micrometer counter for successful purges, (b) if deleted == 0 log at
WARN (or INFO with a clear “no rows deleted” marker) and increment a separate
Micrometer counter for zero-deletes so dashboards can track the two cases; use
existing symbols WeeklyRankingPurgeTasklet.execute,
weeklyProductRankJpaRepository.deleteByPeriodStart, requestDate and WeeklyPeriod
to locate where to add the conditional logging/metrics. Ensure logs include
period.start, period.end and requestDate for observability.
apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/WeeklyPeriodTest.kt (1)

8-69: 경계 케이스 보강 권고

월/연 경계와 요일 정규화는 충분히 커버되었으나, 운영상 발생 빈도가 높은 두 가지 케이스가 누락되어 있다.

  • 윤년 경계: 2024-02-29(목) 같은 윤일 입력을 포함한 주 경계 검증(회귀 예방).
  • ISO 주 특성 상 주의 구간: ISO 주 번호 기준 1월 1일이 전년도 마지막 주에 포함되는 케이스(예: 2027-01-01(금)의 주 시작은 2026-12-28). 현재 spansYearBoundary 는 반대 방향(전년 말일 입력)만 검증한다.

PR 목적에 "시간 경계(윤년·주말) 테스트 포함" 이 명시되어 있으므로 위 두 케이스를 추가해 매칭할 것을 권장한다.

♻️ 제안 수정
+    `@DisplayName`("윤일(2024-02-29)을 입력하면 해당 ISO 주의 월·일요일이 반환된다")
+    `@Test`
+    fun leapDayInputReturnsIsoWeekBoundary() {
+        val leapDay = LocalDate.of(2024, 2, 29) // 목요일
+        val period = WeeklyPeriod.of(leapDay)
+        assertThat(period.start).isEqualTo(LocalDate.of(2024, 2, 26))
+        assertThat(period.end).isEqualTo(LocalDate.of(2024, 3, 3))
+    }
+
+    `@DisplayName`("연초 입력이 전년도 ISO 주에 속해도 올바른 월요일이 반환된다")
+    `@Test`
+    fun januaryFirstBelongsToPreviousYearIsoWeek() {
+        val jan1 = LocalDate.of(2027, 1, 1) // 금요일, ISO 주는 2026-12-28 시작
+        val period = WeeklyPeriod.of(jan1)
+        assertThat(period.start).isEqualTo(LocalDate.of(2026, 12, 28))
+        assertThat(period.end).isEqualTo(LocalDate.of(2027, 1, 3))
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/WeeklyPeriodTest.kt`
around lines 8 - 69, Add two unit tests to WeeklyPeriodTest: one for the
leap-day case and one for the ISO-year-boundary case. For the leap-day test
(e.g., LocalDate.of(2024, 2, 29)) call WeeklyPeriod.of(...) and assert
period.start == LocalDate.of(2024, 2, 26) and period.end == LocalDate.of(2024,
3, 3). For the ISO-year-boundary test (e.g., LocalDate.of(2027, 1, 1)) call
WeeklyPeriod.of(...) and assert period.start == LocalDate.of(2026, 12, 28) and
period.end == LocalDate.of(2027, 1, 3). Name the tests clearly (e.g.,
leapDayNormalizesWeek and isoYearBoundaryStartsPreviousYear) and follow existing
test style using `@DisplayName` and assertThat on WeeklyPeriod.of(...).
apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingPurgeTaskletTest.kt (1)

14-51: 실패 경로 테스트가 부재한다.

requestDatenull, 빈 문자열, 또는 BASIC_ISO_DATE 포맷이 아닌 경우(예: "2026-04-15", "20261315")의 동작은 운영 사고 시 가장 먼저 관심 대상이 되지만 현재 커버되어 있지 않다. MonthlyRankingPurgeTasklet.executeLocalDate.parse 에서 DateTimeParseException 을 그대로 흘려보내는데, 이를 명시적으로 검증하는 테스트를 1건 추가해 파서 변경이나 정규화 로직 추가로 인한 회귀를 막는 편이 좋다.

✅ 추가 테스트 예시
+    `@DisplayName`("requestDate 가 BASIC_ISO_DATE 포맷이 아니면 예외를 던지고 삭제하지 않는다")
+    `@Test`
+    fun invalidRequestDateThrows() {
+        val repository = mockk<MonthlyProductRankJpaRepository>(relaxed = true)
+        val tasklet = MonthlyRankingPurgeTasklet(repository, requestDate = "2026-04-15")
+
+        assertThatThrownBy {
+            tasklet.execute(mockk(relaxed = true), mockk<ChunkContext>(relaxed = true))
+        }.isInstanceOf(java.time.format.DateTimeParseException::class.java)
+
+        verify(exactly = 0) { repository.deleteByYearMonthVal(any()) }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingPurgeTaskletTest.kt`
around lines 14 - 51, Add a failing-path unit test in
MonthlyRankingPurgeTaskletTest that verifies MonthlyRankingPurgeTasklet.execute
throws DateTimeParseException (or propagates the parsing error) when constructed
with invalid requestDate inputs (null, empty string, or non-BASIC_ISO_DATE like
"2026-04-15" or "20261315"); specifically invoke
MonthlyRankingPurgeTasklet(requestDate=...) and call execute(...) and assert
that LocalDate.parse inside MonthlyRankingPurgeTasklet.execute results in the
expected exception being propagated so future changes to parsing/normalization
will be caught.
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/PeriodicRankingRepository.kt (1)

11-19: 인터페이스 계약에 offset/limit 제약을 문서화할 것을 제안한다.

구현체 PeriodicRankingRepositoryImpl.toPageablelimit > 0offset % limit == 0require 로 강제하지만, 인터페이스에는 이 사전 조건이 드러나 있지 않다. 운영 관점에서 다른 구현체(예: 테스트용 Fake, 향후 캐시 레이어)를 추가할 때 동일한 계약을 놓치면 페이징 결과가 어긋나 조용히 잘못된 랭킹이 노출될 수 있다. KDoc 에 사전 조건과 위반 시 IllegalArgumentException 을 던진다는 점을 명시해 두는 편이 좋다.

♻️ 문서화 예시
 interface PeriodicRankingRepository {
+    /**
+     * `@param` offset 0 이상, 반드시 [limit] 의 배수여야 한다.
+     * `@param` limit  1 이상.
+     * `@throws` IllegalArgumentException 사전 조건 위반 시.
+     */
     fun findTopWeekly(periodStart: LocalDate, offset: Long, limit: Long): List<RankedProduct>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/PeriodicRankingRepository.kt`
around lines 11 - 19, 인터페이스 PeriodicRankingRepository에 정의된 페이징 파라미터(offset,
limit)에 대한 사전조건을 문서화하세요: findTopWeekly, findTopMonthly(및 관련 count 메서드)에 KDoc를
추가해 limit은 > 0이어야 하고 offset은 limit의 배수(offset % limit == 0)여야 함을 명시하고, 이 제약을 위반할
경우 PeriodicRankingRepositoryImpl.toPageable에서와 동일하게 IllegalArgumentException을
던진다는 동작 계약을 적어 다른 구현체(예: Fake, 캐시 레이어)도 동일한 규약을 따르도록 하세요.
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImpl.kt (2)

43-49: offset % limit == 0 제약은 과도할 가능성이 있다.

운영/장애 관점에서 본 계약은 현재 유일 호출자(RankingService)가 offset = (page - 1) * size 로 호출한다는 전제에 강하게 결합되어 있다. 향후 커서/스크롤 스타일 사용자(예: 관리자 도구, 디버깅용 조회)가 임의 offset 으로 호출하는 순간 IllegalArgumentException 이 터져 장애처럼 보일 수 있다. 또한 offsetlimit 의 정수배가 아닐 때 PageRequest 를 만들 수 없을 뿐, 논리적 페이징은 가능하다. 다음 중 하나를 권장한다.

  • 해당 사전조건을 인터페이스 KDoc 에 명시(다른 코멘트 참고)하고 구현은 유지.
  • 또는 인자 자체를 (page: Int, size: Int) 로 바꿔 잘못된 사용을 타입으로 차단.

추가로 limit.toInt() / (offset / limit).toInt() 는 입력이 Int.MAX_VALUE 를 초과하면 silent overflow 가 가능하므로, Math.toIntExact(...) 사용을 권장한다.

♻️ overflow 방어 예시
-        val pageNumber = (offset / limit).toInt()
-        val pageSize = limit.toInt()
+        val pageNumber = Math.toIntExact(offset / limit)
+        val pageSize = Math.toIntExact(limit)
         return PageRequest.of(pageNumber, pageSize)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImpl.kt`
around lines 43 - 49, The toPageable(offset: Long, limit: Long) function
currently enforces offset % limit == 0 which is too strict and can cause
IllegalArgumentException for legitimate arbitrary offsets; either document this
precondition in the interface KDoc (keeping the implementation) or change the
API to accept (page: Int, size: Int) to eliminate misuse (update callers such as
RankingService accordingly). Also replace naive casts like (offset /
limit).toInt() and limit.toInt() with Math.toIntExact(...) to guard against
silent overflow when values exceed Int.MAX_VALUE. Ensure any chosen approach is
applied consistently where toPageable is invoked.

17-37: 읽기 전용 쿼리에 @Transactional(readOnly = true) 를 권장한다.

본 Repository 는 조회 4종만 노출하지만 JPA 관점에서 @Transactional(readOnly = true) 가 빠지면 호출 계층(Facade/Service)에 트랜잭션 경계가 없을 경우 매 쿼리마다 새 트랜잭션이 열리거나, 영속성 컨텍스트 플러시가 불필요하게 발생할 수 있다. 운영 성능 관점에서 클래스 단위로 @Transactional(readOnly = true) 를 추가해 두는 편이 안전하다. 코딩 가이드라인상 Repository 계층의 영속성 컨텍스트 오염 가능성 점검 의무에도 부합한다.

As per coding guidelines: "Kotlin Repository/JPA 리뷰 기준이다. ... 영속성 컨텍스트 오염 가능성을 점검한다."

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImpl.kt`
around lines 17 - 37, This repository exposes only read methods (findTopWeekly,
countWeekly, findTopMonthly, countMonthly) but lacks a class-level read-only
transaction boundary; annotate the PeriodicRankingRepositoryImpl class with
`@Transactional`(readOnly = true) (import from
org.springframework.transaction.annotation.Transactional) so all methods run in
a read-only transaction context and avoid unnecessary flushes/new transactions
when callers lack a transaction.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.kt (1)

20-45: 중복 지적 회피 — Weekly 와 동일한 병렬/재시도 주의사항이 그대로 적용된다.

WeeklyRankingProcessor 에서 남긴 병렬화/리스타트 시 rank_position 일관성 가드 의견을 본 파일에도 동일하게 적용해 달라. 또한 WeeklyRankingProcessor 의 클래스 KDoc 에는 단일 스레드 전제가 명시되어 있으나 MonthlyRankingProcessor 에는 동일 문구가 빠져 있으므로, 일관성을 위해 동일 주석을 추가하는 것을 권장한다.

♻️ 주석 보강 예시
 /**
  * Reader 가 내려준 aggregation row 에 1부터 증가하는 `rank_position` 을 부여해
  * 월간 MV 엔티티로 변환한다.
+ *
+ * - `@StepScope` 로 Step 실행마다 새 인스턴스가 생성되므로 `rankCounter` 가 안전하게 리셋된다.
+ * - Reader 가 `ORDER BY score DESC, product_id ASC` 로 내려주는 순서를 신뢰한다 (단일 스레드 step 전제).
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.kt`
around lines 20 - 45, MonthlyRankingProcessor currently mirrors
WeeklyRankingProcessor logic but lacks the same safeguards and documentation
around single-threaded execution and rank_position consistency during parallel
execution or job restarts; update MonthlyRankingProcessor to apply the identical
concurrency/restart guard used in WeeklyRankingProcessor (ensure rankCounter
usage is protected from multi-threading or disable parallel step execution as
Weekly does) and add the same KDoc notice above the MonthlyRankingProcessor
class indicating the processor assumes single-threaded execution to maintain
stable rank_position across restarts, referencing the rankCounter, process(...)
method, and MonthlyProductRankModel so reviewers can locate the changes.
apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImplTest.kt (1)

107-144: 월간 쪽 테스트 커버리지가 주간과 비대칭하다.

주간에는 translatesOffsetLimitToPageRequest, returnsEmptyList, rejectsInvalidOffset 이 모두 있는 반면, 월간(FindTopMonthly)에는 매핑 테스트 1건만 있다. toPageable 은 내부 공통 경로이므로 실수로 월간에서만 깨져도 발견이 늦을 수 있다. 월간에도 동일한 3종 테스트(페이지네이션 변환, 빈 결과, 잘못된 offset)를 추가해 커버리지를 맞추는 편이 좋다.

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

In
`@apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImplTest.kt`
around lines 107 - 144, Monthly tests lack parity with weekly: add the same
three cases—translatesOffsetLimitToPageRequest, returnsEmptyList,
rejectsInvalidOffset—for the FindTopMonthly nested class so toPageable behavior
and edge cases are covered. Create tests that (1) verify
impl.findTopMonthly(yearMonthVal, offset, limit) calls
monthlyJpa.findByYearMonthValOrderByRankPositionAsc with a Pageable produced
from the offset/limit (translatesOffsetLimitToPageRequest), (2) stub
monthlyJpa.findByYearMonthValOrderByRankPositionAsc to return an empty list and
assert impl.findTopMonthly returns an empty list (returnsEmptyList), and (3)
call impl.findTopMonthly with a negative offset and assert it throws/rejects as
the weekly equivalent does (rejectsInvalidOffset); reference impl.findTopMonthly
and monthlyJpa.findByYearMonthValOrderByRankPositionAsc to locate where to add
these tests.
apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerDailyTest.kt (1)

237-262: KST 경계 케이스에 반대편(전일 경계) 테스트 추가를 권고한다.

현재 2026-04-10T15:00:00Z2026-04-11 KST 한 방향만 고정되어 있어, UTC 기준으로 자정 부근에 발생한 이벤트가 전일로 귀속되는 반대 케이스(예: 2026-04-10T14:59:59Z2026-04-10 KST)는 커버되지 않는다. KST 변환 로직이 ZoneId.of("Asia/Seoul")가 아닌 ZoneOffset.ofHours(9) 같은 대체로 회귀되면 DST가 없는 KST라 잡히지 않지만, 경계값 회귀 방지용으로 쌍 테스트를 남겨 두는 것이 안전하다. 운영 관점에서 일 단위 집계 MV가 하루 밀리면 랭킹 전체가 틀어진다.

🧪 제안 테스트
+    `@DisplayName`("occurredAt 이 UTC 기준 자정 직전이면 KST 로도 같은 날로 귀속된다")
+    `@Test`
+    fun utcJustBeforeMidnightStillMapsToSameKstDate() {
+        stubRepositories()
+        // UTC 2026-04-10T14:59:59Z == KST 2026-04-10T23:59:59+09:00
+        val event = catalogEvent(
+            eventId = "utc-boundary-prev",
+            productId = 81L,
+            eventType = CatalogEventType.PRODUCT_VIEWED,
+            delta = 1,
+            version = 1,
+            occurredAt = ZonedDateTime.parse("2026-04-10T14:59:59Z"),
+        )
+
+        handler.handle(event)
+
+        verify(exactly = 1) {
+            productMetricsDailyJpaRepository.upsert(
+                productId = 81L,
+                metricDate = LocalDate.of(2026, 4, 10),
+                likesDelta = 0,
+                viewsDelta = 1,
+                salesDelta = 0,
+            )
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerDailyTest.kt`
around lines 237 - 262, Add a complementary boundary test to cover the opposite
KST-edge case: create a test similar to normalizesOccurredAtToAsiaSeoulDate that
stubs repositories, constructs an event with occurredAt =
ZonedDateTime.parse("2026-04-10T14:59:59Z") (or another UTC timestamp just
before the boundary), calls handler.handle(event), and verify
productMetricsDailyJpaRepository.upsert is invoked with metricDate =
LocalDate.of(2026, 4, 10) and the same deltas; this ensures the conversion logic
used by handler.handle and any ZoneId/ZoneOffset usage correctly maps UTC
timestamps falling just before the KST day boundary.
apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobE2ETest.kt (1)

60-65: docker info 호출에 타임아웃이 없어 테스트가 무기한 블록될 수 있다.

Docker 데몬이 응답 불가(소켓 대기, TLS 핸드셰이크 지연) 상태면 process.waitFor() 가 무한정 블록돼 CI 잡이 기본 타임아웃까지 대기한다. 운영 관점에서 "Docker 가용성 감지"는 수 초 내에 실패 처리되어야 한다. waitFor(timeout, TimeUnit) + destroy() 로 명시적 상한을 두는 것을 권고한다. 또한 stdout 을 소비하지 않으면 OS에 따라 파이프 버퍼가 차 프로세스 종료가 지연될 수 있으니, 출력은 DISCARD 로 버린다.

추가 테스트: CI 환경에서 Docker 미설치 시 테스트가 disabled 로 빠지고 빌드가 통과하는지 회귀 확인을 권고한다.

🛠️ 제안 수정
         `@JvmStatic`
         fun isDockerAvailable(): Boolean = runCatching {
-            val process = ProcessBuilder("docker", "info").redirectErrorStream(true).start()
-            process.waitFor() == 0
+            val process = ProcessBuilder("docker", "info")
+                .redirectOutput(ProcessBuilder.Redirect.DISCARD)
+                .redirectErrorStream(true)
+                .start()
+            val finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
+            if (!finished) {
+                process.destroyForcibly()
+                false
+            } else {
+                process.exitValue() == 0
+            }
         }.getOrDefault(false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobE2ETest.kt`
around lines 60 - 65, isDockerAvailable() can block indefinitely; change it to
start the "docker info" process with output redirected to DISCARD (use
ProcessBuilder.redirectOutput(ProcessBuilder.Redirect.DISCARD) and
redirectErrorStream(true)), then use process.waitFor(timeout, TimeUnit.SECONDS)
with a small timeout (e.g. a few seconds) and if waitFor returns false call
process.destroy() (or destroyForcibly()) and return false; also catch exceptions
and return false so the method never blocks indefinitely and the test disables
gracefully when Docker is unavailable.
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt (1)

32-72: 세 기간 경로에서 RankingPage 생성 로직 중복이 발견된다.

getDailyTopRankings/getWeeklyTopRankings/getMonthlyTopRankings 세 메서드가 동일한 형태의 RankingPage(entries=..., totalElements=..., page=page, size=size)를 생성한다. Daily 만 rank를 offset 기준으로 재부여하는 차이가 있으므로, (entries, totalCount) 튜플만 기간별로 얻고 페이지 변환은 하나의 헬퍼로 모으면 향후 QUARTERLY 등 추가 시 분기점이 준다. 긴급 이슈는 아니며 선택적 리팩터다.

♻️ 예시 리팩터
-    private fun getWeeklyTopRankings(date: LocalDate, offset: Long, size: Int, page: Int): RankingPage {
-        val periodStart = date.with(WeekFields.ISO.dayOfWeek(), 1)
-        val entries = periodicRankingRepository.findTopWeekly(periodStart, offset, size.toLong())
-        val totalCount = periodicRankingRepository.countWeekly(periodStart)
-        return RankingPage(entries = entries, totalElements = totalCount, page = page, size = size)
-    }
+    private fun getWeeklyTopRankings(date: LocalDate, offset: Long, size: Int, page: Int): RankingPage {
+        val periodStart = date.with(WeekFields.ISO.dayOfWeek(), 1)
+        return toPage(
+            entries = periodicRankingRepository.findTopWeekly(periodStart, offset, size.toLong()),
+            total = periodicRankingRepository.countWeekly(periodStart),
+            page = page, size = size,
+        )
+    }
+
+    private fun toPage(entries: List<RankedProduct>, total: Long, page: Int, size: Int) =
+        RankingPage(entries = entries, totalElements = total, page = page, size = size)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt`
around lines 32 - 72, Refactor the duplicate RankingPage construction in
getDailyTopRankings, getWeeklyTopRankings, and getMonthlyTopRankings by
extracting a helper like buildRankingPage(entries: List<RankedProduct>,
totalCount: Long, page: Int, size: Int): RankingPage that returns
RankingPage(entries = entries, totalElements = totalCount, page = page, size =
size); in getDailyTopRankings map repository entries to List<RankedProduct>
(using rank = offset + index + 1, productId, score) then call the helper, and in
getWeeklyTopRankings/getMonthlyTopRankings pass their existing entries and
counts into the helper to remove the duplicated return block.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt (1)

44-45: 주간 Job 과의 공통화 재검토에 대한 후속 처리 요청이다.

KDoc에 "Phase 6 리팩터링에서 공통화 여부를 재검토한다"고 명시되어 있다. Weekly/Monthly Job Config 의 SQL, preparedStatementSetter, reader/writer/step 구성이 period.start/period.end/yearMonthVal 유무 외에는 사실상 동일하므로, 공통 추상화를 방치하면 정책(스코어 가중치, TOP_N, chunk size, saveState 설정 등) 변경 시 두 곳을 동시에 고쳐야 한다. 실제로 위에서 지적한 restart 이슈도 두 파일에 동일하게 번진 사례다. 후속 작업 추적을 위해 이슈 등록을 권장한다.

원하면 공통 상위 추상(AbstractPeriodicRankingJobConfig 또는 PeriodBuilder 주입) 으로 묶는 초안 PR을 따로 열어 둘 수 있다. 새 이슈를 생성할지 알려 달라.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt`
around lines 44 - 45, Create a follow-up issue to track refactoring
Weekly/Monthly Job Configs into a common abstraction because the KDoc in
MonthlyRankingAggregationJobConfig noting "Phase 6 리팩터링에서 공통화 여부를 재검토한다" was not
acted on; the issue should reference MonthlyRankingAggregationJobConfig and
WeeklyRankingAggregationJobConfig, call out duplicated SQL,
preparedStatementSetter, reader/writer/step logic and duplicated restart bug,
and propose introducing a common base (e.g., AbstractPeriodicRankingJobConfig or
inject a PeriodBuilder) that centralizes policy parameters (score weights,
TOP_N, chunk size, saveState) and the shared step construction; optionally offer
to open a draft PR implementing the abstraction and ask whether to proceed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b3625fa4-cb9d-4fcc-b50a-50fc3c6c445c

📥 Commits

Reviewing files that changed from the base of the PR and between 1a14fc3 and de369ef.

⛔ Files ignored due to path filters (1)
  • docs/round10/plan/implementation_plan.md is excluded by !**/*.md and included by **
📒 Files selected for processing (48)
  • .http/ranking/getRankings.http
  • apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/PeriodicRankingRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/mv/MonthlyProductRankModel.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/mv/WeeklyProductRankModel.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImpl.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/RankingV1Controller.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/application/ranking/RankingFacadeTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/RankingServiceTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/mv/PeriodicRankingRepositoryImplTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyAggregationRow.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyAggregationRow.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingPurgeTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsDailyModel.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/MonthlyPeriod.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/MonthlyProductRankModel.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyPeriod.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/mv/WeeklyProductRankModel.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/BatchRankingScorePolicy.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/MonthlyProductRankJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/mv/WeeklyProductRankJpaRepository.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobE2ETest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobE2ETest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingProcessorTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/MonthlyRankingPurgeTaskletTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessorTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingPurgeTaskletTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/MonthlyPeriodTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/domain/mv/WeeklyPeriodTest.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/application/metrics/ProductMetricsEventHandler.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsDailyModel.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerDailyTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerRankingTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/application/metrics/ProductMetricsEventHandlerTest.kt
  • docs/round10/quest/collect_stack_zip
  • docs/round10/quest/round_10_quest

Comment on lines +16 to +60
@Entity
@Immutable
@Table(name = "mv_product_rank_weekly")
class WeeklyProductRankModel(
periodStart: LocalDate,
periodEnd: LocalDate,
rankPosition: Int,
productId: Long,
likesCount: Long,
viewsCount: Long,
salesCount: Long,
score: Double,
) : BaseEntity() {
@Column(name = "period_start", nullable = false)
var periodStart: LocalDate = periodStart
protected set

@Column(name = "period_end", nullable = false)
var periodEnd: LocalDate = periodEnd
protected set

@Column(name = "rank_position", nullable = false)
var rankPosition: Int = rankPosition
protected set

@Column(name = "product_id", nullable = false)
var productId: Long = productId
protected set

@Column(name = "likes_count", nullable = false)
var likesCount: Long = likesCount
protected set

@Column(name = "views_count", nullable = false)
var viewsCount: Long = viewsCount
protected set

@Column(name = "sales_count", nullable = false)
var salesCount: Long = salesCount
protected set

@Column(name = "score", nullable = false)
var score: Double = score
protected set
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 두 모듈의 WeeklyProductRankModel 정의 비교
fd -a 'WeeklyProductRankModel.kt' | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'
# API 모듈 테스트 프로파일의 ddl-auto 설정 확인
rg -nP -C2 'ddl-auto' -g 'apps/commerce-api/**'

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

Length of output: 4015


배치 모듈과 API 모듈의 컬럼 정의는 일치하나, 테이블 제약 조건 정의에 차이가 있다.

컬럼 이름, 타입, nullable 설정은 두 모듈에서 완전히 동일하게 정의되어 있어 스키마 드리프트 위험은 낮다. 다만 배치 모듈(commerce-batch)은 @TableindexesuniqueConstraints를 명시하고 있는 반면, API 모듈(commerce-api)은 이를 정의하지 않고 있다. API 모듈의 테스트 프로파일에서 ddl-autocreate 또는 update로 설정되어 있다면, 제약 조건 없는 스키마가 생성되어 배치 쓰기 작업의 무결성 검증(중복 방지, 성능 인덱스)이 실패할 수 있다.

API 모듈의 테스트 프로파일에서 ddl-auto 설정을 확인하고, 현재 값이 create 또는 update라면 validate 또는 none으로 변경하기 바란다. 또한 배치 모듈의 제약 조건(특히 uk_mpw_start_product, uk_mpw_start_rank)을 API 모듈의 @Table에도 추가하여 양쪽 정의를 일치시키는 것을 권장한다.

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/mv/WeeklyProductRankModel.kt`
around lines 16 - 60, API 모듈의 WeeklyProductRankModel 엔티티(`@Table`)에는 배치
모듈(commerce-batch)에서 사용 중인 인덱스와 유니크 제약조건이 빠져 있어 테스트 프로파일에서 ddl-auto가
create/update일 경우 스키마 불일치가 발생할 수 있으니, 테스트 환경 설정에서 ddl-auto 값을 validate 또는 none으로
변경하고(현재값이 create 또는 update면 수정), WeeklyProductRankModel 클래스의 `@Table` 어노테이션에 배치
모듈에 정의된 인덱스/uniqueConstraints(특히 uk_mpw_start_product, uk_mpw_start_rank)를 동일하게
추가해 두 모듈의 테이블 제약을 일치시키세요.

Comment on lines +10 to +14
enum class RankingPeriod {
DAILY,
WEEKLY,
MONTHLY,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 컨트롤러에서 RankingPeriod 바인딩과 전역 예외 처리 흐름 확인
rg -nP -C3 'RankingPeriod|MethodArgumentTypeMismatch' -g 'apps/commerce-api/**'
rg -nP -C3 'period=' -g '**/*.http'

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

Length of output: 32808


🏁 Script executed:

cat -n apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt

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

Length of output: 5989


🏁 Script executed:

cat -n apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.kt

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

Length of output: 3915


🏁 Script executed:

rg -nP "class ApiResponse" -A 10 apps/commerce-api/src/main/kotlin

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

Length of output: 1189


컨트롤러 슬라이스 테스트에 enum 바인딩 실패 케이스를 추가하다.

RankingV1ControllerTest는 현재 DAILY, WEEKLY, MONTHLY 정상 케이스만 테스트한다. 소문자(daily) 또는 유효하지 않은 값(INVALID)이 전달될 경우 MethodArgumentTypeMismatchException이 발생하고 ApiControllerAdvice에서 ApiResponse 규격으로 처리되므로 운영상 문제는 없으나, 테스트 커버리지 관점에서는 다음 두 케이스를 추가하는 것을 권장한다.

  • period=daily (소문자): 400 응답 확인
  • period=INVALID (존재하지 않는 값): 400 응답 확인

컨트롤러 슬라이스 테스트(MockMvc)에서 HTTP 요청을 통해 실제 바인딩 실패 동작을 검증하면 인수 전환 계층의 안정성을 더욱 명확히 할 수 있다.

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.kt`
around lines 10 - 14, 테스트 커버리지를 위해 RankingV1ControllerTest에 바인딩 실패 케이스 2개를
추가하세요: GET 엔드포인트에 period 파라미터로 소문자 "daily"와 존재하지 않는 값 "INVALID"를 각각 전달하고
MockMvc로 호출하여 ApiControllerAdvice가 처리하는 MethodArgumentTypeMismatchException 흐름에
따라 HTTP 400 응답을 검증하도록 만드세요; 테스트에서 사용되는 식별자는 enum
RankingPeriod(DAILY,WEEKLY,MONTHLY), 컨트롤러 테스트 클래스 RankingV1ControllerTest, 전역
예외처리기 ApiControllerAdvice, 예외 타입 MethodArgumentTypeMismatchException입니다.

Comment on lines +18 to +25
fun getTopRankings(period: RankingPeriod, date: LocalDate, page: Int, size: Int): RankingPage {
val offset = ((page - 1) * size).toLong()
return when (period) {
RankingPeriod.DAILY -> getDailyTopRankings(date, offset, size, page)
RankingPeriod.WEEKLY -> getWeeklyTopRankings(date, offset, size, page)
RankingPeriod.MONTHLY -> getMonthlyTopRankings(date, offset, size, page)
}
}

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
# RankingV1Controller 및 관련 DTO에서 page/size 에 대한 검증(`@Min/`@Positive 등) 이 있는지 확인한다.
rg -nP -C3 '\bRankingV1Controller\b|@RequestParam\b.*\b(page|size)\b|@Min|@Positive' --type=kotlin
ast-grep --pattern $'class RankingV1Controller {
  $$$
}'

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

Length of output: 3517


🏁 Script executed:

# RankingFacade 구현 확인
find . -name "RankingFacade.kt" -type f

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

Length of output: 171


🏁 Script executed:

# RankingService에서 getDailyTopRankings 등의 구현과 offset 사용 방식 확인
rg -A 20 'fun getDailyTopRankings|fun getWeeklyTopRankings|fun getMonthlyTopRankings' --type=kotlin

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

Length of output: 6005


🏁 Script executed:

# 테스트에서 page/size 경계값(0, -1, 음수) 검증 테스트가 있는지 확인
rg -C 5 'page.*=.*0|size.*=.*0|page.*=.*-1' apps/commerce-api/src/test --type=kotlin

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

Length of output: 50399


🏁 Script executed:

# RankingService 전체 구조 확인
cat -n apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt | head -100

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

Length of output: 3803


컨트롤러 또는 서비스 레이어에 page/size 하한 검증 추가 필요

@RequestParam(defaultValue = "1") page: Int@RequestParam(defaultValue = "20") size: Int는 파라미터 생략 시에만 기본값으로 보호되며, 클라이언트가 명시적으로 page=0 또는 size=-1을 전달하면 검증 없이 통과한다.

18-19행의 val offset = ((page - 1) * size).toLong()에서 page≤0이면 음수 offset이 생성되어 Redis ZSET의 ZRANGE 음수 인덱싱(뒤에서부터 조회) 또는 MySQL의 음수 LIMIT로 이어질 수 있다. 이는 클라이언트 입력 오류를 500 대신 400으로 응답해야 하는 운영 관점의 문제다.

조치 방안:

  • 컨트롤러의 page/size 파라미터에 @Min(1)@Positive 검증 추가 또는 @Validated 활성화
  • 또는 getTopRankings() 진입 시 require(page >= 1 && size >= 1) 추방어
  • 테스트에 page=0, size=0, page=-1 케이스 추가로 컨트롤러 레벨 검증(400) 확인
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt`
around lines 18 - 25, Add input validation to prevent non-positive paging
parameters: in the service entry getTopRankings(period, date, page, size) add a
precondition such as require(page >= 1 && size >= 1) so negative or zero values
are rejected with a clear 400-level error, and/or enable/annotate controller
parameters with `@Validated` and `@Min`(1)/@Positive on the controller method's page
and size RequestParam declarations so invalid requests are rejected before
calling getTopRankings; also add tests for page=0, size=0 and page=-1 to assert
a 400 response.

Comment on lines 20 to 28
fun getRankings(
@RequestParam(defaultValue = "DAILY") period: RankingPeriod,
@RequestParam date: String,
@RequestParam(defaultValue = "20") size: Int,
@RequestParam(defaultValue = "1") page: Int,
): ApiResponse<RankingPageInfo> {
val parsedDate = LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE)
return ApiResponse.success(rankingFacade.getRankings(parsedDate, page, size))
return ApiResponse.success(rankingFacade.getRankings(period, parsedDate, page, size))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# ControllerAdvice/ExceptionHandler 에서 파싱 예외 처리 여부 확인
rg -nP -C3 '(ControllerAdvice|ExceptionHandler|MethodArgumentTypeMismatch|DateTimeParseException)'

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

Length of output: 37180


🏁 Script executed:

# Find and examine the RankingV1Controller file
fd -t f 'RankingV1Controller.kt' --exec cat {} \;

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

Length of output: 1243


date 파라미터의 DateTimeParseException을 @ExceptionHandler에서 명시적으로 처리할 것을 권장한다.

현재 코드에서 LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE)는 메서드 본문 내에서 실행되므로, 형식이 맞지 않는 요청(예: date=2026-04-13, date=abc)이 오면 DateTimeParseException이 발생한다. 이 예외는 ApiControllerAdvice의 기존 핸들러에서 명시적으로 처리되지 않아 generic Throwable 핸들러(line 107-110)로 떨어지게 되고, 500 에러로 응답된다. 운영 관점에서 이는 불필요한 에러 로깅 및 모니터링 알람을 유발한다.

ApiControllerAdvice에 다음과 같은 핸들러를 추가하여 400으로 통일할 것을 권장한다:

`@ExceptionHandler`(DateTimeParseException::class)
fun handleDateTimeParseException(e: DateTimeParseException): ResponseEntity<ApiResponse<*>> {
    val message = "date 파라미터는 yyyyMMdd 형식이어야 합니다. (예: 20260413)"
    return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message)
}

다음 테스트 케이스를 추가로 검증할 것을 권장한다:

  • date 미지정 → 400 (필수 파라미터)
  • date=20260413 → 200 (정상)
  • date=2026-04-13 → 400 (ISO 형식)
  • date=abc → 400 (유효하지 않은 날짜)
  • period=weekly (소문자) → 400 (enum 대소문자 구분)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/RankingV1Controller.kt`
around lines 20 - 28, Add a specific DateTimeParseException handler in
ApiControllerAdvice (e.g., fun handleDateTimeParseException(e:
DateTimeParseException): ResponseEntity<ApiResponse<*>>) that returns
failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "date 파라미터는
yyyyMMdd 형식이어야 합니다. (예: 20260413)") so parsing errors from
RankingV1Controller.getRankings (where LocalDate.parse(date,
DateTimeFormatter.BASIC_ISO_DATE) is called) produce HTTP 400 instead of falling
through to the generic Throwable handler; then add controller tests verifying
missing date → 400, date=20260413 → 200, date=2026-04-13 → 400, date=abc → 400
and enum case-sensitivity (e.g., period=weekly lower-case) → 400.

Comment on lines +20 to +33
* - `@StepScope` 로 Step 실행마다 새 인스턴스가 생성되므로 `rankCounter` 가 안전하게 리셋된다.
* - Reader 가 `ORDER BY score DESC, product_id ASC` 로 내려주는 순서를 신뢰한다 (단일 스레드 step 전제).
*/
@StepScope
@ConditionalOnProperty(
name = ["spring.batch.job.name"],
havingValue = WeeklyRankingAggregationJobConfig.JOB_NAME,
)
@Component
class WeeklyRankingProcessor(
@param:Value("#{jobParameters['requestDate']}") private val requestDate: String,
) : ItemProcessor<WeeklyAggregationRow, WeeklyProductRankModel> {
private val period = WeeklyPeriod.of(LocalDate.parse(requestDate, DateTimeFormatter.BASIC_ISO_DATE))
private val rankCounter = AtomicInteger(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C5 '\btaskExecutor\b|\bfaultTolerant\b|\bchunk\s*\(' --type=kt -g '**/WeeklyRankingAggregationJobConfig.kt' -g '**/MonthlyRankingAggregationJobConfig.kt'

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

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Find the configuration files
fd -e kt 'WeeklyRankingAggregationJobConfig|MonthlyRankingAggregationJobConfig'

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

Length of output: 293


🏁 Script executed:

#!/bin/bash
cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobConfig.kt

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

Length of output: 7890


🏁 Script executed:

#!/bin/bash
cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAggregationJobConfig.kt

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

Length of output: 8014


🏁 Script executed:

#!/bin/bash
# Check the WeeklyRankingProcessor implementation
cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.kt | head -60

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

Length of output: 2341


🏁 Script executed:

#!/bin/bash
# Search for any batch configuration properties or restart/retry settings
fd -e properties -e yml -e yaml | xargs rg -l "batch\|retry\|chunk\|restart" 2>/dev/null | head -10

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

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Search for application configuration files
fd -e properties -e yml -e yaml | grep -E "application|batch|config"

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

Length of output: 271


🏁 Script executed:

#!/bin/bash
# Check batch application configuration
cat apps/commerce-batch/src/main/resources/application.yml

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

Length of output: 825


LGTM — 단, 병렬 처리 활성화 및 재시도 정책 변경 시 rank 중복 위험을 명시적으로 차단할 것.

@StepScope로 Step마다 새 인스턴스가 생성되어 rankCounter가 자동 리셋되고, 기본 동작(SyncTaskExecutor, 재시도 비활성화)상 현재 코드는 안전하다. 다만 운영 관점에서 다음을 개선할 것을 권장한다.

  • 병렬 처리 방지: 현재는 기본 SyncTaskExecutor로 단일 스레드 실행되어 safe하나, 향후 .taskExecutor(SimpleAsyncTaskExecutor) 등으로 변경되면 DB 쓰기 순서가 score 순과 어긋나 rank_position이 점수 순과 불일치하게 된다. 이를 방지하기 위해 Step 빌더에 .taskExecutor(SyncTaskExecutor())를 명시적으로 고정하거나, 주석에 "단일 스레드 실행 필수"를 강조할 것.

  • 청크 재시도 금지 명시: .faultTolerant().retryLimit(0) 설정이나 주석으로 chunk 재시도가 비활성화됨을 명시할 것. 만약 향후 chunk 실패 시 자동 재시도를 활성화하면, 같은 item이 process()를 다시 통과하며 rankCounter가 중복 증가하여 rank gap이 발생할 수 있다.

  • 테스트 추가: 다음 시나리오를 단위 테스트로 보장할 것.

    • Step 정상 완료 시 1번~100번 rank가 score 내림차순으로 정확히 부여됨
    • 다른 step만 재실행 후 aggregateStep이 restart되면 기존 데이터와 충돌하지 않음 (purgeStep의 allowStartIfComplete 동작 확인)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.kt`
around lines 20 - 33, The processor relies on single-threaded execution and no
chunk retry to keep AtomicInteger rankCounter correct; explicitly enforce this
in the step configuration by setting the StepBuilder's taskExecutor to
SyncTaskExecutor (e.g., .taskExecutor(SyncTaskExecutor())) and disable chunk
retries with .faultTolerant().retryLimit(0) (or an equivalent explicit
configuration), and add a clear comment in WeeklyRankingProcessor above
rankCounter noting "requires single-threaded execution and retries disabled";
also add unit/integration tests that assert (1) ranks 1..N are assigned in score
DESC order on normal completion and (2) restarting only other steps or the
aggregate step does not produce duplicate/shifted ranks.

Comment on lines +67 to +75
@BeforeEach
fun beforeEach() {
jobLauncherTestUtils.job = job
}

@AfterEach
fun afterEach() {
databaseCleanUp.truncateAllTables()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# DatabaseCleanUp 구현과 MySqlTestContainersConfig 의 reuse 여부 확인.
rg -nP -C5 'class DatabaseCleanUp|truncateAllTables|withReuse' --type=kotlin

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

Length of output: 13141


🏁 Script executed:

# MySqlTestContainersConfig 찾기 및 reuse 설정 확인
find . -name "*TestContainers*" -o -name "*TestContainer*" | head -20

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

Length of output: 276


🏁 Script executed:

# 보다 구체적으로 검색
rg -l 'withReuse|TestcontainersConfig|MySqlTestContainers' --type=kotlin

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

Length of output: 479


🏁 Script executed:

# Spring Batch cleanup 관련 로직 확인
rg -n 'BATCH_JOB|JpaRepository.*Batch|BatchJobInstance' --type=kotlin | head -30

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

Length of output: 69


🏁 Script executed:

# WeeklyRankingAggregationJobE2ETest 전체 파일 확인
cat -n apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobE2ETest.kt

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

Length of output: 10158


🏁 Script executed:

cat -n modules/jpa/src/testFixtures/kotlin/com/loopers/testcontainers/MySqlTestContainersConfig.kt

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

Length of output: 1546


🏁 Script executed:

# Check if BATCH_JOB_INSTANCE is cleaned by looking at DatabaseCleanUp more carefully
cat -n modules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.kt

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

Length of output: 1549


🏁 Script executed:

# Search for any Spring Batch specific cleanup in the codebase
rg -n 'JobRepository|BATCH_|BatchJobInstance|BatchJobExecution' --type=kotlin -i | grep -i cleanup

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

Length of output: 69


🏁 Script executed:

# Check if there's a BatchJobInstanceRepository or similar in tests
rg -n 'interface.*JobInstance|class.*JobInstance' --type=kotlin | head -20

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

Length of output: 69


@beforeeach 에서도 cleanup 을 추가하되, Spring Batch 메타 테이블 정리 필요성을 명확히 하다.

DatabaseCleanUp.truncateAllTables() 는 JPA @Entity 로 등록된 테이블만 정리한다. 반면 Spring Batch 메타 테이블(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION 등)은 JPA 엔티티가 아니므로 정리되지 않는다.

현재 테스트 클래스에서 여러 테스트 메서드가 동일한 requestDate 파라미터로 Job 을 실행한다(예: ignoresDataOutsideWeek()emptyWeekProducesNoRows() 모두 WEDNESDAY 사용). @AfterEach 만으로는 배치 메타 테이블이 남아 있어, 동일 파라미터로 재실행 시 JobInstanceAlreadyCompleteException 또는 파라미터 충돌이 발생할 수 있다.

운영 환경 안정성을 위해 @BeforeEach 에서 databaseCleanUp.truncateAllTables() 를 한 번 더 호출하거나, Spring Batch JobRepository.deleteJobInstance() 를 통해 배치 메타 테이블을 명시적으로 정리하는 것이 권장된다.

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

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAggregationJobE2ETest.kt`
around lines 67 - 75, Add batch meta-table cleanup to the test setup: call
databaseCleanUp.truncateAllTables() in the `@BeforeEach` before setting up
jobLauncherTestUtils.job (or alternatively call
JobRepository.deleteJobInstance(...) to remove Spring Batch meta rows) so that
Spring Batch tables (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, etc.) are cleared
between tests; update WeeklyRankingAggregationJobE2ETest.beforeEach() to perform
this cleanup to avoid JobInstanceAlreadyCompleteException when reusing the same
requestDate parameters across tests.

Comment on lines +53 to +69
private fun upsertDailyMetrics(event: CatalogEventMessage) {
val (likesDelta, viewsDelta, salesDelta) = when (event.eventType) {
CatalogEventType.LIKE_CHANGED -> Triple(event.delta, 0L, 0L)
CatalogEventType.PRODUCT_VIEWED -> Triple(0L, event.delta, 0L)
CatalogEventType.ORDER_COMPLETED -> Triple(0L, 0L, event.delta)
}
val metricDate = event.occurredAt
.withZoneSameInstant(KST)
.toLocalDate()
productMetricsDailyJpaRepository.upsert(
productId = event.productId,
metricDate = metricDate,
likesDelta = likesDelta,
viewsDelta = viewsDelta,
salesDelta = salesDelta,
)
}

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

음수 delta 누적 가능성을 점검할 것을 권고한다.

LIKE_CHANGED 는 언라이크 시 음수 delta 를 동반할 수 있어 likes_count 가 특정 날짜 기준 음수로 기록될 수 있다. KST 날짜 경계를 넘어 좋아요→언라이크가 발생하면 당일 likes_count 가 음수로 떨어지는 것이 자연스러운 "당일 순증" 의미이긴 하지만, 배치에서 주·월간 SUM 시 사용자 의도와 어긋날 수 있다.

운영 관점에서 다음 중 하나를 결정해 문서화하는 것을 권고한다.

  • 일별 테이블 의미를 "순증(net delta)" 로 고정하고 음수 허용(현재 구현).
  • 또는 LIKE_CHANGED 를 좋아요 "활성 상태의 이벤트 카운트" 로 재정의하고 음수를 클램프.

추가 테스트: delta=-1 이벤트 단독, 그리고 KST 날짜 경계에서의 누적 결과 검증.

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/application/metrics/ProductMetricsEventHandler.kt`
around lines 53 - 69, Decide and enforce the chosen semantics for LIKE_CHANGED
in upsertDailyMetrics: if you want to prevent negative daily counts, change the
LIKE_CHANGED handling to compute a safe likesDelta by clamping the incoming
event.delta so the post-update daily total cannot go below zero (read the
existing day row via productMetricsDailyJpaRepository for productId+metricDate,
compute newTotal = max(existing.likes_count + event.delta, 0), and pass
likesDelta = newTotal - existing.likes_count to
productMetricsDailyJpaRepository.upsert); alternatively, if you keep “net delta”
semantics, add tests that assert delta=-1 behavior across KST date boundary and
document the choice. Ensure references to CatalogEventType.LIKE_CHANGED,
upsertDailyMetrics, and productMetricsDailyJpaRepository.upsert are updated
accordingly.

Comment on lines +18 to +39
@Modifying
@Query(
value = """
INSERT INTO product_metrics_daily
(product_id, metric_date, likes_count, views_count, sales_count, created_at, updated_at)
VALUES
(:productId, :metricDate, :likesDelta, :viewsDelta, :salesDelta, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE
likes_count = likes_count + VALUES(likes_count),
views_count = views_count + VALUES(views_count),
sales_count = sales_count + VALUES(sales_count),
updated_at = NOW(6)
""",
nativeQuery = true,
)
fun upsert(
@Param("productId") productId: Long,
@Param("metricDate") metricDate: LocalDate,
@Param("likesDelta") likesDelta: Long,
@Param("viewsDelta") viewsDelta: Long,
@Param("salesDelta") salesDelta: Long,
): Int

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 커넥션 타임존 설정 확인 (jdbc url / hibernate 설정)
rg -nP 'serverTimezone|hibernate\.jdbc\.time_zone|spring\.jpa\.properties\.hibernate\.jdbc\.time_zone' -g '*.yml' -g '*.yaml' -g '*.properties'

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

Length of output: 69


🏁 Script executed:

# 전체 repository 파일 확인
cat -n apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.kt

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

Length of output: 2080


🏁 Script executed:

# BaseEntity 찾아서 JPA Auditing 설정 확인
find . -type f -name "BaseEntity.kt" -o -name "BaseEntity.java" | head -5

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

Length of output: 149


🏁 Script executed:

# upsert 메서드 호출 위치 확인
rg -n "\.upsert\(" --type kt -B 2 -A 5

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

Length of output: 113


🏁 Script executed:

# BaseEntity 내용 확인
cat -n ./modules/jpa/src/main/kotlin/com/loopers/domain/BaseEntity.kt

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

Length of output: 2355


🏁 Script executed:

# upsert 메서드 호출 위치 확인 (type 필터 제거)
rg -n "\.upsert\(" -B 3 -A 3

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

Length of output: 10255


🏁 Script executed:

# ProductMetricsDailyModel 확인
find . -type f -name "*ProductMetricsDaily*.kt" | xargs cat -n

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

Length of output: 6097


🏁 Script executed:

# 애플리케이션 설정 파일 확인
find . -type f \( -name "application*.yml" -o -name "application*.yaml" -o -name "application*.properties" \) | head -10 | xargs cat

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

Length of output: 3972


🏁 Script executed:

# Hibernate 타임존 설정 확인
rg -n "hibernate" -A 3 -B 1 apps/commerce-streamer/src/main/resources/ 2>/dev/null || find apps/commerce-streamer/src/main/resources -type f \( -name "*.yml" -o -name "*.yaml" \) | xargs cat

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

Length of output: 1426


🏁 Script executed:

# jpa.yml 파일 확인
find . -name "jpa.yml" -type f | xargs cat

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

Length of output: 2233


🏁 Script executed:

# MySQL JDBC 드라이버 설정 및 타임존 관련 설정 검색
rg -n "jdbc|serverTimezone|timezone" -i --type yaml --type properties

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

Length of output: 121


@Modifying 영속성 컨텍스트 동기화 옵션 추가 필요

@Modifying native 쿼리는 기본적으로 영속성 컨텍스트를 flush/clear 하지 않는다. 현재 코드에서는 upsert() 직후 즉시 재조회가 없지만, findByProductIdAndMetricDate() 메서드가 같은 트랜잭션 내에서 호출될 경우 캐시된 stale 엔티티가 반환될 수 있다. 방어적으로 flushAutomatically = true, clearAutomatically = true 를 명시하고, 단위 테스트에 "upsert → 동일 트랜잭션 내 재조회 시 최신 값 확인" 케이스를 추가하기 바란다.

♻️ 제안 수정
-    `@Modifying`
+    `@Modifying`(flushAutomatically = true, clearAutomatically = true)
     `@Query`(

MySQL NOW(6) 타임존 명시화 필요

현재 created_at / updated_at 을 MySQL NOW(6) 으로 채우는데, 이는 MySQL 서버 타임존에 의존한다. jpa.yml 에서는 hibernate.jdbc.time_zone: UTC 로 설정되어 있으나, JDBC URL 에 serverTimezone 파라미터가 없고 upsert() 는 JPA 라이프사이클을 우회하는 native SQL 이다. 따라서:

  • BaseEntity 의 @PrePersist (JPA 경로): ZonedDateTime.now() → Hibernate 에서 UTC 정규화
  • upsert() (native SQL 경로): NOW(6) → MySQL 서버 타임존 사용

두 경로의 타임존 해석이 불일치할 위험이 있다. JDBC URL 에 serverTimezone=UTC 를 추가하거나, SQL 을 UTC_TIMESTAMP(6) 으로 명시하여 운영 표준을 통일하기 바란다.

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.kt`
around lines 18 - 39, Add persistence-context synchronization and canonical UTC
timestamps: update the `@Modifying` on upsert(...) to include flushAutomatically =
true and clearAutomatically = true to avoid stale entities when
findByProductIdAndMetricDate() is called in the same transaction, change the
native SQL timestamp functions from NOW(6) to UTC_TIMESTAMP(6) (or alternatively
ensure JDBC URL includes serverTimezone=UTC) so native upsert uses UTC like JPA
`@PrePersist` in BaseEntity, and add a unit test that performs upsert(...) then
immediately calls findByProductIdAndMetricDate() within the same transaction to
assert the returned entity has the updated counts and timestamps.

Comment on lines +38 to +43
## 🧮 Bacth System

<aside>
💡

**Batch Processing** 이 왜 필요할까요? 한번 예

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

오탈자를 수정할 것을 권고한다.

  • Line 38 🧮 Bacth System🧮 Batch System.
  • Line 43 한번 예 는 문장이 중간에 끊겨 있어 문맥이 모호하다. 한번 살펴봅시다 등 완결 문장으로 다듬을 것을 권고한다.

학습 문서는 리뷰어·신규 입사자에게 그대로 노출되므로 용어·오탈자 정리를 권고한다.

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

In `@docs/round10/quest/collect_stack_zip` around lines 38 - 43, Fix typographical
and incomplete sentence issues in the heading and paragraph: replace the heading
string "🧮 Bacth System" with "🧮 Batch System" and update the incomplete
fragment "한번 예" to a complete sentence such as "한번 살펴봅시다" (or another clear,
complete phrasing) in the same document so the terms and sentences are correct
and readable for reviewers/new hires.

Comment on lines +165 to +183
```sql
CREATE TABLE product_metrics_weekly ( // 주간 상품 이벤트 집계
product_id BIGINT PRIMARY KEY,
like_count INT,
order_count INT,
view_count INT,
yearMonthWeek VARCHAR, // 예시입니다.
updated_at DATETIME
);

CREATE TABLE product_metrics_monthly ( // 주간 상품 이벤트 집계
product_id BIGINT PRIMARY KEY,
like_count INT,
order_count INT,
view_count INT,
yearMonth VARCHAR, // 예시입니다.
updated_at DATETIME
);
```

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

예시 DDL 의 테이블명이 본 PR 실제 테이블명과 달라 혼동을 유발한다.

문서에는 product_metrics_weekly/product_metrics_monthly 로 기재되어 있으나, 실제 구현은 mv_product_rank_weekly/mv_product_rank_monthly 이다. 또한 두 번째 DDL 주석이 "주간 상품 이벤트 집계" 로 잘못 복사되어 있다. 문서-코드 일치성을 위해 실제 사용 중인 테이블명/컬럼명으로 바꾸거나, 예시임을 명확히 표기할 것을 권고한다.

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

In `@docs/round10/quest/collect_stack_zip` around lines 165 - 183, The example DDL
uses incorrect table names and a wrong comment: replace
product_metrics_weekly/product_metrics_monthly and the mistaken "주간 상품 이벤트 집계"
comment with the actual table names mv_product_rank_weekly and
mv_product_rank_monthly and correct monthly comment (or explicitly mark the
block as an illustrative example); update column names/comments to match the
real schema (e.g., yearMonthWeek → year_month_week or actual column name used in
mv_product_rank_weekly, yearMonth → year_month for monthly) so the document
matches the implementation, or add a clear "example only" note if you
intentionally keep different names.

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