[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 #410
[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 #410pable91 wants to merge 21 commits intoLoopers-dev-lab:pable91from
Conversation
일# 다음 라인에 작성하세요
📝 WalkthroughWalkthrough기간별(일간/주간/월간) 랭킹 조회를 지원하기 위해 API 파라미터 추가, 저장소 인터페이스 확장, 주간/월간 전용 JPA 엔티티와 저장소 구현, 배치 작업 추가, 메트릭 수집에 날짜 차원을 도입하는 변경이다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Controller as RankingV1Controller
participant Facade as RankingFacade
participant Repository as RankingRepository
participant JpaRepo as JPA Repository<br/>(Weekly/Monthly)
participant Database as Database
Client->>Controller: GET /rankings?date=YYYY-MM-DD&period=weekly
Controller->>Controller: Parse date to LocalDate
Controller->>Facade: getRankings(localDate, "weekly", size, page)
alt period == "weekly"
Facade->>Repository: getWeeklyTopN(localDate, size, page)
else period == "monthly"
Facade->>Repository: getMonthlyTopN(localDate, size, page)
else
Facade->>Repository: getTopN(localDate, size, page)
end
Repository->>Repository: Derive yearWeek/yearMonth
Repository->>JpaRepo: findAllByYearX...OrderByProductRankAsc(yearX, Pageable)
JpaRepo->>Database: SELECT * FROM mv_product_rank_weekly/monthly
Database-->>JpaRepo: RankedProducts
JpaRepo-->>Repository: RankedProducts
Repository-->>Facade: List<RankedProduct>
Facade->>Facade: Fetch Product & Brand details
Facade->>Facade: Build RankingInfo entries
Facade-->>Controller: List<RankingInfo>
Controller-->>Client: HTTP 200 with rankings
sequenceDiagram
participant Batch as Spring Batch
participant Reader as ProductMetricsItemReader
participant Processor as RankingItemProcessor
participant Writer as Weekly/MonthlyMvRankingItemWriter
participant Database as Database
Batch->>Batch: JobParameters: targetDate, period
Batch->>Reader: read()
Reader->>Reader: Derive startDate/endDate from period
Reader->>Database: SELECT SUM(metrics) FROM product_metrics WHERE date BETWEEN startDate AND endDate
Database-->>Reader: ProductMetricsAggregatedDto items (paginated)
Reader-->>Batch: Chunk of ProductMetricsAggregatedDto
loop Per item in chunk
Batch->>Processor: process(ProductMetricsAggregatedDto)
Processor->>Processor: Calculate score = viewCount + likeCount + log1p(quantity)
Processor-->>Batch: RankedProductDto
end
Batch->>Writer: write(Chunk<RankedProductDto>)
Writer->>Writer: Derive yearWeek/yearMonth from targetDate
Writer->>Database: Find existing MvProductRankWeekly/MonthlyEntity by productId
Writer->>Database: INSERT or UPDATE entity with score, yearX, rank=0
Database-->>Writer: Persisted
Batch->>Batch: Next step: RankAssignTasklet
Batch->>Database: Fetch all entities for yearX, ordered by score DESC
Batch->>Database: Assign rank (i+1) to top 100
Batch->>Database: DELETE entities beyond rank 100
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ 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: 13
Note
Due to the large number of review comments, Critical, Major 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-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
46-58:⚠️ Potential issue | 🟠 Major랭킹에 포함된 상품이 삭제/숨김 처리된 경우 NPE 위험이 있다.
productMap.get(rp.productId())가null을 반환하면 49번째 라인product.getRefBrandId()에서 NPE가 발생하여 API 전체 응답이 500으로 실패한다. 랭킹 MV는 배치로 생성되고 그 사이에 상품이 삭제/숨김될 수 있어 근접 불일치(near-cache skew)가 현실적인 운영 시나리오다.product == null인 엔트리는 결과에서 스킵하고, 스킵 건수는 로그/메트릭으로 관측 가능하게 남기길 권장한다. 브랜드도 동일하게 방어적 처리가 필요하다.추가 테스트: (1)
productService.getByIds가 일부만 반환하는 경우 응답 크기/순위가 올바른지, (2) 브랜드 조회 실패 시 응답이 정상 반환되는지.🛡️ 제안 수정
for (int i = 0; i < rankedProducts.size(); i++) { RankedProduct rp = rankedProducts.get(i); Product product = productMap.get(rp.productId()); + if (product == null) { + log.warn("랭킹에 존재하나 상품 조회 실패. productId={}", rp.productId()); + continue; + } Brand brand = brandMap.get(product.getRefBrandId()); + if (brand == null) { + log.warn("브랜드 조회 실패. productId={}, brandId={}", rp.productId(), product.getRefBrandId()); + continue; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 46 - 58, The loop that builds RankingInfo may NPE when productMap.get(rp.productId()) or brandMap.get(...) returns null; update the logic in RankingFacade where rankedProducts is iterated (the block creating new RankingInfo) to skip entries whose product is null (and also handle null brand defensively by using a fallback name like "Unknown" or empty string), increment a skip counter/metric for monitoring, and emit a debug/warn log mentioning rp.productId() and reason skipped; keep the rest of ranking order intact (do not abort on missing entries) so RankingInfo list length and baseRank semantics remain correct.
🟡 Minor comments (10)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java-48-52 (1)
48-52:⚠️ Potential issue | 🟡 Minor
update호출 시 score가 0으로 덮어써지는 경합을 점검해야 한다.
WeeklyRankAssignTasklet에서는existing.update(existing.getScore(), yearWeek, i+1)로 기존 score를 다시 전달하여 보존하지만,WeeklyMvRankingItemWriter에서는existing.update(item.getScore(), yearWeek, 0)로productRank를 0으로 초기화한다. 같은 배치 실행 내에서 writer가 먼저 rank=0으로 리셋하고 뒤이은 tasklet이 rank를 다시 매기므로 현재 흐름은 정상 동작하지만, 잡 실패로 writer 스텝만 성공하고 tasklet이 실행되지 않으면mv_product_rank_weekly전체 productRank가 0으로 남아ORDER BY product_rank ASC질의 결과가 비결정적이 된다. 운영 관점에서는 최소한 다음 중 하나가 필요하다.
- writer 단계에서
update(score, yearWeek, existing.getProductRank())로 기존 rank를 유지하거나,- 잡 설정에 writer 스텝 실패 시 tasklet까지 롤백 가능한 재시도 정책을 명시한다.
추가 테스트: writer 성공 후 tasklet이 실행되지 않는 시나리오(잡 중단)에서 읽기 API(
getWeeklyTopN)가 동일 rank=0 다수 레코드에 대해 어떤 순서를 반환하는지, 그리고 재실행 시 정상 복구되는지 검증한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java` around lines 48 - 52, MvProductRankWeeklyEntity.update가 writer에서 productRank를 0으로 덮어써서 잡 실패 시 모든 rank가 0으로 남을 수 있으므로, WeeklyMvRankingItemWriter에서 기존 rank를 보존하도록 수정하세요: 현재 writer가 호출하는 existing.update(item.getScore(), yearWeek, 0) 대신 existing.update(item.getScore(), yearWeek, existing.getProductRank())로 변경하여 productRank를 유지하거나(권장), 불가할 경우 잡 설정에 writer 실패 시 tasklet(WeeklyRankAssignTasklet)까지 롤백/재시도되도록 트랜잭션/재시도 정책을 추가해 mv_product_rank_weekly의 productRank가 0으로 초기화되는 위험을 제거하고 getWeeklyTopN 호출 결과의 비결정성을 방지하세요.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java-9-9 (1)
9-9:⚠️ Potential issue | 🟡 Minor페이지네이션 시
ORDER BY product_rank ASC단일 정렬은 동률 발생 시 결과가 비결정적이 된다.
WeeklyMvRankingItemWriter는 신규 upsert 시productRank를 0으로 저장하고,WeeklyRankAssignTasklet은TOP_RANK_LIMIT(=100) 초과 항목에 대해 productRank를 재할당하지 않는다(관련 스니펫:WeeklyRankAssignTasklet.java:45-47). 결과적으로mv_product_rank_weekly에는product_rank = 0인 레코드가 다수 존재할 수 있으며, 본 파생 쿼리로 페이지를 넘길 때 동일 rank 내부 순서가 DB/옵티마이저에 따라 달라져 같은 사용자가 이어서 페이지를 요청할 때 항목이 누락/중복될 수 있다.운영 관점에서 2차 정렬 키(예:
score DESC,productId ASC)를 추가하여 결정성을 보장하는 편이 안전하다.♻️ 제안 diff
- List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable); + List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAscScoreDescProductIdAsc(String yearWeek, Pageable pageable);또는 파생 이름이 길어져 가독성이 떨어지므로
@Query("... order by e.productRank asc, e.score desc, e.productId asc")명시 쿼리로 대체하는 방안을 권장한다. 월간 리포지토리에도 동일하게 적용한다.추가 테스트: 동일
product_rank값을 가진 레코드가 존재할 때 연속 페이지 요청에서 항목 중복/누락이 없는지 검증한다.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/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java` at line 9, The repository method findAllByYearWeekOrderByProductRankAsc currently sorts only by productRank which is non-deterministic when many rows have productRank=0; update the query to include deterministic secondary sort keys (e.g. order by productRank ASC, score DESC, productId ASC) either by changing the method to an explicit `@Query`("... order by e.productRank asc, e.score desc, e.productId asc") on MvProductRankWeeklyJpaRepository (and apply the same change to the monthly repository counterpart) and ensure WeeklyMvRankingItemWriter / WeeklyRankAssignTasklet behavior is noted; after the change add an integration test that inserts multiple rows with identical productRank and verifies consecutive pageable requests return no duplicates or gaps.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java-13-18 (1)
13-18:⚠️ Potential issue | 🟡 Minor가중치 매직 넘버 상수화와 productId null 방어가 필요하다.
두 가지 관점에서 개선이 필요하다.
운영/튜닝 관점:
0.1,0.2,0.7가중치가 메서드 본문에 하드코딩되어 있어 튜닝 시 재배포가 강제되고, 동일 로직을 공유하는 주간/월간 잡에서 가중치 일관성을 검증하기 어렵다. 또한view/like는 원시 카운트이고quantity만log1p로 압축되어, 좋아요 수천 건인 상품이 실제 판매량 상위 상품을 쉽게 앞지를 수 있다. 스케일 균형에 대한 의사결정이 코드/문서에 남아 있지 않으면 추후 랭킹 품질 이슈가 발생해도 원인 추적이 어렵다. 최소한private static final double W_VIEW/W_LIKE/W_QTY상수로 추출하고 주석으로 근거를 남기거나, 추후application.yml바인딩으로 외부화할 것을 권장한다.장애 관점:
dto.getProductId()가 null일 수 있는데(상류 DTO는 nullable), 그대로RankedProductDto에 실려 writer의findById에서 예외를 일으킨다. 다음과 같이 프로세서 단계에서 필터링하는 편이 안전하다.♻️ 제안 diff
+ private static final double W_VIEW = 0.1; + private static final double W_LIKE = 0.2; + private static final double W_QTY = 0.7; + `@Override` public RankedProductDto process(ProductMetricsAggregatedDto dto) { - double score = 0.1 * dto.getTotalViewCount() - + 0.2 * dto.getTotalLikeCount() - + 0.7 * Math.log1p(dto.getTotalQuantity()); + if (dto.getProductId() == null) { + return null; // Spring Batch filters null items out + } + double score = W_VIEW * dto.getTotalViewCount() + + W_LIKE * dto.getTotalLikeCount() + + W_QTY * Math.log1p(dto.getTotalQuantity()); return new RankedProductDto(dto.getProductId(), score); }추가 테스트: (a) productId null 입력 시
process가 null을 반환하여 writer로 전달되지 않는지, (b) 동일 입력에 대해 weekly/monthly 프로세서가 동일 score를 산출하는지 단위 테스트를 추가한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java` around lines 13 - 18, Extract the hardcoded weights in process(...) into named constants (e.g., private static final double W_VIEW, W_LIKE, W_QTY) with a brief comment explaining the choice and a TODO/note about externalizing to configuration (application.yml) for tuning; then update the score calculation in RankedProductDto process(ProductMetricsAggregatedDto dto) to use those constants. Also add a null-check for dto.getProductId() inside process(...) and return null when productId is null so the item is filtered out of the write phase (preventing downstream findById NPE). Finally, add unit tests verifying that process returns null for null productId and that the same inputs produce consistent scores for weekly/monthly processors.apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java-38-39 (1)
38-39:⚠️ Potential issue | 🟡 Minor시간대(Zone) 가정이 암묵적이다.
occurredAt.toLocalDate()는ZonedDateTime의 zone을 그대로 사용한다. 이벤트 producer의 타임존이 UTC인지 KST인지에 따라 자정 경계에서 다른 일자로 집계되어 일별 랭킹이 흔들릴 수 있다. 운영/집계 일관성을 위해 집계 기준 zone을 명시적으로 지정(예:occurredAt.withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDate())하고 팀 내 기준 문서화를 권장한다.추가 테스트: UTC 23:30, KST 08:30 이벤트가 동일 일자로 집계되는지(또는 기준대로 분리되는지) 경계 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java` around lines 38 - 39, The code uses occurredAt.toLocalDate() which relies on the event's implicit zone; change the aggregation to convert occurredAt to an explicit aggregation ZoneId (e.g., use occurredAt.withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDate() or a configurable ZoneId) before calling ensureMetricsExists(productId, metricsDate) so date boundaries are consistent; update the ProductMetricsProcessor logic that computes metricsDate (references: occurredAt, ensureMetricsExists) and add boundary unit tests (UTC 23:30 vs KST 08:30) to verify expected day-bucketing.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java-25-32 (1)
25-32:⚠️ Potential issue | 🟡 Minor
period입력 검증이 부재하다.
period는 어떤 값이든 Facade로 통과되고, Facade는 알 수 없는 값을 조용히 일간 랭킹으로 처리한다(위RankingFacade코멘트 참조). 운영 관점에서 잘못된 클라이언트 사용이 4xx 대신 200으로 응답되어 관측되지 않는 버그로 남는다. 또한LocalDate.parse가 던지는DateTimeParseException은CoreException으로 매핑되지 않아 500으로 노출될 수 있다.개선안은 위 Facade 코멘트의 enum 도입과 함께,
date파싱도try/catch로 감싸CoreException(BAD_REQUEST)으로 변환하는 방향이다. As per coding guidelines "상태 코드와 에러 응답 포맷이 일관되는지 점검하고,@ControllerAdvice기반 표준 처리로 유도한다".추가 테스트: (1) 잘못된
period→ 400, (2) 잘못된date포맷 → 400, (3)period미지정 시 일간 기본값 동작.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java` around lines 25 - 32, Validate the incoming period and handle date parse errors in getRankings: add an enum (or use existing one in RankingFacade) for allowed period values and check the `@RequestParam` period against it (preserve the default "daily" when absent) and throw a CoreException with BAD_REQUEST when the value is invalid; wrap LocalDate.parse(date, DATE_FORMATTER) in a try/catch catching DateTimeParseException and convert it to a CoreException(BAD_REQUEST) so malformed date formats return 400; keep using rankingFacade.getRankings(localDate, period, size, page) after validation.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java-35-64 (1)
35-64:⚠️ Potential issue | 🟡 MinorBaseEntity 상속은 불가능하며, 대신 타임존 정보를 포함한 타임스탬프 관리가 필요하다.
MvProductRankMonthlyEntity는productId를 ID로 사용하는 Materialized View 엔티티이므로BaseEntity를 상속할 수 없다(BaseEntity는@GeneratedValue(strategy = GenerationType.IDENTITY)로 자동 생성되는Long id를 기대함).현재
@PrePersist/@PreUpdate구현 자체는BaseEntity와 동일한 패턴이므로 기능상 문제는 없으나,LocalDateTime대신ZonedDateTime을 사용하여 타임존 정보를 보존해야 한다.LocalDateTime으로 저장된 값은 데이터베이스 시간대 설정에 따라 다르게 해석될 수 있으므로, 운영 환경에서 시간대 관련 버그가 발생할 위험이 있다.BaseEntity의ZonedDateTime사용과 일관성을 맞춰서 개선하면 테스트 시 시간 고정이 용이해지고, 전체 프로젝트의 타임스탬프 정책이 일원화된다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java` around lines 35 - 64, MvProductRankMonthlyEntity currently stores timestamps as LocalDateTime; change createdAt and updatedAt to type ZonedDateTime and update their declarations and imports accordingly, then modify prePersist() to set both fields using ZonedDateTime.now(ZoneOffset.UTC) (or the same ZoneId used by your BaseEntity) and preUpdate() to set updatedAt = ZonedDateTime.now(ZoneOffset.UTC) so timezone info is preserved and consistent with BaseEntity; keep the `@Column` annotations and the existing prePersist/preUpdate method names (prePersist, preUpdate) unchanged.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java-12-20 (1)
12-20:⚠️ Potential issue | 🟡 MinorProductMetricsRepository 인터페이스에 트랜잭션 계약을 명시하는 Javadoc 추가가 필요하다.
인터페이스에 메서드 수준 문서가 전혀 없어서,
upsertIfAbsent이후 increment 메서드 호출 간 원자성을 보장하는지 불명확하다. 현재 구현은ProductMetricsProcessor.process()메서드의@Transactional래핑으로 인해 모든 저장소 호출이 단일 트랜잭션 내에서 실행되므로 실제로는 안전하지만, 이 계약이 문서화되지 않으면 향후 재사용 시 트랜잭션 경계 밖에서 메서드를 호출하는 실수가 발생할 수 있다.추가로
ProductMetricsJpaRepository.upsertIfAbsent()의@Transactional주석은 상위 레이어의 트랜잭션에서 호출되면 중복되므로, 필요성을 검토하거나 클래스 레벨 주석으로 통일하는 것을 권장한다. 각 메서드의 Javadoc에는 다음을 명시한다: (1) 이 메서드는 트랜잭션 내에서 호출되어야 함, (2)upsertIfAbsent+ increment 조합은 단일 SQL 레벨이 아닌 트랜잭션 레벨의 원자성을 보장함, (3) 트랜잭션 격리 수준 하에서 동시성 동작.아울러 현재 테스트는 Mock 기반이므로, 동일 상품에 대한 동시 이벤트 처리 시나리오(예: 동시에 여러 뷰/좋아요 이벤트)를 검증하는 통합 테스트를 추가하여 경쟁 조건 안전성을 확보한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java` around lines 12 - 20, Add Javadoc to ProductMetricsRepository and each method (upsertIfAbsent, incrementLikeCount, decrementLikeCount, incrementViewCount, incrementSalesAndQuantity) that states these methods must be invoked within a transaction, that the combination of upsertIfAbsent + any increment is atomic only at the transaction level (not single-SQL), and describes expected concurrency behavior under typical isolation levels; mention that callers such as ProductMetricsProcessor.process wrap these calls in `@Transactional` so they are safe. Review the `@Transactional` on ProductMetricsJpaRepository.upsertIfAbsent and either remove the method-level annotation or consolidate it at the repository/class level to avoid redundant transaction boundaries when called from a transactional service. Finally, add an integration test that simulates concurrent events for the same product (concurrent views/likes/sales) to validate race-condition safety.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java-1-10 (1)
1-10:⚠️ Potential issue | 🟡 Minor배치 작업 장애 시 페이징 안정성 확보를 위해 productId를 tie-breaker로 추가하길 권장한다.
findAllByYearMonthOrderByProductRankAsc는productRank만으로 정렬하는데, 정상 운영 중에는 productRank가 1-100으로 순차 배정되어 유일하다. 그러나 배치 작업(monthlyScoreCalculationStep → monthlyRankAssignStep) 실행 중 Step 2가 중단되거나 실패할 경우, 일부 레코드는 여전히productRank=0을 유지하거나 중복된 productRank 값을 가지게 된다. 이 상태에서 API 페이징 쿼리가 실행되면 페이지 경계에서 누락이나 중복이 발생할 수 있다.
productId를 보조 정렬 키로 추가하면 동일한productRank에 대해서도 결과 순서가 결정되어 페이징 안정성이 보장된다.ORDER BY product_rank ASC, product_id ASC추가 테스트: 배치 작업 중단 시나리오에서 일부 레코드의 productRank가 0 또는 중복된 상태에서 페이지를 순회할 때 전체 집합이 중복/누락 없이 조회되는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java` around lines 1 - 10, The repository method findAllByYearMonthOrderByProductRankAsc in MvProductRankMonthlyJpaRepository sorts only by productRank which can be non-unique during batch interruptions; update the query/signature to include productId as a secondary sort key (ORDER BY productRank ASC, productId ASC) so pagination is stable—locate the interface MvProductRankMonthlyJpaRepository and modify the method name or query to reflect tie-breaker ordering and then add tests that simulate batch interruption with productRank duplicates/zeros to verify no duplicates or omissions across pages.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java-24-38 (1)
24-38:⚠️ Potential issue | 🟡 MinorJPQL 벌크 UPDATE는 감사 컬럼 갱신과 반환값 검증이 필요하다.
@ModifyingJPQL UPDATE는@PreUpdate라이프사이클 콜백을 호출하지 않고 영속성 컨텍스트를 갱신하지 않는다. 운영 관점에서 다음 개선이 필요하다.
updated_at정지 문제:upsertIfAbsent()는 native query로NOW()를 설정하지만, 이후incrementLikeCount/incrementViewCount/incrementSalesAndQuantity는 UPDATE 절에updatedAt을 포함하지 않는다. 각 메서드 쿼리에m.updatedAt = CURRENT_TIMESTAMP추가가 필요하다.영속성 컨텍스트 동기화 리스크: 향후 코드 변경으로 같은 트랜잭션 내에서
ProductMetricsEntity를 먼저 로드한 후 increment를 호출하면, 영속성 컨텍스트 값과 DB 값이 불일치할 수 있다. 각 메서드에@Modifying(clearAutomatically = true, flushAutomatically = true)추가를 권고한다.
decrementLikeCount반환값 부재:AND m.likeCount > 0가드가 있으나 메서드가void를 반환하므로, 호출부에서 "조건을 만족하지 않아 업데이트 0건"인지 "정상 업데이트 1건"인지 구분할 수 없다. 반환 타입을int로 변경하여 업데이트 행 수를 반환하도록 수정하고, 호출부(ProductMetricsProcessor라인 51)에서 반환값이 1인지 검증하는 로직 추가가 필요하다.제안 수정
- `@Modifying` - `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate") - void incrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate); + `@Modifying`(clearAutomatically = true, flushAutomatically = true) + `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1, m.updatedAt = CURRENT_TIMESTAMP WHERE m.productId = :productId AND m.metricsDate = :metricsDate") + int incrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);- `@Modifying` - `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate AND m.likeCount > 0") - void decrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate); + `@Modifying`(clearAutomatically = true, flushAutomatically = true) + `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1, m.updatedAt = CURRENT_TIMESTAMP WHERE m.productId = :productId AND m.metricsDate = :metricsDate AND m.likeCount > 0") + int decrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);나머지
incrementViewCount,incrementSalesAndQuantity도 동일하게clearAutomatically = true, flushAutomatically = true추가 및updatedAt = CURRENT_TIMESTAMP포함, 반환형int로 변경 필요.추가 테스트 권고
decrementLikeCount가likeCount = 0인 행에 호출될 때 0을 반환하고,likeCount > 0인 행에 호출될 때 1을 반환하는지 검증하는 단위 테스트.- 각 increment 메서드 호출 후
updated_at이 갱신되는지 확인하는 회귀 테스트.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java` around lines 24 - 38, The JPQL bulk UPDATEs in ProductMetricsJpaRepository (methods incrementLikeCount, decrementLikeCount, incrementViewCount, incrementSalesAndQuantity) do not update the updatedAt audit column, don't synchronize the persistence context, and decrementLikeCount returns void so callers can't tell if the guarded update ran; update each `@Query` to set m.updatedAt = CURRENT_TIMESTAMP, change each `@Modifying` to `@Modifying`(clearAutomatically = true, flushAutomatically = true), change decrementLikeCount's return type to int (and likewise make increment* methods return int if you want update-count feedback), and update the caller ProductMetricsProcessor (the code currently around line 51) to check the returned int == 1 to detect whether the decrement actually applied.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java-59-75 (1)
59-75:⚠️ Potential issue | 🟡 Minor입력 파라미터 검증 부재로 인한 비정상 에러 응답 반환
page,size파라미터에 대한 가드가 없다.PageRequest.of(page, size)는page < 0또는size < 1일 때IllegalArgumentException을 던지는데, 이는ApiControllerAdvice의 범용Throwable핸들러에 의해 포착되어INTERNAL_ERROR(500)로 반환된다. 입력 검증 오류는BAD_REQUEST(400)으로 반환되어야 한다.또한
size에 상한이 없어 과도한 페이지 크기 요청 시 JPA가 대량 로우를 한 번에 페치하면서 메모리 압박이 발생할 수 있다.다른 DTO들(AdminCouponV1Dto, OrderV1Dto, ProductV1Dto)은
@Min,@Max검증 애노테이션을 사용하는데, RankingV1Controller는 적용되지 않았다.수정안
- RankingV1Controller의
page,size파라미터에@Min(0),@PositiveOrZero(또는@Positivefor size) 추가- 선택사항:
size에@Max(100)같은 상한 제약 추가- 입력 검증 실패 시
BAD_REQUEST(400)이 반환되는지 확인추가 테스트 권고
- 음수
page, 0/음수size입력 시 400 응답 확인size상한이 설정된 경우 초과 요청 시 거동 검증🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java` around lines 59 - 75, Add parameter validation to the RankingV1Controller endpoints that call getWeeklyTopN/getMonthlyTopN: annotate the page and size request parameters with validation annotations (e.g., `@Min`(0) on page and `@Positive` on size, optionally `@Max`(100) on size) and ensure the controller class is annotated with `@Validated` so Spring returns 400 on constraint violations; update any controller method signatures that pass page/size to RankingRepositoryImpl.getWeeklyTopN/getMonthlyTopN accordingly and add tests asserting negative page or non-positive/oversized size produce BAD_REQUEST(400).
🧹 Nitpick comments (14)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java (1)
3-20: record 전환 또는 Lombok@Getter/@RequiredArgsConstructor사용을 권장한다.불변 DTO임에도 수동으로 필드/생성자/게터를 작성하고 있어 유지보수 비용이 불필요하게 발생한다. 프로젝트가 Java 17 이상이라면
public record RankedProductDto(Long productId, double score) {}로 교체하는 편이 가독성과 equals/hashCode/toString 안정성 측면에서 유리하다. 코딩 가이드라인상 "equals/hashCode/toString 구현 안정성"을 점검하도록 되어 있는데, 현재 클래스는 이들을 모두 상속 기본값에 의존하므로 로그 출력/비교 시 가독성이 낮다.As per coding guidelines: "null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java` around lines 3 - 20, Replace the verbose immutable POJO RankedProductDto with a concise immutable representation: either convert the class to a Java record (public record RankedProductDto(Long productId, double score) {}) if using Java 17+, or annotate the class with Lombok’s `@Getter` and `@RequiredArgsConstructor` (and `@ToString`, `@EqualsAndHashCode`) to auto-generate boilerplate; ensure you handle nulls for productId (add `@NonNull` or explicit checks) and preserve immutability/defensive-copy semantics as required by the project guidelines.apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)
8-9: period를 enum으로 승격하는 방안을 제안한다.현재
getTopN/getWeeklyTopN/getMonthlyTopN세 메서드를 별도로 두고RankingFacade에서 문자열period("weekly"/"monthly"/그 외)로 분기하고 있다. 운영 관점에서는 (1) 신규 기간(예: daily 명시, quarterly)을 추가할 때마다 인터페이스가 팽창하고, (2) 파사드의 문자열 분기가 대소문자/오타/공백에 취약하여 잘못된 값 전달 시 의도치 않은 일간 폴백이 일어나므로 사용자에게 "조회 성공"으로 보이면서 전혀 다른 결과를 반환할 위험이 있다.
RankingPeriodenum을 도입하여 단일 시그니처getTopN(RankingPeriod period, LocalDate date, int size, int page)로 통합하고, 컨트롤러 입력 검증 단계에서 알 수 없는 값은CoreException으로 거절하는 편이 안전하다.추가 테스트: 알 수 없는 period 값("WEEKLY ", "day", null) 전달 시 4xx 에러가 반환되는지, 그리고 유효한 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/java/com/loopers/domain/ranking/RankingRepository.java` around lines 8 - 9, Introduce a RankingPeriod enum and consolidate the three repository methods into a single signature: replace getWeeklyTopN(LocalDate,int,int) and getMonthlyTopN(LocalDate,int,int) (and any existing getTopN overloads) with getTopN(RankingPeriod period, LocalDate date, int size, int page) on RankingRepository; update implementations to switch on RankingPeriod to call the existing weekly/monthly logic. Update RankingFacade to accept RankingPeriod (parse/validate controller input earlier) and throw CoreException for unknown/null/invalid values instead of string-based fallback; add tests to assert 4xx on invalid inputs ("WEEKLY ", "day", null) and that valid RankingPeriod.WEEKLY and .MONTHLY trigger the corresponding internal paths.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
26-31:period문자열 리터럴 분기는 검증 공백/오타 위험이 있다.
period가 Controller에서 문자열로 그대로 전달되어switch에서 리터럴 비교만 하므로, 대소문자 차이나 오타("Weekly","day"등)가 조용히default(일간)로 흡수된다. 운영 관점에서 잘못된 파라미터가 감지되지 않고 일간 랭킹으로 응답되면 디버깅이 어렵고 모니터링 지표에도 잡히지 않는다.RankingPeriodenum을 도입해 Controller/Facade/Repository 계층에서 타입 안전하게 전달하고, 미지원 값은CoreException으로 거부하도록 일원화하길 권장한다.추가 테스트: (1) 지원 기간 각각에 대해 대응 repository 메서드가 호출되는지 Mockito로 검증, (2) 알 수 없는 값 입력 시
CoreException발생 테스트.♻️ 제안 수정
- public List<RankingInfo> getRankings(LocalDate date, String period, int size, int page) { - List<RankedProduct> rankedProducts = switch (period) { - case "weekly" -> rankingRepository.getWeeklyTopN(date, size, page); - case "monthly" -> rankingRepository.getMonthlyTopN(date, size, page); - default -> rankingRepository.getTopN(date, size, page); - }; + public List<RankingInfo> getRankings(LocalDate date, RankingPeriod period, int size, int page) { + List<RankedProduct> rankedProducts = switch (period) { + case WEEKLY -> rankingRepository.getWeeklyTopN(date, size, page); + case MONTHLY -> rankingRepository.getMonthlyTopN(date, size, page); + case DAILY -> rankingRepository.getTopN(date, size, page); + };As per coding guidelines "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다" — 문자열 → enum 변환은 컨트롤러 또는 Facade 진입부에서 수행해 불변 도메인 값으로 전파하는 것이 적합하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 26 - 31, The getRankings method currently switches on the raw String period which is error-prone; add a RankingPeriod enum and change the controller (or the Facade entry) to convert/validate the incoming period string into RankingPeriod (throw CoreException for unknown values), then update RankingFacade.getRankings to switch on RankingPeriod and call the specific repository methods (rankingRepository.getWeeklyTopN, getMonthlyTopN, getTopN) accordingly; also add unit tests to verify each enum branch calls the correct repository method and that invalid inputs cause CoreException.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java (1)
48-57: writer upsert 로직과 chunk 트랜잭션 경계의 재처리 안전성 점검이 필요하다.관련 스니펫(
WeeklyMvRankingItemWriter)을 보면 writer가findById후 존재 여부에 따라update또는save를 수행한다. chunk 도중 예외로 롤백·재처리되면 동일productId에 대해 update가 멱등적으로 수행되므로 기능상 안전하지만,findById가 청크 크기만큼 1+N 쿼리를 유발한다.CHUNK_SIZE=100이면 청크당 최대 100회 SELECT + 100회 UPSERT로 DB 왕복이 과다하다. 운영 관점에서 상품 수가 수만 건이면 배치 시간이 선형 증가하므로,saveAll기반 일괄 upsert(MySQLINSERT ... ON DUPLICATE KEY UPDATE또는 JPA batch insert + dirty update) 도입을 권장한다.추가 테스트: 청크 내 동일
productId중복 처리가 없는지, 실패/재시도 시 score가 누적되지 않고 최신 값으로 덮어쓰이는지 검증한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java` around lines 48 - 57, The writer (WeeklyMvRankingItemWriter) currently does per-item findById + update/save inside the chunk configured by weeklyScoreCalculationStep with CHUNK_SIZE=100, causing 1+N queries and poor performance; change the writer to perform batch upserts (e.g. collect chunk items and call a repository.saveAll with a DB-level upsert strategy such as MySQL INSERT ... ON DUPLICATE KEY UPDATE or JPA bulk insert + bulk update) to reduce round trips, ensure idempotency for retries by making upserts overwrite with the latest score (not accumulate), and add tests to cover duplicate productId within a chunk and retry scenarios to verify scores are replaced rather than accumulated.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java (2)
32-38: 청크당 N회 findById 로 인한 SELECT N+1 문제다.청크 크기 100 기준으로 매 write 호출마다 100회의 단건 SELECT 가 발생한다. 운영 관점에서는 배치 처리량이 저하되고 DB 커넥션 점유 시간이 늘어나 장애 상황(트래픽 증가, 슬로우 쿼리 누적) 에서 배치 SLA 미달로 이어질 수 있다.
findAllById로 한 번에 조회하여 Map 으로 분기하거나, MySQL upsert (INSERT ... ON DUPLICATE KEY UPDATE) JPQL/Native 쿼리로 단일 벌크 연산으로 전환하는 것이 바람직하다. 추가 테스트로는 (1) 기존 엔티티 일부 + 신규 엔티티 일부가 섞인 청크에서 최종yearMonth/score가 올바르게 반영되는지, (2) 청크 크기만큼의 아이템 처리 시 SELECT 호출 수가 1~2회 수준인지 검증하는 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java` around lines 32 - 38, The current write loop in MonthlyMvRankingItemWriter issues a findById per item causing SELECT N+1; change it to fetch all existing entities in the chunk with monthlyRepository.findAllById(chunkIds) and build a Map<productId, MvProductRankMonthlyEntity>, then iterate items: if map contains productId call existing.update(...), else collect new MvProductRankMonthlyEntity instances and saveAll at once; alternatively implement a single bulk upsert via a native INSERT ... ON DUPLICATE KEY UPDATE or JPQL bulk update to set yearMonth and score in one operation; add tests covering mixed existing+new chunks and assert DB SELECT count is 1 (or O(1)) per chunk and final yearMonth/score correctness.
24-30: targetDate 파싱 실패 메시지 개선 및 NPE 방어가 필요하다.
targetDate가 누락되거나 포맷이 다를 경우DateTimeParseException/NullPointerException가 그대로 전파되어 운영 알람에서 원인(잡 파라미터 누락) 을 즉시 식별하기 어렵다. 파싱을write마다 반복 수행하는 것도 비효율적이다.@PostConstruct혹은 Step 시작 시점에 한 번만 파싱하여yearMonth를 필드로 캐싱하고, 파싱 실패 시 원인(cause) 을 보존한 채 명확한 메시지를 던지도록 하는 것이 바람직하다. 추가 테스트로targetDate가 null/비정상 문자열일 때 사람이 읽을 수 있는 예외 메시지가 노출되는지 검증해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java` around lines 24 - 30, Parse/validation of targetDate should be moved out of write and done once at step initialization: add a private field (e.g., cachedYearMonth) and parse targetDate in a `@PostConstruct` (or StepExecutionListener beforeStep) using LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE). If targetDate is null or parsing fails, throw an IllegalStateException with a clear human-readable message like "Invalid or missing job parameter 'targetDate': expected yyyyMMdd" and include the original exception as the cause to preserve details. Replace the parsing in write(...) with use of cachedYearMonth (format with RankingMonthlyJobConfig.YEAR_MONTH_FORMAT if needed). Add unit tests for null and malformed targetDate to assert the thrown message contains the helpful text.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java (1)
1-81: commerce-api 측MvProductRankMonthlyEntity와 중복된다.같은 테이블(
mv_product_rank_monthly) 을 매핑하는 엔티티가apps/commerce-api와apps/commerce-batch양쪽에 각각 존재한다. 운영 관점에서 스키마 변경 시 한쪽만 수정되면 런타임에 조용히 컬럼 누락/타입 불일치로 드러나는 장애가 발생한다. 공통 모듈로 추출하거나, 한 쪽을 읽기 전용 프로젝션으로 축소하는 리팩터링을 권장한다. 동일 이슈가MvProductRankWeeklyEntity에도 적용된다. 추가 테스트로 두 모듈의 엔티티가 동일 스키마를 바라보는지 검증하는 컨트랙트 테스트를 두는 것이 바람직하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java` around lines 1 - 81, Duplicate entity mappings exist for mv_product_rank_monthly (MvProductRankMonthlyEntity) across apps/commerce-api and apps/commerce-batch causing drift risk; refactor by extracting the shared entity (MvProductRankMonthlyEntity) into a common library/module used by both projects or convert the batch-side MvProductRankMonthlyEntity into a read-only projection (e.g., DTO or `@Immutable/read-only` mapping) and remove the duplicate class from one module, apply the same change for MvProductRankWeeklyEntity, and add a contract test that loads both modules' metadata to assert identical column names/types for these entities to prevent future schema divergence.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java (1)
37-39:yearWeek변수명과 실제 포맷(yyyyMMdd) 이 불일치한다.
BASIC_ISO_DATE는yyyyMMdd를 출력하므로 "yearWeek" 라는 이름과 저장 값(예:20260413) 이 의미적으로 맞지 않는다. 추후 유지보수자가YYYY'W'ww같은 실제 ISO 주차 포맷으로 오인하여 변경하면, writer(WeeklyMvRankingItemWriter) 와 tasklet 간 yearWeek 값 불일치로 전량 삭제 사고가 발생할 수 있다. 컬럼/필드명을weekStartDate로 바꾸거나 포맷을 실제xxxx-Www로 통일하는 방향을 권장한다. 추가 테스트로 writer 와 tasklet 이 동일targetDate로 동일한 키를 생성하는지 공유 유틸을 통해 검증하는 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java` around lines 37 - 39, The variable yearWeek in WeeklyRankAssignTasklet is misnamed because it stores a yyyyMMdd string (using DateTimeFormatter.BASIC_ISO_DATE); either rename it to weekStartDate (and update usages) or change the formatter to produce an ISO week string (e.g., YYYY-'W'ww) so the name and value align; ensure WeeklyMvRankingItemWriter and WeeklyRankAssignTasklet produce the identical key by extracting the shared key-generation logic into a common utility (used by both) and add a unit test that verifies the same targetDate yields the same key in both WeeklyRankAssignTasklet and WeeklyMvRankingItemWriter.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java (1)
56-70: period 화이트리스팅/대소문자 정책을 정리해야 한다.
"weekly"/"monthly"만 지원하며 그 외에는 예외가 발생한다. 운영상 오타(Weekly,WEEKLY) 로 인한 잡 전체 실패는 원인 추적에 시간이 소요된다. (1)Locale.ROOT기준toLowerCase정규화, (2)enum Period도입으로 컴파일 타임 검증, (3) 예외 메시지에 지원되는 값 목록 포함을 권장한다. 또한resolveStartDate/resolveEndDate에 동일 switch 가 중복되므로Period.range(target)형태로 묶으면 변경점이 하나가 된다. 추가 테스트로 지원되지 않는 period 값 입력 시 예외 메시지에 허용 값이 포함되는지 검증해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java` around lines 56 - 70, The current resolveStartDate/resolveEndDate only accept exact "weekly"/"monthly" strings and duplicate switch logic; normalize the incoming period string with period = period.toLowerCase(Locale.ROOT) and replace the string switches by introducing an enum Period { WEEKLY, MONTHLY } with a static from(String) that normalizes/validates and throws IllegalArgumentException listing allowed values; move the start/end logic into Period methods (e.g., Period.startOf(target) and Period.endOf(target) or Period.range(target) returning both dates) and update resolveStartDate/resolveEndDate to delegate to the enum; add unit tests that assert valid inputs work and that invalid inputs produce an exception containing the allowed value list.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java (2)
43-45:total_quantityNOT NULL 컬럼 추가 시 기존 데이터 기본값 전략이 필요하다.기존
product_metrics테이블에 레코드가 존재하는 환경에서total_quantity NOT NULL을 그대로 추가하면 DDL 이 실패하거나 디폴트 0 으로 일괄 채워진다. 전자는 배포 실패, 후자는 과거 집계를 0 으로 왜곡한다. 외부 마이그레이션 파이프라인 스크립트에서 (1) 초기DEFAULT 0으로 컬럼 추가 → (2) 과거 데이터 백필(주문 이력 재집계) → (3) 애플리케이션 배포의 3단계 순서로 나누는 것이 안전하다. 추가 테스트로 기존 행이 있는 DB 에 대해 Testcontainers 기반 마이그레이션 시뮬레이션 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java` around lines 43 - 45, Adding a NOT NULL column totalQuantity to ProductMetricsEntity (product_metrics.total_quantity) must be performed as a three-step migration outside a single deploy: (1) add the column with NULL allowed or with DEFAULT 0 (migration script creates column total_quantity DEFAULT 0 NULLABLE), (2) run a backfill job that recalculates/hydrates total_quantity from historical orders for existing rows (use your ETL/migration pipeline to reaggregate and update product_metrics.total_quantity), and only after backfill (3) alter the column to NOT NULL (and remove the temporary default if you added one) and then deploy the application change that maps ProductMetricsEntity.totalQuantity to a non-null int; also add a Testcontainers-based migration integration test that simulates an existing DB with rows and validates the three-step migration/backfill sequence succeeds and preserves historical totals.
14-19: 기존 데이터/동시성 관점에서 복합 유니크 제약 전환을 확인해야 한다.유니크 키가
product_id단일에서{product_id, metrics_date}로 변경되었다. 운영 관점에서 (1) 기존 데이터에metrics_date가 없던 행의 백필 전략, (2) 인덱스명 변경(uk_product_metrics_product_id_date) 에 따른 기존 인덱스 DROP/CREATE 순서와 온라인 DDL 소요 시간, (3) 멀티 인스턴스 스트리머가 동일{product_id, metrics_date}에 동시 upsert 할 때 경쟁조건(중복 키 예외 → 재시도) 처리 경로 확인이 필요하다. 외부 마이그레이션 파이프라인 변경 PR 과의 배포 순서(스키마 선반영 → 코드 배포) 도 운영 체크리스트에 포함해야 한다. 추가 테스트로 동일{product_id, metrics_date}동시 삽입 시 유니크 위반 예외가 정상적으로 upsert 로 귀결되는지 통합 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java` around lines 14 - 19, ProductMetricsEntity의 테이블 유니크 제약이 product_id 단일에서 {product_id, metrics_date}로 변경된 점을 반영해 운영·동시성 검증을 수행하세요: 기존 데이터에 metrics_date가 비어있는 행에 대한 백필 전략 수립 및 마이그레이션(백필 스크립트)을 준비하고, 새로운 인덱스명 uk_product_metrics_product_id_date로 인덱스 교체 시 기존 인덱스 DROP/CREATE 순서와 온라인 DDL 소요 시간을 확인·문서화하세요; 멀티 인스턴스에서 ProductMetricsEntity를 upsert 하는 코드 경로(특히 upsert/insert 처리 로직과 retry/duplicate-key 예외 처리)를 점검해 동시 삽입 시 재시도 로직이 정상적으로 작동하도록 보완하고, 스키마 변경 선반영 → 코드 배포 순서에 대한 배포 체크리스트를 추가하며, 동일 {product_id, metrics_date}에 대한 동시 삽입 통합 테스트를 만들어 중복 키 예외가 올바르게 upsert로 귀결되는지 검증하세요.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java (1)
21-41:MonthlyMvRankingItemWriter와 로직이 사실상 동일하여 중복되어 있다.
yearWeek/yearMonth계산식만 다르고 조회·업서트 흐름이 완전히 동일하다. 운영 관점에서 한쪽만 수정(예: 성능 최적화를 위해findAllById도입) 될 때 다른 주기에서는 회귀가 발생한다. 제네릭 기반AbstractMvRankingItemWriter<E>를 두어repository,keyFn,entityFactory,updater를 주입받는 템플릿으로 통합하거나, 전략 패턴으로 기간별 키 계산만 분리하는 것을 권장한다. 또한 이 파일에도MonthlyMvRankingItemWriter와 동일하게 청크 당 N회 findById 이슈가 존재하므로 동일한 벌크 조회/upsert 개선이 필요하다. 추가 테스트로 두 writer 가 동일 계약(신규 저장/기존 업데이트 경계) 을 지키는지 파라미터라이즈드 테스트가 유효하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java` around lines 21 - 41, WeeklyMvRankingItemWriter duplicates MonthlyMvRankingItemWriter behavior (only yearWeek vs yearMonth differs) and performs N findById calls per chunk; refactor into a generic AbstractMvRankingItemWriter<E> (or use a strategy for period-key calculation) that accepts the repository (MvProductRankWeeklyJpaRepository / monthly equivalent), a key function derived from targetDate, an entityFactory and an updater so both writers reuse write(Chunk<? extends RankedProductDto>) logic; replace the per-item weeklyRepository.findById(...) loop in write(...) with a bulk fetch (findAllById on the chunk productIds), build a map of existing entities, then perform updates or create new entities and use saveAll to persist in bulk; add parameterized tests exercising the new writer contract (new vs existing boundary) to ensure both weekly and monthly behaviors remain identical.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java (1)
41-48: 중간 단계 실패 시 재시작 정책이 명시되지 않았다.
monthlyScoreCalculationStep성공 후monthlyRankAssignStep이 실패하면, MV 테이블에는productRank=0인 레코드가 남아 API 가 비정상 순위를 노출할 수 있다. 운영 관점에서 (1) 잡 재시작 시 chunk step 의allowStartIfComplete(true)혹은 두 스텝의 멱등성 보장, (2) 실패 시 알람/대시보드 지표(StepMonitorListener 이외 별도 Micrometer 지표) 추가, (3) rank 미할당 레코드(productRank = 0) 를 API 가 필터링하도록 하는 방어로직이 필요하다. 추가 테스트로 score step 성공 + assign step 실패 후 재시작 시 최종 상태가 정상 top100 으로 수렴하는지 E2E 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java` around lines 41 - 48, 현재 rankingMonthlyJob 구성은 monthlyScoreCalculationStep 성공 후 monthlyRankAssignStep 실패 시 재시작/운영 보호가 없다; monthlyScoreCalculationStep과 monthlyRankAssignStep의 스텝 정의를 수정해 chunk step에 allowStartIfComplete(true)를 설정하거나(또는 두 스텝를 멱등하게 구현) 재시작 정책을 명시하고, 실패 모니터링을 위해 StepMonitorListener 외에 별도의 Micrometer 지표/알람을 monthlyRankAssignStep에 등록하도록 변경하며(예: 별도 MeterRegistry 카운터/타이머), API 쪽 방어로직을 추가해 productRank == 0 레코드를 필터링하도록 하고, 마지막으로 monthlyScoreCalculationStep 성공 후 monthlyRankAssignStep 실패 시 잡 재시작 후 최종 top100이 정상 수렴하는지 검증하는 E2E 테스트를 작성하라; 관련 식별자는 rankingMonthlyJob, monthlyScoreCalculationStep, monthlyRankAssignStep, StepMonitorListener, JOB_NAME, allowStartIfComplete이다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
5-23: 값 객체의 동등성/표현 관점 보완 권고.
ProductMetrics는 모든 필드가final인 불변 도메인이나,equals/hashCode/toString이 부재하여 테스트 어서션·로그 진단·컬렉션 보관 시 식별성이 떨어진다. 운영 관점에서 메트릭 불일치 조사 시 로그에 인스턴스 주소만 찍혀 원인 추적이 어려워질 수 있다.@Value(lombok) 또는record전환, 혹은 수동 구현을 권고한다.As per coding guidelines: "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다. equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."
추가 테스트 권고
- 동일
(productId, metricsDate, counts...)로 생성된 두 인스턴스가equals로 동등한지 검증.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java` around lines 5 - 23, ProductMetrics is an immutable value object but lacks equals/hashCode/toString, which harms comparisons, logging and collection behavior; implement value-based equals and hashCode (use productId, metricsDate and the count fields like likeCount, viewCount, salesCount, totalQuantity) and a descriptive toString, or alternatively convert ProductMetrics to a Java record or annotate with Lombok `@Value` to get these auto-generated; update or add tests to assert two instances with identical productId/metricsDate/counts are equal and produce consistent hashCodes and readable toString output.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e29deba6-c9d2-44fa-9bbc-e68b0f6295c9
⛔ Files ignored due to path filters (1)
docs/10-batch-ranking.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (28)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
| public class MvProductRankMonthlyEntity { | ||
|
|
||
| @Id | ||
| @Comment("상품 id (ref)") | ||
| @Column(name = "product_id", nullable = false, updatable = false) | ||
| private Long productId; | ||
|
|
||
| @Comment("집계 점수") | ||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Comment("집계 기준 월 (e.g. 202604)") | ||
| @Column(name = "year_month", nullable = false, length = 6) | ||
| private String yearMonth; | ||
|
|
||
| @Comment("랭킹 순위") | ||
| @Column(name = "product_rank", nullable = false) | ||
| private int productRank; | ||
|
|
||
| @Column(name = "created_at", nullable = false, updatable = false) | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) { | ||
| this.productId = productId; | ||
| this.score = score; | ||
| this.yearMonth = yearMonth; | ||
| this.productRank = productRank; | ||
| } |
There was a problem hiding this comment.
단일 productId PK 구조는 월별 이력 보존이 불가능하다.
@Id가 productId 단독이므로 동일 상품의 월별 스냅샷을 보관할 수 없고, WeeklyMvRankingItemWriter 등과 동일한 패턴으로 매월 갱신 시 이전 월 데이터가 덮어쓰여 과거 월 랭킹 조회 API가 항상 최신 월 데이터만 반환한다. 컨트롤러/Facade의 period="monthly" + date 파라미터 설계(과거 날짜로 조회 가능)와 정합하지 않는다. (productId, yearMonth) 복합키 또는 별도 PK + unique 제약으로 변경하고, reader/writer/삭제 전략도 함께 재검토가 필요하다.
추가 테스트: (1) 서로 다른 yearMonth로 동일 productId 저장 시 두 행이 공존하는지, (2) 과거 월 날짜로 API 조회 시 해당 월의 랭킹이 반환되는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 16 - 46, MvProductRankMonthlyEntity currently uses productId as the
sole `@Id` which prevents preserving monthly snapshots; change the key strategy so
that productId and yearMonth form a composite primary key (or introduce a
surrogate PK plus a unique constraint on (productId, yearMonth)) and update
persistence mappings accordingly (adjust annotations on productId and yearMonth
in MvProductRankMonthlyEntity); also review and update related components such
as WeeklyMvRankingItemWriter/reader/delete logic to insert/update by the new key
strategy, and add tests verifying that saving the same productId with different
yearMonth produces two rows and that querying by past month returns that month’s
ranking.
| @Column(name = "year_month", nullable = false, length = 6) | ||
| private String yearMonth; |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Is YEAR_MONTH a reserved keyword in MySQL 8.0 and does it require quoting when used as a column identifier?
💡 Result:
Yes, YEAR_MONTH is a reserved keyword in MySQL 8.0. It requires quoting (using backticks) when used as a column identifier.
Citations:
- 1: https://dev.mysql.com/doc/mysqld-version-reference/en/keywords-8-0.html
- 2: https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/keywords.html
- 3: https://dev.mysql.com/doc/mysqld-version-reference/en/keywords-8-4.html
- 4: https://gist.github.com/bgrainger/54e7f3200772734855b08970792f0175
🏁 Script executed:
# 먼저 현재 파일의 위치 확인
find . -name "MvProductRankMonthlyEntity.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 288
🏁 Script executed:
# 관련 엔티티 찾기 - 주간 랭킹 엔티티
find . -name "*MvProductRankWeekly*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 502
🏁 Script executed:
# year_month 또는 yearMonth 필드 검색
rg "yearMonth|year_month" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4745
🏁 Script executed:
# DDL 스크립트나 데이터베이스 설정 파일 확인
find . -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 107
🏁 Script executed:
# Check the WeeklyEntity for similar issues
cat ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2187
🏁 Script executed:
# Check the database schema/DDL
cat ./db/seed-products.sqlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1357
🏁 Script executed:
# Search for other `@Column` usages with backticks or special characters in the codebase
rg "@Column.*\`" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Search for Hibernate/Spring configuration that might affect column naming
find . -name "application*.properties" -o -name "application*.yml" | xargs cat 2>/dev/null | grep -i "hibernate\|jpa" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 132
🏁 Script executed:
# Search for Flyway or Liquibase migration files
find . -path "*/db/migration*" -o -path "*/db/flyway*" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Look for any V*.sql or similar migration patterns
find . -name "V*.sql" -o -name "U*.sql" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check application configuration
find . -name "application*.yml" -o -name "application*.yaml" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
# Search for any CREATE TABLE statements in the codebase
rg "CREATE TABLE.*rank" --type sql -iRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check if there's any documentation about the schema
find . -name "*.md" -o -name "*.txt" | xargs grep -l "year_month" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 111
🏁 Script executed:
# Check the application configuration for Hibernate settings
cat ./apps/commerce-api/src/main/resources/application.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1670
🏁 Script executed:
# Check the batch ranking documentation
cat ./docs/10-batch-ranking.mdRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 8489
🏁 Script executed:
# Search for any SQL or DDL hints in comments
rg "year_month|year_week" --type java -B 2 -A 2 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2635
🏁 Script executed:
# Check for any Hibernate configuration that might handle reserved keywords
cat ./apps/commerce-api/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 163
🏁 Script executed:
# Verify if there's any Hibernate documentation or configuration for column name quoting
rg "hibernate.*naming|dialect|PhysicalNamingStrategy" --type yaml --type propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 119
🏁 Script executed:
# Search for other reserved keywords usage in the codebase
rg "@Column.*name.*=" --type java | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4522
🏁 Script executed:
# Check if the batch module has the same issue - compare both files
diff -u ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Search for Hibernate documentation or examples with reserved keywords
rg "backtick|escape|reserved" --type java --type yaml -iRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check if there are any test files that might show database interactions
find . -name "*Test*.java" -path "*/ranking/*" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Look for any database initialization or schema creation code
rg "CREATE TABLE|year_month" --type java -B 2 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1353
🏁 Script executed:
# Check for jpa.yml content - it might be in a different location
find . -name "*.yml" -o -name "*.yaml" | xargs grep -l "jpa\|hibernate" | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 297
🏁 Script executed:
# Look for application-jpa.yml or similar
find . -name "application*" -type f | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
# Check if there's Hibernate dialect configuration
rg "hibernate.dialect|hibernate.query|quoteIdentifier" --type yaml --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Verify what exact Hibernate/Jakarta version is used
find . -name "pom.xml" | head -3 | xargs grep -A 5 "jakarta.persistence\|hibernate-core"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 69
🏁 Script executed:
# Read the jpa.yml configuration file
cat ./modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2231
🏁 Script executed:
# Check pom.xml for Jakarta/Hibernate versions
cat ./pom.xml | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 126
🏁 Script executed:
# Look specifically for spring-boot-starter-data-jpa and related dependencies
rg "spring-boot-starter-data-jpa|jakarta.persistence" pom.xmlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 132
🏁 Script executed:
# Find the Maven build files
find . -name "pom.xml" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# Check the root directory structure
ls -la | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1228
🏁 Script executed:
# Look for Gradle build files instead
find . -name "build.gradle" -o -name "build.gradle.kts" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 252
🏁 Script executed:
# Check root build.gradle.kts for dependency versions
cat ./build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3761
🏁 Script executed:
# Check modules/jpa build.gradle.kts
cat ./modules/jpa/build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 763
year_month는 MySQL 8.0 예약어이므로 백틱으로 이스케이프해야 한다.
YEAR_MONTH는 MySQL 8.0의 INTERVAL 단위 키워드로 예약되어 있어 DDL/DML 파싱 에러가 발생할 수 있다. 현재 코드의 @Column(name = "year_month", ...) 형태로는 예약어 충돌 위험이 있으므로, Hibernate에서 명시적으로 인식하도록 백틱으로 감싸야 한다.
commerce-api와 commerce-batch 두 모듈의 MvProductRankMonthlyEntity 파일 모두에서 동일하게 수정이 필요하다.
📝 수정안
- `@Column`(name = "year_month", nullable = false, length = 6)
+ `@Column`(name = "`year_month`", nullable = false, length = 6)
private String yearMonth;테스트: Testcontainers MySQL 8.0 환경에서 persist/update/derived query(findAllByYearMonth 등) 모두 정상 실행되는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 28 - 29, The column name year_month is a MySQL 8.0 reserved keyword
(INTERVAL unit) and must be escaped; update the MvProductRankMonthlyEntity class
so the `@Column` maps to a backticked identifier (replace `@Column`(name =
"year_month", ...) with `@Column`(name = "`year_month`", ...)) to ensure Hibernate
emits the escaped identifier, and apply the same change in both commerce-api and
commerce-batch copies of MvProductRankMonthlyEntity; after changes run
persistence/update and derived queries (e.g., findAllByYearMonth) against
Testcontainers MySQL 8.0 to verify no DDL/DML parsing errors.
| @Override | ||
| public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) { | ||
| String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM")); | ||
| return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size)) | ||
| .stream() | ||
| .map(e -> new RankedProduct(e.getProductId(), e.getScore())) | ||
| .toList(); | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
월 단위 포맷 상수를 배치와 공유해 포맷 드리프트를 차단하도록 한다.
배치 writer(apps/commerce-batch/.../MonthlyMvRankingItemWriter.java)는 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 사용해 yearMonth를 적재하는데, 조회 측(본 구현)은 리터럴 "yyyyMM"을 인라인으로 중복 정의하고 있다. 한쪽이 "yyyy-MM"·"yyyyMMM" 등으로 변경되면 조회 결과가 조용히 공집합이 되어 랭킹 화면이 빈 페이지로 노출되는 운영 장애로 번질 수 있다. 주간 BASIC_ISO_DATE 역시 같은 위험이 있어 상수 추출 또는 공유 상수 참조를 권고한다.
♻️ 제안 수정
- `@Override`
- public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
- String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
+ private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
+
+ `@Override`
+ public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
+ String yearMonth = date.format(YEAR_MONTH_FORMAT);
return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
.stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore()))
.toList();
}공용 포맷 상수(예: com.loopers.domain.ranking 하위 상수 클래스)로 끌어올려 배치/조회 양측이 동일 심볼을 참조하게 만들거나, 배치의 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 상향 이동하는 방식을 권고한다.
추가 테스트 권고
2026-01-05같은 경계 날짜에 대해 주간 키가20251229(전년도 마지막 주 월요일)로 산출되는지, 동일 입력에 대해 배치 writer와 조회 구현이 같은 키를 생성하는지 확인하는 계약(contract) 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java`
around lines 68 - 75, getMonthlyTopN uses an inline DateTimeFormatter pattern
"yyyyMM" causing format drift with the batch writer; replace the literal with a
shared constant to ensure both writer and reader use the same symbol (e.g.
reference the existing RankingMonthlyJobConfig.YEAR_MONTH_FORMAT or move a
common constant into a shared package such as com.loopers.domain.ranking) and
update getMonthlyTopN to format the date using that shared constant; also verify
similar weekly formatting uses the shared BASIC_ISO_DATE constant if applicable.
| @Bean(JOB_NAME) | ||
| public Job rankingWeeklyJob() { | ||
| return new JobBuilder(JOB_NAME, jobRepository) | ||
| .start(weeklyScoreCalculationStep()) | ||
| .next(weeklyRankAssignStep()) | ||
| .listener(jobListener) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
Job incrementer/재실행 정책이 부재하다.
rankingWeeklyJob에 .incrementer(...)와 실패 시 재시작 전략이 없어 동일 파라미터(targetDate, period)로 재실행 시 JobInstanceAlreadyCompleteException이 발생하거나 반대로 실수로 중복 집계될 수 있다. 운영 관점에서 주간 배치는 재시도가 잦은 구간이므로 RunIdIncrementer 또는 명시적 run-id 파라미터 설계가 필요하다. 또한 weeklyRankAssignStep은 MV 전체를 다루는 tasklet으로 추정되는데, chunk step 실패 후 rank assign만 재실행되는 경우 데이터 정합성 측면에서 문제가 없는지 확인이 필요하다.
추가 테스트: (1) 동일 targetDate로 두 번 실행했을 때 예외/중복 집계 여부, (2) chunk step 실패 후 재실행 시 마지막 청크 재처리 시 MV 합산값이 중복되지 않는지 검증한다.
♻️ 제안 수정
public Job rankingWeeklyJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.start(weeklyScoreCalculationStep())
.next(weeklyRankAssignStep())
+ .incrementer(new RunIdIncrementer())
.listener(jobListener)
.build();
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java`
around lines 39 - 46, The Job configuration lacks an incrementer and explicit
restart policy causing JobInstanceAlreadyCompleteException or duplicate runs;
update the rankingWeeklyJob() to attach a RunIdIncrementer (or require an
explicit run-id parameter) via .incrementer(...) and configure a restart policy
so identical targetDate/period runs are treated as distinct or rejected; also
mark or protect weeklyRankAssignStep() (the MV-wide tasklet) so it is idempotent
or non-restartable (e.g., make it step-allow-start-if-complete=false or add a
guard/transactional check) to prevent MV double-aggregation when upstream chunk
steps are retried. Ensure you run the suggested tests: re-run with same
targetDate and simulate chunk failure + restart to validate no duplicate
aggregation.
| String yearMonth = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE) | ||
| .format(RankingMonthlyJobConfig.YEAR_MONTH_FORMAT); | ||
|
|
||
| monthlyRepository.deleteAllByYearMonthNot(yearMonth); | ||
|
|
||
| List<MvProductRankMonthlyEntity> ranked = monthlyRepository.findAllByYearMonthOrderByScoreDesc(yearMonth); | ||
|
|
||
| for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) { | ||
| ranked.get(i).update(ranked.get(i).getScore(), yearMonth, i + 1); | ||
| } | ||
|
|
||
| if (ranked.size() > TOP_RANK_LIMIT) { | ||
| monthlyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size())); | ||
| log.info("월간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT); | ||
| } | ||
|
|
||
| log.info("월간 랭킹 확정: {}건 (yearMonth={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearMonth); | ||
| return RepeatStatus.FINISHED; | ||
| } |
There was a problem hiding this comment.
전체 로직을 단일 트랜잭션으로 처리할 때 메모리/락 위험이 있다.
(1) deleteAllByYearMonthNot 이전 단계의 writer 가 방금 저장한 현재월 레코드는 유지되지만, 과거 targetDate 로 재실행 시 최신월 데이터가 삭제될 수 있다. 운영상 장애 복구 시 더 큰 장애로 번질 수 있으므로 targetDate 유효성(현재 월 ± 허용범위) 검증과, 삭제 전 건수 로깅이 필요하다. (2) findAllByYearMonthOrderByScoreDesc 가 수만~수십만 건을 전량 적재하면 OOM 위험과 장 시간 락이 발생한다. Pageable 로 Top 100 + 이후 삭제용 ID 만 조회하는 방식으로 쪼개는 것이 안전하다. (3) ranked.subList(...) 로 넘기는 deleteAll 은 엔티티 단건 delete N회를 유발할 수 있으니 @Modifying JPQL 벌크 delete 로 전환을 권장한다. 추가 테스트로 100건 초과 시나리오, 과거 날짜 재실행 시나리오, 동일 yearMonth 재실행 멱등성 시나리오가 필요하다. WeeklyRankAssignTasklet 도 동일하게 적용된다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java`
around lines 36 - 54, Validate targetDate is within an allowed window (e.g.,
current month ± configured tolerance) before calling
monthlyRepository.deleteAllByYearMonthNot, and log the count that would be
deleted; replace the full-materialization call to
monthlyRepository.findAllByYearMonthOrderByScoreDesc with paged queries to first
fetch TOP_RANK_LIMIT (Pageable) for ranking updates (use the existing
TOP_RANK_LIMIT constant and the update(...) on MvProductRankMonthlyEntity) and
then fetch only IDs for the remainder in pages to delete; replace
monthlyRepository.deleteAll(ranked.subList(...)) with a bulk JPQL/@Modifying
delete (e.g., deleteByIdIn or deleteByYearMonthAndIdIn) or
repository.deleteAllByIdInBatch to avoid N x DELETE operations and OOM/locks;
apply the same changes to WeeklyRankAssignTasklet and add tests for >100 items,
past-date re-run, and idempotent re-run of same yearMonth, plus ensure detailed
logging before destructive deletes.
|
|
||
| List<MvProductRankMonthlyEntity> findAllByYearMonthOrderByScoreDesc(String yearMonth); | ||
|
|
||
| void deleteAllByYearMonthNot(String yearMonth); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd MonthlyRankAssignTasklet -e java | xargs -I{} cat {}Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2415
🏁 Script executed:
find . -name "MvProductRankMonthlyJpaRepository.java" -type fRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 302
🏁 Script executed:
cat -n ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 548
파생 쿼리 기반 bulk delete는 운영상 위험하다.
deleteAllByYearMonthNot는 Spring Data 파생 쿼리로 구현되어 있으며, 기본 동작상 엔티티를 모두 로딩한 뒤 개별 삭제를 수행하므로 MV 테이블 규모가 커질수록 성능이 급락하고 영속성 컨텍스트 오염/메모리 압박이 발생한다. 또한 "현재 yearMonth가 아닌 전부"를 한 번에 파기하는 파괴적 연산이므로, 배치 부분 실패 후 재실행 시 의도치 않은 데이터 손실을 초래한다. @Modifying + JPQL로 단일 DML 쿼리로 변경하고, 삭제 건수를 반환받아 로깅하며, 재실행 시 idempotency를 보장하는 테스트가 필요하다.
♻️ 제안 수정
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
...
- void deleteAllByYearMonthNot(String yearMonth);
+ `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+ `@Query`("delete from MvProductRankMonthlyEntity e where e.yearMonth <> :yearMonth")
+ int deleteAllByYearMonthNot(`@Param`("yearMonth") String yearMonth);변경 후 MonthlyRankAssignTasklet에서 삭제 건수를 로깅하고, (1) 쿼리 로그로 단일 DML 확인, (2) 현재 yearMonth만 남는지 검증, (3) 동일 배치 2회 실행 시 두 번째는 0건 삭제되는지(idempotent) 확인하는 테스트를 추가한다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| void deleteAllByYearMonthNot(String yearMonth); | |
| `@Modifying`(clearAutomatically = true, flushAutomatically = true) | |
| `@Query`("delete from MvProductRankMonthlyEntity e where e.yearMonth <> :yearMonth") | |
| int deleteAllByYearMonthNot(`@Param`("yearMonth") String yearMonth); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java`
at line 10, 현재 Spring Data 파생 쿼리로 선언된 MvProductRankMonthlyJpaRepository의
deleteAllByYearMonthNot는 엔티티를 로드해 개별삭제하므로 성능·메모리·데이터 손실 위험이 있어, 이 메서드를
`@Modifying` 어노테이션과 JPQL 단일 DML DELETE 쿼리로 교체하고 int(또는 long) 삭제건수 반환하도록 변경하십시오; 변경
후 MonthlyRankAssignTasklet에서 반환된 삭제 건수를 로깅하고(단일 DML 실행 확인), 변경된 리포지토리 메서드명이든
기존명을 그대로 쓰든 JPQL을 사용해 "DELETE FROM MvProductRankMonthly m WHERE m.yearMonth <>
:yearMonth" 형태로 구현해 현재 yearMonth만 남는지 검증하는 통합테스트와 동일 배치 2회 실행 시 두 번째 실행에서 0건
삭제되는 idempotency 테스트를 추가하세요.
| public class MvProductRankWeeklyEntity { | ||
|
|
||
| @Id | ||
| @Comment("상품 id (ref)") | ||
| @Column(name = "product_id", nullable = false, updatable = false) | ||
| private Long productId; | ||
|
|
||
| @Comment("집계 점수") | ||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)") | ||
| @Column(name = "year_week", nullable = false, length = 8) | ||
| private String yearWeek; | ||
|
|
||
| @Comment("랭킹 순위") | ||
| @Column(name = "product_rank", nullable = false) | ||
| private int productRank; | ||
|
|
||
| @Column(name = "created_at", nullable = false, updatable = false) | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) { | ||
| this.productId = productId; | ||
| this.score = score; | ||
| this.yearWeek = yearWeek; | ||
| this.productRank = productRank; | ||
| } | ||
|
|
||
| public void update(double score, String yearWeek, int productRank) { | ||
| this.score = score; | ||
| this.yearWeek = yearWeek; | ||
| this.productRank = productRank; | ||
| } | ||
|
|
||
| @PrePersist | ||
| private void prePersist() { | ||
| LocalDateTime now = LocalDateTime.now(); | ||
| this.createdAt = now; | ||
| this.updatedAt = now; | ||
| } | ||
|
|
||
| @PreUpdate | ||
| private void preUpdate() { | ||
| this.updatedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public Long getProductId() { | ||
| return productId; | ||
| } | ||
|
|
||
| public double getScore() { | ||
| return score; | ||
| } | ||
|
|
||
| public String getYearWeek() { | ||
| return yearWeek; | ||
| } | ||
|
|
||
| public int getProductRank() { | ||
| return productRank; | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
@Id 엔티티에 equals/hashCode 부재와 commerce-api 측 동일 엔티티와의 중복을 해소해야 한다.
-
equals/hashCode 누락:
productId가 PK인 JPA 엔티티인데equals/hashCode가 정의되지 않아 기본 Object 구현에 의존한다. 배치에서Set/Map자료구조로 중복 제거를 시도하거나 영속성 컨텍스트 바깥에서 동일성 비교를 할 때 오동작 가능성이 있다. 식별자 기반으로 일관되게 구현할 것을 권장한다. -
모듈 간 엔티티 중복: 동일한 매핑의
MvProductRankWeeklyEntity가apps/commerce-api와apps/commerce-batch양쪽에 중복 정의되어 있다(apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java참조). 테이블 스키마/컬럼 길이/제약이 어느 한쪽만 바뀌면 조용히 드리프트가 발생하여 읽기(API)와 쓰기(배치) 간 런타임 매핑 불일치로 이어질 수 있고, 장애 발생 시 어느 모듈의 정의가 '정'인지 추적이 어려워진다. 공통 모듈(예:modules/commerce-domain또는commerce-infra)로 추출하거나, 최소한 한쪽을 소유자로 지정하고 다른 쪽은 해당 모듈에 의존하도록 구성할 것을 권장한다. 월간 엔티티에도 동일한 지적이 적용된다.
추가 테스트: 통합 테스트에서 commerce-batch가 쓴 레코드를 commerce-api의 엔티티로 조회하여 컬럼 길이/타입이 동일하게 역직렬화되는지 확인한다.
As per coding guidelines: "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.", "equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java`
around lines 16 - 81, MvProductRankWeeklyEntity is missing equals/hashCode and
duplicates exist across modules; implement consistent identifier-based equality
(override equals and hashCode in MvProductRankWeeklyEntity to use productId as
the identity, handling null id per JPA best-practice so transient instances
compare appropriately), and remove the duplicate by extracting the entity into a
shared module (e.g., commerce-domain/commerce-infra) or make one module depend
on the other so both use the single canonical MvProductRankWeeklyEntity
definition; apply the same change to the monthly entity counterpart and add an
integration test that writes via commerce-batch and reads via commerce-api to
validate column lengths/types match.
|
|
||
| List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByScoreDesc(String yearWeek); | ||
|
|
||
| void deleteAllByYearWeekNot(String yearWeek); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Modifying 어노테이션이 붙은 delete 쿼리가 이미 있는지, 그리고 호출 지점의 트랜잭션 경계 확인
rg -nP -C3 '@Modifying|deleteAllByYearWeekNot|deleteAllByYearMonthNot'
rg -nP -C5 'WeeklyRankAssignTasklet|MonthlyRankAssignTasklet'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 23837
🏁 Script executed:
# WeeklyRankAssignTasklet의 전체 메서드 구조와 트랜잭션 경계 확인
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java
# MonthlyRankAssignTasklet 확인
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java
# 엔티티 필드 구조 확인
rg -nP 'class MvProductRankWeeklyEntity|class MvProductRankMonthlyEntity' -A 15Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 13865
deleteAllByYearWeekNot 파생 쿼리는 select-then-delete로 동작하므로 대량 삭제 성능 리스크가 있다.
Spring Data JPA의 파생 삭제 쿼리는 대상 엔티티를 먼저 SELECT한 뒤 영속화 상태에서 row-by-row로 DELETE를 발행한다(@Modifying + 벌크 DELETE JPQL과는 다르다). 주간 MV 정리에서 이전 주 전체 레코드를 지울 때 수만~수십만 건이 쌓이면 (1) 메모리 사용량 급증, (2) 트랜잭션 로그/플러시 지연, (3) 배치 스텝 타임아웃으로 이어질 수 있다. 또한 호출 지점(WeeklyRankAssignTasklet 41행)이 findAllByYearWeekOrderByScoreDesc(43행)보다 먼저 실행되므로 영속성 컨텍스트가 오염된 상태로 이후 조회가 수행되어 1차 캐시 비효율이 겹친다.
♻️ 제안 diff
- void deleteAllByYearWeekNot(String yearWeek);
+ `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+ `@Query`("delete from MvProductRankWeeklyEntity e where e.yearWeek <> :yearWeek")
+ int deleteByYearWeekNot(`@Param`("yearWeek") String yearWeek);월간 쪽 MvProductRankMonthlyJpaRepository.deleteAllByYearMonthNot()도 동일 개선이 필요하다. 추가 테스트: 이전 주/월 레코드가 10k+ 존재하는 시나리오에서 tasklet 실행 시간이 임계치(예: 수 초) 내에 완료되는지, 실행 후 최신 주/월 데이터만 반환되는지 통합 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java`
at line 10, deleteAllByYearWeekNot 파생 삭제는 select-then-delete로 대량 삭제 시 메모리·성능
리스크가 있으니 MvProductRankWeeklyJpaRepository의 deleteAllByYearWeekNot를 `@Modifying`
JPQL bulk delete 또는 EntityManager/JdbcTemplate를 사용하는 명시적 bulk delete로 교체하고, 삭제
후에는 영속성 컨텍스트를 clear(예: EntityManager.clear() 또는
`@Transactional`(propagation=REQUIRES_NEW) 경계 후 clear)하여 WeeklyRankAssignTasklet에서
이어지는 findAllByYearWeekOrderByScoreDesc 조회가 1차 캐시 오염 없이 최신 데이터만 조회하도록 보장하세요; 동일한
방식으로 MvProductRankMonthlyJpaRepository.deleteAllByYearMonthNot도 수정하고, 이전 주/월
레코드가 10k+인 시나리오에서 tasklet 실행 시간과 결과 유효성을 검증하는 통합 테스트를 추가하세요.
| case "ORDER_CONFIRMED" -> { | ||
| productMetricsRepository.incrementSalesCount(productId); | ||
| rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), occurredAt.toLocalDate()); | ||
| productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity); | ||
| rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate); | ||
| } |
There was a problem hiding this comment.
quantity null 역참조 및 음수/0 방어가 필요하다.
ORDER_CONFIRMED 분기에서 quantity가 Integer(박싱 타입)이므로 업스트림 이벤트에 수량 필드가 누락되면 Math.log1p(quantity) 오토언박싱과 incrementSalesAndQuantity(productId, metricsDate, quantity) 호출에서 NPE가 발생해 트랜잭션이 롤백되고 consumer offset이 재시도에 들어간다. 손상된 이벤트 한 건으로 인해 처리 지연이 누적되는 독립 실패가 발생할 수 있으므로, quantity == null || quantity <= 0인 경우는 경고 로그 후 스킵 또는 DLQ 처리를 권장한다.
추가 테스트: (1) quantity=null인 ORDER 이벤트 입력 시 예외 없이 스킵되고 메트릭/랭킹이 갱신되지 않는지, (2) quantity<=0일 때 동일 동작.
🛡️ 제안 수정
case "ORDER_CONFIRMED" -> {
+ if (quantity == null || quantity <= 0) {
+ log.warn("유효하지 않은 quantity. eventId={}, quantity={}", eventId, quantity);
+ break;
+ }
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case "ORDER_CONFIRMED" -> { | |
| productMetricsRepository.incrementSalesCount(productId); | |
| rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), occurredAt.toLocalDate()); | |
| productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity); | |
| rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate); | |
| } | |
| case "ORDER_CONFIRMED" -> { | |
| if (quantity == null || quantity <= 0) { | |
| log.warn("유효하지 않은 quantity. eventId={}, quantity={}", eventId, quantity); | |
| break; | |
| } | |
| productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity); | |
| rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java`
around lines 54 - 57, In the ORDER_CONFIRMED branch, guard against Integer
boxing NPEs and non-positive quantities: before calling
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate,
quantity) and rankingRepository.incrementScore(productId, 0.7 *
Math.log1p(quantity), metricsDate) check if quantity == null or quantity <= 0;
if so, emit a warning-level log with productId/metricsDate/quantity and then
skip processing (or route the event to DLQ) so neither incrementSalesAndQuantity
nor Math.log1p(quantity) are invoked on invalid input.
| public static ProductMetrics restore(Long id, Long productId, LocalDate metricsDate, int likeCount, int viewCount, int salesCount, int totalQuantity) { | ||
| return new ProductMetrics(id, productId, metricsDate, likeCount, viewCount, salesCount, totalQuantity); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# restore()를 5인자로 호출하거나 findByProductId(Long)만 사용하는 잔존 호출부 탐지
rg -nP 'ProductMetrics\.restore\s*\(' -C1
rg -nP '\.findByProductId\s*\(' -C1Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1639
🏁 Script executed:
# ProductMetrics.restore() 정의부 및 모든 호출부 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | head -40
# ProductMetricsEntity.java의 restore() 호출부 전체 확인
sed -n '50,65p' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java
# 테스트 파일 호출부 확인
sed -n '55,60p' apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsProcessorTest.java
# ProductMetricsRepository 메서드 시그니처 확인
rg -A3 'findByProductId|findByProductIdAndMetricsDate' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3974
🏁 Script executed:
# ProductMetricsRepository 인터페이스와 구현의 전체 메서드 조회
fd -t f ProductMetricsRepository apps/commerce-streamer/src/main/java -exec cat {} \;
# 혹은 더 정확히: findByProductId 메서드가 남아있는지 확인
rg 'public.*findByProductId\s*\(' apps/commerce-streamer/src/main/javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 231
🏁 Script executed:
# ProductMetricsRepository 파일 찾기
find apps/commerce-streamer/src/main/java -name '*ProductMetricsRepository*' -type f
# 해당 파일들 내용 확인
find apps/commerce-streamer/src/main/java -name '*ProductMetricsRepository*' -type f | xargs cat
# findByProductId 메서드 유무 확인
rg 'findByProductId' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3311
테스트 컴파일이 실패한다. 저장소 메서드와 restore() 시그니처 모두 변경되었으나 테스트는 수정되지 않았다.
ProductMetricsRepository에서 findByProductId(Long)이 제거되고 findByProductIdAndMetricsDate(Long, LocalDate)로 변경되었으며, ProductMetrics.restore()도 5개 인자에서 7개 인자(metricsDate, totalQuantity 추가)로 변경되었다. 그러나 apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsProcessorTest.java:58의 테스트 스텁은 여전히 구 메서드와 인자 개수를 사용 중이다. 빌드 전 필수 수정 대상이다.
🛠️ 제안 수정
- lenient().when(productMetricsRepository.findByProductId(PRODUCT_ID))
- .thenReturn(Optional.of(ProductMetrics.restore(1L, PRODUCT_ID, 0, 0, 0)));
+ lenient().when(productMetricsRepository.findByProductIdAndMetricsDate(eq(PRODUCT_ID), any(LocalDate.class)))
+ .thenReturn(Optional.of(ProductMetrics.restore(1L, PRODUCT_ID, LocalDate.now(), 0, 0, 0, 0)));추가 테스트 권고
ProductMetrics.create(productId, metricsDate)호출 시 모든 카운트(likeCount,viewCount,salesCount,totalQuantity)가 0으로 초기화되는지 검증하는 단위 테스트.ProductMetrics.restore(...)에 전달한 모든 필드값이 getter로 정확히 반환되는지 왕복 검증 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 29 - 31, The failing tests use the old repository method and
restore signature; update the test stub in ProductMetricsProcessorTest to call
ProductMetricsRepository.findByProductIdAndMetricsDate(productId, metricsDate)
instead of findByProductId(...) and construct the fixture via
ProductMetrics.restore(id, productId, metricsDate, likeCount, viewCount,
salesCount, totalQuantity) (include the metricsDate and totalQuantity
arguments), and adjust any mock/when(...) expectations to match the new method
name and parameter list so the mocked repository returns the restored
ProductMetrics.
✅ ReviewPoint
상황
배치(
commerce-batch)가 다른 모듈의 테이블을 다뤄야 하는 상황이 두 번 있었고, Entity를 어디에 둘지 매번 달리 선택했습니다.product_metrics읽기commerce-streamer가 이미 가짐commerce-batch/commerce-api에 Entity를 각각 정의제가 세운 기준: "이미 다른 모듈이 가진 Entity냐(A), 이번에 새로 만드는 Entity냐(B)"로 나눴습니다.
질문
어떻게 되나요?
🔁 Flow Diagram
Batch Job 실행 흐름
sequenceDiagram participant Scheduler as CronScheduler participant Job as RankingWeeklyJob / MonthlyJob participant MV as MV 테이블 Scheduler->>Job: run(targetDate, period) Note over Job, MV: Step 1 — 점수 계산 및 적재 Job->>MV: UPSERT (product_id, score) × chunk 반복 Note over Job, MV: Step 2 — 순위 확정 Job->>MV: rank 재정렬 후 TOP 100 초과 삭제Ranking API 조회 흐름
sequenceDiagram participant Client as Client participant API as Controller / Facade / Repo participant Storage as Redis or MV 테이블 participant DB as Product / Brand DB Client->>API: GET /api/v1/rankings?date=&period=&size=&page= API->>Storage: period에 따라 분기 조회 (daily→Redis / weekly·monthly→MV) Storage-->>API: 랭킹 목록 (productId, score, rank) API->>DB: Product + Brand Bulk 조회 DB-->>API: 상품명, 브랜드명 API-->>Client: ApiResponse<List<RankingResponse>>🗄️ 핵심 자료구조
product_metrics — 날짜별 증분 저장
product_id→(product_id, metrics_date)total_quantity추가 — 일간과 동일한 점수 공식 유지MV 테이블 — 배치 결과 스냅샷
year_week: 해당 주 월요일 날짜 (yyyyMMdd)year_month: 해당 월 (yyyyMM)점수 공식 — 일간/주간/월간 통일
🧭 Context & Decision
1. 월간 집계 원천 선택 — 주간 MV 파생 vs 원본 독립 집계
고민: 이미 주간 집계 결과(
mv_product_rank_weekly)가 있으므로, 이 데이터를 활용해서 월간 랭킹을 집계하는것도 생각해봤습니다 (저희팀 진수님이 질문남겨주신 덕분에 생각해봤습니다)고려한 방식
결정: ②
product_metrics(원본)에서 weekly, monthly가 각자 독립 집계합니다① 주간 MV 파생을 선택하지 않은 이유
product_metrics)에서 직접 집계하면 이런 문제가 없습니다.2. product_metrics Reader 구현 방식 선택 — JPA vs JDBC
고민:
commerce-batch에서product_metrics테이블을 읽어야 합니다. 그런데 해당 테이블의 Entity(ProductMetricsEntity)는commerce-streamer모듈에 정의되어 있고,commerce-batch는commerce-streamer를 의존하지 않습니다. 즉,commerce-batch빌드 시ProductMetricsEntity.class가 클래스패스에 존재하지 않습니다.고려한 방식
ProductMetricsEntity.class가 있어야 컴파일 가능결정: ③ JDBC 채택. 테이블명을 문자열로 참조하므로 클래스패스 문제 없이 DB 연결만으로 읽기 가능합니다
①② 를 선택하지 않은 이유
FROM ProductMetricsEntity)하므로, 클래스가 클래스패스에 없으면 빌드 자체가 실패합니다3. MV Entity 공유 방식 선택 — 공유 모듈 vs 각 모듈 별도 정의
고민:
commerce-batch와commerce-api가 같은 MV 테이블을 사용하므로, Entity를 어디에 둘지 고민했습니다. 2번과 달리 MV 테이블은 기존 모듈이 소유한 Entity가 아니라 이번 작업에서 새로 만드는 결과물이므로, 어느 모듈이 "원래 주인"이라고 볼 수 없습니다. 또한 batch(쓰기)와 api(읽기)의 역할이 달라 Entity도 목적에 맞게 다르게 구성할 수 있습니다.고려한 방식
결정: ③
commerce-batch(쓰기용),commerce-api(읽기용) 각각에 Entity를 별도 정의. 두 모듈이 서로 의존하지 않는 구조를 유지합니다①② 를 선택하지 않은 이유
modules/jpa는 JPA 설정과 공통 기반(BaseEntity)만 담당하는 모듈입니다. 비즈니스 도메인 Entity를 넣으면 역할이 섞입니다트레이드오프: 스키마 변경 시 두 곳 모두 수정이 필요합니다
4. RankingRepository 메서드 설계 — 통합 vs 기간별 분리
고민:
period파라미터를 추가해getTopN(LocalDate date, String period, int size, int page)형태로 통합하면 인터페이스가 단순해집니다.고려한 방식
getTopN/getWeeklyTopN/getMonthlyTopN)결정: ② 기간별 메서드 분리
① 을 선택하지 않은 이유
RankingRepositoryImpl은 Redis 기반으로 daily만 처리합니다.period파라미터를 받으면 Redis 구현체가 weekly/monthly 케이스를 처리해야 하는데, Redis는 daily 전용이라 구현체 내부 분기가 어색해집니다. 메서드를 분리하면 각 메서드가 저장소와 1:1 대응되어 구현체 책임이 명확해집니다5. 배치 Job을 두 단계(Chunk + Tasklet)로 나눴습니다
고민: 점수 계산과 순위 확정을 하나의 Step에서 처리하는 방법도 있습니다. 하지만 순위를 매기려면 전체 데이터를 한 번에 봐야 합니다.
고려한 방식
결정: ② Step1(Chunk)에서 score 적재, Step2(Tasklet)에서 순위 확정
① 을 선택하지 않은 이유
🔨 작업 순서
Phase 1 — product_metrics 구조 변경
목표: 기간별 집계를 위한 날짜 컬럼 추가. 기존 테이블은 누적값만 저장해 "이번 주에 발생한 이벤트"를 구분할 방법이 없었습니다.
1-1.
ProductMetricsEntity—metricsDate,totalQuantity필드 추가왜 이런 작업을 진행했는가?:
totalQuantity는 주문 수량 합산으로, 배치가 일간과 동일한 점수 공식(0.7 * log1p(qty))을 쓰기 위해추가했습니다.
totalQuantity없이 기존salesCount(판매 건수)를 쓰면 점수가 왜곡됩니다. 예를 들어 A 상품이 1건 주문에 100개, B 상품이 100건 주문에 1개씩 팔렸다면salesCount는 동일하지만 실제 판매 수량은 A가 압도적으로 많습니다. 건수 기준으로 랭킹을 매기면 실제 인기를 반영하지 못합니다.Before
After
1-2.
ProductMetricsJpaRepository—metrics_date조건 추가 및 메서드 통합왜 이런 작업을 진행했는가?:
metrics_date조건 추가 — UNIQUE 제약이product_id→(product_id, metrics_date)로 변경됐습니다. 날짜 조건 없이product_id만으로 조회하면 여러 날짜 행 중 어떤 행을 갱신할지 특정할 수 없습니다.upsertIfAbsent추가 (버그 수정) — increment 쿼리는 행이 이미 존재할 때만 동작합니다. 해당 날짜 행이 없는 상태에서incrementViewCount를 호출하면 에러 없이 0건 업데이트로 조용히 실패하여 데이터가 유실됩니다.incrementSalesAndQuantity통합 — 기존에는 판매건수(salesCount)만 증가시켰습니다.totalQuantity가 추가되면서 두 값을 함께 갱신해야 하는데, 쿼리를 두 번 날리는 대신 하나로 통합해 DB 왕복을 줄였습니다.Before
After
Phase 2 — MV 테이블 생성
목표: 배치 결과를 저장할 MV 테이블 정의. MySQL은 MV 기능이 없으므로 JPA Entity로 테이블을 생성하고 배치가 직접 적재합니다.
2-1.
MvProductRankWeeklyEntity(신규 — commerce-batch)왜 이런 작업을 진행했는가?:
commerce-batch가 집계 결과를 적재하는 쪽이므로 쓰기에 필요한 생성자,update(),@PrePersist/@PreUpdate를 포함했습니다.BaseEntity를 상속하지 않는 이유는 soft delete / audit 기능이 랭킹 스냅샷 테이블에 불필요하기 때문입니다.2-2.
MvProductRankWeeklyJpaRepository(신규 — commerce-batch)왜 이런 작업을 진행했는가?: 순위 확정은 Tasklet으로 처리합니다. Chunk는 데이터를 일부씩 반복 처리하기 때문에 전체 순서를 알 수 없어 순위를 매길 수 없습니다. Tasklet은
execute()한 번에 전체 목록을 불러와 1등부터 순서대로 번호를 할당할 수 있습니다.이를 위해 두 메서드가 필요합니다.
findAllByYearWeekOrderByScoreDesc— Tasklet이 전체 목록을 score 내림차순으로 한 번에 가져와 순위를 순서대로 할당합니다deleteAllByYearWeekNot— MV 테이블에는 현재 주 데이터만 유지합니다. 새 주 배치가 끝나면 이전 주 데이터는 불필요하므로 삭제합니다2-3.
MvProductRankMonthlyEntity/MvProductRankMonthlyJpaRepository(신규 — commerce-batch)weekly와 동일한 구조입니다.
Phase 3 — Spring Batch Job 구현
목표: product_metrics를 읽어 MV 테이블에 점수와 순위를 적재하는 Job 구성. 구현 순서는 의존 방향(DTO → Reader → Processor → Writer → Tasklet → JobConfig)으로 진행했습니다.
ProductMetricsItemReaderRankingItemProcessorWeeklyMvRankingItemWriterWeeklyRankAssignTaskletRankingWeeklyJobConfig3-1. DTO 정의 (신규)
왜 이런 작업을 진행했는가?: Spring Batch의 Chunk 파이프라인은 Reader → Processor → Writer 사이에 타입 안전한 데이터 전달이 필요합니다. 각 경계마다 목적에 맞는 DTO를 분리해 의존성을 명확히 했습니다.
3-2.
ProductMetricsItemReader(신규)왜 이런 작업을 진행했는가?: Context & Decision 2번에서 결정한 대로 JDBC를 채택했습니다.
@StepScope로 선언해 Job 파라미터(targetDate,period)를 런타임에 주입받고,resolveStartDate/resolveEndDate로 기간에 맞는 날짜 범위를 계산합니다.3-3.
RankingItemProcessor(신규)왜 이런 작업을 진행했는가?: Reader가 반환한 집계 수치를 점수로 변환하는 단일 책임 컴포넌트입니다. 일간 랭킹(
ProductMetricsProcessor)과 동일한 공식을 사용해 기간 간 일관성을 유지합니다.3-4.
WeeklyMvRankingItemWriter(신규)왜 이런 작업을 진행했는가?: chunk 단위로 수신한
RankedProductDto를 MV 테이블에 upsert합니다.findById로 기존 행이 있으면update(), 없으면save()로 처리해 배치 재실행 시에도 멱등하게 동작합니다.productRank는 이 시점에서 전체 데이터가 아직 적재 중이라 순위를 알 수 없으므로 0으로 임시 저장하고, 모든 점수 적재가 끝난 뒤 Tasklet에서 최종 확정합니다.3-5.
WeeklyRankAssignTasklet(신규)왜 이런 작업을 진행했는가?: Step1에서 0으로 임시 저장된
productRank를 최종 확정합니다. 이전 주 데이터 정리 → 전체 목록 score 내림차순 조회 → 순위 할당 → TOP 100 초과 삭제 순서로 실행합니다.3-6.
RankingWeeklyJobConfig—@StepScope빈 주입 방식 수정왜 이런 작업을 진행했는가? (버그수정): 초기 구현에서
JobConfig가ProductMetricsItemReader클래스를 주입받아 메서드를 직접 호출했습니다.Before (버그)
After (수정)
Phase 4 — Ranking API 확장
목표: 기존 일간 랭킹 API에
period파라미터를 추가해 주간/월간 MV 조회를 지원합니다. 하위 호환(기본값daily)을 유지하면서 최소한의 변경으로 확장했습니다.4-1.
MvProductRankWeeklyEntity(신규 — commerce-api)왜 이런 작업을 진행했는가?:
commerce-api는commerce-batch를 의존하지 않으므로 batch의 Entity를 그대로 쓸 수 없습니다. 읽기 전용이므로update(), 삭제 메서드 없이 getter와@PrePersist만 포함했습니다.4-2.
MvProductRankWeeklyJpaRepository(신규 — commerce-api)왜 이런 작업을 진행했는가?: batch와 api는 같은 테이블을 사용하지만 용도가 달라 Repository를 각각 정의했습니다.
score내림차순으로 전체를 가져옵니다. api는 이미 Tasklet이 순위를 확정해놨으므로productRank오름차순으로 페이징하면 됩니다.deleteAllByYearWeekNot(쓰기)이 api에 노출되고, api의Pageable메서드가 batch에 노출됩니다.4-3.
RankingRepository— 인터페이스 확장왜 이런 작업을 진행했는가?:
getTopN에period파라미터를 통합하면 Redis 구현체가 weekly/monthly 케이스를 처리해야 해 구현체 책임이 섞입니다. 저장소(Redis / MV 테이블)와 1:1로 대응되는 메서드로 분리했습니다.Before
After
4-4.
RankingRepositoryImpl— weekly/monthly 구현 추가왜 이런 작업을 진행했는가?:
WeeklyMvRankingItemWriter)가yearWeek키를 해당 주 월요일 날짜(yyyyMMdd)로 저장합니다. 조회 시도 동일하게date.with (DayOfWeek.MONDAY)로 변환해야 키가 일치합니다. 이렇게 하면 클라이언트가 주중 어느 날짜를 넘겨도 항상 같은 주의 랭킹을 조회할 수 있습니다.Before
After
4-5.
RankingFacade— period 분기 추가왜 이런 작업을 진행했는가?:
period에 따라 다른 저장소를 사용하는 분기는 응용 레이어(Facade)에서 담당합니다. Controller가 저장소를 알거나 Repository가 period를 알 필요 없이, Facade가 한곳에서 책임집니다.
Before
After
4-6.
RankingV1Controller—period파라미터 추가왜 이런 작업을 진행했는가?:
defaultValue = "daily"를 설정해 기존 클라이언트가period없이 호출해도 그대로 동작합니다. 하위 호환 유지가 목적입니다.Before
After
✅ CheckList
🏗️ Batch Infrastructure
product_metrics테이블에metrics_date,total_quantity컬럼이 추가되었다product_id→(product_id, metrics_date)로 변경되었다mv_product_rank_weekly,mv_product_rank_monthly)이 생성된다⚙️ Batch Job
targetDate,periodJob 파라미터로 날짜 범위가 올바르게 계산된다 (weekly: 월일, monthly: 1일말일)0.1*view + 0.2*like + 0.7*log1p(qty)가 일간 랭킹과 동일하게 적용된다@StepScope빈이 JobConfig에서 프록시를 통해 올바르게 주입된다📈 Ranking API
period=daily일 때 기존 Redis ZSET 조회가 동작한다 (하위 호환)period=weekly일 때mv_product_rank_weekly에서 조회된다period=monthly일 때mv_product_rank_monthly에서 조회된다period파라미터 미입력 시daily가 기본값으로 동작한다변경 목적
기존 일일 단위 랭킹 시스템을 주간(weekly), 월간(monthly) 단위로 확장하면서, 상품 메트릭 데이터에 날짜 차원을 추가하여 더 정교한 시계열 집계가 가능하도록 개선합니다.
핵심 변경점
리스크/주의사항
테스트/검증 방법