Skip to content

[Volume 10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 - 김남진#421

Open
Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-10
Open

[Volume 10] Spring Batch 기반 주간·월간 랭킹 시스템 구현 - 김남진#421
Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-10

Conversation

@Namjin-kimm
Copy link
Copy Markdown

@Namjin-kimm Namjin-kimm commented Apr 17, 2026

📌 Summary

  • 배경: 실시간 Redis ZSET 기반 랭킹(daily/hourly)은 7일/30일 누적 집계를 매 요청마다 수행하기엔 너무 비쌈. 10만 상품 × 168행(7일 × 24시간) = 1,680만 행 GROUP BY를 API 요청 경로에 올릴 수 없다.
  • 목표: Spring Batch Chunk-Oriented Processing으로 ranking_metricsmv_product_rank_weekly/monthly 파이프라인을 구축하고, GET /api/v1/rankings/weekly|monthly 엔드포인트를 제공한다.
  • 결과: 배치 Job 2개(weekly/monthly), Repository UPSERT 테스트 3종, Job 통합 테스트 4종, Facade 통합 테스트 5종, E2E 테스트 통과. 스케줄러(KST 01:00/01:30)와 수동 재실행 CLI 포함.

🧭 Context & Decision

문제 정의

  • 현재 동작: daily 랭킹은 Redis ZSET + 스트리머 이벤트 파이프라인으로 실시간 집계 중
  • 확장 한계: 주간/월간은 집계 기간이 7배/30배 늘어나 동일 방식으로는 불가
  • 성공 기준: 매일 새벽 배치 실행 → MV 테이블 적재 → API 조회 시 단순 SELECT 수행

선택지와 결정

1. 집계 기간 정의 — Rolling Window vs 캘린더 고정

항목 캘린더 고정 (A) Rolling Window (B)
집계 범위 직전 주/달 (월일/1말일) 최근 7/30일 (today-7 ~ today-1)
멱등성 완전 불변 (소스 고정) 실행 시점 독립적 (파라미터 고정)
UX "지난 주 TOP" "이번 주/달 TOP"에 가까움
데이터 공백 주/월 초반 빈 랭킹 노출 항상 꽉 찬 랭킹

최종 결정: B (Rolling Window)
트레이드오프: "이번 주"라는 표현이 엄밀하게는 "최근 7일"임. 하지만 실무 e-commerce 사례 조사 결과 Netflix/Melon 등도 동적 윈도우를 쓰는 게 일반적이었고, [today-7, today-1]로 고정하면 같은 snapshotDate 파라미터로 언제 재실행해도 소스가 불변 → 멱등성 문제도 동시에 해소됨.


2. 소스 테이블 전략 — hourly 직접 집계 vs daily 중간 테이블

항목 hourly 직접 집계 (A) daily 중간 테이블 (B)
주간 스캔량 168행 × 상품 수 7행 × 상품 수
구현 복잡도 단순 (Job 2개) 복잡 (Job 3개 + 실행 순서 의존)
학습 포인트 Chunk 본질 집중 파이프라인 오케스트레이션 추가

최종 결정: A 먼저, 성능 측정 후 B 도입
트레이드오프: 2단계 확장 시 daily → weekly/monthly 집계 구조가 훨씬 효율적이지만, 현재 학습 규모에서는 hourly 직접 집계로도 충분. 측정 없이 미리 최적화하는 YAGNI 위반을 피함.


3. 가중치 관리 — 하드코딩 vs DB SOT

항목 하드코딩 (A) 환경변수 (B) Redis 캐시 (C) DB SOT (D)
변경 방법 코드 수정 + 재배포 설정 변경 + 재시작 Redis 직접 수정 DB UPDATE
변경 이력 코드 이력만 없음 없음 테이블 이력 남음
WEIGHT_CHANGE 재집계 시 재배포 필수 재시작 필수 캐시 TTL 불일치 위험 즉시 반영

최종 결정: D (DB SOT — ranking_weight 테이블)
배치는 매일 1회 실행 → 신속성(캐시 조회 속도)보다 정확성(SOT 일치)이 우선. DB 직접 조회 1회 비용(ms)은 전체 배치 시간 대비 무시 가능. trigger=WEIGHT_CHANGE로 수동 재실행 시 이전 캐시값이 그대로 사용될 위험이 C안의 결정적 단점.

점수 공식: 단순 선형 합산 대신 LOG(1 + x) 변환 적용. 매출이 극단적으로 큰 상품이 랭킹을 독점하는 롱테일 왜곡 완화, +1은 0값의 LOG(0) = -∞ 방지.

score = LOG(1 + view_count) * viewWeight
      + LOG(1 + like_count) * likeWeight
      + LOG(1 + order_revenue) * orderWeight

4. MV 저장 범위 + rank 컬럼

항목 전체 저장 + rank 계산 (A) TOP 100만 + rank 저장 (B)
상품 상세 "N위" 파생 가능 가능 불가 (101위 이하 없음)
가중치 변경 대응 score UPDATE만 가능 재집계 필수
저장량 많음 최소

최종 결정: B (TOP 100 + rank 저장)
결정적 논리: TOP 100만 저장 시 가중치 변경 → 101위 이하 데이터가 없어 어차피 원천 재집계 필수 → 재집계 과정에서 rank도 자연 재계산 → rank 저장의 추가 비용 사실상 0. "가중치 유연성을 훼손하지 않는다."


5. ItemReader 방식 — JdbcCursorItemReader vs JdbcPagingItemReader

배치 Job이 집계 소스(ranking_metrics)에서 TOP 100을 읽어오는 방법을 결정해야 했다. 대규모 데이터 처리에서 일반적으로 거론되는 두 방식을 검토했다.

항목 JdbcPagingItemReader / Keyset (A) JdbcCursorItemReader (B)
DB 커넥션 청크마다 반납·재획득 배치 전체 시간 동안 유지
깊은 페이지 성능 OFFSET 비용 없음 (Keyset) 해당 없음
Keyset 가능 조건 안정적인 정렬 키 필요 없음
현재 상황 score가 GROUP BY 파생값 → Keyset 불가 LIMIT 100 규모, 커넥션 유지 리스크 없음

최종 결정: B (JdbcCursorItemReader + Chunk 50)
score는 GROUP BY + LOG(1 + SUM(x)) 의 파생값이라 안정적인 Keyset 키가 없다. 또한 LIMIT 100 규모에서는 OFFSET 성능 문제도 없어 Paging 방식의 이점이 없다. Chunk 50으로 설정한 것은 학습 목적 — 100건을 2청크로 처리하면서 Chunk-Oriented Processing의 트랜잭션 경계를 직접 관찰하기 위함이다.


6. ItemWriter 방식 — JpaItemWriter vs 커스텀 Writer

MV 테이블에 데이터를 쓸 때 어떤 Writer 구현을 쓸지 결정해야 했다. 기존 프로젝트의 JPA 관행을 그대로 쓰고 싶었지만, 배치의 요구사항(UPSERT 멱등성)과 충돌이 있었다.

방식 문제점
JpaItemWriter (merge 모드) merge()가 내부적으로 SELECT 발사 → 청크 50건에 SELECT 50회 추가 왕복
JpaItemWriter (persist 모드) INSERT 전용 → 재실행 시 UNIQUE 위반으로 PersistenceException
JdbcBatchItemWriter 직접 SQL이 Writer Bean에 박혀 Repository 분리 원칙과 어긋남
커스텀 Writer + Repository.upsertAll() 위임 SQL이 infrastructure 레이어에 위치, UPSERT 멱등성 보장, 프로젝트 관행 일관

최종 결정: 커스텀 ItemWriter + Repository.upsertAll() 위임
트레이드오프: Spring Batch 표준 Writer를 쓰지 않아 프레임워크 지원을 일부 잃음. 하지만 JpaItemWriter의 merge 모드는 SELECT N회라는 숨겨진 비용이 있고, UPSERT는 JPA가 자연스럽게 지원하지 않는 연산이다. SQL을 infrastructure 레이어에 모으는 프로젝트 관행을 유지하는 것이 더 중요하다고 판단했다.


7. 배치 멱등성 — 2-Layer 방어

같은 날 배치를 두 번 실행하거나 가중치 변경 후 재집계할 때 데이터 정합성을 보장하는 방법을 설계했다.

Layer 1 — Spring Batch JobRepository
snapshotDate + trigger 조합이 같으면 이미 완료된 JobInstance의 재실행을 프레임워크가 차단 (JobInstanceAlreadyCompleteException). 정상 스케줄 실행의 중복을 막는 첫 번째 방어선.

Layer 2 — Writer UPSERT
ON DUPLICATE KEY UPDATE로 같은 (snapshot_date, product_id)가 들어오면 덮어쓰기. Layer 1을 통과한 의도적 재실행(가중치 변경)에서 데이터 정합성을 보장하는 두 번째 방어선.

trigger enum 도입 이유: 재실행 유형을 SCHEDULED / WEIGHT_CHANGE / DATA_FIX / MANUAL_RERUN으로 구분해 BATCH_JOB_EXECUTION_PARAMS에 이력을 남긴다. "왜 같은 날 두 번 실행했는가"를 DB에서 바로 확인 가능.


8. 배치 실행 주체 — @scheduled vs 외부 크론

배치 Job을 어디서 트리거할지 결정해야 했다. 실무 관점과 현재 프로젝트 맥락을 구분해 선택했다.

항목 외부 크론 (K8s CronJob / AWS EventBridge) 애플리케이션 내부 @scheduled
다중 인스턴스 안전성 인프라가 보장 ShedLock 별도 필요
실행 이력 관리 인프라 로그 + Batch JobRepository Batch JobRepository만
인프라 셋업 비용 있음 없음
실무 기준 권장 단일 인스턴스 가정에서만 안전

최종 결정: @scheduled (단일 인스턴스 가정)
현재 인프라 셋업 비용 없이 빠르게 구현. weekly 01:00 KST / monthly 01:30 KST로 30분 간격 분산해 DB/로그 관찰성 확보. 단, 다중 인스턴스 환경으로 확장 시 ShedLock 또는 외부 크론으로의 전환이 필수인 기술부채임을 인지하고 있다.


9. latest_date 캐시 — 왜 필요한가, 어떻게 동기화하는가

배경 — 이 캐시가 왜 필요한가

daily 랭킹은 Redis ZSET에 실시간으로 점수가 쌓이므로 "오늘 랭킹"이 항상 존재한다. 반면 weekly/monthly는 배치가 매일 한 번 새 snapshot_date로 MV 테이블을 적재한다. 사용자가 날짜 파라미터 없이 GET /rankings/weekly를 요청하면 API는 "가장 최근 배치가 만든 snapshot"을 알아야 한다.

가장 단순한 방법은 매 요청마다 SELECT MAX(snapshot_date) FROM mv_product_rank_weekly를 날리는 것인데, 트래픽이 많을 때 이 쿼리가 부담이 된다. latest_date 캐시는 배치 완료 직후 Listener가 Redis에 최신 snapshot_date를 써두는 방식으로 이 조회를 대체한다.

캐시 키 구조:

  • rankings:weekly:latest_date"2026-04-17" (TTL 25h)
  • rankings:monthly:latest_date"2026-04-17" (TTL 25h)

설계 선택지와 결정:

항목 짧은 TTL + DB 폴백 (A) DB 매번 조회 (B) Listener 기반 (C)
스탬피드 위험 있음 (TTL 만료 순간) 있음 (트래픽 증가 시) 없음 (배치 성공 시만 put)
최신성 TTL 지연 실시간 배치 완료 직후
실패한 Job의 캐시 오염 위험 있음 없음 없음 (COMPLETED만 put)

최종 결정: C (JobExecutionListener 기반, 25h TTL)
FAILED/STOPPED 시 put 생략 — 실패한 Job의 snapshotDate를 캐시에 박으면 "존재하지 않는 snapshot 가리킴" 위험. 이전 성공값 유지가 더 안전. 25h TTL은 다음 배치(24h 주기) 이전에 자연 만료되지 않도록 하는 안전망.

구현 중 추가된 방어 로직: Job COMPLETED이지만 적재 데이터 count=0인 경우 캐시 갱신 스킵. 서비스 초기 등 집계 대상 상품이 없는 상태에서 빈 snapshot을 latest로 만들지 않기 위함.


10. 엔티티 모듈 배치 — 공유 모듈 vs 앱별 중복 선언

항목 공유 모듈 신설 (A) 앱별 중복 선언 (B)
코드 중복 없음 있음 (같은 테이블 두 번 매핑)
앱 독립성 훼손 유지
Repository 분리 어색 자연스러움 (타입 파라미터 다름)
역할 명시 불명확 batch=MvProductRankWeekly(쓰기), api=WeeklyRank(읽기)

최종 결정: B (앱별 중복 선언)
같은 테이블을 "다른 역할로 본다"는 점이 이름부터 드러남. Spring Data JPA Repository가 엔티티 타입으로 파라미터화되므로 엔티티 분리 결정이 Repository 분리를 자동으로 강제. ISP(쓰기/읽기 메서드 무관) + 트랜잭션 경계(batch=chunk tx, api=readOnly) 자연 분리 부수효과.


🏗️ Design Overview

변경 범위

모듈 변경 내용
commerce-batch 신규: 주간/월간 Job, Scheduler, ManualRunner, Listener, Domain+Infra
commerce-api 신규: WeeklyRank, MonthlyRank 도메인+인프라, /weekly, /monthly 엔드포인트
commerce-api/RankingFacade 확장: findWeeklyRanking(), findMonthlyRanking() + resolveSnapshotDate() 공통 메서드

주요 컴포넌트 책임

컴포넌트 책임
WeeklyRankReaderConfig ranking_metrics → 집계 쿼리 실행 (Rolling Window, LOG 변환, DB 가중치 동적 조회)
WeeklyRankProcessor 수신 순서로 rank 부여 + MvProductRankWeekly POJO 생성
WeeklyRankWriter Repository.upsertAll() 호출 (얇은 어댑터)
WeeklyRankingJobConfig Job/Step 빌더 + Listener 조립 (@ConditionalOnProperty)
RankingLatestDateCacheListener Job 성공 + count>0 시 rankings:{period}:latest_date Redis put
RankingScheduler KST 01:00/01:30 cron, trigger=SCHEDULED 파라미터로 JobInstance 생성
RankingManualRunner --trigger 옵션 있을 때만 Job 실행 (수동 재집계 CLI 진입점)
WeeklyRankRepository (api) 최신 snapshot 조회 + rank 오름차순 페이지 조회
RankingFacade latest_date 캐시 → 메인 캐시 → DB JPA 조회 순서로 응답 조립

🔁 Flow Diagram

Batch 파이프라인 — 매일 새벽 배치가 MV 테이블을 만들고 캐시를 동기화하는 흐름

flowchart TD
    A["🕐 매일 KST 01:00\n(RankingScheduler)"] --> B

    subgraph B["Spring Batch Job"]
        direction LR
        R["Reader\n최근 7일 ranking_metrics 집계\nLOG 변환 + DB 가중치 적용\nTOP 100 ORDER BY score"] --> P["Processor\n수신 순서대로 rank 부여"]
        P --> W["Writer\nUPSERT ALL\n(ON DUPLICATE KEY UPDATE)"]
    end

    B --> C["mv_product_rank_weekly 적재 완료"]

    C --> D{"Job COMPLETED\n count > 0?"}
    D -- Yes --> E["Redis\nrankings:weekly:latest_date = '2026-04-17'\nTTL 25h"]
    D -- No\n(실패 또는 데이터 없음) --> F["캐시 갱신 스킵\n이전 성공값 유지"]
Loading

API 조회 흐름 — 두 단계 캐시를 거쳐 응답을 조립하는 흐름

flowchart TD
    A["GET /api/v1/rankings/weekly\n(date 파라미터 없음)"] --> B

    subgraph B["snapshot 날짜 결정"]
        B1{"Redis\nrankings:weekly:latest_date\n캐시 HIT?"}
        B1 -- HIT --> B2["Redis에서 읽은 날짜 사용\n예: 2026-04-17"]
        B1 -- MISS --> B3["DB MAX(snapshot_date) 조회\n→ Redis에 다시 put (25h TTL)"]
    end

    B2 & B3 --> C

    subgraph C["랭킹 데이터 조회"]
        C1{"Redis\nrankings:weekly:2026-04-17:0:20\n메인 캐시 HIT?"}
        C1 -- HIT --> C2["직렬화된 결과 즉시 반환"]
        C1 -- MISS --> C3["DB에서 Page 조회\n→ 상품/브랜드 JOIN\n→ Redis에 캐시 put (24h TTL)"]
    end

    C2 & C3 --> D["200 OK\n랭킹 목록 반환"]
Loading

멱등성 2-Layer — 중복 실행 방어 흐름

flowchart TD
    A["배치 실행 시도\n(자동 스케줄 또는 수동 CLI)"] --> B

    subgraph B["Layer 1: Spring Batch JobRepository"]
        B1{"같은 snapshotDate + trigger로\n이미 COMPLETED된 JobInstance 있음?"}
        B1 -- Yes --> B2["JobInstanceAlreadyCompleteException\n중복 실행 차단"]
        B1 -- No --> B3["새 JobInstance 생성\n배치 실행 시작"]
    end

    B3 --> C

    subgraph C["Layer 2: Writer UPSERT"]
        C1["INSERT INTO mv_product_rank_weekly\nON DUPLICATE KEY UPDATE\n(snapshot_date, product_id) 기준"]
        C1 --> C2["중복 행 → 값 덮어쓰기\n첫 행 → 새로 INSERT\ncreated_at은 최초 시각 유지"]
    end
Loading

수동 재집계 흐름 — 가중치 변경 후 재집계

flowchart LR
    A["운영자\n가중치 DB 변경"] --> B
    B["./gradlew bootRun\n--trigger=WEIGHT_CHANGE\n--snapshotDate=2026-04-17"] --> C["RankingManualRunner\ntrigger 옵션 확인"]
    C --> D{"(snapshotDate, trigger) 조합\n이미 실행된 적 있음?"}
    D -- Yes\nJobInstanceAlreadyCompleteException --> E["⚠ 같은 trigger 재실행 불가\n다른 trigger 값 사용 필요\n(기술부채)"]
    D -- No --> F["새 JobInstance\n→ 최신 가중치로 재집계\n→ MV UPSERT → 캐시 갱신"]
Loading

🔑 핵심 코드 변경

1. DB 가중치 동적 조회 + LOG 변환 점수 공식

apps/commerce-batch/.../weekly/WeeklyRankReaderConfig.java

// 배치 실행 시점에 DB SOT에서 가중치 조회 — Redis 캐시하지 않음
BigDecimal viewWeight  = weightRepository.findWeightByEventType("VIEW");
BigDecimal likeWeight  = weightRepository.findWeightByEventType("LIKE");
BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER");

// 가중치를 SQL 리터럴로 포맷 (PreparedStatement의 ? 바인딩과 함께 사용)
// LOG(1+x): 매출 롱테일 압축, 다양한 상품 경쟁 가능
String sql = """
    SELECT product_id, SUM(view_count) AS view_count, ...
           LOG(1 + SUM(view_count))    * %s
         + LOG(1 + SUM(like_count))    * %s
         + LOG(1 + SUM(order_revenue)) * %s AS score
    FROM ranking_metrics
    WHERE metrics_date BETWEEN ? AND ?
    GROUP BY product_id ORDER BY score DESC LIMIT 100
    """.formatted(viewWeight, likeWeight, orderWeight);

trigger=WEIGHT_CHANGE 재집계 시 항상 최신 가중치 사용 보장. 가중치는 BigDecimal 타입으로 DB에서 제어된 값이라 SQL 인젝션 위험 없음.


2. count=0 방어 로직이 있는 캐시 Listener

apps/commerce-batch/.../ranking/RankingLatestDateCacheListener.java

// Job COMPLETED이어도 적재 데이터가 없으면 캐시 갱신 스킵
long count = countByJobName(jobName, snapshotDate);
if (count == 0) {
    log.warn("[{}] snapshotDate={} 적재 데이터 없음 — latest_date 캐시 갱신 스킵", jobName, snapshotDate);
    return;
}
stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL);

