Skip to content

[7팀 김철중] 10주차 과제 제출 #199

Open
Kimchuljoong wants to merge 1 commit into
Loopers-dev-lab:Kimchuljoongfrom
Kimchuljoong:feature/round10
Open

[7팀 김철중] 10주차 과제 제출 #199
Kimchuljoong wants to merge 1 commit into
Loopers-dev-lab:Kimchuljoongfrom
Kimchuljoong:feature/round10

Conversation

@Kimchuljoong

@Kimchuljoong Kimchuljoong commented Apr 16, 2026

Copy link
Copy Markdown

📌 Summary

일간 이벤트 메트릭을 날짜별로 영속하는 product_metrics_daily 테이블을 도입하고, 이를 소스로 삼는 Spring Batch 주간/월간 랭킹 Job(WeeklyRankingJob, MonthlyRankingJob)을 구현했습니다. Ranking API를 기간별로 분리해 GET /api/v1/products/rankings/{daily|weekly|monthly}로 DAILY(Redis ZSET)/WEEKLY/MONTHLY(DB Materialized View) 랭킹을 제공합니다.


💬 Review Points

1. Chunk + Tasklet 혼합: accumulationStep → rankingAndTrimStep 2-Step 구조

배경 및 문제 상황:
같은 product_id의 메트릭이 여러 날짜 row로 흩어져 있고, Reader는 product_id ASC 순으로 페이지네이션 읽기를 하기 때문에 동일 상품이 서로 다른 청크에 분산됩니다. 따라서 Writer 단계에서는 해당 상품의 전체 score 합산이 아직 끝나지 않아 rank를 확정할 수 없습니다.

단순히 GROUP BY product_id SUM 쿼리를 Tasklet 하나로 처리하는 방법도 있지만, 그러면 대량 데이터에서 청크 단위 트랜잭션 관리가 없어 중간 실패 시 처음부터 전체 재실행해야 합니다.

해결 방안:
Step 1(accumulationStep)은 Chunk-500 방식으로 데이터를 읽고 score를 MV 테이블에 누적 UPSERT합니다. Step 2(rankingAndTrimStep)는 Tasklet으로 전체 누적이 완료된 후 ROW_NUMBER() window function으로 rank를 한 번에 부여하고, TOP 100 초과 행을 삭제합니다.

구현 세부사항:
Reader는 기간 범위(주간: 월일, 월간: 1일말일)를 계산해 product_metrics_daily에서 pageSize=500으로 읽습니다. Processor는 viewCount×0.1 + likeCount×0.2 + salesCount×0.7 점수 공식을 적용하고, Writer는 (product_id, year_week_value) UNIQUE 키 기반 UPSERT로 score를 누적합니다. 같은 상품이 여러 청크에 걸쳐 처리되어도 UPSERT가 score를 계속 더하기 때문에 최종적으로 기간 전체 합산 결과가 남습니다.


2. MV 테이블 isFirstChunk DELETE + UPSERT 전략

배경 및 문제 상황:
배치를 재실행할 때 이전 실행의 데이터가 남아 있으면 score가 중복 누적됩니다. 그렇다고 Job 시작 직후 전체 TRUNCATE하면, 집계가 완료되는 수 분간 랭킹 API가 빈 결과를 반환합니다. 배치 실행 중에도 API는 이전 랭킹 데이터를 보여주어야 합니다.

해결 방안:
AtomicBoolean isFirstChunk로 첫 번째 청크 직전에만 해당 yearWeek 데이터를 DELETE하고, 이후 청크는 UPSERT만 수행합니다. 이 방식은 첫 청크 커밋 이전까지는 이전 랭킹이 살아있고, 첫 청크 커밋 이후부터 점진적으로 신규 데이터로 교체됩니다.

구현 세부사항:
Writer의 write() 진입 시 isFirstChunk.compareAndSet(true, false)로 정확히 한 번만 DELETE를 실행하고, 이후 청크는 UPSERT만 수행합니다. 이 isFirstChunk는 Job 실행마다 새 Bean이 생성되므로 재실행 안전성이 보장됩니다.


3. 배치 소스로 product_metrics_daily 도입: Redis TTL 연장 대신 DB 스냅샷

배경 및 문제 상황:
주간 집계는 최근 7일, 월간은 최대 31일치 메트릭이 필요합니다. 기존에 이미 있는 데이터 소스를 그대로 쓰는 방법을 먼저 검토했습니다.

  • Redis ZSET(Round 9)을 배치 소스로 쓰는 방법: ranking:all:yyyyMMdd 키의 TTL을 35일 이상으로 늘리면 됩니다. 하지만 Redis는 랭킹 조회용으로 설계된 인메모리 저장소인데, 한 달치 날짜 키를 계속 유지하면 메모리 압박이 생기고, 키가 많아질수록 ZUNIONSTORE 비용도 늘어납니다.
  • product_metrics(누적 테이블)를 배치 소스로 쓰는 방법: product_id UNIQUE + 전체 누적값 구조라 날짜별 히스토리가 없어 기간 범위 쿼리 자체가 불가능합니다.

해결 방안:
이벤트 핸들러에서 기존 처리 로직 직후 product_metrics_daily에 오늘 날짜로 UPSERT합니다. 배치는 이 테이블에서 metric_date BETWEEN ? AND ? 쿼리로 기간 범위 데이터를 읽습니다.

구현 세부사항:
(product_id, metric_date) 복합 유니크 키로 날짜별 row를 관리합니다. 세 이벤트 핸들러 모두 UPSERT 방식이므로 같은 날 이벤트가 여러 번 들어와도 안전하게 누적됩니다. 좋아요 취소(-1 delta) 시에는 GREATEST(like_count + delta, 0) 조건으로 0 미만을 방지합니다.


4. 동일 score 공식이 실시간(Redis)과 배치(Batch) 두 경로에 분산

배경 및 문제 상황:
Daily 랭킹은 commerce-streamerProductRankingWriteService가 이벤트 발생 시 Redis에 score를 즉시 누적합니다. Weekly/Monthly 랭킹은 commerce-batchMetricsAggregationProcessorproduct_metrics_daily를 읽어 score를 계산합니다. 두 경로 모두 viewCount×0.1 + likeCount×0.2 + salesCount×0.7 공식을 사용합니다.

이 공식이 streamer와 batch에 각각 하드코딩되어 있어서, 가중치 정책이 바뀌면 두 곳을 동시에 수정해야 합니다. 수정 시점이 어긋나면 Daily 랭킹과 Weekly 랭킹의 첫날 점수가 서로 다른 공식으로 계산된 값이 섞이는 상황이 생깁니다.

해결 방안:
현재는 공식을 각 모듈에 유지했습니다. 공유 모듈에 score 계산 로직을 올리는 것도 검토했지만, streamer와 batch의 배포 단위가 다르고 두 모듈 모두 특정 공유 모듈에 의존하게 되면 공식 변경 시 오히려 양쪽 앱을 동시에 재배포해야 하는 결합이 생깁니다.

구현 세부사항:
commerce-streamer에서는 ScoringStrategy 인터페이스로 공식을 추상화해 구현체를 Bean으로 교체할 수 있게 했고, commerce-batch에서는 MetricsAggregationProcessor 내에 동일 가중치를 유지합니다.


## 주요 변경사항

### 1. API 엔드포인트 분리
- GET /api/v1/products/rankings/daily → 일일 랭킹
- GET /api/v1/products/rankings/weekly → 주간 랭킹
- GET /api/v1/products/rankings/monthly → 월간 랭킹
- 기존 period 파라미터 기반 단일 엔드포인트를 3개로 분리

### 2. Materialized View (MV) 테이블 설계
- mv_product_rank_weekly: 주간 상품 랭킹 데이터
- mv_product_rank_monthly: 월간 상품 랭킹 데이터
- Spring Batch 작업으로 매일 갱신
- MySQL 예약어 처리: rank → rank_value 컬럼으로 변경

### 3. Repository 패턴 구현
- MvProductRankRepository 인터페이스
- MvProductRankRepositoryImpl 구현체
- findWeeklyRanking/findMonthlyRanking: 페이지네이션 포함
- countWeekly/countMonthly: 데이터 개수 조회

### 4. 테스트 인프라 개선
- DatabaseCleanUp: MV 테이블 제외 및 에러 처리 추가
- RedisCleanUp: Redis 연결 실패 시 graceful degradation
- ProductRankingApiE2ETest: MV 리포지토리 옵션 주입
- E2E 테스트로 전체 흐름 검증

### 5. 이벤트 핸들러 통합
- ProductViewedEventHandler/OrderCreatedEventHandler/LikeCountEventHandler
- ProductMetricsDaily 엔티티 자동 주입 및 저장

## 테스트 결과
- 564 tests: 2 failed, 6 skipped
- 테스트 실패 원인 분석 진행 중

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

주간 및 월간 상품 랭킹 기능을 API, 서비스, 저장소, 배치 작업에 걸쳐 추가한다. 기존 일일 랭킹과 병렬로 주간/월간 조회를 지원하도록 API 엔드포인트를 분리하고, 새로운 JPA 엔티티 및 Redis 기반 데이터 파이프라인을 구성한다.

Changes

