[7팀 김철중] 10주차 과제 제출 #199
Conversation
## 주요 변경사항 ### 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>
📝 WalkthroughWalkthrough주간 및 월간 상품 랭킹 기능을 API, 서비스, 저장소, 배치 작업에 걸쳐 추가한다. 기존 일일 랭킹과 병렬로 주간/월간 조회를 지원하도록 API 엔드포인트를 분리하고, 새로운 JPA 엔티티 및 Redis 기반 데이터 파이프라인을 구성한다. Changes
Sequence DiagramsequenceDiagram
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>>
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45분 Possibly related PRs
추가 검토 의견이 PR은 다음 항목들을 주의 깊게 검토해야 한다: 운영 관점:
데이터 일관성:
성능 관점:
테스트 누락:
🚥 Pre-merge checks | ❌ 3❌ Failed checks (1 warning, 2 inconclusive)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.incrementViewCount와productMetricsDailyRepository.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_DATE는yyyyMMdd만 허용한다. 다른 잡 파라미터 포맷(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 | 🟠 Majorapplication 레이어가 interfaces.api 레이어 타입에 의존하는 방향이 역전되어 있다.
RankingPeriod를com.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 | 🟠 MajorTRUNCATE 실패를 조용히 삼키면 테스트 간 상태 오염이 발생한다.
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 월요일)을 로그로 남겨 운영 시 재현성 확보.
ProductMetricsDaily의metricDate와 동일한 타임존을 쓰는지 엔드투엔드 테스트로 잠금.추가 테스트: 월/일/주 경계(예: 일요일, 월초, 월말, 연말)에서 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일일 집계 엔티티의 무결성/감사 필드가 부족하다.
운영 관점에서 다음 문제가 예상된다.
- UNIQUE 제약 누락 위험: 상위 PR 설명상 이벤트 핸들러가
productId + metricDate기준 UPSERT를 수행하는데, 엔티티 수준에@Table(uniqueConstraints = ...)가 선언되어 있지 않다. 동시 이벤트 유입 시 중복 행이 생겨 일일 집계가 이중 계상될 수 있고, 이후 배치 score 계산(0.1·view + 0.2·like + 0.7·sales)이 오염된다. DB DDL에 유니크가 있더라도 엔티티/스키마 일원화를 위해 선언을 권고한다.createdAt기본값이 인스턴스 생성 시각:LocalDateTime.now()가 객체 생성 시점에 평가되어, 재시도/재생성 경로에서 실제 DB 반영 시각과 차이가 발생한다. 감사 용도라면@CreationTimestamp또는 DBDEFAULT CURRENT_TIMESTAMP사용을 권고한다.updatedAt부재: 카운터 UPSERT 이력이 누적되는 테이블임에도 갱신 시각이 없어, 배치/장애 조사 시 최근성 판별이 어렵다. 추가를 권고한다.- 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와 불일치하게 된다. 운영 서버의 기본 로캘에 종속된 결함은 재현이 어려워 장애 분석 비용이 크다. detektImplicitDefaultLocale경고와도 일치한다.수정안
- 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 | 🟠 MajorISO 주 기준에서는
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)동일한 패턴이
MvProductRankMonthlyJpaRepository의deleteByYearMonth메서드에도 적용되므로 함께 수정한다. 동일 주차 재실행 시 이전 데이터가 남지 않고 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 요청 경로를 확인해야 한다.추가 테스트 권고:
MockMvc로GET /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 | 🟡 MinorCleanup 실패가 파이프라인 실패로 전파되는 것은 부적절하다.
운영 관점에서 이 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")을 적용하고 있어 불일치가 발생한다. 실제 바인딩은 구현체 메서드의 애노테이션을 따르므로 현재 운영 에러는 없으나, 다음 두 가지 운영 리스크가 있다.
- 문서화 누락: SpringDoc이 spec 인터페이스를 소스로 OpenAPI 문서를 생성할 경우,
@DateTimeFormat정보가 누락되어 클라이언트가 ISO-8601 포맷(2026-04-14)으로 호출하면 400을 맞는다.- 신규 구현체의 포맷 누락: 이 인터페이스를 상속하는 다른 구현이 추가될 때, 개발자가 포맷 요구사항을 인지하지 못해
LocalDate.parse의 기본값(ISO-8601)으로 처리될 수 있다.수정안: spec 인터페이스의
date파라미터에@DateTimeFormat(pattern = "yyyyMMdd")을 추가하고, 구현체의 중복 애노테이션은 제거한다. 이후20260414와2026-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
📒 Files selected for processing (47)
.gitignoreapps/commerce-api/src/main/kotlin/com/loopers/application/api/product/ProductRankingFacade.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankMonthly.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/MvProductRankWeekly.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductRankingReadService.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.ktapps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ApiSpec.ktapps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1Controller.ktapps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/ranking/RankingPeriod.ktapps/commerce-api/src/test/kotlin/com/loopers/application/api/product/ProductRankingFacadeTest.ktapps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/ProductRankingReadServiceTest.ktapps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/MvProductRankRepositoryImplTest.ktapps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/product/ProductRankingApiE2ETest.ktapps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/ranking/ProductRankingV1ControllerTest.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankMonthly.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/domain/ranking/MvProductRankWeekly.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankMonthlyJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/infrastructure/ranking/MvProductRankWeeklyJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/CleanupTasklet.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingAndTrimTasklet.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriter.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/MonthlyRankingMetricsReader.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationItem.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingAggregationTasklet.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkProcessor.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/RankingChunkReader.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingAndTrimTasklet.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriter.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/job/ranking/WeeklyRankingMetricsReader.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MetricsAggregationProcessorTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/MonthlyRankingChunkWriterTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/RankingAndTrimTaskletTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/RankingMetricsReaderTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/job/ranking/WeeklyRankingChunkWriterTest.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/LikeCountEventHandler.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandler.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandler.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/productmetrics/ProductMetricsDaily.ktapps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/productmetrics/ProductMetricsDailyRepository.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/LikeCountEventHandlerTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/OrderCreatedEventHandlerTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/event/handler/ProductViewedEventHandlerTest.ktmodules/jpa/src/testFixtures/kotlin/com/loopers/utils/DatabaseCleanUp.ktmodules/redis/src/testFixtures/kotlin/com/loopers/utils/RedisCleanUp.kt
| 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(), | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f 'MvProductRankWeekly.kt' --exec cat {}
rg -nP 'OrderByRank\b|\.rank\b' --type=kotlin -C2Repository: 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.ktRepository: 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를 일관되게 정렬한다:
수정안
- 두 엔티티의 속성명 변경
// MvProductRankMonthly, MvProductRankWeekly 동일 적용
`@Column`(name = "rank_value")
val rankValue: Int,- 배치 작업의 네이티브 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- Repository의 매핑 로직 수정
// MvProductRankRepositoryImpl.kt
rankValue = it.rankValue.toLong() // 라인 23, 36- JPA 파생 메서드 이름 수정
// MvProductRankRepositoryImpl.kt
fun findByYearWeekOrderByRankValue(...) // 라인 53
fun findByYearMonthOrderByRankValue(...) // 라인 61- 배치 작업 호출처와 테스트 코드에서
.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.
| 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) | ||
| } |
There was a problem hiding this comment.
🧩 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".
| @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, | ||
| ) { |
There was a problem hiding this comment.
🧩 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 -20Repository: 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 -30Repository: 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 -B2Repository: 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=kotlinRepository: 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.
| 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 | ||
| """ |
There was a problem hiding this comment.
🧩 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와 불일치하여 월간/주간 랭킹 산정이 런타임에 실패한다.
엔티티 매핑 확인 결과, MvProductRankMonthly와 MvProductRankWeekly 모두 @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도 동일하게 rank → rank_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.
| 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() | ||
| """ |
There was a problem hiding this comment.
🧩 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=sqlRepository: 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 -20Repository: 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 -30Repository: 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.ktRepository: 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.ktRepository: 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.ktRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 4433
🏁 Script executed:
rg -n "mv_product_rank" --type-list | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 476
🏁 Script executed:
rg -n "mv_product_rank" -A 5 -B 5Repository: 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.ktRepository: 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.ktRepository: 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 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 3444
🏁 Script executed:
rg -n "deleteByYearMonth|deleteByYearWeek" --type=kotlin -A 5Repository: 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.ktRepository: 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 2Repository: 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 1Repository: 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 -55Repository: 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 3Repository: 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.
| @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, | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 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 -20Repository: 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' -C3Repository: 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=50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 69
🏁 Script executed:
# MetricsAggregationProcessor 사용 현황
rg -nP --type=kotlin '\bMetricsAggregationProcessor\b' -C2Repository: 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.ktRepository: 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=20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 203
RankingChunkProcessor와 RankingChunkReader, 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.
| 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 | ||
| """ | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# 리포지토리 구조 파악 및 엔티티/테스트 파일 위치 찾기
find . -type f -name "*MvProductRankWeekly*" 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 381
🏁 Script executed:
# WeeklyRankingAndTrimTasklet 파일 확인
find . -type f -name "WeeklyRankingAndTrimTasklet.kt" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 185
🏁 Script executed:
# 관련 테스트 파일 찾기
find . -type f -name "*RankingAndTrimTaskletTest*" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 183
🏁 Script executed:
# MonthlyRankingAndTrimTasklet 파일 찾기
find . -type f -name "MonthlyRankingAndTrimTasklet.kt" 2>/dev/nullRepository: 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 -20Repository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.
| 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() | ||
| """ | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "*.kt" | grep -i "ranking\|weekly" | head -20Repository: 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 -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 298
🏁 Script executed:
find . -type f -name "*schema*" -o -name "*ddl*" | head -20Repository: 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.ktRepository: 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.ktRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-kotlin
Length of output: 907
🏁 Script executed:
find . -type f -name "*.kt" | xargs rg "WeeklyRankingAndTrimTasklet" | head -5Repository: 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 -10Repository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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.ktRepository: 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가 빈 결과를 반환하는 장애로 이어진다.
테스트 강화: 현재 WeeklyRankingChunkWriterTest와 MonthlyRankingChunkWriterTest는 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.
| 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>>>()) | ||
| } |
There was a problem hiding this comment.
🧩 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 컬럼을 참조하므로 배치 실행 시 즉시 실패한다.
WeeklyRankingChunkWriter의 UPSERT_SQL 문(라인 60)은 rank 컬럼에 삽입을 수행하지만, MvProductRankWeekly 엔티티는 @Column(name = "rank_value")로 실제 컬럼명을 명시했다. JdbcTemplate을 모킹한 단위 테스트는 이 오류를 감지하지 못하므로, 배치 실행 시 SQLSyntaxErrorException으로 즉시 실패하는 운영 장애가 발생한다.
수정안:
UPSERT_SQL의 컬럼명을rank_value로 변경- SQL 문자열을
argumentCaptor로 캡처하여 컬럼명 일치 여부를 검증하는 단위 테스트 추가 - 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.
📌 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일치 메트릭이 필요합니다. 기존에 이미 있는 데이터 소스를 그대로 쓰는 방법을 먼저 검토했습니다.
ranking:all:yyyyMMdd키의 TTL을 35일 이상으로 늘리면 됩니다. 하지만 Redis는 랭킹 조회용으로 설계된 인메모리 저장소인데, 한 달치 날짜 키를 계속 유지하면 메모리 압박이 생기고, 키가 많아질수록 ZUNIONSTORE 비용도 늘어납니다.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-streamer의ProductRankingWriteService가 이벤트 발생 시 Redis에 score를 즉시 누적합니다. Weekly/Monthly 랭킹은commerce-batch의MetricsAggregationProcessor가product_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내에 동일 가중치를 유지합니다.