계획서에 없던 추가 방어. 서비스 초기 등 집계 대상 상품이 없으면 빈 snapshot을 latest로 만들지 않아 이전 성공 snapshot을 유지한다.


3. trigger 명시로 운영 이력 분리

apps/commerce-batch/.../ranking/RankingScheduler.java

// trigger=SCHEDULED 명시 → BATCH_JOB_EXECUTION_PARAMS에 이력 기록
// "자동 실행 vs 수동 재집계"를 DB 조회로 구분 가능
jobLauncher.run(job, new JobParametersBuilder()
    .addString("snapshotDate", snapshotDate.toString())
    .addString("trigger", RankingJobTrigger.SCHEDULED.name())
    .toJobParameters());

RankingManualRunner--trigger 옵션이 없으면 아무것도 하지 않아 스케줄러 데몬 모드와 충돌 없음.


4. resolveSnapshotDate — latest_date 캐시 Miss 폴백

apps/commerce-api/.../application/ranking/RankingFacade.java

private LocalDate resolveSnapshotDate(LocalDate given, String latestKey,
        Supplier<Optional<LocalDate>> dbFallback) {
    if (given != null) return given;                       // date 파라미터 명시 시 바로 사용

    String cached = stringRedisTemplate.opsForValue().get(latestKey);
    if (cached != null) return LocalDate.parse(cached);    // 캐시 HIT

    // 캐시 MISS (cold start) → DB MAX() 폴백 후 재 put
    Optional<LocalDate> fromDb = dbFallback.get();
    fromDb.ifPresent(d -> stringRedisTemplate.opsForValue()
        .set(latestKey, d.toString(), Duration.ofHours(25)));
    return fromDb.orElse(null);
}

weekly/monthly에서 공통 사용. Supplier<Optional<LocalDate>>로 DB 폴백을 주입받아 테스트에서 Mock으로 교체 용이.


📋 기술부채 (인지 중)

항목 설명 우선순위
Slack 실패 알림 log.error()만 있고 Slack Appender 미연결 (supports/logging/logback-spring.xml 수정 필요) Medium
오래된 snapshot cleanup TTL 없이 MV 테이블에 과거 snapshot 누적 중 Low
다중 인스턴스 스케줄러 @Scheduled + 단일 인스턴스 가정, ShedLock 미적용 Medium
메인 캐시 cold-start 스탬피드 배치 완료 직후 첫 요청에 다수 DB 쿼리 발생 가능 Low
ManualRunner run.id 동일 (snapshotDate, trigger) 재실행 불가 Low
daily 중간 테이블 성능 비교 hourly 직접 집계 vs ranking_metrics_daily SOT 성능 실측 미완료 Low

변경 목적

Spring Batch 기반 주간/월간 랭킹 배치 파이프라인 구축 및 API 엔드포인트 노출. ranking_metrics 테이블의 실시간 메트릭을 롤링윈도우(주간 7일, 월간 30일)로 집계하여 미리 계산된 랭킹 데이터(mv_product_rank_weekly, mv_product_rank_monthly)를 제공하고, 캐시 기반 조회로 응답 성능을 향상.

핵심 변경점

배치 로직: JdbcCursorItemReader로 ranking_metrics에서 메트릭을 읽고, LOG(1+x) 기반 가중치식으로 점수 계산 (score = LOG(1+views)×w_view + LOG(1+likes)×w_like + LOG(1+revenue)×w_order). 가중치는 런타임에 ranking_weight 테이블에서 조회(DB SOT). Processor에서 상위 100개 상품만 순위 부여 후 Writer가 UPSERT로 저장(Chunk size 50).

API 측: Weekly/Monthly RankingRepository 인터페이스 및 JPA 구현 추가. RankingFacade가 Redis latest_date 캐시 → DB fallback으로 스냅샷 날짜 해석하고, 캐시-어사이드 패턴으로 조회 결과 저장. /api/v1/rankings/weekly, /api/v1/rankings/monthly 엔드포인트 추가.

스케줄링 & 수동 실행: RankingScheduler가 매일 01:00/01:30 KST에 자동 실행(@EnableScheduling). RankingManualRunner가 --trigger CLI 옵션으로 수동 실행 지원(SCHEDULED/WEIGHT_CHANGE/DATA_FIX/MANUAL_RERUN). RankingLatestDateCacheListener가 배치 완료 시 Redis latest_date 업데이트(TTL 25h, 로드된 레코드 > 0일 때만).

리스크/주의사항

배치 작업이 매일 두 번 실행되므로 ranking_metrics 테이블 크기 및 집계 성능 모니터링 필요. 스냅샷 날짜 기반 쿼리이므로 시간대 변환(KST) 정확성 확인 필수. DB 스키마(관계 테이블 DDL)가 제공되지 않아 ranking_weight 테이블 구조/초기값 검증이 필요한가요?

테스트/검증

리포지토리 UPSERT 테스트(삽입, 중복 키 업데이트, createdAt 보존 검증), 배치 통합 테스트(롤링윈도우 경계 조건, TOP-100 필터링, 멱등성, 수동 재실행), 파사드 통합 테스트(Redis 캐시 히트/미스, DB fallback), E2E API 테스트(weekly/monthly 엔드포인트, 페이지네이션, 빈 스냅샷 처리).

Namjin-kimm and others added 6 commits April 17, 2026 17:05
- RankingEntry DTO 도입으로 ZSetOperations.TypedTuple 반환 타입 제거
- RealtimeRankingRepository 인터페이스 신설, RankingService는 이를 통해 조회
- RealtimeRankingRepositoryImpl에서 Redis ZSET 접근 로직 캡슐화