Cohort / File(s) Summary
API & 인터페이스 계층
apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ApiSpec.kt, ProductRankingV1Controller.kt, RankingPeriod.kt
기존 단일 getRankedProducts 엔드포인트를 /daily, /weekly, /monthly 3개로 분리하고, RankingPeriod 열거형으로 기간을 표현한다. 요청 매핑과 라우트(/api/v1/rankings/api/v1/products/rankings)가 변경된다.
애플리케이션 계층
apps/commerce-api/src/main/kotlin/com/loopers/application/api/product/ProductRankingFacade.kt
getRankedProducts 메서드에 RankingPeriod 파라미터가 추가되고, 기간별로 서비스에 전달된다.
도메인 서비스 계층
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.kt
getRankedProductsWithCount 메서드가 RankingPeriod에 따라 세 가지 분기로 나뉜다: DAILY는 Redis 기반, WEEKLYMONTHLY는 새로운 MV 저장소를 사용한다. ISO 주/월 포맷 계산 로직이 추가된다.
도메인 엔티티
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRank{Weekly,Monthly}.kt, apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRank{Weekly,Monthly}.kt
주간/월간 랭킹용 새로운 JPA 엔티티가 정의된다. mv_product_rank_weekly, mv_product_rank_monthly 테이블에 매핑된다.
저장소 인터페이스
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankRepository.kt, apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRank{Weekly,Monthly}JpaRepository.kt
주간/월간 조회 및 카운트용 저장소 인터페이스가 새로 추가된다.
저장소 구현
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.kt
MvProductRankWeeklyJpaRepository, MvProductRankMonthlyJpaRepository를 주입받아 주간/월간 데이터를 ProductRankingReadModel로 변환하는 구현체다.
배치 작업 설정
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/Weekly{RankingJobConfig,RankingMetricsReader,RankingAndTrimTasklet,RankingChunkWriter}.kt, Monthly{RankingJobConfig,RankingMetricsReader,RankingAndTrimTasklet,RankingChunkWriter}.kt
주간/월간 랭킹 계산 및 저장용 배치 작업이 추가된다. 각각 메트릭 수집, 점수 계산, 데이터베이스 저장, 상위 100위 필터링을 수행한다.
배치 지원 컴포넌트
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/{RankingAggregationTasklet,RankingAggregationItem,RankingChunkReader,RankingChunkProcessor,CleanupTasklet}.kt
Redis 기반 점수 합산, 청크 처리, 임시 키 정리를 담당하는 배치 구성 요소들이 추가된다.
이벤트 핸들러
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/{ProductViewedEventHandler,OrderCreatedEventHandler,LikeCountEventHandler}.kt
각 이벤트 핸들러가 ProductMetricsDailyRepository에 일일 메트릭을 기록하도록 변경된다.
일일 메트릭 엔티티 & 저장소
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/productmetrics/ProductMetricsDaily.kt, apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/productmetrics/ProductMetricsDailyRepository.kt
새로운 product_metrics_daily 테이블 엔티티와 일일 메트릭 증분 업데이트용 저장소가 추가된다. 조회수, 판매량, 좋아요 수를 일별로 기록한다.
테스트
apps/commerce-api/src/test/kotlin/com/loopers/application/api/product/ProductRankingFacadeTest.kt, apps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/ProductRankingReadServiceTest.kt, apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImplTest.kt, apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/product/ProductRankingApiE2ETest.kt, apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ControllerTest.kt, apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/*
기간별 조회, 배치 작업, 메트릭 계산 로직에 대한 단위 및 통합 테스트가 추가된다.
유틸리티 및 설정
modules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.kt, modules/redis/src/testFixtures/kotlin/com/loopers/utils/RedisCleanUp.kt, .gitignore
테스트 유틸리티가 MV 테이블 제외 및 예외 처리를 추가하고, .gitignoreround10/이 추가된다.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller as ProductRankingV1Controller
    participant Facade as ProductRankingFacade
    participant Service as ProductRankingReadService
    participant Repo as MvProductRankRepository
    participant Cache as Redis/DB
    participant ProductSvc as ProductFacade

    Client->>Controller: GET /daily|/weekly|/monthly?page=0&size=20&date=...
    Controller->>Facade: getRankedProducts(date, page, size, period)
    
    alt period == DAILY
        Facade->>Service: getRankedProductsWithCount(date, page, size, DAILY)
        Service->>Cache: Redis 조회 (일일 랭킹)
        Cache-->>Service: RankedProductsWithCount
    else period == WEEKLY
        Facade->>Service: getRankedProductsWithCount(date, page, size, WEEKLY)
        Service->>Service: yearWeek 계산 (ISO week)
        Service->>Repo: findWeeklyRanking(yearWeek, page, size)
        Repo->>Cache: DB 조회 (mv_product_rank_weekly)
        Cache-->>Repo: List<ProductRankingReadModel>
        Repo-->>Service: List<ProductRankingReadModel>
        Service->>Repo: countWeekly(yearWeek)
        Repo->>Cache: COUNT 조회
        Cache-->>Repo: Long
        Repo-->>Service: Long
        Service-->>Facade: RankedProductsWithCount
    else period == MONTHLY
        Facade->>Service: getRankedProductsWithCount(date, page, size, MONTHLY)
        Service->>Service: yearMonth 계산 (YYYY-MM)
        Service->>Repo: findMonthlyRanking(yearMonth, page, size)
        Repo->>Cache: DB 조회 (mv_product_rank_monthly)
        Cache-->>Repo: List<ProductRankingReadModel>
        Repo-->>Service: List<ProductRankingReadModel>
        Service->>Repo: countMonthly(yearMonth)
        Repo->>Cache: COUNT 조회
        Cache-->>Repo: Long
        Repo-->>Service: Long
        Service-->>Facade: RankedProductsWithCount
    end

    Facade->>ProductSvc: 각 productId별로 getCachedProductInfo(productId)
    ProductSvc-->>Facade: ProductInfo (제품명, 이미지 등)
    Facade->>Facade: rank 정보와 product 정보 병합
    Facade-->>Controller: PageImpl<ProductInfo>
    Controller-->>Client: ApiResponse<PageResponse<ProductInfo>>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45분

Possibly related PRs


추가 검토 의견

이 PR은 다음 항목들을 주의 깊게 검토해야 한다:

운영 관점:

  • 배치 작업 스케줄링: 주간/월간 배치 작업의 실행 시간(월요 0시, 매월 1일 등)이 설정되어 있는지 확인 필요. 현재 코드에서는 spring.batch.job.name 조건부 로드만 보이므로, 실제 스케줄러 설정이 있는지 별도로 확인해야 한다.

데이터 일관성:

  • MV 테이블 동기화: mv_product_rank_weekly, mv_product_rank_monthly 테이블이 배치 작업에 의존하므로, 배치 실패 시 데이터 공백이 발생할 수 있다. 배치 재시도 정책과 실패 알림이 필요하다.
  • 일일 메트릭과 집계 메트릭의 격차: 이벤트 핸들러(ProductViewedEventHandler 등)에서 ProductMetricsDailyRepository에 기록된 데이터가 배치 작업에서 정확히 수집되는지 검증하는 테스트가 필요하다.

성능 관점:

  • 페이지네이션 상한선 제거: ProductRankingReadServiceTest에서 "beyondPage100" 테스트가 있는데, 주간/월간 조회 시 상위 100위 제한이 없는지 명시적으로 확인해야 한다. 만약 상위 100위만 저장된다면, 페이지 오프셋이 100을 초과할 경우 빈 결과가 반환된다.
  • Redis 메모리 사용: RankingAggregationTasklet에서 생성한 임시 키의 TTL이 300초로 설정되어 있는데, 동시성이 높은 경우 메모리 누적 위험이 있다. 모니터링이 필요하다.

테스트 누락:

  • ProductMetricsDailyRepositoryincrementLikeCount 메서드에서 GREATEST(like_count + VALUES(like_count), 0) 표현식이 음수 방지를 의도했다면, 실제로 음수가 반환되지 않는지 테스트하는 케이스가 필요하다.
  • 배치 작업에서 metricDate 범위 선택(시작일과 마지막일)이 정확한지 검증하는 테스트가 필요하다. 특히 윤년 처리와 월/주 경계 케이스를 확인해야 한다.
🚥 Pre-merge checks | ❌ 3

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive PR 제목이 구체적인 변경 내용을 설명하지 않고 '10주차 과제 제출'이라는 일반적인 표현만 사용했다. 제목을 '[7팀 김철중] 주간/월간 랭킹 배치 및 API 분리' 등으로 변경하여 주요 변경 내용을 명확히 전달하도록 한다.
Description check ❓ Inconclusive PR 설명이 template의 필수 섹션을 대부분 충족하지만 Design Overview 섹션이 누락되어 있다. Design Overview 섹션을 추가하여 변경 범위, 신규 추가/제거 컴포넌트, 각 컴포넌트 책임을 명확히 정리할 필요가 있다.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Note

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

Caution

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

⚠️ Outside diff range comments (1)
apps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/LikeCountEventHandlerTest.kt (1)

38-46: ⚠️ Potential issue | 🟡 Minor

새로 추가된 일일 메트릭 호출에 대한 검증이 없다.

두 테스트 모두 productMetricsDailyRepository.incrementLikeCount(...)를 stub만 하고 verify를 하지 않는다. 핸들러에서 이 호출을 누락하거나 잘못된 delta/날짜(KST 오늘)로 호출해도 테스트가 통과해, 주/월간 랭킹의 기반 데이터가 비어있는 회귀를 잡을 수 없다. INCREMENT 시 +1, DECREMENT 시 -1, 그리고 Asia/Seoul 기준 오늘 날짜로 호출되는지까지 매칭해야 한다.

추가 테스트: 자정 경계 근처에서 KST와 UTC가 서로 다른 날짜로 판정될 때에도 KST 기준 오늘로 기록되는지, 가능한 경우 Clock 추상화로 결정적 테스트를 구성하라.

💚 제안 수정안
-        every { productLikeCountRepository.increment(1L) } returns Unit
-        every { productMetricsDailyRepository.incrementLikeCount(any(), any(), any()) } returns Unit
+        every { productLikeCountRepository.increment(1L) } returns Unit
+        every { productMetricsDailyRepository.incrementLikeCount(any(), any(), any()) } returns Unit
 
         // When
         handler.handle(event)
 
         // Then
         verify { productLikeCountRepository.increment(1L) }
+        verify { productMetricsDailyRepository.incrementLikeCount(1L, 1L, any()) }

DECREMENT 케이스는 두 번째 인자를 -1L로 매칭한다.

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

Also applies to: 58-66

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

In
`@apps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/LikeCountEventHandlerTest.kt`
around lines 38 - 46, The test currently stubs
productMetricsDailyRepository.incrementLikeCount(...) but never verifies it;
update the tests around handler.handle to verify
productMetricsDailyRepository.incrementLikeCount was called with the correct
delta (+1 for INCREMENT, -1 for DECREMENT) and with a LocalDate matching "today"
in Asia/Seoul (KST) — e.g., construct the expected KST date and use it in the
verify matcher against
productMetricsDailyRepository.incrementLikeCount(expectedDate, expectedDelta,
any()). Also add a deterministic boundary test that injects a Clock into the
handler (or use the existing Clock abstraction) to simulate times near UTC
midnight and assert the repository call still uses the KST-local date so the
handler.convert-to-KST logic is exercised.
🟠 Major comments (13)
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandler.kt-11-23 (1)

11-23: ⚠️ Potential issue | 🟠 Major

두 개의 쓰기에 대한 트랜잭션 경계와 시계 결정성을 점검하라.

  • productMetricsRepository.incrementViewCountproductMetricsDailyRepository.incrementViewCount가 연속으로 실행되는데 핸들러에 @Transactional이 없다. 컨슈머 상위에서 트랜잭션이 걸려 있지 않다면, 첫 UPSERT는 성공하고 두 번째가 실패하는 부분 실패가 발생하여 일일 기준과 누적 기준 메트릭이 불일치하게 된다. 카프카/메시지 재시도와 결합되면 누적 view_count만 이중 증가하는 회귀로 이어질 수 있다. 상위에서 트랜잭션을 보장하지 않는다면 본 메서드에 @Transactional을 선언하거나, 최소한 의도된 원자성 정책을 주석으로 명시하라.
  • ZoneId.of("Asia/Seoul")는 호출마다 파싱이 발생한다. 상수로 추출하고 가능하면 Clock을 주입해 테스트에서 결정적으로 자정 경계를 검증할 수 있도록 하라.

추가 테스트: productMetricsDailyRepository.incrementViewCount가 예외를 던지도록 한 경우에도 일반 productMetricsRepository 증가가 롤백/보상되는지(또는 정책상 그대로 유지되는지) 기대되는 계약이 테스트로 명시되어야 한다.

♻️ 제안 수정안
-import java.time.LocalDate
-import java.time.ZoneId
+import java.time.Clock
+import java.time.LocalDate
+import java.time.ZoneId
 
 `@Component`("ProductViewedEvent")
 class ProductViewedEventHandler(
     private val productMetricsRepository: ProductMetricsRepository,
     private val productMetricsDailyRepository: ProductMetricsDailyRepository,
+    private val clock: Clock = Clock.system(KST),
 ) : EventHandler {
 
-    override fun handle(event: Any) {
+    `@Transactional`
+    override fun handle(event: Any) {
         val viewedEvent = event as ProductViewedEvent
-        val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
+        val today = LocalDate.now(clock)
 
         productMetricsRepository.incrementViewCount(viewedEvent.productId)
         productMetricsDailyRepository.incrementViewCount(viewedEvent.productId, today)
     }
+
+    companion object {
+        private val KST: ZoneId = ZoneId.of("Asia/Seoul")
+    }
 }

As per coding guidelines: "예외 처리는 도메인 예외와 인프라 예외를 구분하고" 운영 상 원자성 요구를 명시적으로 선언해야 한다.

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandler.kt`
around lines 11 - 23, The handler currently does two DB writes in
ProductViewedEventHandler.handle (productMetricsRepository.incrementViewCount
and productMetricsDailyRepository.incrementViewCount) without transaction
boundaries and constructs ZoneId on each call; make the method transactional by
annotating the handler (e.g., add `@Transactional` to
ProductViewedEventHandler.handle or the class) or document the atomicity policy
if you intend partial commits, extract ZoneId.of("Asia/Seoul") to a single
constant and inject a java.time.Clock into the handler to compute today
deterministically (use LocalDate.now(clock.withZone(ZoneId.of("Asia/Seoul"))) or
equivalent), and add a unit/integration test that simulates
productMetricsDailyRepository.incrementViewCount throwing to assert the expected
rollback/compensation behavior for productMetricsRepository.incrementViewCount
so the contract is explicit.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt-27-40 (1)

27-40: ⚠️ Potential issue | 🟠 Major

재시작/파티셔닝 상황에서 "첫 청크 삭제" 가드가 깨진다.

  • isFirstChunk@StepScope 인스턴스의 mutable 상태에 의존한다. Step이 중간에 실패한 뒤 재실행되면 @StepScope는 새 인스턴스를 만들어 다시 true가 되므로, 이전 실행에서 부분적으로 누적된 score가 또 한 번 deleteByYearMonth로 통째로 삭제된 뒤 재누적된다. 단일 실행 기준으로는 멱등이지만 "재시작 시 이어서 처리" 시나리오에서는 의도치 않게 데이터가 리셋된다.
  • 동일 step이 multi-threaded/partitioned로 전환되는 순간 isFirstChunk 가드는 쓰레드 간 경쟁이 발생하고, 여러 쓰레드가 각자 "첫 청크"를 인식해 deleteByYearMonth를 중복 호출하거나 한 쓰레드의 첫 청크가 다른 쓰레드가 이미 누적한 행을 삭제할 수 있다.
  • BASIC_ISO_DATEyyyyMMdd만 허용한다. 다른 잡 파라미터 포맷(yyyy-MM-dd)이 주입되면 DateTimeParseException이 발생해 Step 전체가 죽는다.
  • @Transactional은 Spring Batch의 chunk 트랜잭션과 중복되는 선언이다. 의미 없는 AOP 프록시 비용만 생기고, 이 클래스가 다른 빈에서 self-invocation 구조로 호출될 여지도 있으니 제거가 낫다.

수정안: 첫 청크 삭제 로직을 StepExecutionListener.beforeStep 또는 별도 TaskletStep으로 분리해 idempotent한 "Step 시작 시 1회 삭제"로 명확히 하라. 그렇게 하면 multi-thread/partition 구성에도 안전하고 재시작 시에도 동일 보장을 유지한다. 날짜 파라미터는 ISO_LOCAL_DATE 등 실제 전달 포맷과 일치하도록 고정하거나 두 가지 포맷을 모두 허용하도록 방어적으로 파싱하라.

추가 테스트:

  • Step을 실패시킨 뒤 재실행하는 시나리오에서 최종 score 합계가 정상 값이 되는지 검증.
  • 잘못된 targetDate 포맷이 들어왔을 때 명확한 검증 예외가 발생하는지 검증.
♻️ 제안 수정안
-    `@Value`("#{jobParameters['targetDate']}")
-    private lateinit var targetDate: String
-
-    private val dateFormatter = DateTimeFormatter.BASIC_ISO_DATE
-    private var isFirstChunk = true
-
-    override fun beforeStep(stepExecution: StepExecution) {}
-
-    `@Transactional`
-    override fun write(chunk: Chunk<out RankingScoreContribution>) {
-        val date = LocalDate.parse(targetDate, dateFormatter)
-        val yearMonth = getYearMonth(date)
-
-        if (isFirstChunk) {
-            monthlyRepository.deleteByYearMonth(yearMonth)
-            isFirstChunk = false
-        }
-
-        if (chunk.items.isEmpty()) return
+    `@Value`("#{jobParameters['targetDate']}")
+    private lateinit var targetDate: String
+
+    private val dateFormatter = DateTimeFormatter.BASIC_ISO_DATE
+    private lateinit var yearMonth: String
+
+    override fun beforeStep(stepExecution: StepExecution) {
+        val date = LocalDate.parse(targetDate, dateFormatter)
+        yearMonth = getYearMonth(date)
+        monthlyRepository.deleteByYearMonth(yearMonth)
+    }
+
+    override fun write(chunk: Chunk<out RankingScoreContribution>) {
+        if (chunk.items.isEmpty()) return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt`
around lines 27 - 40, MonthlyRankingChunkWriter uses an instance-scoped
isFirstChunk guard and `@Transactional` in write(), which breaks on
restart/partitioning and mis-parses targetDate; move the "deleteByYearMonth"
call out of write() into the StepExecutionListener.beforeStep (or a dedicated
TaskletStep) so deletion is executed exactly once per step invocation
(idempotent on restart/partitioned runs), remove the isFirstChunk field and the
`@Transactional` annotation from write(), and replace the brittle dateFormatter
usage by either using DateTimeFormatter.ISO_LOCAL_DATE or a defensive parse that
accepts both "yyyy-MM-dd" and "yyyyMMdd" and throws a clear validation error for
bad targetDate; references: MonthlyRankingChunkWriter.write(), beforeStep(),
isFirstChunk, monthlyRepository.deleteByYearMonth, dateFormatter, targetDate.
apps/commerce-api/src/main/kotlin/com/loopers/application/api/product/ProductRankingFacade.kt-5-5 (1)

5-5: 🛠️ Refactor suggestion | 🟠 Major

application 레이어가 interfaces.api 레이어 타입에 의존하는 방향이 역전되어 있다.

RankingPeriodcom.loopers.interfaces.api.ranking에서 import한다. 일반적으로 의존성 방향은 interfaces → application → domain이어야 하며, application이 interfaces의 타입을 참조하면 컨트롤러/DTO 변경이 유스케이스 계층까지 전이된다. 해당 enum은 도메인/응용 공용 개념이므로 com.loopers.domain.ranking 또는 com.loopers.application.api.product로 이동시키고, controller에서 import 방향만 반대로 두는 편이 적절하다.

♻️ 제안 수정안
-import com.loopers.interfaces.api.ranking.RankingPeriod
+import com.loopers.domain.ranking.RankingPeriod

RankingPeriod 파일 자체를 도메인 패키지로 이동하고, 컨트롤러는 도메인 enum을 import하도록 조정한다.

추가 테스트는 기존 facade 테스트에서 RankingPeriod.WEEKLY/MONTHLY 전달 시 productRankingReadService 호출 인자가 그대로 위임되는지 검증하는 케이스를 보강한다.

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/application/api/product/ProductRankingFacade.kt`
at line 5, 현재 ProductRankingFacade가
com.loopers.interfaces.api.ranking.RankingPeriod를 참조해 의존성 역전이 발생하므로
RankingPeriod를 application 계층이 아닌 도메인 계층으로 이동하거나 응용 계층
내(com.loopers.domain.ranking 또는 com.loopers.application.api.product)로 옮기고,
ProductRankingFacade와 컨트롤러에서 해당 enum의 import를 도메인(또는 새 위치)으로 바꿔 주세요; 또한
ProductRankingFacade에서 productRankingReadService로 전달되는 인자가 변경되지 않음을 보장하는 기존
facade 테스트에 RankingPeriod.WEEKLY/MONTHLY 케이스를 추가해 위임 인자가 그대로 전달되는지 검증하도록 보강하세요.
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/LikeCountEventHandler.kt-18-31 (1)

18-31: ⚠️ Potential issue | 🟠 Major

집계/일일 메트릭 쓰기 사이에 트랜잭션 경계가 없어 데이터가 어긋날 수 있다.

productLikeCountRepository의 증감과 productMetricsDailyRepository.incrementLikeCount(...)는 서로 다른 네이티브 UPSERT이며 handle(...)에는 @Transactional이 없다. 중간 실패 시 한쪽만 반영되어 집계값과 일일 메트릭이 영구적으로 어긋난다. 컨슈머 재처리(재전송)가 일어나면 이미 반영된 쪽이 중복 증가하여 문제가 확대된다. 운영 관점에서 주/월간 MV 점수(0.1*views + 0.2*likes + 0.7*sales)가 실제와 괴리되어 랭킹 신뢰도가 떨어진다.

♻️ 제안 수정안
+import org.springframework.transaction.annotation.Transactional
 ...
-    override fun handle(event: Any) {
+    `@Transactional`
+    override fun handle(event: Any) {

추가 테스트로 (1) 일일 메트릭 저장 중 예외 발생 시 집계 증감이 롤백되는지, (2) 동일 이벤트 재처리 시 멱등 보장(또는 이벤트 ID 기반 중복 억제) 동작을 확인하는 통합 테스트를 권장한다.

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/LikeCountEventHandler.kt`
around lines 18 - 31, Add a transactional boundary to
LikeCountEventHandler.handle so productLikeCountRepository.increment/decrement
and productMetricsDailyRepository.incrementLikeCount(...) execute in the same
transaction (e.g., annotate the handle method or class with `@Transactional` and
ensure both repositories use the same datasource/transaction manager), do not
catch-and-suppress exceptions so rollback will occur on failure, and implement
event-level idempotency (use a unique eventId on LikeCountEvent and a
processed-events marker or a DB unique constraint + safe upsert to ignore
duplicates) to prevent double-apply on retries; then add integration tests that
(1) simulate an exception during incrementLikeCount and assert the
productLikeCount change is rolled back and (2) simulate duplicate LikeCountEvent
delivery and assert idempotent behavior.
modules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.kt-32-39 (1)

32-39: ⚠️ Potential issue | 🟠 Major

TRUNCATE 실패를 조용히 삼키면 테스트 간 상태 오염이 발생한다.

runCatching { ... TRUNCATE ... }.onFailure { System.err.println(...) } 구성은 실제 테이블이 존재하는데 lock, FK, 권한 등 일시적 사유로 TRUNCATE가 실패한 경우에도 경고만 남기고 다음 테스트로 진행한다. 결과적으로 남은 데이터가 후속 테스트에 유입되어 플래키 테스트의 원인이 된다. 또한 System.err.println은 테스트 로그 표준이 아니므로 CI에서 감지가 어렵다.

존재하지 않는 테이블만 허용하고 그 외 실패는 즉시 실패시키는 것이 테스트 격리 관점에서 안전하다.

♻️ 제안 수정안
-        tableNames.forEach { table ->
-            runCatching {
-                entityManager.createNativeQuery("TRUNCATE TABLE `$table`").executeUpdate()
-            }.onFailure { ex ->
-                // 테이블이 없거나 다른 이유로 실패해도 계속 진행
-                System.err.println("Warning: Failed to truncate table $table: ${ex.message}")
-            }
-        }
+        val logger = org.slf4j.LoggerFactory.getLogger(DatabaseCleanUp::class.java)
+        tableNames.forEach { table ->
+            try {
+                entityManager.createNativeQuery("TRUNCATE TABLE `$table`").executeUpdate()
+            } catch (ex: jakarta.persistence.PersistenceException) {
+                // 스키마에 없는 테이블만 건너뛰고, 그 외는 재던짐
+                logger.warn("Skip truncate for missing table {}: {}", table, ex.message)
+                if (ex.message?.contains("doesn't exist", ignoreCase = true) != true) throw ex
+            }
+        }

추가 테스트로 고의로 잘못된 테이블명을 포함했을 때 실패가 전파되는지 검증하는 유닛 테스트를 권장한다.

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

In `@modules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.kt`
around lines 32 - 39, The current DatabaseCleanUp.kt loop (tableNames.forEach +
entityManager.createNativeQuery("TRUNCATE TABLE `$table`").executeUpdate())
swallows all failures via runCatching and System.err.println, which can hide
transient TRUNCATE failures and cause test state leakage; change the failure
handling to only ignore the specific "table does not exist" error (detect via
the SQL/JPQL exception type or SQL state/error code from the thrown
PersistenceException / SQLException) and for any other exception rethrow it so
the test fails; replace System.err.println with the standard test logger (or
throw the exception) and add a unit test that includes an intentionally
malformed table name to assert non-existence is ignored while other failures
propagate.
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandler.kt-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major

집계/일일 메트릭 두 UPSERT가 비트랜잭션으로 묶여 있어 데이터 불일치 및 부분 재처리 위험이 있다.

handle@Transactional이 없어, 라인 아이템 반복 중 한 상품의 일일 UPSERT가 실패하면 직전 상품의 집계만 반영되고 메시지는 실패 처리되어 재전송 시 이미 반영된 집계가 중복 증가한다. 주문 이벤트는 금액 집계와 직결되어 운영 영향도가 크다.

♻️ 제안 수정안
+import org.springframework.transaction.annotation.Transactional
 ...
-    override fun handle(event: Any) {
+    `@Transactional`
+    override fun handle(event: Any) {

추가로 이벤트 멱등 키(예: orderId + productId) 기반 중복 처리 방지 테이블/로직 도입을 권장한다. 테스트로는 두 번째 라인 아이템 UPSERT에서 예외가 발생했을 때 첫 번째 집계 증분이 롤백되는 통합 테스트를 추가한다.

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandler.kt`
around lines 17 - 25, 핵심 문제는 OrderCreatedEventHandler.handle에서 각 라인아이템별로
productMetricsRepository.incrementSalesCount와
productMetricsDailyRepository.incrementSalesCount가 분리되어 수행되어 부분 반영(부분 성공 후 실패) 시
재처리로 중복 집계가 발생할 수 있다는 점입니다; 해결 방법은 handle 메서드(또는 클래스)에 Spring의 `@Transactional을`
적용해 동일 트랜잭션 안에서 두 UPSERT가 원자적으로 커밋/롤백되도록 하고(참고: OrderCreatedEventHandler.handle,
productMetricsRepository.incrementSalesCount,
productMetricsDailyRepository.incrementSalesCount), 추가로 주문 멱등성을 보장하기 위해
orderId+productId 기반의 중복 처리(예: processed_events 또는 idempotency 테이블에 삽입 시 중복 키로
실패 처리 후 무시) 로직을 도입하고, 통합 테스트로 두 번째 라인아이템 UPSERT 실패 시 첫 번째 증분이 롤백되는 시나리오를 검증하세요.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.kt-68-73 (1)

68-73: ⚠️ Potential issue | 🟠 Major

주 경계 계산이 targetDate의 타임존을 전제하지 않아 오집계 가능성이 있다.

buildWeeklyKeys는 ISO 월요일 기준으로 7일을 만들지만, 소스 키 이름이 ranking:all:yyyyMMdd 형태이고 streamer 측 이벤트 핸들러가 KST(Asia/Seoul) 기준 일자 키를 쓴다. 배치가 UTC 환경에서 targetDate를 해석하는 경우(예: 사용자가 targetDate를 생략/다른 표준 사용), 주 경계가 하루 밀려 일 키 하나가 누락되고 다른 하나가 전주에 속해 집계 손실이 발생한다. 다음을 권고한다.

  • targetDate 파라미터는 반드시 KST 기준이라는 문서/검증을 추가(필요 시 "Asia/Seoul" ZoneId로 명시 계산).
  • Job 진입부에서 기대 타임존과 주의 시작 요일(ISO 월요일)을 로그로 남겨 운영 시 재현성 확보.
  • ProductMetricsDailymetricDate와 동일한 타임존을 쓰는지 엔드투엔드 테스트로 잠금.

추가 테스트: 월/일/주 경계(예: 일요일, 월초, 월말, 연말)에서 Streamer가 쓴 일 키들과 주간/월간 집계 입력이 정확히 일치하는지 확인하는 통합 테스트 1건을 권고한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.kt`
around lines 68 - 73, buildWeeklyKeys currently computes week boundaries from
the provided date without considering timezone, causing mismatch with streamer
keys which use KST; update buildWeeklyKeys to interpret the incoming targetDate
explicitly in ZoneId.of("Asia/Seoul") (convert to LocalDate in KST before
computing mondayOfWeek and calling key(...)), add validation or a clear
comment/doc that targetDate must be KST, and log the expected ZoneId and
week-start policy at job entry (where the job parses targetDate) for
reproducibility; also ensure the same ZoneId is used when comparing/creating
ProductMetricsDaily.metricDate in aggregation and add an integration test that
verifies day/week/month boundary alignment between streamer-generated keys and
the weekly/monthly aggregation.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.kt-57-63 (1)

57-63: ⚠️ Potential issue | 🟠 Major

임시 키 TTL 300초 하드코딩은 장기 실행 배치에서 데이터 유실로 이어질 수 있다.

운영 관점에서 월간 집계처럼 소스 키가 28~31개이고 하위 accumulation/ranking/trim 스텝이 chunk 처리로 진행되는 경우, 전체 소요가 300초를 초과하면 downstream RankingChunkReader가 읽는 중 임시 키가 만료되어 부분 결과만 기록되거나 빈 결과가 되어 MV가 잘못 갱신된다. TTL은 다음 중 하나로 개선을 권고한다.

  • 환경설정(@Value 또는 application.yml)으로 외부화하고, 운영 SLO에 맞춰 여유 있는 값(예: 1~2시간)을 기본값으로 설정.
  • Cleanup이 확실히 수행됨을 전제로 TTL을 넉넉하게 잡고(예: 4시간), CleanupTasklet에서 항상 삭제.
🛠️ 제안 변경
-        // Set TTL to prevent leak in case of app crash
-        redisTemplate.expire(tempKey, Duration.ofSeconds(300))
+        // TTL은 배치 최대 실행시간보다 충분히 길게 설정. Cleanup 스텝에서 명시적으로 제거한다.
+        redisTemplate.expire(tempKey, tempKeyTtl)

그리고 @Value("\${batch.ranking.temp-key-ttl-seconds:14400}") private val tempKeyTtlSeconds: Long 등으로 외부화한다.

추가 테스트: 소스 키가 많은(예: 31일치) 월간 시나리오에서 TTL 만료 전 downstream이 모든 멤버를 읽어갈 수 있는지 확인하는 통합 테스트 1건을 권고한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.kt`
around lines 57 - 63, The hardcoded 300s TTL on tempKey (see
redisTemplate.expire(tempKey, Duration.ofSeconds(300))) can expire during
long-running aggregation and break RankingChunkReader downstream; inject a
configurable property (e.g.
`@Value`("${batch.ranking.temp-key-ttl-seconds:14400}") private val
tempKeyTtlSeconds: Long) and replace the literal with
Duration.ofSeconds(tempKeyTtlSeconds), add the property to application.yml with
a sensible default (1–4 hours), ensure CleanupTasklet still removes keys, and
add an integration test that runs a large-month scenario (e.g. 31 source keys)
to verify RankingChunkReader can read all members before TTL expiry.
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/productmetrics/ProductMetricsDaily.kt-14-35 (1)

14-35: ⚠️ Potential issue | 🟠 Major

일일 집계 엔티티의 무결성/감사 필드가 부족하다.

운영 관점에서 다음 문제가 예상된다.

  1. UNIQUE 제약 누락 위험: 상위 PR 설명상 이벤트 핸들러가 productId + metricDate 기준 UPSERT를 수행하는데, 엔티티 수준에 @Table(uniqueConstraints = ...)가 선언되어 있지 않다. 동시 이벤트 유입 시 중복 행이 생겨 일일 집계가 이중 계상될 수 있고, 이후 배치 score 계산(0.1·view + 0.2·like + 0.7·sales)이 오염된다. DB DDL에 유니크가 있더라도 엔티티/스키마 일원화를 위해 선언을 권고한다.
  2. createdAt 기본값이 인스턴스 생성 시각: LocalDateTime.now()가 객체 생성 시점에 평가되어, 재시도/재생성 경로에서 실제 DB 반영 시각과 차이가 발생한다. 감사 용도라면 @CreationTimestamp 또는 DB DEFAULT CURRENT_TIMESTAMP 사용을 권고한다.
  3. updatedAt 부재: 카운터 UPSERT 이력이 누적되는 테이블임에도 갱신 시각이 없어, 배치/장애 조사 시 최근성 판별이 어렵다. 추가를 권고한다.
  4. equals/hashCode 미구현: JPA 엔티티이므로 컬렉션/캐시에 담길 때 식별자 기반 equality 구현을 권고한다.
🛠️ 제안 변경
-@Table(name = "product_metrics_daily")
+@Table(
+    name = "product_metrics_daily",
+    uniqueConstraints = [UniqueConstraint(name = "uk_pmd_product_date", columnNames = ["product_id", "metric_date"])],
+    indexes = [Index(name = "idx_pmd_metric_date", columnList = "metric_date")],
+)
 class ProductMetricsDaily(
     ...
-    `@Column`(name = "created_at")
-    val createdAt: LocalDateTime = LocalDateTime.now(),
+    `@Column`(name = "created_at", updatable = false)
+    val createdAt: LocalDateTime = LocalDateTime.now(),
+    `@Column`(name = "updated_at")
+    var updatedAt: LocalDateTime = LocalDateTime.now(),
 )

추가 테스트: 동일 productId + metricDate에 대한 동시 UPSERT가 행을 하나만 유지하는지 확인하는 동시성 통합 테스트를 권고한다.

As per coding guidelines: "JPA 엔티티에 data class 사용은 원칙적으로 지양하고 equals/hashCode 구현 안정성(프록시/식별자 기반)을 점검한다."

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

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/domain/productmetrics/ProductMetricsDaily.kt`
around lines 14 - 35, The entity ProductMetricsDaily lacks a DB-level unique
constraint, reliable audit timestamps, and JPA-safe equality: add a
`@Table`(uniqueConstraints = [UniqueConstraint(columnNames =
["product_id","metric_date"])]) to the class to enforce productId+metricDate
uniqueness; replace the createdAt default LocalDateTime.now() with a JPA-managed
timestamp (annotate createdAt with `@CreationTimestamp`) and add an updatedAt:
LocalDateTime field annotated with `@UpdateTimestamp` to record updates; and
implement equals() and hashCode() on ProductMetricsDaily using the JPA-safe
pattern (compare id when non-null, otherwise fallback to reference) rather than
relying on data-class semantics so entity identity/proxying works correctly.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt-37-40 (1)

37-40: ⚠️ Potential issue | 🟠 Major

String.format에 명시적 Locale 지정이 필요하다.

일부 로캘(예: ar)에서는 %d가 아랍숫자가 아닌 문자로 포맷되어 DB 질의 키가 실제 MV의 year_week와 불일치하게 된다. 운영 서버의 기본 로캘에 종속된 결함은 재현이 어려워 장애 분석 비용이 크다. detekt ImplicitDefaultLocale 경고와도 일치한다.

수정안
-    private fun getYearWeek(date: LocalDate): String {
-        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
-        return String.format("%d-W%02d", date.year, week)
-    }
+    private fun getYearWeek(date: LocalDate): String {
+        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
+        val weekBasedYear = date.get(IsoFields.WEEK_BASED_YEAR)
+        return String.format(java.util.Locale.ROOT, "%d-W%02d", weekBasedYear, week)
+    }

추가로, ISO 주 기준에서는 date.year가 아닌 IsoFields.WEEK_BASED_YEAR를 사용해야 한다. 예: 2026-01-01이 W53(2025년 주)에 속할 수 있어 현재 구현은 2026-W53처럼 실제로 존재하지 않는 키를 만들 수 있다. 이는 WEEK_BASED_YEAR의 경계값(연말·연초) 테스트 부재로 인해 드러나지 않은 결함이다. 주간 경계 테스트를 반드시 추가한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt`
around lines 37 - 40, getYearWeek currently uses String.format without a Locale
and date.year (calendar year), which can produce locale-dependent numerals and
incorrect week-based years; change getYearWeek to obtain the week-based year via
date.get(IsoFields.WEEK_BASED_YEAR) and format the string with an explicit
Locale (e.g., Locale.US) to avoid ImplicitDefaultLocale issues, and add unit
tests for boundary cases (e.g., 2026-01-01 and year-end weeks like W53) to
validate WEEK_BASED_YEAR behavior and correct year_week keys.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt-53-56 (1)

53-56: ⚠️ Potential issue | 🟠 Major

ISO 주 기준에서는 IsoFields.WEEK_BASED_YEAR를 사용하고 Locale을 ROOT로 고정한다.

date.year는 달력 연도이므로 2026-01-01(목)처럼 실제 ISO 주가 2025-W53에 속하는 경우 2026-W53과 같이 존재하지 않는 키가 생성되어, API의 주간 조회 키(MvProductRankRepository.findWeeklyRanking(yearWeek, ...))와 배치 기록 키가 엇갈린다. 운영 상 "주간 랭킹이 비어 보이는" 현상의 원인이 된다. Locale 이슈도 동일하므로 함께 수정한다.

수정안
-    private fun getYearWeek(date: LocalDate): String {
-        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
-        return String.format("%d-W%02d", date.year, week)
-    }
+    private fun getYearWeek(date: LocalDate): String {
+        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
+        val weekBasedYear = date.get(IsoFields.WEEK_BASED_YEAR)
+        return String.format(java.util.Locale.ROOT, "%d-W%02d", weekBasedYear, week)
+    }

추가 테스트: 20260101 → 2025-W53, 20251229 → 2026-W01 등 ISO 경계 케이스를 WeeklyRankingChunkWriterTest에 명시적으로 추가한다. Tasklet 측 getYearWeek에도 동일한 수정이 필요하다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt`
around lines 53 - 56, getYearWeek currently uses date.year and the platform
default Locale which breaks ISO week-boundary keys; change it to read the ISO
week-based year and week using date.get(IsoFields.WEEK_BASED_YEAR) and
date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) and format with a fixed locale (e.g.
String.format(Locale.ROOT, "%d-W%02d", isoYear, week)); update the same logic in
the Tasklet's getYearWeek and add unit tests in WeeklyRankingChunkWriterTest for
boundary cases like 2026-01-01 -> 2025-W53 and 2025-12-29 -> 2026-W01.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingMetricsReader.kt-32-37 (1)

32-37: ⚠️ Potential issue | 🟠 Major

product_metrics_daily 테이블에 (product_id, metric_date) 복합 유니크 제약이 엔티티 정의에 누락되었으므로 추가 필요다.

ProductMetricsDaily 엔티티에 @UniqueConstraint가 없어 Hibernate 자동 DDL 생성 시 유니크 제약이 빠질 위험이 있다. 그러나 ProductMetricsDailyRepository의 INSERT ... ON DUPLICATE KEY UPDATE 쿼리는 (product_id, metric_date) 조합을 자연키로 가정하고 있다.

JdbcPagingItemReader의 페이징 커서 연속성은 sortKeys 순서의 유니크성에 의존하므로, 제약이 없으면 페이지 경계에서 동일 튜플이 중복 또는 누락될 수 있다. 또한 ON DUPLICATE KEY UPDATE도 UNIQUE 제약이나 PK 없이는 의도대로 작동하지 않을 수 있다.

수정 사항:

`@Entity`
`@Table`(
    name = "product_metrics_daily",
    uniqueConstraints = [UniqueConstraint(columnNames = ["product_id", "metric_date"])]
)
class ProductMetricsDaily { ... }

또한 월 범위(최대 수십만 행) 페이징 정합성을 검증하는 통합 테스트를 추가하고, pageSize 500이 커넥션 점유 시간 관점에서 적절한지 재검토하면 좋다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingMetricsReader.kt`
around lines 32 - 37, Add a composite unique constraint on (product_id,
metric_date) to the ProductMetricsDaily entity so Hibernate DDL creates the same
natural key the ProductMetricsDailyRepository expects; specifically annotate the
ProductMetricsDaily `@Table` with a UniqueConstraint for columnNames
["product_id","metric_date"]. Also verify JdbcPagingItemReader’s sortKeys
(product_id, metric_date) rely on that uniqueness for stable paging boundaries,
add an integration test that pages a large monthly range to validate no
duplicates/misses, and reevaluate pageSize (currently 500) for connection
hold-time impact.
apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/productmetrics/ProductMetricsDailyRepository.kt-39-50 (1)

39-50: ⚠️ Potential issue | 🟠 Major

초기 좋아요 취소 이벤트가 먼저 도달하면 like_count가 음수로 저장되는 버그다.

GREATEST(like_count + VALUES(like_count), 0)ON DUPLICATE KEY UPDATE 경로에서만 동작한다. 해당 (product_id, metric_date) 행이 아직 없고 DECREMENT 이벤트(delta = -1)가 첫 이벤트로 처리되면 INSERT ... VALUES (:productId, 0, 0, -1, :metricDate, NOW())가 그대로 수행되어 like_count = -1로 저장된다. 그 후 INCREMENT가 와도 GREATEST(-1 + 1, 0) = 0이 되어 집계가 영구적으로 틀어진다.

실제 운영 관점에서,

  • 레이트 컨디션: 다른 이벤트가 같은 (product_id, today) 행을 아직 insert 하지 않은 상태에서 취소 이벤트가 먼저 도달하는 것은 충분히 발생할 수 있다.
  • 이 값은 이후 배치가 weekly/monthly 점수 누적 시 likeCount * 0.2 가중치로 그대로 반영된다.
🔧 제안 수정안
-        value = "INSERT INTO product_metrics_daily (product_id, view_count, sales_count, like_count, metric_date, created_at) " +
-            "VALUES (:productId, 0, 0, :delta, :metricDate, NOW()) " +
-            "ON DUPLICATE KEY UPDATE like_count = GREATEST(like_count + VALUES(like_count), 0)",
+        value = "INSERT INTO product_metrics_daily (product_id, view_count, sales_count, like_count, metric_date, created_at) " +
+            "VALUES (:productId, 0, 0, GREATEST(:delta, 0), :metricDate, NOW()) " +
+            "ON DUPLICATE KEY UPDATE like_count = GREATEST(like_count + :delta, 0)",

추가 테스트(Spring Data JPA + testcontainers):

  • 새 (productId, metricDate)에 DECREMENT가 첫 호출 → like_count == 0
  • 새 행에 INCREMENT 2회 후 DECREMENT 1회 → like_count == 1
  • 새 행에 DECREMENT 후 INCREMENT → like_count == 1(현재 구현에서는 0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/productmetrics/ProductMetricsDailyRepository.kt`
around lines 39 - 50, The bug is that a first-event DECREMENT can insert a
negative like_count because the INSERT uses raw :delta; modify the native query
in incrementLikeCount so the inserted like_count is clamped to >=0 (e.g. use
GREATEST(0, :delta) for the VALUES part) while keeping the ON DUPLICATE KEY
UPDATE clause (GREATEST(like_count + VALUES(like_count), 0)) as-is; this ensures
new rows never start negative and satisfies the provided test scenarios.
🟡 Minor comments (8)
apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankWeeklyJpaRepository.kt-12-14 (1)

12-14: ⚠️ Potential issue | 🟡 Minor

@Modifying 벌크 삭제 후 영속성 컨텍스트를 정리해야 한다.

deleteByYearWeek는 배치 writer의 첫 청크에서 기존 주차 MV 행을 삭제한 직후, 동일 트랜잭션 내에서 UPSERT가 이어진다. JPQL 벌크 DELETE는 영속성 컨텍스트에 반영되지 않으므로, 같은 트랜잭션 범위에서 MV 엔티티를 조회·갱신하는 경로가 있으면 삭제 전 스냅샷이 보여지는 위험이 있다. 주간 랭킹 재계산 시 중복 행 또는 UNIQUE 제약조건 위반으로 인한 배치 실패가 발생할 수 있다.

♻️ 수정안
-    `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`("DELETE FROM MvProductRankWeekly m WHERE m.yearWeek = :yearWeek")
     fun deleteByYearWeek(`@Param`("yearWeek") yearWeek: String)

동일한 패턴이 MvProductRankMonthlyJpaRepositorydeleteByYearMonth 메서드에도 적용되므로 함께 수정한다. 동일 주차 재실행 시 이전 데이터가 남지 않고 UNIQUE(product_id, period) 위반이 없는지 확인하는 배치 통합 테스트를 추가한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankWeeklyJpaRepository.kt`
around lines 12 - 14, The JPQL bulk DELETE in deleteByYearWeek
(MvProductRankWeeklyJpaRepository) leaves stale entities in the persistence
context causing duplicate/unique-constraint failures when UPSERTs run in the
same transaction; update the `@Modifying` annotation to clear the persistence
context automatically (use clearAutomatically=true) or explicitly call
EntityManager.clear() after the delete, and apply the same fix to
deleteByYearMonth in MvProductRankMonthlyJpaRepository; also add a batch
integration test that reruns the weekly/monthly job for the same period to
assert no leftover rows and no UNIQUE(product_id, period) violations.
apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ControllerTest.kt-80-105 (1)

80-105: ⚠️ Potential issue | 🟡 Minor

"기본값" 테스트가 실제 기본값을 검증하지 않는다.

shouldGetDailyRankingByDefault가 이름과 달리 getDailyRankings(0, 20, date)로 모든 인자를 명시해 호출하므로, Controller의 @RequestParam 기본값(page/size/date 미지정) 동작을 전혀 검증하지 못한다. 운영 관점에서 기본값 회귀가 발생해도 이 테스트는 통과한다. 진정한 기본값 검증은 MockMvc로 쿼리 파라미터를 생략한 실제 HTTP 요청 경로를 확인해야 한다.

추가 테스트 권고:

  • MockMvcGET /api/v1/products/rankings/daily를 파라미터 없이 호출하여 기본 page=0, size=20, date=today(KST)가 facade에 전달되는지 검증.
  • MONTHLY 경로(shouldGetMonthlyRanking) 케이스 누락 보완.
  • 경계값: size 과대값, page 음수 입력에 대한 검증 요청 파라미터 처리 테스트.

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

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

In
`@apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ControllerTest.kt`
around lines 80 - 105, The test should actually exercise HTTP defaults via
MockMvc instead of calling controller.getDailyRankings with explicit args:
replace or add a test that performs a MockMvc GET to
"/api/v1/products/rankings/daily" with no query params, capture the LocalDate
passed to facade.getRankedProducts (or verify argument values) and assert it was
called with page=0, size=20 and date equal to today in KST
(ZoneId.of("Asia/Seoul")); similarly add a parallel MockMvc test for the MONTHLY
endpoint (e.g., shouldGetMonthlyRanking) and add extra tests for boundary cases
(oversized size and negative page) to verify request param validation/handling.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/CleanupTasklet.kt-17-26 (1)

17-26: ⚠️ Potential issue | 🟡 Minor

Cleanup 실패가 파이프라인 실패로 전파되는 것은 부적절하다.

운영 관점에서 이 Tasklet은 단순 임시 키 회수 역할이며, 상위 RankingAggregationTasklet에서 이미 300초 TTL을 설정해 두므로 Redis 네트워크 일시 오류로 delete가 실패해도 리소스 누수는 없다. 그런데 현재 구현은 예외가 그대로 올라가 Batch Step/Job 실패로 전파된다. 랭킹 파이프라인 가용성을 위해 try/catch로 경고 로깅 후 삼켜 주는 편이 안전하다.

또한 executionContext.get("tempKey")as? String으로 안전 캐스팅하는 것은 좋으나, 값이 잘못된 타입으로 들어있을 때 조용히 스킵되어 원인 추적이 어렵다. 타입 불일치는 경고 로그를 남길 것을 권고한다.

🛠️ 제안 변경
-        if (tempKey != null) {
-            redisTemplate.delete(tempKey)
-        }
+        if (tempKey != null) {
+            runCatching { redisTemplate.delete(tempKey) }
+                .onFailure { log.warn("Failed to delete temp ranking key={}, will expire via TTL", tempKey, it) }
+        } else {
+            log.debug("No tempKey found in execution context; skipping cleanup")
+        }

추가 테스트: (1) tempKey 부재 시 FINISHED, (2) Redis 예외 발생 시에도 FINISHED 반환, (3) 정상 삭제 케이스 — 3종의 단위 테스트 추가를 권고한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/CleanupTasklet.kt`
around lines 17 - 26, The CleanupTasklet.execute currently allows Redis delete
failures and wrong-type tempKey to propagate and fail the job; change it so
executionContext.get("tempKey") is checked and if present but not a String you
log a warning (include the actual value/type) and skip deletion, and wrap the
redisTemplate.delete(tempKey) call in a try/catch that catches Exception, logs a
warning including the tempKey and the exception, and swallows the error so the
method always returns RepeatStatus.FINISHED; reference the execute method in
CleanupTasklet, the tempKey retrieval from
chunkContext.stepContext.stepExecution.jobExecution.executionContext, and
redisTemplate.delete to locate the code to modify.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt-29-41 (1)

29-41: ⚠️ Potential issue | 🟡 Minor

isFirstChunk 인스턴스 변수는 Step 재시작 시나리오에서 멱등성을 깬다.

@StepScope로 Step 실행마다 인스턴스가 새로 생성되므로 단일 실행에서는 문제없으나, 동일 targetDate로 Step이 실패 후 재실행되면 isFirstChunk가 다시 true가 되어 이미 일부 upsert된 MV 행을 전부 삭제한다. 또한 본 플래그가 ExecutionContext에 저장되지 않으므로 장애 복구 동작이 예측 불가하다.

개선안:

  • 삭제 로직을 Writer에서 분리해 accumulationStep 이전의 별도 "pre-delete" Tasklet으로 옮긴다. 해당 Tasklet은 @StepScope로 구성하여 Step 단위 멱등성을 확보한다.
  • 혹은 StepExecutionListener.beforeStep에서 한 번만 수행하도록 이동(현재 비어 있음)한다.

추가 테스트: Step 실패 후 재시작 시 기존 upsert 데이터가 보존되는지(또는 올바르게 재계산되는지) 통합 테스트로 검증한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt`
around lines 29 - 41, The isFirstChunk flag in WeeklyRankingChunkWriter (used in
write) breaks idempotency on step restarts; remove deletion from write and
perform weeklyRepository.deleteByYearWeek(yearWeek) once per step instead—either
move the deletion into the existing
StepExecutionListener.beforeStep(stepExecution) implementation (compute yearWeek
from targetDate there and delete) or extract it into a separate `@StepScope`
"pre-delete" Tasklet that runs before the accumulation step; ensure the writer
no longer uses isFirstChunk and add an integration test that restarts the step
to confirm existing upserted rows are not lost on retry.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkReader.kt-21-24 (1)

21-24: ⚠️ Potential issue | 🟡 Minor

getString 결과에 ?: ""는 null 처리는 필요하나, 누락된 키를 명시적으로 검증해야 한다.

ExecutionContext.getString(key)는 키 부재 시 null을 반환하며, 예외를 던지지 않는다. 현재 코드는 ?: ""로 null을 처리하지만, 빈 문자열로 변환된 tempKey를 reverseRangeWithScores("", 0, 99)에 전달하게 되고, 이는 조용히 빈 결과를 반환하여 "아무 일도 일어나지 않는 성공"이 되어 운영 상 디버깅을 어렵게 만든다. 누락되거나 공백인 tempKey는 배치 실패로 명시적으로 드러나야 한다.

수정안
    override fun beforeStep(stepExecution: StepExecution) {
-        tempKey = stepExecution.jobExecution.executionContext.getString("tempKey") ?: ""
+        val ctx = stepExecution.jobExecution.executionContext
+        require(ctx.containsKey("tempKey")) { "tempKey is missing in JobExecutionContext" }
+        tempKey = ctx.getString("tempKey")
+        require(tempKey.isNotBlank()) { "tempKey must not be blank" }
        initializeItems()
    }

추가 테스트: tempKey 누락/공백일 때 IllegalArgumentException이 발생하는지 검증하는 단위 테스트를 추가한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkReader.kt`
around lines 21 - 24, The beforeStep implementation currently defaults tempKey
to empty string which lets initializeItems call reverseRangeWithScores("", ...)
and silently succeed; change beforeStep in RankingChunkReader to explicitly
validate the ExecutionContext.getString("tempKey") result (in method beforeStep)
and if the value is null or blank throw an IllegalArgumentException with a clear
message (e.g., "tempKey is missing or blank"); keep calling initializeItems
afterwards only when tempKey is valid, and add a unit test that verifies
RankingChunkReader.beforeStep (or the job step execution) throws
IllegalArgumentException when tempKey is missing or blank.
apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt-33-49 (1)

33-49: ⚠️ Potential issue | 🟡 Minor

@Transactional을 제거하거나 전파 정책과 테스트를 명시해야 한다.

Spring Batch의 chunk 지향 Step은 StepBuilder.chunk(..., txManager)에서 이미 chunk 경계마다 트랜잭션을 시작한다. write() 메서드의 @Transactional은 기본 전파 정책인 REQUIRED에 따라 Batch가 시작한 트랜잭션에 참여하게 되므로, 실질적으로 새로운 트랜잭션을 생성하지 않는다. 즉, 애너테이션이 no-op 상태가 되어 혼동을 야기한다.

운영 관점에서 기대치가 Batch 기본 TX라면 @Transactional을 제거하는 것이 명확하다. 만약 첫 chunk의 deleteByYearWeek() 실패 후 이후 upsert의 부분 성공을 방지해야 한다면, 명시적으로 propagation = Propagation.REQUIRES_NEW를 지정하고, 중간 chunk 실패 시 재시작되는 시나리오에 대한 테스트(예: 첫 삭제 후 세 번째 chunk에서 실패 → 재실행 시 일관성 검증)를 추가해야 한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt`
around lines 33 - 49, The `@Transactional` on WeeklyRankingChunkWriter.write is
misleading/no-op under Spring Batch chunk transactions; either remove the
annotation or make propagation explicit. Remove `@Transactional` from the write
method to rely on the Step chunk transaction, or change it to
`@Transactional`(propagation = Propagation.REQUIRES_NEW) if you need
deleteByYearWeek to run in its own transaction isolation from subsequent
jdbcTemplate.batchUpdate UPSERT_SQL calls; if you choose REQUIRES_NEW, add tests
around isFirstChunk/weeklyRepository.deleteByYearWeek failure and chunk restart
scenarios (e.g., delete succeeds then a later chunk fails and the step is
restarted) to validate consistency.
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.kt-55-60 (1)

55-60: ⚠️ Potential issue | 🟡 Minor

String.format에 Locale을 명시하기 바란다.

detekt가 ImplicitDefaultLocale 경고를 주고 있다. 서버 locale에 따라 숫자 포맷이 달라지는 환경(ar/fa 등 별도 digit)에서는 %d가 의도치 않은 문자로 출력되어 MV 키와 불일치를 유발할 수 있어 운영 리스크가 존재한다. Locale.ROOT 고정을 권장한다.

🔧 제안 수정안
-        return String.format("%d-W%02d", year, week)
+        return String.format(java.util.Locale.ROOT, "%d-W%02d", year, week)
-        return String.format("%d-%02d", date.year, date.monthValue)
+        return String.format(java.util.Locale.ROOT, "%d-%02d", date.year, date.monthValue)

추가 테스트: Locale이 다른 환경(Locale.setDefault(Locale.forLanguageTag("ar")))에서도 키가 ASCII 숫자로 생성되는지 확인하는 단위 테스트 추가를 권장한다.

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.kt`
around lines 55 - 60, The String.format calls in the formatting helpers are
using the implicit default Locale and should be fixed to use a fixed locale;
update the formatting in the functions that build keys (e.g., the method
producing "%d-W%02d" — refer to the getYearWeek/key builder — and getYearMonth)
to call String.format(Locale.ROOT, ...) instead of String.format(...). Also add
a unit test that sets Locale.setDefault(Locale.forLanguageTag("ar")) and
verifies the produced key strings contain ASCII digits to prevent
locale-dependent digits.
apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ApiSpec.kt-22-58 (1)

22-58: ⚠️ Potential issue | 🟡 Minor

인터페이스와 구현체 간 @DateTimeFormat 불일치로 문서화 위험 발생

명시된 포맷(yyyyMMdd)을 따르지 않는 클라이언트 요청으로 인한 400 에러를 방지하기 위해, spec 인터페이스의 date 파라미터에 @DateTimeFormat(pattern = "yyyyMMdd")을 명시적으로 선언해야 한다.

현재 ProductRankingV1ApiSpec 인터페이스의 세 엔드포인트(/daily, /weekly, /monthly)는 @Parameter 설명에 yyyyMMdd 포맷을 기재하고 있으나, 애노테이션으로는 강제하지 않는다. 이에 반해 ProductRankingV1Controller 구현체는 모든 date 파라미터에 @DateTimeFormat(pattern = "yyyyMMdd")을 적용하고 있어 불일치가 발생한다. 실제 바인딩은 구현체 메서드의 애노테이션을 따르므로 현재 운영 에러는 없으나, 다음 두 가지 운영 리스크가 있다.

  1. 문서화 누락: SpringDoc이 spec 인터페이스를 소스로 OpenAPI 문서를 생성할 경우, @DateTimeFormat 정보가 누락되어 클라이언트가 ISO-8601 포맷(2026-04-14)으로 호출하면 400을 맞는다.
  2. 신규 구현체의 포맷 누락: 이 인터페이스를 상속하는 다른 구현이 추가될 때, 개발자가 포맷 요구사항을 인지하지 못해 LocalDate.parse의 기본값(ISO-8601)으로 처리될 수 있다.

수정안: spec 인터페이스의 date 파라미터에 @DateTimeFormat(pattern = "yyyyMMdd")을 추가하고, 구현체의 중복 애노테이션은 제거한다. 이후 202604142026-04-14 두 포맷에 대한 MockMvc 바인딩 테스트를 추가하여 포맷 계약을 검증한다.

♻️ 제안 패치
 import org.springframework.web.bind.annotation.GetMapping
 import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.format.annotation.DateTimeFormat
 import java.time.LocalDate
@@
     fun getDailyRankings(
         `@RequestParam`(defaultValue = "0") page: Int,
         `@RequestParam`(defaultValue = "20") size: Int,
         `@Parameter`(description = "KST 기준 처리 일자 (yyyyMMdd, 미지정시 오늘)", required = false)
-        `@RequestParam`(required = false) date: LocalDate?,
+        `@RequestParam`(required = false)
+        `@DateTimeFormat`(pattern = "yyyyMMdd")
+        date: LocalDate?,
     ): ApiResponse<PageResponse<ProductInfo>>

(weekly/monthly 동일 적용)

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ApiSpec.kt`
around lines 22 - 58, Add the `@DateTimeFormat`(pattern = "yyyyMMdd") annotation
to the date parameter in ProductRankingV1ApiSpec for getDailyRankings,
getWeeklyRankings and getMonthlyRankings (so the interface documents and
enforces the expected yyyyMMdd format), remove the duplicated `@DateTimeFormat`
from the corresponding parameters in ProductRankingV1Controller to avoid
redundancy, and add MockMvc tests (e.g., in ProductRankingV1ControllerTest) that
assert a request with date=20260414 binds successfully and a request with
date=2026-04-14 yields a 400 to verify the contract.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f8a62223-ee43-46fe-bc8a-da20ececddaf

📥 Commits

Reviewing files that changed from the base of the PR and between d574f64 and e730ef2.

📒 Files selected for processing (47)
  • .gitignore
  • apps/commerce-api/src/main/kotlin/com/loopers/application/api/product/ProductRankingFacade.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankMonthly.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankWeekly.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ApiSpec.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1Controller.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/RankingPeriod.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/application/api/product/ProductRankingFacadeTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/ProductRankingReadServiceTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImplTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/product/ProductRankingApiE2ETest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ControllerTest.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankMonthly.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankWeekly.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankMonthlyJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankWeeklyJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/CleanupTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingMetricsReader.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationItem.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkProcessor.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkReader.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingMetricsReader.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MetricsAggregationProcessorTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriterTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/RankingAndTrimTaskletTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/RankingMetricsReaderTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriterTest.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/LikeCountEventHandler.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandler.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandler.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/productmetrics/ProductMetricsDaily.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/productmetrics/ProductMetricsDailyRepository.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/LikeCountEventHandlerTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandlerTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandlerTest.kt
  • modules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.kt
  • modules/redis/src/testFixtures/kotlin/com/loopers/utils/RedisCleanUp.kt

Comment on lines +13 to +24
class MvProductRankMonthly(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,

val productId: Long,
@Column(name = "rank_value")
val rank: Int,
val score: Double,
// e.g., "2026-04"
val yearMonth: String,
val updatedAt: LocalDateTime = LocalDateTime.now(),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'MvProductRankWeekly.kt' --exec cat {}
rg -nP 'OrderByRank\b|\.rank\b' --type=kotlin -C2

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

Length of output: 18449


🏁 Script executed:

# 배치 작업의 SQL 쿼리 전체 내용 확인
cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt | head -80

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt | head -80

# 데이터베이스 스키마 정의 확인
find . -type f \( -name "*.sql" -o -name "*migration*" -o -name "*schema*" \) -exec grep -l "rank_value\|rank" {} \;

# JPA Repository 인터페이스 확인
cat -n apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.kt

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

Length of output: 7809


배치 작업의 네이티브 SQL에서 테이블 컬럼명이 잘못 참조되어 순위 업데이트가 실패한다.

엔티티는 @Column(name = "rank_value")로 정의되어 DB 컬럼명을 rank_value로 설정했으나, WeeklyRankingAndTrimTasklet(라인 50, 55)과 MonthlyRankingAndTrimTasklet(라인 48, 53)의 네이티브 SQL은 rank 컬럼을 참조한다. 배치 작업에서 SQL 실행 시 rank 컬럼을 찾지 못해 순위 업데이트(SET w1.rank = w2.new_rank)와 상위 100개 초과 레코드 삭제(WHERE rank > 100)가 모두 무시되어 배치 장애로 이어진다.

JPA 파생 메서드 findByYearMonthOrderByRank는 Kotlin 속성명 rank를 기준으로 동작하므로 현재는 정상이지만, 향후 누군가 메서드명을 findByYearMonthOrderByRankValue로 변경하면 PropertyReferenceException이 발생한다. 속성명을 rankValue로 변경하여 코드와 DB를 일관되게 정렬한다:

수정안
  1. 두 엔티티의 속성명 변경
// MvProductRankMonthly, MvProductRankWeekly 동일 적용
`@Column`(name = "rank_value")
val rankValue: Int,
  1. 배치 작업의 네이티브 SQL 수정
// WeeklyRankingAndTrimTasklet.kt
SET w1.rank_value = w2.new_rank  // 라인 50
WHERE year_week = ? AND rank_value > 100  // 라인 55

// MonthlyRankingAndTrimTasklet.kt
SET m1.rank_value = m2.new_rank  // 라인 48
WHERE year_month = ? AND rank_value > 100  // 라인 53
  1. Repository의 매핑 로직 수정
// MvProductRankRepositoryImpl.kt
rankValue = it.rankValue.toLong()  // 라인 23, 36
  1. JPA 파생 메서드 이름 수정
// MvProductRankRepositoryImpl.kt
fun findByYearWeekOrderByRankValue(...)  // 라인 53
fun findByYearMonthOrderByRankValue(...)  // 라인 61
  1. 배치 작업 호출처와 테스트 코드에서 .rank 접근을 .rankValue로 수정 필요
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankMonthly.kt`
around lines 13 - 24, The entity property name "rank" in MvProductRankMonthly
(and its weekly counterpart) conflicts with the actual DB column name
"rank_value" used via `@Column`, causing native SQL in WeeklyRankingAndTrimTasklet
and MonthlyRankingAndTrimTasklet to fail; rename the Kotlin property to
rankValue (keeping `@Column`(name = "rank_value")) in MvProductRankMonthly (and
MvProductRankWeekly), update all native SQL in WeeklyRankingAndTrimTasklet and
MonthlyRankingAndTrimTasklet to reference rank_value instead of rank, update
mapping code in MvProductRankRepositoryImpl to use rankValue (e.g., conversion
lines), and rename any JPA-derived methods (findByYearMonthOrderByRank ->
findByYearMonthOrderByRankValue and equivalent week method) plus all callsites
and tests that access .rank to use .rankValue.

Comment on lines +52 to +56
private fun getYearWeek(date: LocalDate): String {
val year = date.year
val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
return String.format("%d-W%02d", year, week)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 배치 측 yearWeek 계산 방식 확인: API와 같은 IsoFields.WEEK_BASED_YEAR 기반인지 점검
rg -nP --type=kotlin -C5 'yearWeek|WEEK_OF_WEEK_BASED_YEAR|WEEK_BASED_YEAR'

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

Length of output: 40672


연말/연초 경계에서 yearWeek 키 생성 오류로 인한 랭킹 조회 실패 위험

IsoFields.WEEK_OF_WEEK_BASED_YEAR는 반드시 IsoFields.WEEK_BASED_YEAR와 짝을 이루어야 하는데, 현재 코드는 달력 연도(date.year)와 ISO 주차를 조합하고 있다. 이로 인해 연말/연초 경계 주차에서 저장 키와 조회 키가 불일치한다.

  • 2024-12-30(월): 달력 연도 2024, ISO 주차 1 → 생성 키 "2024-W01", 실제 ISO 키 "2025-W01"
  • 2023-01-01(일): 달력 연도 2023, ISO 주차 52 → 생성 키 "2023-W52", 실제 ISO 키 "2022-W52"

배치(WeeklyRankingChunkWriter, WeeklyRankingAndTrimTasklet)도 동일한 버그를 가지고 있어, 현재는 우연히 두 시스템이 같은 방식으로 오동작하고 있다. 배치가 수정되면 API의 조회 키와 MV 테이블의 저장 키가 엇갈려 해당 주차의 랭킹이 통째로 조회되지 않는 사일런트 장애가 발생한다.

🔧 제안 수정안
     private fun getYearWeek(date: LocalDate): String {
-        val year = date.year
-        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
-        return String.format("%d-W%02d", year, week)
+        val weekBasedYear = date.get(IsoFields.WEEK_BASED_YEAR)
+        val week = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
+        return String.format(java.util.Locale.ROOT, "%d-W%02d", weekBasedYear, week)
     }

추가로 배치의 WeeklyRankingChunkWriter.getYearWeek()WeeklyRankingAndTrimTasklet.getYearWeek() 메서드도 동일하게 수정하고, API 테스트(ProductRankingReadServiceTest)에 다음 경계 케이스를 필수 추가한다:

  • LocalDate.of(2024, 12, 30)"2025-W01" 검증
  • LocalDate.of(2023, 1, 1)"2022-W52" 검증
🧰 Tools
🪛 detekt (1.23.8)

[warning] 55-55: String.format("%d-W%02d", year, week) uses implicitly default locale for string formatting.

(detekt.potential-bugs.ImplicitDefaultLocale)

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

In
`@apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.kt`
around lines 52 - 56, getYearWeek currently mixes date.year with
IsoFields.WEEK_OF_WEEK_BASED_YEAR which breaks ISO week-year boundaries; update
ProductRankingReadService.getYearWeek (and the same methods in
WeeklyRankingChunkWriter.getYearWeek and
WeeklyRankingAndTrimTasklet.getYearWeek) to use IsoFields.WEEK_BASED_YEAR for
the year component and IsoFields.WEEK_OF_WEEK_BASED_YEAR for the week component
(format "%d-W%02d"), then add tests in ProductRankingReadServiceTest asserting
LocalDate.of(2024,12,30) -> "2025-W01" and LocalDate.of(2023,1,1) -> "2022-W52".

Comment on lines +18 to +26
@Disabled("MV 리포지토리는 배치 모듈에서 관리. 배치 테스트에서 검증")
@SpringBootTest
@SpringJUnitConfig(MySqlTestContainersConfig::class)
class MvProductRankRepositoryImplTest @Autowired constructor(
private val repository: MvProductRankRepository,
private val weeklyJpaRepository: MvProductRankWeeklyJpaRepository,
private val monthlyJpaRepository: MvProductRankMonthlyJpaRepository,
private val databaseCleanUp: DatabaseCleanUp,
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 배치 모듈에 동일한 조회 경로 커버리지가 있는지
rg -nP --type=kotlin -C3 'findWeeklyRanking|findMonthlyRanking|countWeekly|countMonthly'

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

Length of output: 19699


🏁 Script executed:

find . -type f -name "*Test.kt" -o -name "*Tests.kt" | grep -i batch | head -20

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

Length of output: 845


🏁 Script executed:

# 배치 모듈 구조 탐색
git ls-files | grep -E '(batch|Batch)' | grep -E '(\.kt|\.java)$' | head -30

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

Length of output: 2860


🏁 Script executed:

rg -n 'MvProductRankRepository|findWeeklyRanking|findMonthlyRanking|countWeekly|countMonthly' apps/commerce-batch/src/test --type=kotlin -A2 -B2

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

Length of output: 69


🏁 Script executed:

# 배치 모듈에서 MvProductRankRepository 사용 여부 확인
rg -n 'MvProductRankRepository' apps/commerce-batch/src/main --type=kotlin

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

Length of output: 69


@Disabled 어노테이션을 제거하여 테스트를 활성화하기 바란다.

현재 MvProductRankRepositoryImplTest@Disabled("MV 리포지토리는 배치 모듈에서 관리. 배치 테스트에서 검증")로 비활성화되어 있으나, 배치 모듈은 MvProductRankRepository의 조회 메서드(findWeeklyRanking, findMonthlyRanking, countWeekly, countMonthly)를 사용하지 않는다. API 모듈의 MvProductRankRepositoryImpl은 페이징·정렬·매핑 로직을 포함한 실제 구현으로서, 배치 테스트로는 해당 회귀를 감지할 수 없다.

테스트 파일 자체는 페이징 경계값(0-19, 20-29 페이지), 컬럼 카운트, 비존재 데이터 엣지 케이스, 정렬 순서까지 포괄적으로 작성되어 있으므로, @Disabled를 제거하여 CI 파이프라인에서 실행되도록 전환하기 바란다. 그렇지 않으면 운영 중 페이징 오류나 쿼리 회귀가 무시되어 사용자 영향을 초래할 수 있다.

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

In
`@apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImplTest.kt`
around lines 18 - 26, The test class MvProductRankRepositoryImplTest is
currently disabled via `@Disabled` and should be re-enabled so CI runs its
paging/sorting/mapping regressions; remove the `@Disabled`("MV 리포지토리는 배치 모듈에서 관리.
배치 테스트에서 검증") annotation from the MvProductRankRepositoryImplTest class
declaration so the test suite executes the class (which verifies paging
boundaries, column counts, missing-data edge cases and sort order) and ensure
the test runs against the existing MySqlTestContainersConfig and injected
repositories (MvProductRankRepository, MvProductRankWeeklyJpaRepository,
MvProductRankMonthlyJpaRepository) that exercise methods like findWeeklyRanking,
findMonthlyRanking, countWeekly and countMonthly.

Comment on lines +41 to +54
private const val RANK_SQL = """
UPDATE mv_product_rank_monthly m1
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
FROM mv_product_rank_monthly
WHERE year_month = ?
) m2 ON m1.id = m2.id
SET m1.rank = m2.new_rank, m1.updated_at = NOW()
"""

private const val TRIM_SQL = """
DELETE FROM mv_product_rank_monthly
WHERE year_month = ? AND rank > 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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# mv_product_rank_* 엔티티의 실제 컬럼명 확인
rg -nP --type=kotlin -C3 'class\s+MvProductRank(Weekly|Monthly)\b'
rg -nP --type=kotlin -C1 '@Column\s*\('
# 마이그레이션/DDL의 실제 컬럼명 확인
fd -e sql -e xml . | xargs rg -nP -C2 'mv_product_rank_(weekly|monthly)'
# WeeklyRankingAndTrimTasklet에도 같은 패턴이 있는지
rg -nP --type=kotlin -C3 'RANK_SQL|TRIM_SQL|ROW_NUMBER\(\)'

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

Length of output: 30797


네이티브 SQL의 rank 컬럼명이 엔티티 매핑 rank_value와 불일치하여 월간/주간 랭킹 산정이 런타임에 실패한다.

엔티티 매핑 확인 결과, MvProductRankMonthlyMvProductRankWeekly 모두 @Column(name = "rank_value") 로 정의되어 있다. 그러나 이 tasklet의 네이티브 SQL은 여전히 m1.rank, w1.rank 로 컬럼을 참조하므로, 배치 실행 시 "Unknown column 'rank'" 에러가 발생하여 UPDATE/DELETE 문이 실패한다. 결과적으로 월간/주간 랭킹 산정이 통째로 실패하고, 이전 accumulation 스텝에서 UPSERT된 데이터가 rank 미갱신 상태로 mv 테이블에 남는다(부분 완료 상태로 인한 데이터 정합성 문제).

또한 year_month/year_week 컬럼은 MySQL 예약어는 아니지만, 안정성을 위해 백틱으로 감싸는 것을 권장한다.

🔧 수정 방안
- private const val RANK_SQL = """
-     UPDATE mv_product_rank_monthly m1
-     JOIN (
-         SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
-         FROM mv_product_rank_monthly
-         WHERE year_month = ?
-     ) m2 ON m1.id = m2.id
-     SET m1.rank = m2.new_rank, m1.updated_at = NOW()
- """
- 
- private const val TRIM_SQL = """
-     DELETE FROM mv_product_rank_monthly
-     WHERE year_month = ? AND rank > 100
- """
+ private const val RANK_SQL = """
+     UPDATE mv_product_rank_monthly m1
+     JOIN (
+         SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
+         FROM mv_product_rank_monthly
+         WHERE `year_month` = ?
+     ) m2 ON m1.id = m2.id
+     SET m1.`rank_value` = m2.new_rank, m1.updated_at = NOW()
+ """
+ 
+ private const val TRIM_SQL = """
+     DELETE FROM mv_product_rank_monthly
+     WHERE `year_month` = ? AND `rank_value` > 100
+ """

WeeklyRankingAndTrimTasklet도 동일하게 rankrank_value, year_week`year_week` 로 수정 필요.

수정 후 다음을 반드시 검증하라:

  • 월간/주간 Tasklet 모두 update row count > 0 인지 확인하는 통합 테스트 추가
  • mv_product_rank_monthly/weekly 에 rank_value 컬럼이 정상적으로 갱신되는지 확인
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt`
around lines 41 - 54, The native SQL in MonthlyRankingAndTrimTasklet uses the
wrong column name and unquoted identifiers: update RANK_SQL and TRIM_SQL to
reference the mapped column name rank_value (e.g., replace m1.rank / m2.new_rank
→ m1.`rank_value` / m2.new_rank) and wrap year_month in backticks (`year_month`)
to avoid identifier issues; make the analogous changes in
WeeklyRankingAndTrimTasklet (replace rank → `rank_value`, year_week →
`year_week`) so the SQL matches the entity MvProductRankMonthly /
MvProductRankWeekly mappings, then run/extend integration tests to assert update
row counts > 0 and that mv_product_rank_monthly/weekly.rank_value is updated.

Comment on lines +57 to +61
private const val UPSERT_SQL = """
INSERT INTO mv_product_rank_monthly (product_id, year_month, score, rank, updated_at)
VALUES (?, ?, ?, 0, NOW())
ON DUPLICATE KEY UPDATE score = score + VALUES(score), updated_at = NOW()
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# DDL/마이그레이션에서 mv_product_rank_monthly의 컬럼명을 확인한다.
fd -t f -e sql -e yaml -e yml | xargs rg -nP -C2 '\bmv_product_rank_(monthly|weekly)\b'
rg -nP -C2 "rank_value|\brank\b" --type=sql

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

Length of output: 69


🏁 Script executed:

fd -type f -name "MonthlyRankingChunkWriter.kt"

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

Length of output: 257


🏁 Script executed:

fd -type f -name "*MvProductRankMonthly*"

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

Length of output: 257


🏁 Script executed:

fd -type f -name "*.kt" | xargs rg -l "MvProductRankMonthly" | head -20

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

Length of output: 1061


🏁 Script executed:

find . -type f \( -name "*.sql" -o -name "*migration*" -o -name "*.ddl" \) | head -30

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

Length of output: 298


🏁 Script executed:

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankMonthly.kt

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

Length of output: 915


🏁 Script executed:

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt

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

Length of output: 2807


🏁 Script executed:

cat -n apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriterTest.kt

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

Length of output: 4433


🏁 Script executed:

rg -n "mv_product_rank" --type-list | head -50

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

Length of output: 476


🏁 Script executed:

rg -n "mv_product_rank" -A 5 -B 5

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

Length of output: 14784


🏁 Script executed:

cat -n apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankMonthly.kt

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

Length of output: 909


🏁 Script executed:

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankWeekly.kt

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

Length of output: 913


🏁 Script executed:

rg -n "rank_value|rank" apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/ -A 2 -B 2

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

Length of output: 3444


🏁 Script executed:

rg -n "deleteByYearMonth|deleteByYearWeek" --type=kotlin -A 5

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

Length of output: 6282


🏁 Script executed:

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankMonthlyJpaRepository.kt

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

Length of output: 842


🏁 Script executed:

rg -n "BASIC_ISO_DATE|targetDate" apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/ -B 2 -A 2

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

Length of output: 11847


🏁 Script executed:

rg -n "VALUES\(" apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/ -A 1

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

Length of output: 675


🏁 Script executed:

cat -n apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt | head -55

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

Length of output: 2389


🏁 Script executed:

rg -n "isFirstChunk|@StepScope" apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt -A 3 -B 3

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

Length of output: 845


치명적 컬럼명 불일치: INSERT와 UPDATE 모두 실패한다.

엔티티 MvProductRankMonthly@Column(name = "rank_value")로 매핑되어 있으나, 이 파일의 UPSERT_SQL은 여전히 컬럼명 rank를 사용한다. 배치 실행 시 Unknown column 'rank' in field list 오류로 월간 랭킹 데이터가 전혀 삽입되지 않는다. 추가로 MonthlyRankingAndTrimTasklet의 48번 줄 SET m1.rank = ... 도 동일한 문제를 가진다. UPSERT_SQL을 수정하지 않으면 Tasklet의 순위 갱신도 실패한다.

컬럼명을 rank_value로 수정하고, 함께 VALUES(score) 참조 구문도 개선하라. MySQL 8.0.20 이후 VALUES(col) 은 deprecated이므로 alias 기반 참조(AS new ... new.score)로 전환하길 권장한다.

🐛 제안 수정안
         private const val UPSERT_SQL = """
-            INSERT INTO mv_product_rank_monthly (product_id, year_month, score, rank, updated_at)
+            INSERT INTO mv_product_rank_monthly (product_id, year_month, score, rank_value, updated_at)
             VALUES (?, ?, ?, 0, NOW())
-            ON DUPLICATE KEY UPDATE score = score + VALUES(score), updated_at = NOW()
+            ON DUPLICATE KEY UPDATE score = score + new.score, updated_at = NOW()
         """

MonthlyRankingAndTrimTasklet 48번 줄도 동일하게 수정:

-            SET m1.rank = m2.new_rank, m1.updated_at = NOW()
+            SET m1.rank_value = m2.new_rank, m1.updated_at = NOW()

추가 테스트: Testcontainers MySQL 환경에서 write 호출 후 실제 데이터 삽입이 성공하는지, 이어서 Tasklet 실행 후 순위 갱신이 정상 작동하는지 통합 테스트를 추가하라. 현재 단위 테스트는 모킹 기반이라 SQL 오류를 감지하지 못한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt`
around lines 57 - 61, UPSERT_SQL in MonthlyRankingChunkWriter uses the wrong
column name "rank" and the deprecated VALUES(...) reference; update the
INSERT/ON DUPLICATE KEY UPDATE to use the entity column name "rank_value"
instead of "rank", and switch to an alias-based VALUES reference (e.g., add an
INSERT VALUES alias like "AS new" and use new.score in the UPDATE) so the ON
DUPLICATE KEY UPDATE reads something like "score = score + new.score, updated_at
= NOW()". Also fix the corresponding update in MonthlyRankingAndTrimTasklet
where "SET m1.rank = ..." should set "m1.rank_value = ...". After these changes,
add an integration test with Testcontainers MySQL that calls write and then runs
the tasklet to verify rows are inserted and ranks updated correctly.

Comment on lines +7 to +17
@Component
class RankingChunkProcessor : ItemProcessor<RankingItem, RankedItem> {
override fun process(item: RankingItem): RankedItem {
// itemIndex는 0부터 시작, rank는 1부터 시작
return RankedItem(
productId = item.productId,
score = item.score,
rank = item.itemIndex + 1,
)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# RankingChunkProcessor / RankingItem / RankedItem의 실제 사용처 조사
rg -nP --type=kotlin -C3 '\bRankingChunkProcessor\b'
rg -nP --type=kotlin -C3 '\bRankingItem\b'
rg -nP --type=kotlin -C3 '\bRankedItem\b'

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

Length of output: 7705


🏁 Script executed:

# Job 설정 파일 탐색
fd -e kt -p ".*[Jj]ob.*" | head -20

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

Length of output: 1928


🏁 Script executed:

# MetricsAggregationProcessor와 RankingChunkProcessor의 빈 등록/주입 확인
rg -nP --type=kotlin '\bstep\(.*RankingChunkProcessor\b|\bRankingChunkProcessor.*step\b' -C3
rg -nP --type=kotlin 'itemProcessor.*RankingChunkProcessor\b|RankingChunkProcessor.*itemProcessor'  -C3

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

Length of output: 69


🏁 Script executed:

# 전체 Job 설정 파일 조사
rg -nP --type=kotlin '@Bean.*Step|fun.*step\(' --max-count=50

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

Length of output: 69


🏁 Script executed:

# MetricsAggregationProcessor 사용 현황
rg -nP --type=kotlin '\bMetricsAggregationProcessor\b' -C2

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

Length of output: 4307


🏁 Script executed:

# Check if RankedItem is used anywhere
rg -nP --type=kotlin '\bRankedItem\b'

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

Length of output: 639


🏁 Script executed:

# Check the actual step configuration to confirm no RankingChunkProcessor usage
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.kt
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.kt

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

Length of output: 5181


🏁 Script executed:

# Verify if RankingChunkReader is actually used in job configuration
rg -nP --type=kotlin 'RankingChunkReader' --max-count=20

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

Length of output: 203


RankingChunkProcessorRankingChunkReader, RankedItem은 실제 배치 파이프라인에서 사용되지 않는 데드 코드이다.

주간/월간 Job 설정 모두 MetricsAggregationProcessor만 주입받으며, RankingChunkProcessor(라인 7-17)와 RankingChunkReader는 어디에서도 참조되지 않는다. RankingItem은 Reader에서만 생성되고, RankedItem은 Processor에서만 생성된 후 소비되지 않는다.

운영 관점에서 사용되지 않는 빈 등록은 불필요한 메모리 점유, 초기화 비용 발생, 추후 유지보수 시 어느 경로에 쓰이는지 혼동을 초래한다. 다음 중 하나를 수행할 것을 권장한다:

  • 전부 삭제: RankingChunkProcessor, RankingChunkReader, RankingItem, RankedItem 제거
  • 보관 시: 명시적으로 사용처 주석 기재 및 통합 테스트에서 빈 로딩 및 Step 연결 검증 추가
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkProcessor.kt`
around lines 7 - 17, RankingChunkProcessor, RankingChunkReader, RankingItem, and
RankedItem are dead code not used by the batch pipeline (only
MetricsAggregationProcessor is injected); remove them or explicitly mark and
test them: either delete the four classes (including removing the `@Component` on
RankingChunkProcessor) to avoid unused bean registration, or keep them but add a
clear comment on each explaining they are intentionally unused, annotate with
`@Deprecated`, and add an integration test that asserts their beans are loadable
and that the Job/Step wiring uses MetricsAggregationProcessor (verify Step bean
injection) to prevent accidental removal; update any build/tests accordingly.

Comment on lines +42 to +57
companion object {
private const val RANK_SQL = """
UPDATE mv_product_rank_weekly w1
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
FROM mv_product_rank_weekly
WHERE year_week = ?
) w2 ON w1.id = w2.id
SET w1.rank = w2.new_rank, w1.updated_at = NOW()
"""

private const val TRIM_SQL = """
DELETE FROM mv_product_rank_weekly
WHERE year_week = ? AND rank > 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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# 리포지토리 구조 파악 및 엔티티/테스트 파일 위치 찾기
find . -type f -name "*MvProductRankWeekly*" 2>/dev/null | head -20

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

Length of output: 381


🏁 Script executed:

# WeeklyRankingAndTrimTasklet 파일 확인
find . -type f -name "WeeklyRankingAndTrimTasklet.kt" 2>/dev/null

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

Length of output: 185


🏁 Script executed:

# 관련 테스트 파일 찾기
find . -type f -name "*RankingAndTrimTaskletTest*" 2>/dev/null

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

Length of output: 183


🏁 Script executed:

# MonthlyRankingAndTrimTasklet 파일 찾기
find . -type f -name "MonthlyRankingAndTrimTasklet.kt" 2>/dev/null

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

Length of output: 186


🏁 Script executed:

# JPA 엔티티 파일들을 찾아서 rank_value 관련 검색
find . -path "*/entity/*" -name "*.kt" -type f 2>/dev/null | head -20

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

Length of output: 69


🏁 Script executed:

# batch 버전의 MvProductRankWeekly 엔티티 확인
cat ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankWeekly.kt

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

Length of output: 745


🏁 Script executed:

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

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

Length of output: 2490


🏁 Script executed:

# 테스트 파일 확인
cat -n ./apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/RankingAndTrimTaskletTest.kt

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

Length of output: 4025


🏁 Script executed:

# MonthlyRankingAndTrimTasklet.kt 확인
cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt

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

Length of output: 2398


rank 컬럼 참조 오류 — 런타임 실패가 확실한 치명 결함이다.

엔티티 MvProductRankWeekly@Column(name = "rank_value")로 정의되어 있고, PR 설명에도 MySQL 예약어 충돌로 rank → rank_value로 변경했다고 명시되어 있다. 그러나 본 파일의 RANK_SQL(라인 50)과 TRIM_SQL(라인 55)은 여전히 rank 컬럼을 직접 참조한다. 실 운영 DB에는 rank 컬럼이 존재하지 않으므로 Unknown column 'rank' 오류로 배치 Step이 실패하고, 주간 랭킹 MV는 갱신되지 않은 채 누적된 원시 score만 남아 TOP 100 트리밍도 동작하지 않는다.

운영 영향:

  • 배치 Job이 항상 Fail → 주간 랭킹 API가 stale/과다 데이터를 반환
  • 트리밍 미동작으로 mv_product_rank_weekly 무제한 증가
수정안
-        private const val RANK_SQL = """
-            UPDATE mv_product_rank_weekly w1
-            JOIN (
-                SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
-                FROM mv_product_rank_weekly
-                WHERE year_week = ?
-            ) w2 ON w1.id = w2.id
-            SET w1.rank = w2.new_rank, w1.updated_at = NOW()
-        """
-
-        private const val TRIM_SQL = """
-            DELETE FROM mv_product_rank_weekly
-            WHERE year_week = ? AND rank > 100
-        """
+        private const val RANK_SQL = """
+            UPDATE mv_product_rank_weekly w1
+            JOIN (
+                SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS new_rank
+                FROM mv_product_rank_weekly
+                WHERE year_week = ?
+            ) w2 ON w1.id = w2.id
+            SET w1.rank_value = w2.new_rank, w1.updated_at = NOW()
+        """
+
+        private const val TRIM_SQL = """
+            DELETE FROM mv_product_rank_weekly
+            WHERE year_week = ? AND rank_value > 100
+        """

월간 태스크릿(MonthlyRankingAndTrimTasklet)도 동일한 결함이 있으므로 동반 수정이 필요하다. 추가로 현재 RankingAndTrimTaskletTest는 SQL 문자열에 ROW_NUMBER/rank > 100가 포함되는지만 검사하므로 본 버그를 잡지 못한다. 실 DB(H2 MySQL 모드 또는 Testcontainers MySQL)를 이용한 통합 테스트로 실제 update row 수와 결과를 검증하는 테스트를 추가할 것을 권고한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt`
around lines 42 - 57, The SQL constants RANK_SQL and TRIM_SQL in
WeeklyRankingAndTrimTasklet still reference the non-existent column "rank"
causing runtime failures; update both SQL strings to use the actual column name
"rank_value" (e.g., change SET w1.rank = w2.new_rank and WHERE ... rank > 100 to
SET w1.rank_value = w2.new_rank and WHERE ... rank_value > 100) and make the
same change in MonthlyRankingAndTrimTasklet; after code changes, update tests so
they assert behavior against the real column (or add an integration test using
H2 MySQL mode/Testcontainers MySQL to validate updated row counts and trimming)
rather than only string contents.

Comment on lines +58 to +64
companion object {
private const val UPSERT_SQL = """
INSERT INTO mv_product_rank_weekly (product_id, year_week, score, rank, updated_at)
VALUES (?, ?, ?, 0, NOW())
ON DUPLICATE KEY UPDATE score = score + VALUES(score), updated_at = NOW()
"""
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.kt" | grep -i "ranking\|weekly" | head -20

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

Length of output: 2007


🏁 Script executed:

find . -type f -name "*WeeklyRankingChunkWriter*"

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

Length of output: 282


🏁 Script executed:

find . -type f -name "*.sql" | head -20

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

Length of output: 298


🏁 Script executed:

find . -type f -name "*schema*" -o -name "*ddl*" | head -20

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

Length of output: 69


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt

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

Length of output: 2897


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankWeekly.kt

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

Length of output: 907


🏁 Script executed:

find . -type f -name "*.kt" | xargs rg "WeeklyRankingAndTrimTasklet" | head -5

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

Length of output: 710


🏁 Script executed:

find . -type f \( -name "*.sql" -o -name "*.kt" \) | xargs rg -l "mv_product_rank_weekly" | head -10

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

Length of output: 536


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.kt

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

Length of output: 2490


🏁 Script executed:

find . -type f -name "*MonthlyRankingChunkWriter*"

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

Length of output: 284


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankWeekly.kt

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

Length of output: 913


🏁 Script executed:

cat -n ./apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriterTest.kt

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

Length of output: 4497


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.kt

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

Length of output: 2807


🏁 Script executed:

find . -type f -name "MvProductRankWeeklyJpaRepository*"

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

Length of output: 201


🏁 Script executed:

find . -type f -name "*MonthlyRankingAndTrimTasklet*"

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

Length of output: 186


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankMonthly.kt

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

Length of output: 909


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.kt

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

Length of output: 2398


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankWeeklyJpaRepository.kt

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

Length of output: 833


🏁 Script executed:

find . -type f -name "*MvProductRankMonthlyJpaRepository*"

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

Length of output: 202


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankMonthlyJpaRepository.kt

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

Length of output: 842


UPSERT SQL의 rank 컬럼 참조는 치명 결함이다. 주간 및 월간 파이프라인 모두 영향을 받는다.

실제 컬럼명은 rank_value이다. WeeklyRankingChunkWriter.kt (60번 행), WeeklyRankingAndTrimTasklet.kt (50번, 55번 행), MonthlyRankingChunkWriter.kt (58번 행), MonthlyRankingAndTrimTasklet.kt (48번, 53번 행)의 4개 위치 모두 rank 대신 rank_value로 수정해야 한다.

잘못된 컬럼명으로 인해 INSERT/UPDATE 실행 시 Unknown column 'rank' 오류가 발생하고, 주간 및 월간 랭킹 배치 전체가 실패한다. 특히 첫 번째 청크에서 기존 데이터 삭제(deleteByYearWeek, deleteByYearMonth) 후 write 단계에서 실패하면 데이터만 삭제되고 재적재가 이루어지지 않아 랭킹 조회 API가 빈 결과를 반환하는 장애로 이어진다.

테스트 강화: 현재 WeeklyRankingChunkWriterTestMonthlyRankingChunkWriterTest는 mock을 사용하여 SQL 호출 여부만 검증하므로 실제 SQL 실행 성공 여부를 보장하지 못한다. Testcontainers MySQL 또는 H2 MySQL 모드를 사용하여 실제 테이블에 대한 UPSERT 동작을 통합 검증하도록 개선한다.

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

In
`@apps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.kt`
around lines 58 - 64, The UPSERT SQL uses the wrong column name `rank` (should
be `rank_value`) which breaks INSERT/UPDATE; update the SQL in
WeeklyRankingChunkWriter.Companion.UPSERT_SQL, WeeklyRankingAndTrimTasklet,
MonthlyRankingChunkWriter.Companion.UPSERT_SQL, and MonthlyRankingAndTrimTasklet
to replace `rank` with `rank_value` in both the VALUES and ON DUPLICATE KEY
UPDATE clauses, then run integration tests: replace or extend
WeeklyRankingChunkWriterTest and MonthlyRankingChunkWriterTest to use
Testcontainers (or H2 in MySQL mode) so the UPSERT against the real schema is
executed and verified rather than only mocking SQL calls.

Comment on lines +19 to +89
class WeeklyRankingChunkWriterTest {

private val weeklyRepository: MvProductRankWeeklyJpaRepository = mock()
private val jdbcTemplate: JdbcTemplate = mock()
private lateinit var writer: WeeklyRankingChunkWriter

@BeforeEach
fun setUp() {
writer = WeeklyRankingChunkWriter(weeklyRepository, jdbcTemplate)
// 2026-04-14 (화요일) → ISO 주차: 2026-W16
ReflectionTestUtils.setField(writer, "targetDate", "20260414")
}

@DisplayName("첫 번째 청크: 기존 주간 데이터 삭제 후 upsert 수행")
@Test
fun shouldDeleteExistingDataOnFirstChunk() {
val chunk = Chunk(listOf(RankingScoreContribution(productId = 1L, score = 70.0)))

writer.write(chunk)

verify(weeklyRepository).deleteByYearWeek("2026-W16")
verify(jdbcTemplate).batchUpdate(any<String>(), any<List<Array<Any>>>())
}

@DisplayName("두 번째 청크: 삭제 없이 upsert만 수행")
@Test
fun shouldNotDeleteOnSubsequentChunks() {
val firstChunk = Chunk(listOf(RankingScoreContribution(productId = 1L, score = 70.0)))
val secondChunk = Chunk(listOf(RankingScoreContribution(productId = 2L, score = 50.0)))

writer.write(firstChunk)
writer.write(secondChunk)

verify(weeklyRepository, times(1)).deleteByYearWeek("2026-W16")
verify(jdbcTemplate, times(2)).batchUpdate(any<String>(), any<List<Array<Any>>>())
}

@DisplayName("upsert 호출 시 productId, yearWeek, score가 올바르게 전달됨")
@Test
fun shouldPassCorrectArgsToUpsert() {
val chunk = Chunk(
listOf(
RankingScoreContribution(productId = 10L, score = 75.5),
RankingScoreContribution(productId = 20L, score = 50.0),
),
)

writer.write(chunk)

val sqlCaptor = argumentCaptor<String>()
val argsCaptor = argumentCaptor<List<Array<Any>>>()
verify(jdbcTemplate).batchUpdate(sqlCaptor.capture(), argsCaptor.capture())

val batchArgs = argsCaptor.firstValue
assertThat(batchArgs).hasSize(2)

val first = batchArgs[0]
assertThat(first[0]).isEqualTo(10L) // productId
assertThat(first[1]).isEqualTo("2026-W16") // yearWeek
assertThat(first[2]).isEqualTo(75.5) // score
}

@DisplayName("청크 내 아이템이 없으면 upsert 호출하지 않음")
@Test
fun shouldNotUpsertOnEmptyChunk() {
val chunk = Chunk(emptyList<RankingScoreContribution>())

writer.write(chunk)

verify(jdbcTemplate, never()).batchUpdate(any<String>(), any<List<Array<Any>>>())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# WeeklyRankingChunkWriter.kt 의 UPSERT SQL 이 rank 인지 rank_value 인지 확인
fd -t f 'WeeklyRankingChunkWriter.kt' -x rg -nP -C2 'INSERT INTO mv_product_rank_weekly|rank|rank_value' {}

# 실제 테이블 DDL 확인 (마이그레이션/schema 파일)
fd -t f -e sql | xargs rg -nP -C2 'mv_product_rank_weekly|mv_product_rank_monthly'

# 엔티티 컬럼 매핑 확인
fd -t f 'MvProductRankWeekly.kt' -x rg -nP -C3 '@Column|rank|rank_value' {}
fd -t f 'MvProductRankMonthly.kt' -x rg -nP -C3 '@Column|rank|rank_value' {}

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

Length of output: 2638


SQL 스키마 불일치: UPSERT_SQL에서 존재하지 않는 rank 컬럼을 참조하므로 배치 실행 시 즉시 실패한다.

WeeklyRankingChunkWriterUPSERT_SQL 문(라인 60)은 rank 컬럼에 삽입을 수행하지만, MvProductRankWeekly 엔티티는 @Column(name = "rank_value")로 실제 컬럼명을 명시했다. JdbcTemplate을 모킹한 단위 테스트는 이 오류를 감지하지 못하므로, 배치 실행 시 SQLSyntaxErrorException으로 즉시 실패하는 운영 장애가 발생한다.

수정안:

  1. UPSERT_SQL의 컬럼명을 rank_value로 변경
  2. SQL 문자열을 argumentCaptor로 캡처하여 컬럼명 일치 여부를 검증하는 단위 테스트 추가
  3. Testcontainers 기반 통합 테스트로 실제 스키마에 대해 UPSERT가 정상 작동하는지 검증 추가
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriterTest.kt`
around lines 19 - 89, The UPSERT_SQL in WeeklyRankingChunkWriter references a
non-existent "rank" column causing runtime SQL errors; update the SQL in
WeeklyRankingChunkWriter (the UPSERT_SQL constant) to use the actual column name
"rank_value" (matching MvProductRankWeekly's `@Column`(name = "rank_value")), then
update the unit test WeeklyRankingChunkWriterTest to capture the SQL string via
the existing sqlCaptor and assert it contains "rank_value" instead of "rank",
and add (or plan) an integration Testcontainers-based test to run the UPSERT
against the real schema to ensure the statement succeeds end-to-end.

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