DIP 준수 — domain 레이어가 Redis 구현 세부(RedisTemplate)를 직접 알지 않도록 분리.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WeeklyRankingJob / MonthlyRankingJob: JdbcCursorItemReader → Processor → Writer Chunk(50) 구조
- Reader: Rolling Window [today-7, today-1] / [today-30, today-1] 집계 SQL, LOG(1+x) 점수 공식
- 가중치는 ranking_weight 테이블에서 실행 시점 동적 조회 (BatchRankingWeightRepository)
- MvProductRankWeeklyRepository / Monthly: JDBC UPSERT, ON DUPLICATE KEY UPDATE로 멱등성 보장
- RankingScheduler: 매일 01:00 / 01:30 KST cron 자동 실행 (trigger=SCHEDULED)
- RankingManualRunner: --trigger 옵션으로 CLI 수동 재실행 (WEIGHT_CHANGE / DATA_FIX / MANUAL_RERUN)
- RankingLatestDateCacheListener: Job 성공 + 적재 건수 > 0 시 Redis latest_date 캐시 갱신 (TTL 25h)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WeeklyRank / MonthlyRank: mv_product_rank_weekly/monthly 읽기 전용 JPA 엔티티
- WeeklyRankRepository / MonthlyRankRepository: findLatestSnapshotDate, findBySnapshotDateOrderByRankAsc 인터페이스
- WeeklyRankJpaRepository / Monthly: Spring Data JPA MAX() 쿼리 + 페이지네이션

배치(JDBC UPSERT)와 API(JPA 읽기)가 같은 테이블을 다른 엔티티로 접근하는 구조 — 쓰기/읽기 역할 분리.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RankingFacade: findWeeklyRanking / findMonthlyRanking 추가
  - date 미지정 시 Redis latest_date 캐시 → DB MAX() 폴백 순으로 snapshot 결정
  - 메인 캐시 키: rankings:{period}:{snapshotDate}:{page}:{size} (RankingCacheRepository 위임)
- RankingV1Controller: GET /weekly, GET /monthly 엔드포인트 추가
- RankingV1ApiSpec: 주간/월간 OpenAPI 명세 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MvProductRankWeeklyRepositoryImplIntegrationTest: UPSERT INSERT/UPDATE/created_at 보존 검증
- WeeklyRankingJobIntegrationTest: Rolling Window 경계, TOP 100 제한, JobInstance 멱등성 검증
- Monthly 동일 세트
- RankingFacadeWeeklyIntegrationTest: date 미지정 캐시 히트/미스/DB 폴백, 빈 snapshot 응답 검증
- RankingV1ApiWeeklyMonthlyE2ETest: E2E API 응답 스키마 검증
- WeeklyRankTestFixture / MonthlyRankTestFixture / RankingMetricsTestFixture 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- design-notes.md: Rolling Window, 소스 테이블 전략, MV 스키마, 캐시 설계 등 주요 결정 기록
  - 가중치 동적 조회(DB SOT) 결정 및 Redis 미사용 이유 포함
- implementation_plan.md: Step별 구현 가이드 및 실제 구현과의 차이점 기록
- learning-point.md, quests.md: Week 10 학습 과제

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

변경 요약

Walkthrough

주간 및 월간 랭킹 조회를 위한 새로운 공개 메서드와 배치 처리 인프라가 추가되었다. Redis 기반 실시간 랭킹과 데이터베이스 기반 스냅샷 랭킹 조회를 지원하는 새로운 도메인 타입과 리포지토리가 도입되었다.

Changes

코호트 / 파일 요약
RankingFacade 핵심 기능
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
주간/월간 랭킹 조회 메서드 추가. Redis latest_date 기반 캐시-어사이드 패턴 구현. 일일/시간별 랭킹에서 ZSetOperations.TypedTuple<String>RankingEntry 로 변경하여 파싱 로직 제거.
RankingService 레이어 리팩토링
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java, RealtimeRankingRepository.java
Redis 직접 접근 제거. RealtimeRankingRepository 추상화 도입으로 의존성 역전. 반환 타입 List<ZSetOperations.TypedTuple<String>>List<RankingEntry> 변경.
데이터 도메인 타입 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/{WeeklyRank,MonthlyRank,RankingEntry}.java
JPA 엔티티 WeeklyRank, MonthlyRank 및 불변 레코드 RankingEntry(productId, score) 추가.
도메인 리포지토리 인터페이스
apps/commerce-api/src/main/java/com/loopers/domain/ranking/{Weekly,Monthly}RankRepository.java
스냅샷 기반 조회 메서드 findLatestSnapshotDate(), findBySnapshotDateOrderByRankAsc(...) 정의.
Redis 리포지토리 구현
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.java
Redis ZSET 기반 실시간 랭킹 조회 구현. Z-set 튜플을 RankingEntry 로 매핑. Null 체크 및 예외 처리 필요.
JPA 및 구현 리포지토리
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/{Weekly,Monthly}Rank{JpaRepository,RepositoryImpl}.java
Spring Data JPA 리포지토리 및 도메인 인터페이스 구현체 추가. 쿼리 메서드 기반 스냅샷 조회.
API 레이어 확장
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/{RankingV1ApiSpec,RankingV1Controller}.java
/api/v1/rankings/weekly, /api/v1/rankings/monthly GET 엔드포인트 추가. 페이징 지원 (기본값: page=0, size=20).
API 통합 테스트
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java
E2E 테스트로 캐시 히트/미스, 빈 스냅샷, 스키마 검증 시나리오 커버.
Facade 단위 테스트
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java
주간 랭킹 캐시 동작, DB 폴백, 스냅샷 처리 검증. Redis 키 정리 필요.
테스트 픽스처
apps/commerce-api/src/test/java/com/loopers/fixture/{Weekly,Monthly}RankTestFixture.java
주간/월간 랭킹 데이터 삽입 헬퍼. 제품 생성 및 랭킹 행 삽입 지원.
배치 애플리케이션 설정
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
@EnableScheduling 추가. System.exit() 호출 제거로 배치 프로세스 지속 실행 지원.
배치 스케줄러 및 수동 실행
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/{RankingScheduler,RankingManualRunner,RankingJobTrigger,RankingLatestDateCacheListener}.java
일일 스케줄링 (01:00, 01:30), CLI 트리거 지원, 캐시 업데이트 리스너 추가. 중복 실행 처리 필요.
주간 랭킹 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/{WeeklyRank*,WeeklyRankingJobConfig}.java
7일 롤링 윈도우 기반 aggregation. JDBC 커서 리더, 프로세서, 라이터, 작업 설정 추가. 상위 100개 상품만 저장.
월간 랭킹 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/{MonthlyRank*,MonthlyRankingJobConfig}.java
30일 롤링 윈도우 기반 aggregation. 주간과 동일한 구조. 가중치 기반 점수 계산.
배치 도메인 엔티티
apps/commerce-batch/src/main/java/com/loopers/domain/rank/{MvProductRank{Weekly,Monthly},RankingMetrics,BatchRankingWeightRepository}.java
배치 처리 대상 엔티티 및 리포지토리 인터페이스. 메트릭 추적 엔티티 추가.
배치 리포지토리 구현
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/{BatchRankingWeightRepositoryImpl,MvProductRank{Weekly,Monthly}RepositoryImpl}.java
INSERT ... ON DUPLICATE KEY UPDATE 기반 upsert 구현. Null 체크 및 변환 로직 포함.
배치 통합 테스트
apps/commerce-batch/src/test/java/com/loopers/batch/ranking/{weekly,monthly}/*IntegrationTest.java
롤링 윈도우, 상위 100개 필터링, 멱등성, 수동 재실행 시나리오 검증.
배치 테스트 픽스처
apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java
배치 소스 메트릭 데이터 삽입 헬퍼.
배치 설정
apps/commerce-batch/src/main/resources/application.yml
spring.batch.job.enabled: false로 변경. 수동 실행 및 스케줄러 기반 실행 전환.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller as RankingV1Controller
    participant Facade as RankingFacade
    participant Cache as StringRedisTemplate<br/>(Redis)
    participant DB as Weekly/MonthlyRankRepository
    participant Product as ProductService

    Client->>Controller: GET /api/v1/rankings/weekly<br/>(date?, page, size)
    activate Controller
    Controller->>Facade: findWeeklyRanking(date, page, size)
    activate Facade

    Facade->>Facade: resolveSnapshotDate(date)
    alt date 제공됨
        Facade->>Facade: 입력 date 사용
    else date 미제공
        Facade->>Cache: GET rankings:weekly:latest_date
        alt Redis 캐시 히트
            Cache-->>Facade: snapshotDate
        else 캐시 미스
            Facade->>DB: findLatestSnapshotDate()
            DB-->>Facade: snapshotDate
            Facade->>Cache: SET latest_date = snapshotDate<br/>(TTL: 25h)
        end
    end

    Facade->>Cache: GET rankings:weekly:{snapshotDate}:{page}:{size}
    alt 캐시 히트
        Cache-->>Facade: RankingResult (캐시됨)
    else 캐시 미스
        Facade->>DB: findBySnapshotDateOrderByRankAsc<br/>(snapshotDate, PageRequest)
        DB-->>Facade: Page<WeeklyRank>
        
        Facade->>Product: 각 productId마다<br/>제품/브랜드명 조회
        Product-->>Facade: Product, Brand
        
        Facade->>Facade: RankingResult 생성<br/>(RankingItem 리스트)
        Facade->>Cache: SET rankings:weekly:{key}<br/>= RankingResult
        Cache-->>Facade: OK
    end

    Facade-->>Controller: RankingResult
    deactivate Facade
    Controller->>Controller: ApiResponse.success()<br/>(RankingListResponse.from())
    Controller-->>Client: 200 OK + JSON
    deactivate Controller
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45분

근거: 변경 범위가 광범위 (30개 이상 파일), commerce-api와 commerce-batch 양쪽 모두 영향, Redis 캐시 로직과 배치 처리 파이프라인 이질적 변경, 로직 밀도 높음 (cache-aside, 롤링 윈도우, upsert, 스케줄링), 테스트 커버리지 높음. 특히 운영 관점의 캐시 일관성, 배치 멱등성, 중복 실행 처리 검증 필요.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 Spring Batch 기반 주간·월간 랭킹 시스템 구현이라는 주요 변경을 명확히 나타낸다.
Description check ✅ Passed PR 설명이 요구되는 모든 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 포함하고 있으며, 기술적 선택 이유와 트레이드오프를 상세히 기록했다.

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

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

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

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Note

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

🟡 Minor comments (13)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java-73-83 (1)

73-83: ⚠️ Potential issue | 🟡 Minor

ZonedDateTime.now() 가 시스템 기본 타임존에 종속된다.

본 프로젝트는 KST(Asia/Seoul) 경계로 일 단위 집계·스케줄링(01:00/01:30 KST)을 수행한다. 서버 TZ가 UTC 로 배포될 경우 createdAt/updatedAt 이 KST 기준 날짜와 어긋나 운영/디버깅 시 metrics_date 와 감사 타임스탬프 간 날짜 경계 혼선이 발생할 수 있다. 본 엔티티가 테스트 DDL 용이라 해도 컨벤션 일관성 관점에서 고정 존 사용을 권고한다.

수정 후 시스템 TZ가 UTC 인 환경(예: -Duser.timezone=UTC) 에서도 경계값 날짜(자정 직전/직후)로 삽입 시 createdAt.getZone() 이 KST 로 고정되는지 검증하는 단위 테스트 추가를 제안한다.

🛠 제안 수정
-import java.time.ZonedDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
@@
-    `@PrePersist`
-    private void prePersist() {
-        ZonedDateTime now = ZonedDateTime.now();
+    private static final ZoneId KST = ZoneId.of("Asia/Seoul");
+
+    `@PrePersist`
+    private void prePersist() {
+        ZonedDateTime now = ZonedDateTime.now(KST);
         this.createdAt = now;
         this.updatedAt = now;
     }
 
     `@PreUpdate`
     private void preUpdate() {
-        this.updatedAt = ZonedDateTime.now();
+        this.updatedAt = ZonedDateTime.now(KST);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java`
around lines 73 - 83, prePersist()/preUpdate() use ZonedDateTime.now() which
depends on system TZ; change both to use a fixed ZoneId.of("Asia/Seoul") (e.g.,
ZonedDateTime.now(ZoneId.of("Asia/Seoul"))) when assigning createdAt and
updatedAt in the RankingMetrics entity so timestamps are always KST, and add a
unit test that simulates JVM timezone UTC (e.g., with -Duser.timezone or by
temporarily setting TimeZone/ZoneId) to verify createdAt.getZone() and
updatedAt.getZone() are Asia/Seoul around boundary times (just before/after
midnight) to ensure date boundaries align with metrics_date.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java-38-52 (1)

38-52: ⚠️ Potential issue | 🟡 Minor

주간/월간 엔드포인트의 날짜 파라미터 포맷을 일간/시간별과 통일하여 클라이언트 혼선을 제거한다.

운영/SDK 관점에서 동일 API 그룹 내 엔드포인트가 파라미터 포맷을 다르게 취급하면 클라이언트 측 형식 분기 로직이 증가하고 자동 생성 SDK의 개발자 경험이 악화된다.

  • 현재: 일/시간별은 String date (yyyyMMdd), 주/월간은 LocalDate date (yyyy-MM-dd)
  • 문제: 클라이언트는 동일 랭킹 API 그룹의 다른 엔드포인트마다 날짜 포맷을 변경해야 함

글로벌 MethodArgumentTypeMismatchException 핸들러(ApiControllerAdvice 34-40줄)가 이미 존재하므로 LocalDate 파싱 실패 시 오류 응답 포맷은 일관되나, API 설계 일관성을 위해 주/월간도 String으로 받고 도메인 레이어(RankingKeyResolver 계열)에서 파싱·검증하여 CoreException으로 통일할 것을 권장한다. 이는 기존 일/시간별 방식과 정합되며 향후 유사한 바인딩 타입 예외에 대한 일괄 처리보다 명시적인 제어 흐름을 확보한다.

테스트: date=invalid, date=2025-02-30, date= (빈 값) 세 케이스에 대해 응답이 CoreException 경로와 동일한지 검증하는 E2E 테스트를 추가한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java`
around lines 38 - 52, Change the weekly/monthly endpoints to accept String date
(like daily/hourly) instead of LocalDate: update method signatures in
RankingV1ApiSpec for getWeeklyRanking and getMonthlyRanking to use String date,
then move parsing/validation into the domain resolver (RankingKeyResolver
family) so it converts the incoming String to the appropriate date/key and
throws CoreException on invalid input; ensure parsing uses the same format/logic
as the existing daily/hourly path and keep ApiControllerAdvice unchanged, and
add E2E tests for date=invalid, date=2025-02-30, and date= (empty) to verify
they follow the CoreException error path.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java-14-17 (1)

14-17: ⚠️ Potential issue | 🟡 Minor

API 모듈 MonthlyRank 엔티티에서 인덱스 정의를 추가해야 한다.

배치 모듈의 MvProductRankMonthly 엔티티에는 @Index(name = "idx_snapshot_rank", columnList = "snapshot_date, rank_position")이 정의되어 있으나, API 모듈의 MonthlyRank 엔티티에는 이 인덱스 선언이 누락되어 있다. 동일한 테이블(mv_product_rank_monthly)에 매핑되는 엔티티이므로 양쪽 정의를 동기화해야 한다.

운영 관점의 추가 확인 사항:

  1. 페이지네이션 성능: findBySnapshotDateOrderByRankAsc에서 (snapshot_date, rank_position) 인덱스가 실제 DB 스키마에 존재하는지 확인 필요. 인덱스 부재 시 대량 스냅샷에 대해 full table scan 비용 발생.
  2. MAX 스캔 최적화: findLatestSnapshotDate는 현재 테이블 전체 MAX 스캔이다. 스냅샷 보관 기간 증가 시 snapshot_date DESC 역방향 스캔 가능 여부를 DB 쿼리 플랜으로 검증하고, 필요시 인덱스 힌트 추가 고려.

추가 테스트: (1) 특정 snapshotDate에 대해 페이지 2, 3까지 rank 연속성 검증, (2) 데이터 미존재 시 findLatestSnapshotDate()Optional.empty()를 정상 반환하는지 검증.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java`
around lines 14 - 17, The MonthlyRank entity is missing the DB index that exists
in the batch module; add an equivalent `@Index`(name = "idx_snapshot_rank",
columnList = "snapshot_date, rank_position") to the MonthlyRank entity mapping
for table mv_product_rank_monthly so the (snapshot_date, rank_position) index is
declared and kept in sync; then re-run schema validations/DDL and verify that
findBySnapshotDateOrderByRankAsc will use the composite index and that
findLatestSnapshotDate still returns Optional.empty() when no rows (and consider
checking query plan for MAX(snapshot_date) to decide if additional index hints
or DESC access are needed.
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java-27-35 (1)

27-35: ⚠️ Potential issue | 🟡 Minor

필수 입력(snapshotDate, 가중치)에 대한 방어가 부족하다.

  • LocalDate.parse(snapshotDateStr)snapshotDateStr가 null이면 NullPointerException, 포맷 오류면 DateTimeParseException을 던지며 배치는 FAILED로 떨어진다. ManualRunner/스케줄러에서 파라미터 누락 시 로그만으로 원인 추적이 어려우므로, 사전 검증 후 명시적 메시지로 실패시키는 편이 운영에 유리하다.
  • weightRepository.findWeightByEventType("VIEW"/"LIKE"/"ORDER")null을 반환하면 라인 69의 toPlainString()에서 NPE가 발생해 SQL 빌드 단계에서 터진다. DB SOT라는 전제하에 행이 누락된 경우(마이그레이션 미실행 등)를 대비해 명시적 검증을 추가할 것을 권장한다.
🛡️ 제안 변경
-        BigDecimal viewWeight  = weightRepository.findWeightByEventType("VIEW");
-        BigDecimal likeWeight  = weightRepository.findWeightByEventType("LIKE");
-        BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER");
-
-        String sql = buildSql(viewWeight, likeWeight, orderWeight);
-
-        LocalDate snapshot = LocalDate.parse(snapshotDateStr);
+        if (snapshotDateStr == null || snapshotDateStr.isBlank()) {
+            throw new IllegalArgumentException("jobParameters['snapshotDate'] 는 필수이다.");
+        }
+        BigDecimal viewWeight  = requireWeight("VIEW",  weightRepository.findWeightByEventType("VIEW"));
+        BigDecimal likeWeight  = requireWeight("LIKE",  weightRepository.findWeightByEventType("LIKE"));
+        BigDecimal orderWeight = requireWeight("ORDER", weightRepository.findWeightByEventType("ORDER"));
+
+        String sql = buildSql(viewWeight, likeWeight, orderWeight);
+
+        LocalDate snapshot = LocalDate.parse(snapshotDateStr);

그리고 requireWeight는 null 시 IllegalStateException("ranking_weight[" + type + "] 누락") 을 던지도록 헬퍼로 추출한다. 동일 로직은 MonthlyRankReaderConfig에도 일관 적용한다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java`
around lines 27 - 35, WeeklyRankReaderConfig currently parses snapshotDateStr
with LocalDate.parse and reads weights via
weightRepository.findWeightByEventType without null checks, which can throw
unclear NPE/DateTimeParseException during batch runs; add validation: check
snapshotDateStr for null/empty and parse with try/catch to throw a clear
IllegalArgumentException or fail fast with a descriptive message, and validate
each weight returned from weightRepository (for "VIEW","LIKE","ORDER") before
calling toPlainString(), throwing an IllegalStateException when a weight is
missing; extract a small helper (e.g., requireWeight(String type)) that calls
findWeightByEventType, checks for null and throws
IllegalStateException("ranking_weight[" + type + "] 누락") to reuse here and in
MonthlyRankReaderConfig, and then pass validated BigDecimals into buildSql.
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java-39-47 (1)

39-47: ⚠️ Potential issue | 🟡 Minor

Redis 장애 시 Job 상태 일관성을 방어한다.

stringRedisTemplate.opsForValue().set(...)LocalDate.parse(...)는 예외 전파 경로인데, afterJob에서 예외가 던져지면 스프링 배치는 이 Job Execution을 이미 COMPLETED로 기록한 뒤라도 리스너 예외로 로그가 오염되고, 운영상 "DB 적재는 성공했으나 latest_date 캐시만 지연"이라는 흔한 사건이 알람 노이즈가 된다. 아래와 같이 파싱 실패/Redis 실패를 분리 로깅하고 스왈로우하는 것이 의도(캐시는 성능 보조 · DB가 SOT)에 부합한다.

🛡️ 제안 변경
-        LocalDate snapshotDate = LocalDate.parse(snapshotDateStr);
-        long count = countByJobName(jobName, snapshotDate);
+        LocalDate snapshotDate;
+        try {
+            snapshotDate = LocalDate.parse(snapshotDateStr);
+        } catch (DateTimeParseException e) {
+            log.error("[{}] snapshotDate 파라미터 파싱 실패: {}", jobName, snapshotDateStr, e);
+            return;
+        }
+        long count = countByJobName(jobName, snapshotDate);
         if (count == 0) {
             log.warn("[{}] snapshotDate={} 적재 데이터 없음 — latest_date 캐시 갱신 스킵", jobName, snapshotDate);
             return;
         }
-
-        stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL);
-        log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDateStr);
+        try {
+            stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL);
+            log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDateStr);
+        } catch (DataAccessException e) {
+            log.error("[{}] latest_date 캐시 put 실패 (DB MAX() fallback 으로 복구 가능): {}", jobName, cacheKey, e);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java`
around lines 39 - 47, The current code in RankingLatestDateCacheListener (in
afterJob) lets LocalDate.parse(snapshotDateStr) and
stringRedisTemplate.opsForValue().set(...) throw and propagate from the
listener; change it to swallow and separate errors: wrap the parse in a
try/catch that logs a warning including jobName and snapshotDateStr and returns
without throwing, then after count check wrap the Redis set call
(stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL)) in its
own try/catch that logs an error including jobName, cacheKey, snapshotDateStr
and the exception but does not rethrow; keep countByJobName and logging behavior
otherwise so DB success remains authoritative and listener failures do not fail
the Job execution.
apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java-47-53 (1)

47-53: ⚠️ Potential issue | 🟡 Minor

사용되지 않는 ignoredProductId 파라미터는 호출자에게 혼동을 준다

인자로 받은 ignoredProductId는 메서드 내부에서 전혀 참조되지 않고 항상 새로 만든 product.getId()를 사용한다. 운영/테스트 관점에서 호출자가 "전달한 productId로 insert된다"고 오해해 테스트 가정이 틀어질 수 있다. 해당 파라미터를 제거하거나, 의도적으로 "ID 자동 생성" 의미를 드러내는 이름으로 바꾸는 편이 안전하다.

또한 매 호출마다 Brand를 새로 생성하므로 다수 호출 시 브랜드가 N개 쌓인다. 고정 브랜드를 공유하도록 1회만 생성해 캐싱하면 테스트 데이터 준비 비용이 줄어든다. 추가 테스트로 동일 snapshotDate에 여러 rank를 insert한 뒤 countBySnapshotDate 또는 상위 API 조회 결과 순서가 기대치와 일치하는지 검증하는 케이스를 권장한다.

♻️ 제안 수정
-    public long insertWithProduct(LocalDate snapshotDate, long ignoredProductId, int rank) {
-        Brand brand = brandJpaRepository.save(new Brand("테스트브랜드"));
+    public long insertWithProduct(LocalDate snapshotDate, int rank) {
+        Brand brand = brandJpaRepository.save(new Brand("테스트브랜드"));
         Product product = productJpaRepository.save(
             new Product(brand.getId(), "테스트상품" + rank, new Money(10000), new Stock(100)));
         insert(snapshotDate, product.getId(), rank);
         return product.getId();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java`
around lines 47 - 53, The method insertWithProduct currently declares an unused
parameter ignoredProductId which is confusing; remove that parameter from the
signature of insertWithProduct and update all callers to stop passing an ID (or
rename to indicate "autoGenerated" if you must preserve API), and change the
method body to use the created product's ID as it does now; also avoid creating
a new Brand on every call by introducing a cached/shared Brand instance (e.g., a
private field initialized once or lazily in MonthlyRankTestFixture) and use its
id when constructing new Product to reduce test setup cost; update any tests
calling insertWithProduct accordingly.
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java-48-60 (1)

48-60: ⚠️ Potential issue | 🟡 Minor

광범위한 catch (Exception)InterruptedException 인터럽트 플래그 복원 누락

운영 관점에서 두 가지 우려가 있다.

  1. jobLauncher.runJobExecutionAlreadyRunningException, JobRestartException, JobParametersInvalidException 등 의미가 다른 체크 예외를 던지는데, 모두 동일하게 error 로그만 남기고 알림/재시도 훅이 없다. 최소한 예외 타입별로 분기해 "이미 실행 중"은 warn, "파라미터 불량"은 error + 알림 대상 표기로 구분하는 것이 장애 분류에 유리하다.
  2. catch (Exception)은 체크 예외 아래로 흘러 들어올 수 있는 InterruptedException도 삼키며 인터럽트 플래그를 복원하지 않는다. 스케줄 스레드가 종료 신호를 상실할 수 있다.

추가 테스트로 JobInstanceAlreadyCompleteException 외에 JobExecutionAlreadyRunningException 발생 시의 로그/미전파 동작을 커버하는 것이 좋다.

🛡️ 제안 수정
         } catch (JobInstanceAlreadyCompleteException e) {
             log.warn("[{}] snapshotDate={} 이미 완료된 JobInstance — 재실행 스킵", jobName, snapshotDate);
-        } catch (Exception e) {
-            log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e);
+        } catch (JobExecutionAlreadyRunningException e) {
+            log.warn("[{}] snapshotDate={} 이미 실행 중 — 중복 실행 스킵", jobName, snapshotDate);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("[{}] 실행 중 인터럽트 발생 snapshotDate={}", jobName, snapshotDate);
+        } catch (Exception e) {
+            log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java`
around lines 48 - 60, The run method in RankingScheduler swallows all exceptions
and fails to restore the interrupt flag; update run(Job job, String jobName) to
catch specific exceptions from jobLauncher.run—handle
JobInstanceAlreadyCompleteException (keep warn),
JobExecutionAlreadyRunningException (warn + no retry), JobRestartException
(error + alert hook), and JobParametersInvalidException (error + alert/skip)
separately with distinct log messages including jobName and snapshotDate; add a
separate catch for InterruptedException that restores
Thread.currentThread().interrupt() and logs interrupt at warn level; finally
keep a narrow fallback for other Exceptions but ensure they trigger
alerting/retry hooks as appropriate and add unit/integration tests that simulate
JobExecutionAlreadyRunningException and InterruptedException to verify logging
and interrupt flag restoration.
apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java-42-52 (1)

42-52: ⚠️ Potential issue | 🟡 Minor

CLI 인자 파싱 실패 시 예외 메시지/스택이 불친절하다

운영 관점에서 다음 세 경로가 모두 원시 예외를 그대로 던져 운영자가 원인을 파악하기 어렵다.

  • --trigger 만 주고 값이 없는 경우: getOptionValues("trigger")가 빈 리스트를 반환하며 get(0)에서 IndexOutOfBoundsException 발생.
  • 오탈자(--trigger=WIEGHT_CHANGE 등) 시: RankingJobTrigger.valueOf(...)IllegalArgumentException: No enum constant ...를 그대로 던진다. 허용 값 목록이 로그에 남지 않는다.
  • --snapshotDate=2026/04/10 등 포맷 오류: DateTimeParseException이 그대로 전파된다.

세 경우 모두 입력 검증 후 명확한 warn 로그로 전환하고 프로세스를 정상 종료하도록 수정하는 편이 수동 재실행 UX에 적합하다. 추가 테스트로는 (1) --trigger 값 누락, (2) 알 수 없는 trigger, (3) 잘못된 snapshotDate 포맷의 3종 케이스를 CLI 진입점 통합 테스트에 포함시키는 것이 좋다.

🛡️ 제안 수정
-        String triggerStr = args.getOptionValues("trigger").get(0);
-        RankingJobTrigger trigger = RankingJobTrigger.valueOf(triggerStr);
+        List<String> triggerValues = args.getOptionValues("trigger");
+        if (triggerValues == null || triggerValues.isEmpty()) {
+            log.warn("[RankingManualRunner] --trigger 값이 누락되었다. 허용 값={}",
+                Arrays.toString(RankingJobTrigger.values()));
+            return;
+        }
+        String triggerStr = triggerValues.get(0);
+        RankingJobTrigger trigger;
+        try {
+            trigger = RankingJobTrigger.valueOf(triggerStr);
+        } catch (IllegalArgumentException e) {
+            log.warn("[RankingManualRunner] 알 수 없는 trigger={} (허용={})",
+                triggerStr, Arrays.toString(RankingJobTrigger.values()));
+            return;
+        }
@@
-        LocalDate snapshotDate = args.containsOption("snapshotDate")
-            ? LocalDate.parse(args.getOptionValues("snapshotDate").get(0))
-            : LocalDate.now(ZoneId.of("Asia/Seoul"));
+        LocalDate snapshotDate;
+        try {
+            snapshotDate = args.containsOption("snapshotDate")
+                ? LocalDate.parse(args.getOptionValues("snapshotDate").get(0))
+                : LocalDate.now(ZoneId.of("Asia/Seoul"));
+        } catch (DateTimeParseException | IndexOutOfBoundsException e) {
+            log.warn("[RankingManualRunner] 잘못된 --snapshotDate 입력 (yyyy-MM-dd 필요)", e);
+            return;
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java`
around lines 42 - 52, RankingManualRunner currently lets raw exceptions
propagate when CLI parsing fails: calling args.getOptionValues("trigger").get(0)
can throw IndexOutOfBoundsException, RankingJobTrigger.valueOf(...) can throw
IllegalArgumentException, and LocalDate.parse(...) can throw
DateTimeParseException; update the CLI parsing in RankingManualRunner to
validate inputs and handle these cases by (1) checking
args.containsOption("trigger") and that getOptionValues("trigger") is non-empty
before calling get(0) and log a clear warn + exit, (2) wrapping
RankingJobTrigger.valueOf(triggerStr) in a safe lookup (e.g., try/catch or Enum
lookup with a set of allowed names) to log allowed enum values on unknown input
and exit, and (3) parsing snapshotDate with a try/catch around
LocalDate.parse(...) to log the expected yyyy-MM-dd format and exit on parse
failure; add unit/integration tests covering the three scenarios (missing
trigger, unknown trigger, bad snapshotDate) to the CLI entrypoint tests.
apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java-75-92 (1)

75-92: ⚠️ Potential issue | 🟡 Minor

Thread.sleep(10)에 의존하는 타이밍 테스트는 플래키하다

운영/CI 관점에서 NOW(6)의 마이크로초 해상도라도 빠른 머신에서는 두 UPSERT의 타임스탬프가 드물게 동일해질 수 있고, 그럴 경우 "값이 갱신되지 않았다"와 "동일 시각에 갱신됐다"를 구분할 수 없다. created_at 보존의 의도는 "두 번째 UPSERT가 created_at을 갱신하지 않는다"이므로, 물리 시간이 아닌 DB가 명시적으로 다른 값을 가지도록 제어하는 것이 안정적이다.

수정안: 첫 INSERT 직후 jdbc.update(...)created_at을 과거 시각(예: '2000-01-01')으로 강제 업데이트한 뒤 두 번째 upsertAll 호출 후 조회하여 여전히 과거 시각인지 검증한다. 이렇게 하면 Thread.sleep 없이 결정적으로 검증된다.

🧪 제안 수정
-            LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
-            Thread.sleep(10);
+            jdbc.getJdbcTemplate().update(
+                "UPDATE mv_product_rank_monthly SET created_at = ? WHERE snapshot_date = ? AND product_id = ?",
+                LocalDateTime.of(2000, 1, 1, 0, 0), date, 1L);
+            LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();

추가 테스트로 (a) upsertAll(List.of()) 빈 입력 안전성, (b) 한 청크에 동일 (snapshot_date, product_id)가 중복으로 들어온 경우의 동작을 포함하면 경계값 커버리지가 향상된다.

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

In
`@apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java`
around lines 75 - 92, The test createdAtIsPreservedOnUpdate relies on
Thread.sleep and is flaky; replace the timing dependency by forcing the row's
created_at to a deterministic past value via a direct JDBC update after the
first repository.upsertAll call (e.g. set created_at = '2000-01-01'), then call
repository.upsertAll again and assert that
repository.findBySnapshotDateAndProductId still returns the forced past
created_at; remove Thread.sleep and add separate tests for upsertAll(List.of())
(empty input) and behavior when a single chunk contains duplicate
(snapshot_date, product_id) entries to cover boundary cases.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java-144-197 (1)

144-197: ⚠️ Potential issue | 🟡 Minor

월간 E2E 커버리지가 주간 대비 비대칭이다.

주간 Nested 클래스에는 withoutDate_cacheHitresponseSchemaValidation 이 있는데 월간에는 둘 다 빠져 있다. 월간 경로는 리포지토리 구현이 다르기 때문에(monthlyRankRepository::findLatestSnapshotDate, MonthlyRank 엔티티) 동일한 회귀가 발생할 수 있다. 특히 latest_date 캐시 HIT 경로와 응답 스키마(productName/brandName/price 비어있지 않음)는 월간에서도 별도로 검증할 것을 권장한다.

추가 테스트 제안: 주간과 동일 구조로 withoutDate_cacheHit, responseSchemaValidation 시나리오를 월간에 복제한다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java`
around lines 144 - 197, Add two tests inside the MonthlyRanking nested class
mirroring the weekly scenarios: implement withoutDate_cacheHit to pre-seed
MONTHLY_LATEST_KEY in Redis, call
restTemplate.exchange("/api/v1/rankings/monthly?page=0&size=20", ...) and assert
200 plus that the cached date was used (no DB fallback; verify
stringRedisTemplate opsForValue still returns the seeded date) by referencing
monthlyRankRepository::findLatestSnapshotDate / MONTHLY_LATEST_KEY; and
implement responseSchemaValidation to insert a MonthlyRank via
monthlyFixture.insertWithProduct(...) then call the monthly endpoint with
explicit date and assert the response body rankings contain non-empty
productName, brandName and price fields (same assertions as the weekly
responseSchemaValidation), using the existing restTemplate exchange and
ApiResponse<RankingV1Dto.RankingListResponse> parsing.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-170-176 (1)

170-176: ⚠️ Potential issue | 🟡 Minor

명시된 date 에 스냅샷이 아직 없을 때 빈 결과가 그대로 캐시에 저장된다.

given != null 경로는 resolveSnapshotDate 의 null 체크를 우회하므로, 사용자가 date=2026-04-17 을 요청하고 아직 해당 스냅샷 행이 적재되기 전이라면, 빈 RankingResult 가 cacheKey 로 저장된다. 이후 배치가 해당 일자에 TOP-100 을 적재해도 TTL 만료 전까지는 빈 목록이 계속 서빙된다. 운영 관점에서 배치 지연/재실행 직후 사용자 체감 장애로 이어진다. 빈 결과는 캐시에 쓰지 않거나(권장), 매우 짧은 negative TTL 을 별도로 적용하는 전략이 필요하다.

🛡️ 제안 패치
         Page<WeeklyRank> ranks = weeklyRankRepository.findBySnapshotDateOrderByRankAsc(
             snapshot, PageRequest.of(page, size));
         RankingResult result = toRankingResult(ranks.getContent(), r -> r.getRank(),
             r -> r.getProductId(), r -> r.getScore(), page, size, ranks.getTotalElements());
-        rankingCacheRepository.save(cacheKey, result);
+        if (!result.items().isEmpty()) {
+            rankingCacheRepository.save(cacheKey, result);
+        }
         return result;

(월간 경로에도 동일 적용)

추가 테스트 제안: 명시 date 로 조회 시 빈 결과가 캐시에 저장되지 않는지(두 번째 호출이 DB 를 다시 조회하는지) 검증하는 케이스 추가를 권장한다.

Also applies to: 196-202

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 170 - 176, 현재 구현은 사용자가 명시한 date 경로(given != null /
resolveSnapshotDate)를 통해 스냅샷이 아직 없을 때 빈 RankingResult를 생성해
rankingCacheRepository.save(cacheKey, result)로 바로 캐시해 버립니다; 빈 결과는 캐시에 쓰지 않거나(권장)
별도의 짧은 negative TTL을 적용하도록 수정하세요: findBySnapshotDateOrderByRankAsc(...)로 조회한 후
ranks.getContent()가 빈 경우에는 rankingCacheRepository.save를 호출하지 않도록 하고(월간 경로도 동일
적용), 만약 부득이 캐싱한다면 save 시점에 negative TTL 옵션을 사용해 매우 짧게 설정하도록 하며
resolveSnapshotDate, RankingResult 및 rankingCacheRepository.save 호출 지점을 변경하세요;
또한 빈 결과를 캐시에 쓰지 않는 동작을 보장하는 통합/단위 테스트(명시 date로 첫 호출이 빈 결과일 때 두번째 호출이 DB를 다시
조회하는지)를 추가하세요.
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java-99-111 (1)

99-111: ⚠️ Potential issue | 🟡 Minor

mainCacheHit 테스트가 캐시 적중을 실제로 검증하지 못한다.

현재 단언문은 첫 호출 후 키 존재 여부만 확인한다. 이는 "캐시 키가 생성되었다"만 증명할 뿐, "두 번째 호출이 캐시에서 응답을 반환한다"는 시나리오명과 불일치한다. 운영 관점에서 캐시 로직이 비활성화되거나 키 스키마가 변경되어도 이 테스트는 여전히 통과할 수 있어 리그레션 감지력이 약하다.

수정안: 첫 호출 후 DB 레코드를 삭제(또는 값 변경)하고 두 번째 호출이 이전 결과와 동일하게 반환되는지(즉 DB가 아닌 캐시에서 응답) 검증하는 방식을 권장한다.

♻️ 제안 패치
 void mainCacheHit() {
     // arrange
-    fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+    long productId = fixture.insertWithProduct(SNAPSHOT, 1L, 1);
 
     // act
-    rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+    RankingResult first = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
     String cacheKey = "rankings:weekly:%s:0:20".formatted(SNAPSHOT);
 
-    // assert
-    assertThat(stringRedisTemplate.hasKey(cacheKey)).isTrue();
+    // DB 상태 변경 후에도 두 번째 호출은 캐시에서 동일 결과를 반환해야 함
+    databaseCleanUp.truncateAllTables();
+    RankingResult second = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+
+    // assert
+    assertThat(stringRedisTemplate.hasKey(cacheKey)).isTrue();
+    assertThat(second.items()).hasSameSizeAs(first.items());
+    assertThat(second.totalElements()).isEqualTo(first.totalElements());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java`
around lines 99 - 111, The test mainCacheHit currently only checks that the
Redis key exists after the first call; change it to verify an actual cache hit
by: call rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20) once, capture the
returned result, then remove or mutate the underlying DB data inserted by
fixture.insertWithProduct (so subsequent DB reads would differ or fail), call
rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20) a second time and assert the
second result equals the first (proving the response came from cache), while
still optionally asserting stringRedisTemplate.hasKey(cacheKey) remains true;
use the existing methods fixture.insertWithProduct and
rankingFacade.findWeeklyRanking to locate the code to modify.
apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java-62-66 (1)

62-66: ⚠️ Potential issue | 🟡 Minor

JobListener 가 남기는 Redis rankings:weekly:latest_date 키 정리가 누락되어 있다.

현재 tearDown 은 DB truncate 와 JobExecution 정리만 수행한다. 그러나 이번 PR 의 JobListenerCOMPLETED + loaded>0 조건일 때 Redis rankings:weekly:latest_date 를 쓰므로, 본 테스트 클래스 실행 이후 Redis 에 해당 키가 SNAPSHOT 값으로 남는다. 이는 동일 Redis 를 공유하는 다른 통합 테스트(특히 RankingFacadeWeeklyIntegrationTest, RankingV1ApiWeeklyMonthlyE2ETest 의 "cacheMiss" 시나리오)의 전제 조건을 깨뜨려 간헐적 실패를 유발할 수 있다. tearDown 에 Redis 키 삭제를 추가할 것을 권장한다.

♻️ 제안 패치
     `@Autowired`
     private DatabaseCleanUp databaseCleanUp;
+
+    `@Autowired`
+    private org.springframework.data.redis.core.StringRedisTemplate stringRedisTemplate;
 
     `@BeforeEach`
     void setUp() {
         jobLauncherTestUtils.setJob(weeklyRankingJob);
     }
 
     `@AfterEach`
     void tearDown() {
         databaseCleanUp.truncateAllTables();
         jobRepositoryTestUtils.removeJobExecutions();
+        stringRedisTemplate.delete("rankings:weekly:latest_date");
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java`
around lines 62 - 66, tearDown 메서드가 DB와 JobExecution만 정리하고 있어 JobListener가 남긴
Redis 키 "rankings:weekly:latest_date"를 제거하지 못해 이후 통합테스트에 간헐적 실패를 유발합니다;
tearDown( )에 Redis 정리 로직을 추가해 테스트가 사용 중인 Redis 클라이언트(예: RedisTemplate 또는
StringRedisTemplate 등 테스트 컨텍스트에서 사용 중인 인스턴스)를 사용해 키
"rankings:weekly:latest_date"를 삭제하도록 하세요(참조 메서드: tearDown,
databaseCleanUp.truncateAllTables(),
jobRepositoryTestUtils.removeJobExecutions(), JobListener).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: acfebd74-39d3-491b-ae73-0eb5fa0fa07c

📥 Commits

Reviewing files that changed from the base of the PR and between 9723b8c and e57fa56.

⛔ Files ignored due to path filters (4)
  • .docs/week10/design-notes.md is excluded by !**/*.md and included by **
  • .docs/week10/implementation_plan.md is excluded by !**/*.md and included by **
  • .docs/week10/learning-point.md is excluded by !**/*.md and included by **
  • .docs/week10/quests.md is excluded by !**/*.md and included by **
📒 Files selected for processing (48)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RealtimeRankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java
  • apps/commerce-api/src/test/java/com/loopers/fixture/WeeklyRankTestFixture.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/BatchRankingWeightRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/BatchRankingWeightRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java

Comment on lines +234 to +244
private LocalDate resolveSnapshotDate(LocalDate given, String latestKey,
Supplier<Optional<LocalDate>> dbFallback) {
if (given != null) return given;

String cached = stringRedisTemplate.opsForValue().get(latestKey);
if (cached != null) return LocalDate.parse(cached);

Optional<LocalDate> fromDb = dbFallback.get();
fromDb.ifPresent(d -> stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25)));
return fromDb.orElse(null);
}
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

Redis 장애 시 API 가 완전 실패한다 — 조회 경로에 예외 격리가 없다.

RankingCacheRepositoryImpl.get/save 는 모든 예외를 삼키고 Optional.empty() 또는 로그만 남기는 캐시-어사이드 철학으로 구현되어 있다. 그러나 resolveSnapshotDate 의 238번째 줄 Redis 읽기와 242번째 줄 Redis 쓰기는 보호 없이 수행되므로, Redis 다운/타임아웃 시 DB 폴백이 있음에도 불구하고 요청 전체가 500 으로 떨어진다. 운영 관점에서 Redis 는 보조 캐시이므로 장애 시 DB MAX 폴백만으로 서비스가 되어야 한다. RankingCacheRepository 와 동일한 패턴으로 try/catch 로 감싸 degrade 경로를 보장할 것을 권장한다.

🛡️ 제안 패치
     private LocalDate resolveSnapshotDate(LocalDate given, String latestKey,
             Supplier<Optional<LocalDate>> dbFallback) {
         if (given != null) return given;
 
-        String cached = stringRedisTemplate.opsForValue().get(latestKey);
-        if (cached != null) return LocalDate.parse(cached);
+        String cached = null;
+        try {
+            cached = stringRedisTemplate.opsForValue().get(latestKey);
+        } catch (Exception e) {
+            log.warn("latest_date Redis 조회 실패 (key={}): {}", latestKey, e.getMessage());
+        }
+        if (cached != null) {
+            try {
+                return LocalDate.parse(cached);
+            } catch (DateTimeParseException e) {
+                log.warn("latest_date 포맷 오류 (key={}, value={})", latestKey, cached);
+            }
+        }
 
         Optional<LocalDate> fromDb = dbFallback.get();
-        fromDb.ifPresent(d -> stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25)));
+        fromDb.ifPresent(d -> {
+            try {
+                stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25));
+            } catch (Exception e) {
+                log.warn("latest_date Redis 저장 실패 (key={}): {}", latestKey, e.getMessage());
+            }
+        });
         return fromDb.orElse(null);
     }

추가 테스트 제안: Redis 연결을 강제로 실패시키는(혹은 StringRedisTemplate 을 스파이로 throw 하도록 세팅한) 시나리오에서 주간/월간 API 가 DB 폴백으로 200 을 반환하는지 검증하는 테스트 케이스 추가를 권장한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 234 - 244, resolveSnapshotDate currently calls
stringRedisTemplate.opsForValue().get(latestKey) and .set(...) without
isolation, so Redis failures bubble up; wrap both the read and the write in
try/catch blocks (mirroring RankingCacheRepositoryImpl’s degrade pattern) so any
Redis exception is caught, logged, and ignored, then continue to use
dbFallback.get() (and still set the cache only if no exception and value
present, preserving Duration.ofHours(25)); ensure the method still returns the
given date if non-null, and returns from dbFallback.orElse(null) when Redis is
unavailable.

Comment on lines +50 to +70
@GetMapping("/weekly")
@Override
public ApiResponse<RankingV1Dto.RankingListResponse> getWeeklyRanking(
@RequestParam(required = false) LocalDate date,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size)
{
var result = rankingFacade.findWeeklyRanking(date, page, size);
return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
}

@GetMapping("/monthly")
@Override
public ApiResponse<RankingV1Dto.RankingListResponse> getMonthlyRanking(
@RequestParam(required = false) LocalDate date,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size)
{
var result = rankingFacade.findMonthlyRanking(date, page, size);
return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
}
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
rg -nP -C2 'findWeeklyRanking|findMonthlyRanking' --type=java
rg -nP -C3 'getWeeklyRanking|getMonthlyRanking' --type=java

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

Length of output: 10954


🏁 Script executed:

#!/bin/bash
# Check daily/hourly endpoint definitions
rg -nP 'getDaily|getDailyRanking|getHourly|getHourlyRanking' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/

# Check for DATE_FORMAT constant
rg -nP 'DATE_FORMAT|SimpleDateFormat|DateTimeFormatter' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/

# Look for request parameter handling with yyyyMMdd pattern
rg -nP 'yyyyMMdd|yyyy-MM-dd' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/

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

Length of output: 2356


🏁 Script executed:

#!/bin/bash
# Get the full RankingV1Controller to see all endpoints
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java

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

Length of output: 3576


🏁 Script executed:

#!/bin/bash
# Check for ApiSpec parameter documentation
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java

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

Length of output: 2636


🏁 Script executed:

#!/bin/bash
# Check for error/exception handlers in the codebase
rg -nP 'ApiControllerAdvice|@ExceptionHandler|MethodArgumentTypeMismatchException|DateTimeParseException' --type=java apps/commerce-api/src/main/java/

# Look for global error handling configuration
rg -nP 'class.*ControllerAdvice|class.*ExceptionHandler' --type=java apps/commerce-api/src/main/java/

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

Length of output: 1801


🏁 Script executed:

#!/bin/bash
# Check if there are tests that show actual error behavior
rg -nP 'MethodArgumentTypeMismatchException|DateTimeParseException|invalid.*date|wrong.*format' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check the RankingFacade to see if date validation happens there
rg -A5 'findDailyRanking|findWeeklyRanking' --type=java apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | head -40

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

Length of output: 955


🏁 Script executed:

#!/bin/bash
# Get the full ApiControllerAdvice
cat -n apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

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

Length of output: 7266


🏁 Script executed:

#!/bin/bash
# Check if there's any DateTimeParseException handler
rg -B2 -A10 'DateTimeParseException' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Look for any tests that verify error responses for invalid date formats
rg -B3 -A5 'getWeekly|getMonthly|getDailyRanking|getHourlyRanking' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/ | head -80

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for E2E or integration tests for ranking endpoints
find apps/commerce-api/src/test -name "*RankingV1*" -o -name "*Ranking*E2E*" | head -20

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

Length of output: 281


🏁 Script executed:

#!/bin/bash
# Check if there are any tests that verify invalid date format handling
rg -B2 -A8 'invalid|wrong|yyyyMMdd|yyyy-MM-dd' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/ 2>/dev/null || echo "No matches found"

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

Length of output: 1877


🏁 Script executed:

#!/antml:bash
# Verify the exact signature of DateTimeParseException in daily/hourly 
# and confirm no try-catch is present
rg -B5 -A5 'LocalDate.parse\(date, DATE_FORMAT\)' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java

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

Length of output: 1225


🏁 Script executed:

#!/bin/bash
# Check the weekly/monthly E2E test to see what format it's using
cat -n apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java | head -80

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

Length of output: 4109


🏁 Script executed:

#!/bin/bash
# Search for any try-catch or exception handling around date parsing in the controller
rg -B10 -A10 'public ApiResponse.*getDaily|public ApiResponse.*getHourly|public ApiResponse.*getWeekly|public ApiResponse.*getMonthly' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java | head -50

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

Length of output: 2295


🏁 Script executed:

#!/bin/bash
# Check if DateTimeParseException is mentioned or handled anywhere in the interfaces/api module
rg -nP 'DateTimeParseException' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check what error type is returned for DateTimeParseException (should be generic INTERNAL_ERROR)
rg -A5 'handle\(Throwable e\)' --type=java apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java

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

Length of output: 336


🏁 Script executed:

#!/antml:bash
# Check if there are any tests that verify error responses for invalid formats
rg -B3 -A8 'invalid.*date|wrong.*format|DateTimeParseException|MethodArgumentTypeMismatchException' --type=java apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Look for try-catch patterns around LocalDate.parse in the codebase
rg -B5 -A10 'try.*LocalDate\.parse|LocalDate\.parse.*catch' --type=java apps/commerce-api/src/main/java/

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

Length of output: 67


🏁 Script executed:

#!/antml:bash
# Check RankingKeyResolver to see how it handles DateTimeParseException
cat -n apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKeyResolver.java | head -80

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

Length of output: 199


🏁 Script executed:

#!/bin/bash
# Find the RankingKeyResolver file
find apps/commerce-api -name "RankingKeyResolver.java" -type f

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

Length of output: 67


🏁 Script executed:

#!/antml:bash
# Search for the domain class mentioned in learnings
rg -l 'RankingKeyResolver' --type=java apps/commerce-api/src/main/java/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Look for any try-catch patterns with DateTimeParseException in the entire codebase
rg -B5 -A8 'catch.*DateTimeParseException|DateTimeParseException.*catch' --type=java apps/commerce-api/src/main/java/

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

Length of output: 1562


동일 리소스군에서 date 파라미터 타입·포맷이 불일치하고, 파싱 실패 시 에러 응답이 불일관하다.

같은 /api/v1/rankings/* 네임스페이스에서 daily/hourlyString + yyyyMMdd 형식인데(라인 26-48), weekly/monthlyLocalDate + yyyy-MM-dd 형식(라인 50-70)으로 받고 있다. 클라이언트 입장에서 같은 엔드포인트 패밀리에 두 가지 날짜 포맷을 요구하게 되므로 문서화 혼란과 호출 오류 가능성이 크다.

더 심각한 것은 파싱 실패 시 에러 응답이 불일관하다는 점이다:

  • weekly/monthly (LocalDate 자동 변환): MethodArgumentTypeMismatchException → ApiControllerAdvice가 캡처 → BAD_REQUEST (400)
  • daily/hourly (수동 파싱): DateTimeParseException 발생 → 미캡처 → 일반 Throwable 핸들러 → INTERNAL_ERROR (500)

사용자 입력 오류인데 서버 에러로 응답하는 것은 운영 관점에서 심각하다. 기존 daily/hourly 포맷(yyyyMMdd)으로 통일하되, 파싱 실패 시 try-catch로 CoreException을 던져 ApiControllerAdvice를 통한 표준 응답 포맷(BAD_REQUEST)을 보장해야 한다.

♻️ 제안 변경
    `@GetMapping`("/weekly")
    `@Override`
    public ApiResponse<RankingV1Dto.RankingListResponse> getWeeklyRanking(
-           `@RequestParam`(required = false) LocalDate date,
+           `@RequestParam`(required = false) String date,
            `@RequestParam`(defaultValue = "0") int page,
            `@RequestParam`(defaultValue = "20") int size)
    {
+       LocalDate targetDate = null;
+       if (date != null) {
+           try {
+               targetDate = LocalDate.parse(date, DATE_FORMAT);
+           } catch (DateTimeParseException e) {
+               throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (형식: yyyyMMdd)");
+           }
+       }
-       var result = rankingFacade.findWeeklyRanking(date, page, size);
+       var result = rankingFacade.findWeeklyRanking(targetDate, page, size);
        return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
    }

monthly 핸들러도 동일하게 적용한다. ApiSpec·RankingFacade 시그니처는 이미 LocalDate 타입이므로 변경 없음. E2E 테스트(RankingV1ApiWeeklyMonthlyE2ETest 라인 58)에서 SNAPSHOT.toString() 대신 SNAPSHOT.format(DATE_FORMAT)으로 수정해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 50 - 70, The controllers getWeeklyRanking and getMonthlyRanking in
RankingV1Controller use LocalDate binding (yyyy-MM-dd) while daily/hourly use
String (yyyyMMdd), causing inconsistent formats and inconsistent error handling;
change weekly/monthly to accept String date in yyyyMMdd, parse it with
DateTimeFormatter.ofPattern("yyyyMMdd") inside the methods, wrap parsing in
try-catch and throw the existing CoreException (so ApiControllerAdvice returns
BAD_REQUEST) before calling rankingFacade.findWeeklyRanking/findMonthlyRanking
(no signature change to RankingFacade). Also update the E2E test
RankingV1ApiWeeklyMonthlyE2ETest to use SNAPSHOT.format(DATE_FORMAT) instead of
SNAPSHOT.toString() so tests use the yyyyMMdd format.

Comment on lines +18 to +30
private final AtomicInteger rankCounter = new AtomicInteger(0);

public MonthlyRankProcessor(@Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
this.snapshotDate = LocalDate.parse(snapshotDateStr);
}

@Override
public MvProductRankMonthly process(RankingAggregateRow item) {
int rank = rankCounter.incrementAndGet();
return new MvProductRankMonthly(
snapshotDate, item.productId(), rank, item.score(),
item.viewCount(), item.likeCount(), item.orderRevenue());
}
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
# Job에 preventRestart 또는 재시작 정책이 설정되어 있는지 확인
rg -nP -C3 '(MonthlyRankingJobConfig|WeeklyRankingJobConfig)\b'
rg -nP -C2 'preventRestart|allowStartIfComplete|startLimit'
# reader가 rank를 DB에서 계산하는지 확인
rg -nP -C3 'ROW_NUMBER|RANK\(\)|DENSE_RANK'

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

Length of output: 3979


🏁 Script executed:

#!/bin/bash
# Get the full MonthlyRankingJobConfig to check restart policy
find . -name "MonthlyRankingJobConfig.java" -type f | head -1 | xargs cat -n

# Check MonthlyRankProcessor for `@StepScope` and other scope annotations
find . -name "MonthlyRankProcessor.java" -type f | head -1 | xargs cat -n

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

Length of output: 4120


🏁 Script executed:

#!/bin/bash
# Check the MonthlyRankingJobIntegrationTest for restart test
find . -name "MonthlyRankingJobIntegrationTest.java" -type f | head -1 | xargs cat -n | head -100

# Search for the monthlyRankReader bean definition (should be in same config or reader config)
rg -n "monthlyRankReader|RankingAggregateRow" --type java apps/commerce-batch/src/main/java -A 5 | head -80

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

Length of output: 14879


🏁 Script executed:

#!/bin/bash
# Read more of the integration test
find . -name "MonthlyRankingJobIntegrationTest.java" -type f | head -1 | xargs cat -n | tail -n +100

# Find reader configuration
find . -path "*/ranking/*" -name "*Reader*.java" -o -name "*ReaderConfig*.java" | head -10

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

Length of output: 2249


🏁 Script executed:

#!/bin/bash
# Check the reader configuration for ranking computation
find . -name "MonthlyRankReaderConfig.java" -type f | head -1 | xargs cat -n

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

Length of output: 3543


스텝 재시작 시 rankCounter가 0부터 다시 증가하여 랭크 순서 정합성이 깨진다.

@StepScope 덕에 각 Step 실행마다 MonthlyRankProcessor는 새 인스턴스로 생성되고, AtomicInteger rankCounter는 0부터 시작한다. 청크 처리 중 실패로 동일 JobExecution 내에서 스텝이 재시작되면, 리더는 동일한 SQL 쿼리를 다시 실행하여 상위 100개 상품을 동일한 순서로 반환한다. 프로세서는 이 상품들에 대해 1부터 100까지 순위를 다시 할당하게 되는데, 라이터의 UPSERT((snapshot_date, product_id) 기준)로 인해 재처리된 상품의 순위는 새로운 값으로 덮어써지는 반면, 실패 지점 이전까지 처리된 상품들은 기존 순위를 유지한다. 결과적으로 rank_position에 중복이 발생하고 순서 번호에 간격이 생겨 TOP-100 랭킹의 순서 정합성이 깨진다.

운영 관점에서 이 지점은 배치 장애 복구 시 "부분 재실행" 시나리오에 노출되는 사용자 랭킹을 왜곡시킨다. 다음 중 하나의 방안을 선택하여 수정한다:

  1. 해당 Job을 재시작 불가(.restartable(false))로 구성하고, 실패 시 snapshotDate를 동일하게 유지하여 새 JobExecution으로 처음부터 재실행한다.
  2. ItemStream을 구현하여 ExecutionContext에 카운터를 체크포인트로 저장 및 복원한다.
  3. 리더 수준에서 SQL ORDER BY ... LIMIT 100 대신 ROW_NUMBER() OVER (ORDER BY score DESC) AS rank를 사용하여 DB가 순위를 계산하도록 위임한다.

추가로 스텝 재시작 시나리오에 대한 통합 테스트를 추가하여 청크 중간 실패 후 재시작 시 모든 rank_position이 1..N 유일성을 유지하는지 검증한다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java`
around lines 18 - 30, MonthlyRankProcessor currently resets AtomicInteger
rankCounter to 0 on step restart causing duplicate/missing rank_positions;
implement ItemStream on MonthlyRankProcessor and persist the counter into the
ExecutionContext (use a unique key like "MonthlyRankProcessor.rankCounter") by:
add implements ItemStream, add open(ExecutionContext) to restore rankCounter
from the context if present, add update(ExecutionContext) to write current
rankCounter.get(), and leave close() no-op; ensure process(RankingAggregateRow)
continues to use rankCounter.incrementAndGet() so resumed runs continue ranking
from the last checkpointed value.

Comment on lines +66 to +67
ORDER BY score DESC
LIMIT 100
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

ORDER BY score DESC 단일 정렬 키로는 동점 시 순위가 비결정적이다.

점수가 동일한 상품이 여러 개인 경우 MySQL 정렬 결과는 실행 계획/InnoDB 내부 상태에 따라 달라진다. 동일 snapshotDate 로 배치를 재실행하거나 manualReRun 트리거로 돌릴 때마다 rank_position 이 뒤바뀔 수 있고, UPSERT 로 이전 순위가 덮어쓰여 사용자에게 노출되는 Top 100 목록이 흔들린다. 배치 재실행의 멱등성/관측 가능성 관점에서 보조 정렬 키(product_id)를 추가해 결정론적 순서를 보장할 것을 권장한다. 주간 리더(WeeklyRankReaderConfig.buildSql) 에도 동일 이슈가 있으니 함께 반영해야 한다.

♻️ 제안 패치
-            ORDER BY score DESC
+            ORDER BY score DESC, product_id ASC
             LIMIT 100

추가 테스트 제안: 동점 스코어가 발생하도록 메트릭을 시드한 뒤 동일 snapshotDatemanualReRun 경로를 돌려 rank_position 이 동일하게 유지되는지 검증하는 케이스를 추가할 것을 권장한다.

📝 Committable suggestion

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

Suggested change
ORDER BY score DESC
LIMIT 100
ORDER BY score DESC, product_id ASC
LIMIT 100
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java`
around lines 66 - 67, The ORDER BY clause in MonthlyRankReaderConfig (ORDER BY
score DESC) is non-deterministic for ties; update the SQL built by
MonthlyRankReaderConfig (and the analogous WeeklyRankReaderConfig.buildSql) to
add a stable secondary sort key (e.g., ORDER BY score DESC, product_id ASC) so
tied scores produce deterministic rank_position, and add a test that seeds equal
scores and reruns the manualReRun path for the same snapshotDate to assert
rank_position remains identical.

Comment on lines +50 to +64
private long countByJobName(String jobName, LocalDate snapshotDate) {
return switch (jobName) {
case "weeklyRankingJob" -> weeklyRepository.countBySnapshotDate(snapshotDate);
case "monthlyRankingJob" -> monthlyRepository.countBySnapshotDate(snapshotDate);
default -> 0L;
};
}

private String resolveCacheKey(String jobName) {
return switch (jobName) {
case "weeklyRankingJob" -> "rankings:weekly:latest_date";
case "monthlyRankingJob" -> "rankings:monthly:latest_date";
default -> null;
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Job 이름 하드코딩은 상수 참조로 전환한다.

WeeklyRankingJobConfig.JOB_NAME, MonthlyRankingJobConfig.JOB_NAME 상수가 이미 존재하는데 리스너에서 문자열 리터럴("weeklyRankingJob"/"monthlyRankingJob")을 이중 선언하고 있어, Job 이름 변경 시 한쪽만 바뀌면 resolveCacheKey/countByJobName이 조용히 null/0L을 반환해 latest_date 캐시 갱신이 무음으로 누락된다. 운영 관점에서 "COMPLETED 인데 캐시는 갱신 안 됨"이라는 원인 추적이 어려운 장애를 유발할 수 있으므로 상수로 단일화하는 것이 바람직하다.

♻️ 제안 변경
-    private long countByJobName(String jobName, LocalDate snapshotDate) {
-        return switch (jobName) {
-            case "weeklyRankingJob"  -> weeklyRepository.countBySnapshotDate(snapshotDate);
-            case "monthlyRankingJob" -> monthlyRepository.countBySnapshotDate(snapshotDate);
-            default -> 0L;
-        };
-    }
-
-    private String resolveCacheKey(String jobName) {
-        return switch (jobName) {
-            case "weeklyRankingJob"  -> "rankings:weekly:latest_date";
-            case "monthlyRankingJob" -> "rankings:monthly:latest_date";
-            default -> null;
-        };
-    }
+    private long countByJobName(String jobName, LocalDate snapshotDate) {
+        if (WeeklyRankingJobConfig.JOB_NAME.equals(jobName))  return weeklyRepository.countBySnapshotDate(snapshotDate);
+        if (MonthlyRankingJobConfig.JOB_NAME.equals(jobName)) return monthlyRepository.countBySnapshotDate(snapshotDate);
+        return 0L;
+    }
+
+    private String resolveCacheKey(String jobName) {
+        if (WeeklyRankingJobConfig.JOB_NAME.equals(jobName))  return "rankings:weekly:latest_date";
+        if (MonthlyRankingJobConfig.JOB_NAME.equals(jobName)) return "rankings:monthly:latest_date";
+        return null;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java`
around lines 50 - 64, Replace the hard-coded job name string literals in
countByJobName and resolveCacheKey with the existing constants
WeeklyRankingJobConfig.JOB_NAME and MonthlyRankingJobConfig.JOB_NAME; update the
switch cases in the methods countByJobName(String jobName, LocalDate
snapshotDate) and resolveCacheKey(String jobName) to use those constants (and
keep the same return values for each branch and the default behavior), so
job-name changes are centralized on the WeeklyRankingJobConfig and
MonthlyRankingJobConfig constants.

Comment on lines +54 to +72
private String buildSql(BigDecimal viewWeight, BigDecimal likeWeight, BigDecimal orderWeight) {
return """
SELECT product_id,
SUM(view_count) AS view_count,
SUM(like_count) AS like_count,
SUM(order_revenue) AS order_revenue,
LOG(1 + SUM(view_count)) * %s
+ LOG(1 + SUM(like_count)) * %s
+ LOG(1 + SUM(order_revenue)) * %s AS score
FROM ranking_metrics
WHERE metrics_date BETWEEN ? AND ?
GROUP BY product_id
ORDER BY score DESC
LIMIT 100
""".formatted(
viewWeight.toPlainString(),
likeWeight.toPlainString(),
orderWeight.toPlainString());
}
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
# ranking_weight 편집 경로/관리자 API 존재 여부 확인
rg -nP -C2 'ranking_weight|findWeightByEventType|RankingWeight' --type=java
# TOP-N 하드코딩 분포 확인
rg -nP -C1 '\bLIMIT\s+100\b' --type=java --type=sql

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

Length of output: 27343


String.formatted()로 SQL을 조립하는 방식을 PreparedStatement 바인딩으로 전환하라.

현재 viewWeight/likeWeight/orderWeight는 DB의 ranking_weight에서 조회되지만, 문서에서 명시된 대로 향후 관리자 API를 통해 동적으로 변경될 수 있다. 문자열 포매팅은 이러한 변경 시 SQL 인젝션의 진입점이 되며, 현재 상태에서도 보안 아키텍처로 권장되지 않는다. 또한 toPlainString() 결과와 MySQL DECIMAL 타입의 문자열 변환 경로 차이로 인해 수치 부정확성이 누적될 수 있다.

NamedParameterJdbcTemplate의 명명된 파라미터 바인딩을 사용하도록 리팩토링하며, score 계산식의 가중치 값들을 PreparedStatement ? 파라미터로 바인딩하라. JdbcCursorItemReaderpreparedStatementSetter에서 바인딩을 처리하는 구조가 권장된다.

LIMIT 100을 상수로 추출하라.

LIMIT 100WeeklyRankReaderConfigMonthlyRankReaderConfig 양쪽에 하드코딩되어 있어, 순위 정책 변경 시 수정 지점이 분산된다. RankingConstants 등의 상수 클래스로 TOP-N 값을 추출하여 단일 지점에서 관리하라.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java`
around lines 54 - 72, Replace the string-formatted SQL in buildSql
(WeeklyRankReaderConfig) with a parameterized query that uses named (or
positional) placeholders for the three weight values instead of injecting
viewWeight/likeWeight/orderWeight via String.formatted; switch the reader to use
a JdbcCursorItemReader with a preparedStatementSetter (or
NamedParameterJdbcTemplate) to bind the three weight parameters into the score
expression at runtime (use BigDecimal values directly, not toPlainString), and
move the hardcoded LIMIT 100 into a single constant (e.g., TOP_N) in a shared
RankingConstants class so both WeeklyRankReaderConfig and
MonthlyRankReaderConfig reference that constant. Ensure the
preparedStatementSetter binds the weights in the same order the placeholders
appear and that the SQL uses placeholders for the score multipliers.

Comment on lines 22 to 24
public static void main(String[] args) {
int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args));
System.exit(exitCode);
SpringApplication.run(CommerceBatchApplication.class, args);
}
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
# 배포/실행 방식과 수동 러너 구현을 확인한다.
fd -t f -e yml -e yaml . | xargs rg -nP -C2 'commerce-batch|CommerceBatchApplication' 2>/dev/null
fd -t f Dockerfile | xargs rg -nP -C2 'commerce-batch|java ' 2>/dev/null
rg -nP -C3 'ApplicationRunner|CommandLineRunner|RankingJobTrigger' --type=java

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

Length of output: 6729


🏁 Script executed:

cat -n apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java

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

Length of output: 994


🏁 Script executed:

cat -n apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java

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

Length of output: 2690


🏁 Script executed:

rg -nP '@EnableScheduling|@ConditionalOnProperty|@ConditionalOnExpression|@Profile' apps/commerce-batch --type=java -A2

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

Length of output: 2060


🏁 Script executed:

rg -nP 'graceful|shutdown|ShutdownHook|ExecutorService.*shutdown' apps/commerce-batch --type=java -A2

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

Length of output: 67


🏁 Script executed:

fd -t f -e yml -e yaml -e properties apps/commerce-batch | xargs rg -nP 'shutdown|graceful|timeout' 2>/dev/null

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

Length of output: 6010


🏁 Script executed:

fd -t f \( -name 'Dockerfile' -o -name 'deployment*' -o -name '*manifest*' -o -name '*.sh' \) | xargs rg -nP 'commerce-batch|java' 2>/dev/null | head -50

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

Length of output: 4242


🏁 Script executed:

rg -nP '@Profile|spring.profiles' apps/commerce-batch --type=java -B1 -A1

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

Length of output: 67


🏁 Script executed:

fd --full-path 'apps/commerce-batch.*' -e yml -e yaml | xargs cat -n 2>/dev/null

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

Length of output: 1153


🏁 Script executed:

fd -i dockerfile | xargs cat -n 2>/dev/null | head -100

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

Length of output: 183


🏁 Script executed:

cat -n apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java | head -70

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

Length of output: 2762


🏁 Script executed:

rg -nP 'System.exit|SpringApplication.exit|applicationContext.close' apps/commerce-batch --type=java

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

Length of output: 67


@EnableScheduling 조건부 활성화 및 수동 러너의 명시적 종료 처리 필수다.

CommerceBatchApplication에서 @EnableScheduling을 무조건 활성화하면서 RankingManualRunner에서 수동 실행 후 프로세스를 종료하지 않으므로, 다음과 같은 운영 문제가 발생할 수 있다.

  • Kubernetes Job/CronJob이나 CI 파이프라인에서 수동 트리거(--trigger=WEIGHT_CHANGE 등)로 실행할 때, 프로세스가 자발적으로 종료되지 않아 타임아웃이나 리소스 누수 발생.
  • 수동 실행 후 RankingScheduler의 스케줄이 계속 동작하면서, 동일 Job에 대해 중복 실행 시도.
  • application.yml에 graceful shutdown 설정이 없어 JVM 종료 시 실행 중인 Job이 강제 중단될 수 있음.

수정 방향:

(1) CommerceBatchApplication에서 @EnableScheduling을 별도 프로파일(예: @ConditionalOnProperty(name = "app.batch.mode", havingValue = "daemon"))로 조건부 활성화하거나, RankingManualRunner에 trigger 옵션 감지 시 @EnableScheduling을 비활성화하는 구조로 변경.

(2) RankingManualRunner의 run() 메서드에서 job 완료 후 명시적으로 System.exit(exitCode) 호출 또는 applicationContext.close()를 통해 프로세스 종료.

(3) application.yml에 graceful shutdown 타임아웃 설정 추가 (예: spring.lifecycle.timeout-per-shutdown-phase: 60s).

추가 테스트:

  • 수동 트리거 프로파일에서 스케줄러가 비활성화되는지 확인하는 통합 테스트.
  • Job 실행 완료 후 프로세스가 정상 종료되는지 검증하는 시나리오 테스트.
  • 데몬 모드에서 SIGTERM 수신 시 실행 중인 Job이 graceful하게 중단되는지 확인하는 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java`
around lines 22 - 24, Make `@EnableScheduling` conditional and ensure manual runs
exit: move or guard `@EnableScheduling` behind a property (e.g., use a config
class annotated with `@EnableScheduling` and
`@ConditionalOnProperty`(name="app.batch.mode", havingValue="daemon")) so
scheduling is only active in daemon mode instead of always on in
CommerceBatchApplication; in RankingManualRunner.run(...) detect manual trigger
flags and after the job completes call applicationContext.close() or
System.exit(exitCode) to terminate the process explicitly; also add a graceful
shutdown timeout to application.yml
(spring.lifecycle.timeout-per-shutdown-phase: 60s) and add integration tests
that verify scheduler is disabled in manual mode and that the process exits
after a manual run.

Comment on lines +55 to +59
@Column(name = "view_count", nullable = false)
private int viewCount;

@Column(name = "like_count", nullable = false)
private int likeCount;
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
fd -t f 'RankingMetrics*.java' apps/commerce-streamer | xargs -r rg -n -C2 'view_count|like_count|viewCount|likeCount'
rg -n -C2 'ranking_metrics' --type sql || true

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

Length of output: 546


🏁 Script executed:

#!/bin/bash
# Search for RankingAggregateRow definition
rg -n 'RankingAggregateRow' --type java -A 5

echo "---"

# Search for ranking_metrics in SQL files
rg -n 'ranking_metrics' --type sql

echo "---"

# Search for view_count/like_count in SQL or query files
rg -n 'view_count|like_count' --type sql

echo "---"

# Look for test files related to RankingMetrics
fd -t f 'RankingMetrics.*Test' --type java

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

Length of output: 18778


🏁 Script executed:

#!/bin/bash

# Look at RankingAggregateRow full definition
cat apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java

echo "---"

# Search for SQL queries with SUM view_count or like_count
find apps/commerce-batch -name "*.sql" -o -name "*.xml" | xargs grep -l "view_count\|like_count" 2>/dev/null || true

echo "---"

# Look for test cases related to RankingMetrics
fd -i 'rankingmetrics.*test' apps/commerce-batch

echo "---"

# Search for `@ColumnDefinition` or schema info related to ranking_metrics
rg -n 'ranking_metrics' -A 5 -B 5

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

Length of output: 29772


🏁 Script executed:

#!/bin/bash

# Get full RankingMetrics definition from commerce-streamer
cat apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingMetrics.java | head -100

echo "---"

# Check if there are any test cases covering Integer.MAX_VALUE or overflow scenarios
rg -n 'Integer.MAX_VALUE|overflow|BIGINT' apps/commerce-batch --type java

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

Length of output: 2487


🏁 Script executed:

#!/bin/bash

# Look for database migration files or schema definitions
find . -name "*.sql" -o -name "*schema*" -o -name "*migration*" | grep -v node_modules | head -20

echo "---"

# Check streamer RankingMetrics entity to see field types
rg -n '@Column.*view_count|@Column.*like_count' apps/commerce-streamer --type java -B 1 -A 1

echo "---"

# See if there are any integration tests with large data values
rg -n 'insertMetrics|viewCount|likeCount' apps/commerce-batch/src/test --type java -A 2 | head -80

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

Length of output: 7689


🏁 Script executed:

#!/bin/bash

# Compare field definitions between batch and streamer RankingMetrics
echo "=== Batch RankingMetrics viewCount/likeCount ==="
rg -A 1 'private int (viewCount|likeCount)' apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java

echo ""
echo "=== Streamer RankingMetrics viewCount/likeCount ==="
rg -A 1 'private int (viewCount|likeCount)' apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingMetrics.java

echo ""
echo "=== Test data values used (no overflow testing) ==="
rg 'insertMetrics.*\b[0-9]{5,}' apps/commerce-batch/src/test --type java | head -5

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

Length of output: 357


RankingAggregateRowlong 타입과 RankingMetricsint 타입 간 불일치를 개선한다.

RankingAggregateRow 는 reader 에서 rs.getLong("view_count") 로 읽으므로 long viewCount, long likeCount 로 정의되어 있으나, 실제 데이터를 저장하는 양쪽 RankingMetrics 엔티티(batch, streamer 모두)는 int viewCount, int likeCount 로 정의되어 있다. SUM(view_count) 는 원본 테이블의 int 누적값이므로 누적 기간이 길어지면 Integer.MAX_VALUE 를 초과할 수 있으며, 현재 테스트에는 이러한 경계값 케이스가 없다.

RankingMetricsviewCount / likeCountlong 으로 변경하여 데이터 누적 오버플로우를 방지하고, 월별 집계 테스트에 1회 이상 Integer.MAX_VALUE 를 초과하는 누적치 시나리오를 추가할 것을 제안한다.

🛠 제안 수정
-    `@Column`(name = "view_count", nullable = false)
-    private int viewCount;
-
-    `@Column`(name = "like_count", nullable = false)
-    private int likeCount;
+    `@Column`(name = "view_count", nullable = false)
+    private long viewCount;
+
+    `@Column`(name = "like_count", nullable = false)
+    private long likeCount;
📝 Committable suggestion

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

Suggested change
@Column(name = "view_count", nullable = false)
private int viewCount;
@Column(name = "like_count", nullable = false)
private int likeCount;
`@Column`(name = "view_count", nullable = false)
private long viewCount;
`@Column`(name = "like_count", nullable = false)
private long likeCount;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java`
around lines 55 - 59, RankingMetrics currently uses int viewCount/likeCount
while RankingAggregateRow reads rs.getLong(...); change the fields in the
RankingMetrics entity classes (the batch and streamer variants that declare
viewCount and likeCount) from int to long, keep the `@Column`(name="view_count",
nullable=false) / `@Column`(name="like_count", nullable=false) annotations, update
any getters/setters and constructors referencing viewCount/likeCount to use
long, and adjust persistence/mapping code that assumes int; additionally add or
update the monthly aggregation test to include a scenario where the aggregated
SUM exceeds Integer.MAX_VALUE to verify no overflow occurs.

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