[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현#412
[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현#412leeedohyun wants to merge 6 commits intoLoopers-dev-lab:leeedohyunfrom
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@coderabbitai ignore |
|
Note Reviews pausedUse the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthrough주간 및 월간 제품 랭킹 기능을 추가하는 변경이다. 배치 작업을 통해 메트릭 데이터를 집계하여 읽기 전용 뷰 테이블에 저장하고, 이를 API 엔드포인트로 노출한다. 메트릭 그래뉼래리티도 시간 단위에서 일 단위로 변경한다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant API as API Controller
participant UseCase as ReadWeekly/MonthlyRankingsUseCase
participant Service as RankingService
participant Repo as Weekly/MonthlyRankingRepository
participant Assembler as RankingResultAssembler
participant DB as Database
User->>API: GET /api/v1/rankings/weekly<br/>(userId, date, page, size)
API->>UseCase: execute(userId, date, pageSize)
UseCase->>UseCase: Parse date (yyyyMMdd → LocalDate)
UseCase->>Service: readWeeklyTopRanked(scoreDate, page, size)
Service->>Repo: readTopRanked(scoreDate, page, size)
Repo->>DB: findByScoreDateOrderByScoreDesc<br/>(scoreDate, pageable)
DB-->>Repo: List<ProductRankingWeekly>
Repo-->>Service: List<ProductRankingWeekly>
Service-->>UseCase: List<ProductRankingWeekly>
UseCase->>UseCase: toRankingItems(products, offset,<br/>mapper functions)
UseCase->>Assembler: assemble(userId, rankingItems, pageSize)
Assembler->>Assembler: Fetch product details & brand info<br/>Fetch user likes
Assembler-->>UseCase: RankingPageResult
UseCase-->>API: RankingPageResult
API-->>User: ApiResponse<RankingResponse>
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes 검토 시 주의 사항:
Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 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.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
주간·월간 랭킹을 Spring Batch로 집계해 DB(Materialized View 테이블)에 적재하고, API에서 해당 랭킹을 조회할 수 있도록 확장합니다. 또한 product_metrics 집계 단위를 시간→일로 변경해 장기 집계 비용을 줄입니다.
Changes:
- Spring Batch(Tasklet) 기반
weeklyRankingJob/monthlyRankingJob추가 및 upsert 적재 구현 - 주간/월간 랭킹 조회 API 및 DB 조회 리포지토리/유스케이스/도메인 확장
product_metrics를 일 단위(metric_date)로 전환 및 관련 테스트 보강
Reviewed changes
Copilot reviewed 45 out of 45 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java | metrics 집계 단위 변경(hour→date)에 맞춰 테스트 수정 |
| apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java | product_metrics upsert 컬럼을 metric_date 기반으로 변경 |
| apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | metric_date 전환 및 인덱스/유니크 제약 갱신 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java | 주간 Tasklet 단위 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java | 주간 Job E2E 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java | 주간 랭킹 upsert 저장 통합 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java | 월간 랭킹 upsert 저장 통합 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java | 월간 Tasklet 단위 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java | 월간 Job E2E 테스트 추가 |
| apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java | 배치 자동 실행 비활성화로 컨텍스트 로드 안정화 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java | 주간 랭킹 upsert 저장 구현체 추가 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java | 주간 랭킹 upsert 네이티브 쿼리 추가 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java | 월간 랭킹 upsert 저장 구현체 추가 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java | 월간 랭킹 upsert 네이티브 쿼리 추가 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java | 집계 쿼리 호출 어댑터 추가 |
| apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java | 주간/월간 스코어 집계 JPQL 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java | 주간 랭킹 저장 포트 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | 주간 MV 엔티티 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java | 월간 랭킹 저장 포트 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java | 월간 MV 엔티티 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java | 스코어 집계 결과 프로젝션 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java | 스코어 조회 포트 추가 |
| apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | 배치 모듈용 product_metrics 엔티티 추가 |
| apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java | 주간 집계 Tasklet 구현 |
| apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java | 월간 집계 Tasklet 구현 |
| apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java | 주간 Job/Step 구성 추가 |
| apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java | 월간 Job/Step 구성 추가 |
| apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java | 주간/월간 랭킹 API E2E 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java | 주간/월간 랭킹 호출 스텝 추가 |
| apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java | 주간 랭킹 조회 리포지토리 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java | 월간 랭킹 조회 리포지토리 통합 테스트 추가 |
| apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java | 주간/월간 랭킹 API 스펙 추가 |
| apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java | 주간/월간 랭킹 엔드포인트 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java | 주간 랭킹 DB 조회 구현체 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java | 주간 랭킹 JPA 조회 리포지토리 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java | 월간 랭킹 DB 조회 구현체 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java | 월간 랭킹 JPA 조회 리포지토리 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java | 주간 랭킹 조회 포트 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java | Redis(일/시간) + DB(주/월) 라우팅 확장 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java | DB 엔티티→RankingItem 변환 유틸 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | 주간 MV 읽기 엔티티 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java | 월간 MV 읽기 엔티티 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java | 월간 랭킹 조회 포트 추가 |
| apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java | 주간 랭킹 조회 유스케이스 추가 |
| apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java | 월간 랭킹 조회 유스케이스 추가 |
Comments suppressed due to low confidence (3)
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java:1
LocalDate.now()는 테스트 실행 시각/타임존(특히 DB의CURDATE()기준 타임존) 및 자정 경계에 따라 간헐적으로 실패할 수 있습니다. 테스트 안정성을 위해 고정 날짜(예:LocalDate.of(...))를 사용하거나, 애플리케이션/DB가 공유하는Clock또는 테스트용 기준일을 주입해 검증 기준을 고정하는 방식으로 바꾸는 것을 권장합니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java:1- 현재
saveAll이 랭킹 1건당 upsert 1회로 수행되어(최대 100건이더라도) DB round-trip이 누적됩니다.INSERT ... VALUES (...), (...), ... ON DUPLICATE KEY UPDATE ...형태의 멀티-row upsert(또는JdbcTemplate#batchUpdate)로 합치면 네트워크/DB 부하를 줄이고 배치 실행 시간을 안정화할 수 있습니다. (권장 수정)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java:1 - 월간도 주간과 동일하게 1건당 upsert 1회로 수행되어 DB 호출이 N번 발생합니다. 멀티-row upsert(또는 JDBC 배치)로 변경해 round-trip을 줄이는 편이 운영/성능 측면에서 유리합니다. (권장 수정)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly", indexes = { | ||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | ||
| }) |
There was a problem hiding this comment.
@Index(columnList = \"score_date, score DESC\")에서 DESC 지정은 JPA 표준이 아니며, JPA 구현체/DDL 생성 설정에 따라 스키마 생성 시 오류를 유발할 수 있습니다. 또한 배치 모듈은 (product_id, score_date) 유니크 제약에 의존해 upsert 멱등성을 보장하는데, API 모듈 엔티티에는 동일한 유니크 제약이 선언되어 있지 않아(스키마 자동 생성/검증을 켠 환경에서) 모듈 간 스키마 정의가 불일치할 수 있습니다. DESC는 제거하고(정렬 최적화는 실제 DB 마이그레이션으로 관리), 유니크 제약은 배치/조회 양쪽 엔티티에서 동일하게 유지하는 것을 권장합니다.
| import lombok.AccessLevel; | |
| import lombok.Getter; | |
| import lombok.NoArgsConstructor; | |
| @Entity | |
| @Table(name = "mv_product_rank_weekly", indexes = { | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | |
| }) | |
| import jakarta.persistence.UniqueConstraint; | |
| import lombok.AccessLevel; | |
| import lombok.Getter; | |
| import lombok.NoArgsConstructor; | |
| @Entity | |
| @Table( | |
| name = "mv_product_rank_weekly", | |
| uniqueConstraints = { | |
| @UniqueConstraint(name = "uk_product_rank_weekly_product_date", columnNames = {"product_id", "score_date"}) | |
| }, | |
| indexes = { | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score") | |
| } | |
| ) |
| */ | ||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly", indexes = { | ||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") |
There was a problem hiding this comment.
주간 엔티티와 동일하게 @Index의 columnList에 DESC를 포함하는 것은 표준이 아니어서 DDL 생성/검증 환경에서 실패할 수 있습니다. 정렬 인덱스는 마이그레이션에서 명시적으로 관리하고, 엔티티는 컬럼명만 지정하는 형태로 맞추는 것을 권장합니다.
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | |
| @Index(name = "idx_score_date_score", columnList = "score_date, score") |
There was a problem hiding this comment.
Actionable comments posted: 14
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/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
22-39:⚠️ Potential issue | 🟠 Major스트리머의
CURDATE()평가 타임존과 배치의LocalDate타임존 일치를 확인해야 한다.현재 JDBC URL에
serverTimezone파라미터가 없어 MySQL이 서버의 기본 타임존을 사용하게 되고, 이것이 애플리케이션의 Asia/Seoul 타임존과 일치한다는 보장이 없다. 스트리머의CURDATE()(native SQL, DB에서 평가됨)로 기록된metric_date와 배치가 JVM 타임존(Asia/Seoul)에서 파싱한LocalDate로 쿼리하는 범위가 자정 경계에서 어긋날 수 있다. 예를 들어 23:59:59 KST에 들어온 이벤트가 스트리머에는 UTC 기준 전날로, 배치 쿼리에는 당일로 기록되어 당일 이벤트가 누락되거나 중복 집계될 위험이 있다. 시간 단위였던 기존 스키마보다 경계 영향 범위가 24배 증가한다.권고사항:
- JDBC URL에
serverTimezone=Asia/Seoul추가 또는serverTimezone=UTC로 명시하고 모든 애플리케이션의 타임존 설정과 단일 기준으로 정렬한다.- 또는 스트리머 upsert에서
CURDATE()대신 애플리케이션에서 계산한LocalDate파라미터를 주입하여 DB 타임존 의존을 제거한다.추가 테스트:
- 자정 경계 이벤트(23:59:59 / 00:00:00)를 주입하고 스트리머와 배치가 동일한 버킷에 기록하는지 검증하는 통합 테스트를 추가한다.
🤖 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 22 - 39, ProductMetrics' metric_date can drift between DB CURDATE() and JVM LocalDate; fix by either specifying serverTimezone (e.g., serverTimezone=Asia/Seoul or =UTC) in the application's JDBC URL so DB CURDATE() and the app JVM share the same TZ, or change the streamer upsert to stop using CURDATE() and instead pass an application-computed LocalDate parameter into the upsert (affecting the code that writes metric_date in the streamer/upsert SQL); additionally add an integration test that injects events at 23:59:59 and 00:00:00 and asserts ProductMetrics entries (productId/metricDate) are recorded into the same expected date bucket to prevent midnight-boundary mismatches.
♻️ Duplicate comments (1)
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java (1)
77-85:⚠️ Potential issue | 🟡 MinorMonthly 동일 테스트와 같은 격리 이슈가 반복된다.
앞선 테스트들이 동일한
SCORE_DATE로mv_product_rank_weekly에 데이터를 적재하므로,BaseIntegrationTest의 clean-up 전략이 없다면returnsEmptyList_whenNoData가 실행 순서에 따라 실패할 수 있다. Monthly 테스트의 동일 지적(루트 원인)과 함께 한 번에 정리하면 된다. 별도scoreDate를 사용하거나@BeforeEach에서 삭제 처리를 추가하기를 권고한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java` around lines 77 - 85, 테스트 격리 문제: WeeklyRankingRepositoryImplIntegrationTest의 returnsEmptyList_whenNoData가 다른 테스트가 같은 SCORE_DATE로 mv_product_rank_weekly에 남긴 데이터 때문에 순서에 따라 실패할 수 있으니, 테스트를 격리하도록 수정하세요; 해결 방법은 (1) returnsEmptyList_whenNoData 또는 해당 클래스의 테스트에서 readTopRanked 호출에 사용하는 SCORE_DATE를 고유한 값으로 변경하거나 (2) WeeklyRankingRepositoryImplIntegrationTest에 `@BeforeEach` 메서드를 추가해 mv_product_rank_weekly에서 SCORE_DATE 관련 레코드를 삭제하도록 정리하는 것입니다 — 참고로 영향을 주는 심볼: returnsEmptyList_whenNoData, SCORE_DATE, readTopRanked, WeeklyRankingRepositoryImplIntegrationTest, BaseIntegrationTest, mv_product_rank_weekly.
🧹 Nitpick comments (17)
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java (1)
35-35: 자정 경계에서 플래키 가능성이 있다.
CURRENT_METRIC_DATE는 클래스 로딩 시점의LocalDate.now()로 고정되지만,upsertXxx내부에서 결정되는metricDate는 실제 upsert 실행 시각을 기준으로 한다. 테스트가 자정 직전에 로딩되어 upsert가 자정 이후 실행되면 기대값과 실제값의LocalDate가 달라져 CI에서 간헐적으로 실패할 수 있다. 또한 타임존이 기본 시스템 타임존에 암묵적으로 의존하므로, 실행 환경(UTC 컨테이너 등)이 달라질 경우에도 결과가 달라질 여지가 있다.운영 관점에서 배치/스트리머의 날짜 기준과 테스트의 날짜 기준을 동일 소스(시계 주입 또는 프로덕션 코드 내부
LocalDate)로 맞추는 것이 안전하다.♻️ 수정안 예시
- private static final LocalDate CURRENT_METRIC_DATE = LocalDate.now(); + // 생산 코드와 동일한 타임존/시계 소스를 사용하거나, 실행 시점에 조회해 자정 경계 이슈를 줄인다. + private LocalDate currentMetricDate() { + return LocalDate.now(); + }그리고 단언을
currentMetricDate()로 교체하고, 장기적으로는 생산 코드에Clock을 주입해 고정된 시점으로 테스트하도록 리팩터링을 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java` at line 35, CURRENT_METRIC_DATE is set at class load via LocalDate.now() which can drift across midnight and different timezones causing flaky tests; replace uses of the static CURRENT_METRIC_DATE with a call to a helper method currentMetricDate() so the test computes the expected metric date at assertion time (matching the upsertXxx decision), and update tests to use a fixed Clock or inject the same Clock used by the production upsert logic (prefer refactoring production code to accept a java.time.Clock) so both test and upsertXxx compute metricDate from the same time source.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java (1)
19-25: 단건 upsert 반복으로 인한 라운드트립 비용이 문제다.
rankings.forEach(...)로 원소마다 개별 네이티브 쿼리를 실행한다. 상위 100건 기준이라 지금은 큰 이슈가 아니지만, 운영 관점에서 다음 리스크가 있다:
- 배치 Job이 커질 경우(예: 백필로 다중 날짜를 한 번에 upsert하거나 상한이 늘어날 경우) N회 네트워크 라운드트립 및 트랜잭션 로그 비용이 선형 증가한다.
@Modifying+ 개별 쿼리는 JDBC batch가 자동 적용되지 않으므로,hibernate.jdbc.batch_size설정만으로는 묶여 나가지 않는다.운영/성능 관점에서 단일 멀티-로우
INSERT ... VALUES (...), (...), ... ON DUPLICATE KEY UPDATE score = VALUES(score)쿼리로 변경하거나,JdbcTemplate.batchUpdate로 명시적 배치 처리를 권장한다. 또한 통합 테스트에List<ProductRankingMonthly>크기 N(예: 100)을 한 번에 저장했을 때 실제로 1~2개의 쿼리로 나가는지 검증하는 테스트(예:@DataJpaTest+ Hibernate Statistics, 또는 P6Spy/datasource-proxy 기반 카운트) 추가를 제안한다.♻️ 수정안 예시 (단일 쿼리 멀티-로우 upsert)
- `@Override` - `@Transactional` - public void saveAll(List<ProductRankingMonthly> rankings) { - rankings.forEach(r -> - productRankingMonthlyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) - ); - } + `@Override` + `@Transactional` + public void saveAll(List<ProductRankingMonthly> rankings) { + if (rankings.isEmpty()) { + return; + } + productRankingMonthlyJpaRepository.upsertAll(rankings); + }
upsertAll은JdbcTemplate기반 별도 컴포넌트에서INSERT ... VALUES (?,?,?), ... ON DUPLICATE KEY UPDATE score = VALUES(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/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java` around lines 19 - 25, saveAll currently iterates and calls productRankingMonthlyJpaRepository.upsert per item causing N round-trips; replace this with a single batch upsert: add a new method (e.g., upsertAll) in ProductRankingMonthlyRepositoryImpl that uses JdbcTemplate (or JdbcTemplate.batchUpdate) to execute one multi-row "INSERT ... VALUES (...),(...)" with "ON DUPLICATE KEY UPDATE score=VALUES(score)" for all List<ProductRankingMonthly> entries (or build and execute a single SQL string with bound params), stop using per-item productRankingMonthlyJpaRepository.upsert in saveAll and delegate to upsertAll, and add an integration test (e.g., `@DataJpaTest` using Hibernate Statistics or P6Spy/datasource-proxy) that persists ~100 ProductRankingMonthly and asserts the number of executed SQL statements is 1–2.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java (1)
11-13: 파생 쿼리의 인덱스 활용과 대용량 조회 성능 검증이 필요하다.파생 쿼리
findByScoreDateOrderByScoreDesc는ProductRankingMonthly의idx_score_date_score(score_date, score DESC)인덱스를 정확히 활용할 수 있는 형태다. 다만 운영 관점에서 다음을 확인한다:
- 총 개수(count)가 필요 없는 조회이므로
Page<T>대신List<T>+Pageable을 사용한 선택은 적절하다(불필요한 count 쿼리 회피).- 페이지 크기 상한은
PageSize.withMaxSize()로 100으로 제한되어 있으므로 과도한 페이지 크기 요청은 방지된다.- 다만 대용량 시나리오에서 실제로
idx_score_date_score가 사용되고filesort가 발생하지 않는지EXPLAIN기반 통합 테스트 또는 리뷰 체크리스트 항목으로 검증할 것을 권장한다.🤖 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/persistence/MonthlyRankingJpaRepository.java` around lines 11 - 13, Add an integration test that verifies the derived query in MonthlyRankingJpaRepository (findByScoreDateOrderByScoreDesc) actually uses the idx_score_date_score(index on score_date, score DESC) and does not trigger a filesort for large datasets: execute an EXPLAIN for the SQL generated by findByScoreDateOrderByScoreDesc against a populated test table (ProductRankingMonthly) with realistic volume and assert the EXPLAIN output shows the index is used and no "filesort" appears; include test setup using Pageable with PageSize.withMaxSize() to ensure page size limits and run this as part of CI so index usage is validated automatically.apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java (1)
11-23: 도메인 인터페이스에 MySQL 특화 구문(ON DUPLICATE KEY UPDATE)이 언급되는 점을 재고할 것을 권장한다.도메인 계층은 인프라 종속성을 숨기는 것이 원칙이다. 상세 구현(MySQL 문법)을 Javadoc에 명시하면 추후 DB 교체/테스트 DB 변경 시 계약과 구현이 어긋날 수 있다. "멱등성 보장 upsert"라는 의도만 도메인 문서에 남기고, 구현 수단은
ProductRankingWeeklyJpaRepository/Impl쪽 주석에 한정한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java` around lines 11 - 23, The Javadoc on ProductRankingWeeklyRepository currently mentions MySQL-specific syntax; change the doc on the interface (ProductRankingWeeklyRepository and its saveAll(List<ProductRankingWeekly> rankings) method) to describe only the intent: that saveAll performs an idempotent upsert (updates score when a (productId, scoreDate) exists, otherwise creates), and remove any reference to MySQL or ON DUPLICATE KEY UPDATE; move any implementation-specific notes about using MySQL upsert syntax into the infrastructure layer (e.g., ProductRankingWeeklyJpaRepository or its Impl) Javadoc or implementation comments.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java (1)
26-36:date파라미터 포맷/필수 여부가 스펙 문서화에 드러나지 않는다.운영 관점에서 구현체(
RankingV1Api)는@RequestParam String date로 필수값이나, OpenAPI 문서에는 포맷(yyyyMMdd)·필수 여부가 드러나지 않아 클라이언트 연동 시 오해를 유발한다.@Parameter(description = "yyyyMMdd 형식", required = true, example = "20260414")등을 추가해 일/시간 엔드포인트(date/datetime)와의 포맷 차이도 분명히 한다.추가 테스트: 잘못된 포맷 입력 시
CoreException기반 일관 에러 포맷이 반환되는지 E2E로 검증한다(앞선 E2E 테스트 코멘트와 연계).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java` around lines 26 - 36, The API spec methods in RankingV1ApiSpec (getWeeklyRankings and getMonthlyRankings) do not document the expected date format or requiredness; update the method signatures' OpenAPI metadata by adding `@Parameter` annotations (io.swagger.v3.oas.annotations.Parameter) to the date parameter for both getWeeklyRankings and getMonthlyRankings with description="yyyyMMdd 형식", required=true, and example="20260414" so the generated OpenAPI clearly shows the format and that the param is mandatory; ensure imports are added and that the annotation is applied to the date parameter so clients see the format/requiredness in docs and spec-driven clients.apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java (1)
249-249: 하드코딩된 미래 날짜"20260414"로 인해 시간 경과 시 테스트 의도가 흐려진다.운영 관점에서 배치 랭킹 API는 "특정 날짜의 집계 결과"를 반환하므로, 고정 날짜가 현재 시점과 괴리되면 실제 운영과 동떨어진 케이스만 검증하게 되고, 실수로 "오늘 데이터가 없어 빈 배열" 같은 현상도 같은 날짜로 테스트되어 회귀를 놓칠 수 있다. 가능하면
LocalDate.now()기반으로 동적으로 생성하거나, 고정 과거 날짜(예:"20250101")를 사용해 의미를 분명히 한다.♻️ 제안 수정안
- private static final String DATE = "20260414"; + private static final String DATE = LocalDate.now() + .format(DateTimeFormatter.ofPattern("yyyyMMdd"));이 경우
setUp에서의LocalDate.of(2026, 4, 14)도LocalDate.now()로 맞춰야 한다.추가 테스트: 잘못된
date포맷("2026-04-14","20261340"등) 입력 시CoreException을 통해 일관된 에러 응답이 반환되는지 확인하는 케이스를WeeklyRankings/MonthlyRankings에 추가한다.Also applies to: 324-324
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java` at line 249, Replace the hardcoded future DATE constant ("20260414") in RankingV1ApiE2ETest with a dynamic value (e.g., LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) or a clearly past fixed date like "20250101", and update the setUp code that currently uses LocalDate.of(2026,4,14) to derive its date from the same source so the test intent remains consistent; then add extra negative test cases in the WeeklyRankings/MonthlyRankings tests to assert that invalid date strings (e.g., "2026-04-14", "20261340") produce a CoreException and the expected error response.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java (1)
19-25: 행(row)당 1회 upsert로 인한 N회 왕복이 장기적으로 병목이 될 수 있다.운영 관점에서 현재는 상한 100건이라 문제 없으나, 집계 대상 확장/다중 카테고리 확장 시 네트워크 왕복이 선형 증가한다. 또한 전체를
@Transactional로 감싼 상태에서 개별 native upsert가 flush 시점·자동 커밋/배치 설정에 영향을 받으므로 JDBC batch(rewriteBatchedStatements=true+hibernate.jdbc.batch_size) 활용이나 단일INSERT ... VALUES (...), (...) ON DUPLICATE KEY UPDATE쿼리로의 일괄 upsert를 검토한다.또한
rankings가null/빈 리스트일 때의 동작(불필요한 트랜잭션 시작)도 가드가 있으면 좋다.♻️ 제안 수정안(가드 + 확장 포인트)
`@Override` `@Transactional` public void saveAll(List<ProductRankingWeekly> rankings) { + if (rankings == null || rankings.isEmpty()) { + return; + } rankings.forEach(r -> productRankingWeeklyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) ); }추가 테스트: 100건 저장 시 발행 쿼리 수를
DataSourceProxy/hibernate.generate_statistics로 확인하는 통합 테스트를 추가해 향후 일괄 upsert 전환의 효과를 회귀로 보장한다.🤖 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/persistence/ProductRankingWeeklyRepositoryImpl.java` around lines 19 - 25, saveAll currently performs a per-row upsert via productRankingWeeklyJpaRepository.upsert in a loop which causes N database round-trips and unnecessary transactions for null/empty input; add a null/empty guard for rankings and replace the loop with a batch upsert path (either implement a new repository method like productRankingWeeklyJpaRepository.upsertBatch(List<ProductRankingWeekly>) that executes a single native INSERT ... VALUES (...),(... ) ON DUPLICATE KEY UPDATE or use JDBC batching by setting rewriteBatchedStatements=true and hibernate.jdbc.batch_size and using a batched PreparedStatement execution), keep `@Transactional` on saveAll but delegate to the new batch method, and add an integration test (using DataSourceProxy or hibernate.generate_statistics) that asserts the number of executed queries for 100 items to prevent regressions.apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
8-8:[start, end)인지[start, end]인지 경계 의미 명시를 권장한다.운영 관점에서
start/end의 포함/배타 여부는 주간(7일)·월간(30일) 집계 결과에 직접적으로 영향을 미친다. 호출부(WeeklyRankingTasklet/MonthlyRankingTasklet)에서start = scoreDate.minusDays(6)vsminusDays(7)처럼 off-by-one이 쉽게 발생할 수 있으므로, Javadoc으로 경계 규약(inclusive/exclusive)과limit의 상한(최대 100) 의미를 명시한다.추가 테스트: Tasklet 테스트에서 경계일(
start,end당일)의 메트릭이 집계에 포함/제외되는지 명시적으로 단언한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java` at line 8, Add explicit Javadoc to the repository method findTopScores(LocalDate start, LocalDate end, int limit) stating whether the date range is inclusive/exclusive (e.g., [start, end) or [start, end]) and documenting that limit has a maximum of 100 and its behavior when exceeded; update any callers (WeeklyRankingTasklet / MonthlyRankingTasklet) to follow that convention and add unit/integration tests that assert whether metrics on the boundary dates (start and end) are included or excluded to catch off-by-one mistakes.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (1)
47-53: 필수 JobParameterdate검증이 Job 레벨에 없어 실패 시점이 늦어진다.현재 구성은
date가 누락돼도 Job이 기동되어 Tasklet 실행 중 예외가 발생하는 구조로 보인다. 운영에서 스케줄러/CLI 오류로 파라미터가 빠지면, JobRepository에 실패한 JobInstance가 남고 재실행 시 파라미터 키 충돌로 조사 부담이 커진다.DefaultJobParametersValidator로date를 required로 선언해 기동 전에 빠르게 실패시키는 것이 바람직하다.추가 테스트:
MonthlyRankingJobE2ETest에 "date 파라미터 누락 시 JobParametersInvalidException 발생" 케이스 추가를 권고한다.♻️ 제안 변경
`@Bean`(JOB_NAME) public Job monthlyRankingJob() { return new JobBuilder(JOB_NAME, jobRepository) .start(monthlyRankingStep()) .listener(jobListener) + .validator(new DefaultJobParametersValidator(new String[]{"date"}, new String[]{})) .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/MonthlyRankingJobConfig.java` around lines 47 - 53, Add a Job-level required-parameter validator so the job fails fast when the "date" parameter is missing: in the monthlyRankingJob() bean (where JOB_NAME is built with JobBuilder, jobRepository, monthlyRankingStep(), jobListener) attach a DefaultJobParametersValidator configured with "date" as a required key before build() so the job throws JobParametersInvalidException at startup if date is absent; also add an E2E test in MonthlyRankingJobE2ETest asserting that launching the job without the date parameter results in JobParametersInvalidException.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java (1)
57-79:WeeklyRankingTasklet과 구조가 동일하여 유지보수 비용이 이중화된다.현재
MonthlyRankingTasklet과WeeklyRankingTasklet은 window 길이(7/30)와 대상 엔티티/레포지토리 타입만 다르고 실행 흐름(검증 → 파싱 → 집계 → 매핑 → 저장 → 로깅)이 100% 일치한다. 향후 스코어 공식 변경/로깅 추가/관측 지표 삽입 시 두 파일을 동시에 수정해야 하여 불일치 위험이 크다. 제네릭 추상 Tasklet(또는 전략 함수 주입)으로 추출하는 것을 권장한다.abstract class AbstractRankingTasklet<T> implements Tasklet { protected abstract int windowDays(); protected abstract T toRanking(Long productId, LocalDate baseDate, Double score); protected abstract void save(List<T> rankings); // execute(...) 공통화 }추가 테스트로, 동일
date파라미터로 Weekly/Monthly를 함께 실행했을 때 window 계산이 독립적으로 정확한지(경계일 포함/제외) 파라미터라이즈드 테스트로 보강할 것을 권장한다.🤖 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/MonthlyRankingTasklet.java` around lines 57 - 79, MonthlyRankingTasklet duplicates WeeklyRankingTasklet logic (only window size and target types differ); extract common flow into an abstract generic tasklet (e.g., AbstractRankingTasklet<T> implements Tasklet) that defines protected abstract int windowDays(), protected abstract T toRanking(Long productId, LocalDate baseDate, Double score), and protected abstract void save(List<T> rankings), move the shared execute(...) logic (validate date, compute base/start/end using windowDays(), query repository method call injection or abstract finder, map projections via toRanking(...), call save(...), and centralized logging) into AbstractRankingTasklet, then make MonthlyRankingTasklet and WeeklyRankingTasklet subclasses that only implement windowDays(), toRanking(), save() (and provide repository/finder instances), and add parameterized tests that run both tasklets with the same date to assert start/end bounds and saved counts.apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java (1)
47-63: native upsert 후 영속성 컨텍스트 동기화가 명시되지 않아 플래키 가능성이 있다.
upsert는@Modifying네이티브 쿼리이므로clearAutomatically/flushAutomatically가 꺼져 있으면 이전saveAll결과가 1차 캐시에 남은 상태에서 두 번째saveAll→findAll이 수행될 때 동일 키 엔티티가 존재할 경우 구 버전 엔티티가 병합되어 보일 수 있다. 현재 구현(Impl이 upsert만 호출하고save는 호출하지 않는 경우)에서는 1차 캐시에 관리 대상 엔티티가 없어 괜찮을 가능성이 높지만, 구현 세부사항에 테스트가 묵시적으로 의존하게 된다. 테스트 말미에entityManager.flush(); entityManager.clear();를 명시하거나@Transactional을 사용하지 않는 방식을 명시해 운영에서 발생할 동시성/캐시 이슈와 테스트 경로가 일치하도록 하는 것을 권장한다.추가로, 누적 멱등성(같은 배치를 두 번 돌려도 동일 결과)을 더 강하게 검증하기 위해
saveAll리스트에 동일 키 두 건을 한 번에 넣는 케이스와, 서로 다른productId가 섞인 케이스를 추가하면 좋다.As per coding guidelines (
**/*Test*.java): "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java` around lines 47 - 63, The test ProductRankingMonthlyRepositoryIntegrationTest is flakey because the repository's native upsert (invoked via productRankingMonthlyRepository.saveAll) may not synchronize the JPA persistence context before asserting via productRankingMonthlyJpaRepository.findAll; fix by explicitly synchronizing the context after the saveAll calls (call entityManager.flush(); entityManager.clear(); in the test) or make the test non-transactional so the DB state is read fresh, and while here add two extra assertions/cases: one where saveAll is given two entries with the same productId in a single list (to validate in-batch idempotency) and one mixed-productId batch to ensure unaffected rows remain. Ensure you reference the test method updatesScore_whenDuplicateKey and update it to flush/clear the EntityManager before calling findAll.apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
38-45: 집계 카운터 기본값/음수 방어가 없다.
viewCount/likeCount/orderCount가nullable = false로만 정의되어 있고 생성자/팩토리나 기본값이 없어, 누군가 Reflection/부분 persist 경로로 null을 저장하거나 음수 값이 들어올 경우 집계 스코어(view×0.1 + like×0.2 + order×0.7)가 왜곡되어 랭킹 결과가 깨진다. 도메인 모델로서 최소한 생성 시 0 이상 검증과 기본값(0L)을 명시하여 불변식을 강제하고, 상태 변경 API(예:increaseViewCount)를 통해서만 갱신되도록 캡슐화하는 것을 권장한다.추가 테스트로 엔티티 생성 시 음수 주입에 대한 검증 실패 케이스, 기본값이 0임을 확인하는 단위 테스트를 추가해야 한다.
As per coding guidelines (
**/domain/**/*.java): "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java` around lines 38 - 45, ProductMetrics currently declares viewCount/likeCount/orderCount as non-nullable fields but lacks defaults and validation; add a constructor or static factory in ProductMetrics that initializes these to 0L, enforce non-negative checks there (throw IllegalArgumentException on negative input), make any public setters private/remove direct mutation, and expose controlled mutators such as increaseViewCount(), increaseLikeCount(), increaseOrderCount() that validate and update counts atomically; finally add unit tests asserting default values are 0 and that constructing or mutating with negative values fails.apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java (1)
86-101: product2의scoreDate검증이 누락되었다.product1은
scoreDate를 확인하지만 product2는score만 검증하다. 매핑 로직이 전체 결과에 대해 일관되게 동작하는지 확인하려면 product2에도 동일하게scoreDate단언을 포함하는 것이 좋다. 대량 저장 시 특정 항목만scoreDate가 누락/오설정되는 회귀를 잡기 위한 최소한의 안전장치다.♻️ 제안 수정
() -> { ProductRankingWeekly product2 = rankings.stream() .filter(r -> r.getProductId().equals(2L)) .findFirst().orElseThrow(); assertThat(product2.getScore()).isEqualTo(8.1); + assertThat(product2.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13)); }As per coding guidelines: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java` around lines 86 - 101, 테스트에서 product2의 scoreDate 검증이 빠져 있으므로 WeeklyRankingTaskletTest의 assertions 내 두 번째 람다(상품 조회하는 블록)를 수정해 ProductRankingWeekly product2 = ... 뒤에 product2.getScoreDate()에 대한 단언을 추가하여 product1과 동일한 기대값(LocalDate.of(2026, 4, 13) 또는 도메인에서 정한 기대 날짜)을 검증하도록 하세요; 관련 식별자는 rankings, ProductRankingWeekly, getScore(), getScoreDate()입니다.apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java (1)
41-86: Upsert 경계 동작에 대한 추가 검증을 권장한다.현재 테스트는 단일 product에 대한 (1) 동일 키 덮어쓰기와 (2) 다른
score_date분리만 다룬다. 운영 관점에서 다음 케이스가 함께 검증되면 멱등성/벌크 upsert 버그를 조기에 포착할 수 있다.
- 서로 다른
productId가 동일score_date에 섞인 배치의 한 번saveAll호출 시, 각 row별로 올바르게 insert/update가 분기되는지(부분 중복 배치).- 빈 리스트
saveAll(List.of())호출 시 예외 없이 0건으로 종료되는지(스케줄 초기 구동·상위 N이 0건인 엣지 케이스).이는 네이티브
ON DUPLICATE KEY UPDATE구문과 파라미터 바인딩에서 흔히 발생하는 회귀를 막는다.As per coding guidelines: "쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java` around lines 41 - 86, Add two integration tests to cover the upsert edge cases: (1) a test that calls ProductRankingWeeklyRepository.saveAll with a mixed list containing multiple ProductRankingWeekly.create entries having the same score_date but different productId values and then assert via productRankingWeeklyJpaRepository.findAll that each productId has its own row with the correct score (verifying partial updates/inserts), and (2) a test that calls saveAll with an empty list (e.g., List.of()) and asserts no exception is thrown and productRankingWeeklyJpaRepository.findAll returns zero changes; implement these new tests alongside the existing SaveAll nested class to ensure bulk upsert idempotency and empty-batch handling.apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java (1)
117-145: 윈도 경계 데이터에 대한 명시적 검증 부재.월간 윈도(
date=20260414→03/15 ~ 04/13)의 경계 데이터(예:03/14,03/15,04/13,04/14)가 포함/제외되는지 검증하는 케이스가 없다. 운영에서 가장 자주 발생하는 버그가 범위 경계(half-open vs closed) 오류이므로, 적어도 시작·종료 경계 각각 "포함/제외"되어야 하는 날짜 각 1건씩을 시드해 집계 결과로 경계 동작을 고정하는 것이 좋다. 이는 Weekly E2E 테스트에도 동일하게 적용된다.♻️ 추가 테스트 스케치
// date=20260414, expected window = [2026-03-15, 2026-04-13] saveMetrics(10L, LocalDate.of(2026, 3, 14), ...); // 제외되어야 함 saveMetrics(11L, LocalDate.of(2026, 3, 15), ...); // 포함 saveMetrics(12L, LocalDate.of(2026, 4, 13), ...); // 포함 saveMetrics(13L, LocalDate.of(2026, 4, 14), ...); // 제외되어야 함 // ... 집계 결과에서 productId 10, 13이 존재하지 않는지 단언As per coding guidelines: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java` around lines 117 - 145, The test is missing explicit boundary-date checks for the monthly window; update the isIdempotent_whenRerunWithSameParameters test to seed metrics on the window boundaries using saveMetrics (e.g., dates: start-1, start, end, end+1 for date="20260414" where expected window is 2026-03-15..2026-04-13) and then assert that only the metrics for the included boundary dates (start and end) appear in productRankingMonthlyJpaRepository results while the outside dates (start-1 and end+1) do not; keep the existing idempotency launches (jobLauncherTestUtils.launchJob with jobParameters1/2) but extend the arrange and assert sections to check inclusion/exclusion of productIds for those boundary records.apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java (2)
95-114: double 값에 대한 정확 일치 단언은 깨지기 쉽다.
isEqualTo(46.5),isEqualTo(8.1)처럼 double을 정확 일치로 비교한다. 지금은 합산 조합이 우연히 정확히 표현 가능한 값들이지만, 집계 로직이 나중에BigDecimal/가중치 변경 등으로 미세하게 바뀌면 부동소수점 오차로 플래키 실패가 발생한다. 허용 오차 기반 단언으로 고정할 것을 권장한다.♻️ 제안 수정
- assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScore()).isCloseTo(46.5, within(1e-6)); ... - assertThat(product2.getScore()).isEqualTo(8.1); + assertThat(product2.getScore()).isCloseTo(8.1, within(1e-6));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java` around lines 95 - 114, Replace exact double equality assertions in WeeklyRankingJobE2ETest with tolerance-based comparisons: for ProductRankingWeekly score checks (product1.getScore() and product2.getScore()) use AssertJ's close/offset matcher (e.g., isCloseTo(..., within/offset) or isCloseTo with Percentage) with a small epsilon (e.g., 1e-6 or 1e-4) instead of isEqualTo(46.5) and isEqualTo(8.1); keep other assertions (jobExecution exit status, rankings size, scoreDate) unchanged and update imports if needed.
147-162:saveMetrics헬퍼가 Monthly E2E 테스트와 중복된다.
WeeklyRankingJobE2ETest와MonthlyRankingJobE2ETest에 동일한 reflective 생성자 호출 + 필드 세팅 로직이 그대로 복제되어 있다.ProductMetrics구조(필드 추가/이름 변경) 변경 시 양쪽 테스트가 같이 깨지고, 한쪽만 고쳐지는 실수가 발생하기 쉽다. 테스트 공용 모듈로 고정 팩토리(또는ProductMetricsFixture)를 추출하는 것을 권장한다.또한 운영 관점에서는
ProductMetrics에@Builder(또는 패키지 private 정적 팩토리)를 테스트용으로 열어두면 리플렉션 의존을 제거할 수 있다. 리플렉션 기반 생성은 필드 rename 시 컴파일 타임에 감지되지 않는 취약점이 있다.♻️ 공용 Fixture 예시
// apps/commerce-batch/src/testFixtures/.../ProductMetricsFixture.java 등 public final class ProductMetricsFixture { private ProductMetricsFixture() {} public static ProductMetrics of(Long productId, LocalDate metricDate, Long viewCount, Long likeCount, Long orderCount) { try { var ctor = ProductMetrics.class.getDeclaredConstructor(); ctor.setAccessible(true); ProductMetrics m = ctor.newInstance(); ReflectionTestUtils.setField(m, "productId", productId); ReflectionTestUtils.setField(m, "metricDate", metricDate); ReflectionTestUtils.setField(m, "viewCount", viewCount); ReflectionTestUtils.setField(m, "likeCount", likeCount); ReflectionTestUtils.setField(m, "orderCount", orderCount); return m; } catch (ReflectiveOperationException e) { throw new IllegalStateException("ProductMetrics fixture 생성 실패", e); } } }As per coding guidelines: "Java 테스트 리뷰 기준이다. ... 통합 테스트는 ... 테스트 데이터 준비/정리 전략을 점검한다."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java` around lines 147 - 162, The saveMetrics helper in WeeklyRankingJobE2ETest duplicates reflection-based construction used in MonthlyRankingJobE2ETest; extract that logic into a shared test fixture (e.g., ProductMetricsFixture.of(...)) and replace both saveMetrics/inline reflective blocks to call ProductMetricsFixture.of(productId, metricDate, viewCount, likeCount, orderCount) then persist via productMetricsJpaRepository.save(...); alternatively, if feasible add a package-private static factory or `@Builder` to ProductMetrics and update both tests to use that instead of ReflectionTestUtils to remove fragile reflection usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java`:
- Line 36: The LocalDate.parse call in ReadMonthlyRankingsUseCase is allowing
DateTimeParseException to bubble up and produce a 500; catch
DateTimeParseException around LocalDate.parse(date, DATE_FORMAT) (and similarly
in ReadWeeklyRankingsUseCase) and instead throw a CoreException with
ErrorType.INVALID_RANKING_DATE_FORMAT, preserving the original exception as the
cause and separating user message from log message; better yet, move parsing
into a shared helper (e.g., RankingKeyResolver.parseDate(...)) that performs
this catch-and-wrap behavior so both weekly/monthly use it, and add an E2E test
in RankingV1ApiE2ETest that requests date=invalid and asserts a 400 with
INVALID_RANKING_DATE_FORMAT.
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java`:
- Around line 35-38: The execute method in ReadWeeklyRankingsUseCase parses the
date with LocalDate.parse which can throw DateTimeParseException and bypass API
error normalization; wrap the parse in a try/catch that catches
DateTimeParseException and rethrow a CoreException with BAD_REQUEST (reuse the
message/format style used by RankingKeyResolver.validateDateFormat), then
proceed to call rankingService.readWeeklyTopRanked as before; apply the
identical change to the monthly use case (MonthlyReadRankingsUseCase / similar
execute method) and add an E2E test in RankingV1ApiE2ETest asserting that
invalid date formats (e.g., "invalid", "2026-04-14") return the standardized 4xx
CoreException error response.
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java`:
- Around line 22-49: ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java`:
- Around line 26-30: Remove the infrastructure-layer transaction annotation by
deleting the `@Transactional`(readOnly = true) on the
MonthlyRankingRepositoryImpl#readTopRanked method; leave the method as a plain
repository implementation that delegates to
monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(...). Ensure
transaction boundaries are enforced at the application layer (verify
ReadMonthlyRankingsUseCase or its ApplicationService is annotated with
`@Transactional`(readOnly = true) and add it there if missing), and run regression
tests to confirm persistence-context reads work as expected.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java`:
- Around line 26-30: Remove the redundant `@Transactional` on
WeeklyRankingRepositoryImpl.readTopRanked: delete the `@Transactional`(readOnly =
true) annotation from the readTopRanked method in WeeklyRankingRepositoryImpl so
transaction boundaries are not set in the infrastructure layer (leave the call
to weeklyRankingJpaRepository.findByScoreDateOrderByScoreDesc as-is); ensure
transaction management is handled in the application layer (e.g.,
ReadWeeklyRankingsUseCase) and add/extend an integration test that verifies
ReadWeeklyRankingsUseCase is annotated with `@Transactional`(readOnly = true) (or
otherwise executes reads within a single read-only transaction) and returns
identical results.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java`:
- Around line 57-75: getWeeklyRankings and getMonthlyRankings declare
`@RequestParam` String date (required=true) while getDailyRankings uses
`@RequestParam`(required = false) String date, causing inconsistent behavior and
potential MissingServletRequestParameterException; make the endpoints consistent
by either: (A) marking date optional for getWeeklyRankings/getMonthlyRankings
(use `@RequestParam`(required = false) String date) and delegating a null/blank
date to the corresponding ReadWeeklyRankingsUseCase/ReadMonthlyRankingsUseCase
to default to today, or (B) keep them required but explicitly catch/convert the
missing-parameter and parse errors into your CoreException type within
RankingV1Api (or inside ReadMonthlyRankingsUseCase/ReadWeeklyRankingsUseCase) so
ApiControllerAdvice emits the unified 4xx payload; also add E2E tests in
RankingV1ApiE2ETest for (1) missing date, (2) invalid format, (3) future date to
validate the behavior and ensure PageSize.withMaxSize usage remains unchanged.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java`:
- Around line 278-306: Add a positive-liked test similar to the existing
returnsRankedProducts: in RankingV1ApiE2ETest create/login a test user, issue a
like for a specific productId via the same service/client used in other tests,
call getWeeklyRankings (and getMonthlyRankings analogously) with the
authenticated testRestTemplate (or include the auth token), then assert that the
returned RankingDto.RankedProductResponse for that productId has liked() ==
true; place this alongside/analogous to returnsRankedProducts and
supportsPagination so it uses getWeeklyRankings/getMonthlyRankings,
productId1/productId2/productId3, and the existing assertion patterns.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java`:
- Around line 62-64: MonthlyRankingTasklet currently calls LocalDate.parse(date,
DATE_FORMAT) which lets DateTimeParseException escape; catch
DateTimeParseException around the parse in MonthlyRankingTasklet (and mirror the
same change in WeeklyRankingTasklet), throw a new IllegalArgumentException with
a clear user-facing message that includes the supplied date string (ensure it’s
not sensitive) and pass the caught exception as the cause to preserve
stacktrace, and log a separate detailed debug/error log message containing the
parse exception; also add two unit tests in MonthlyRankingTaskletTest for
malformed inputs ("20260230" and "2026-04-14") asserting an
IllegalArgumentException is thrown and the message contains the invalid input.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java`:
- Line 3: Change the nullable wrapper type to a primitive: update
ProductScoreProjection (record ProductScoreProjection(Long productId, Double
score)) to use a primitive double for score and make the same change in
ProductRankingWeekly and ProductRankingMonthly to reflect non-nullable, non-null
`@Column`(nullable=false) semantics; then add integration tests in
ProductMetricsRepositoryImpl that cover boundary cases: (1) a product with
metrics only for a subset of the aggregation period (partial presence) and (2) a
product whose all counts are zero to assert SUM(...) yields 0.0 and mappings to
the primitive score behave correctly. Ensure you update any
constructors/mappers/usages that expect Double to use double and adjust
assertions in the new tests to check for exact 0.0 where appropriate.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java`:
- Around line 17-44: ProductRankingWeekly lacks equals/hashCode and has an
`@UniqueConstraint` that may be only documentation: either implement
identifier-based equals/hashCode (e.g., using id when non-null, otherwise
fallback to business key productId+scoreDate) on ProductRankingWeekly to make
behavior explicit for future Set/Map use, or add a clear class-level comment
stating equals/hashCode are intentionally omitted; additionally, verify that the
`@UniqueConstraint` on the entity is documented as non-enforcing when ddl-auto is
disabled and confirm the actual unique constraint exists in your external
migration scripts and is consistent with other modules' entity definitions (API
module) so schema/behavior remain synchronized.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java`:
- Around line 16-27: The weighted-sum expression is duplicated in the JPQL
SELECT and ORDER BY inside ProductMetricsJpaRepository.findTopScores, risking
inconsistency when tuning weights; fix by centralizing the aggregation: either
repeat the exact same SUM(...) expression in both the constructor expression and
ORDER BY (ensure the numeric literals 0.1, 0.2, 0.7 remain identical), or
replace the JPQL query with a CriteriaQuery or native SQL that computes the
weighted score once and maps it to
com.loopers.domain.metrics.ProductScoreProjection; update findTopScores to use
the chosen approach and add a unit test that verifies ordering/tie-break
behavior when weights change.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java`:
- Around line 14-26: The native upsert method
ProductRankingWeeklyJpaRepository.upsert is annotated with `@Modifying` but lacks
flushAutomatically/clearAutomatically, which can leave the persistence context
stale after the native INSERT ... ON DUPLICATE KEY UPDATE; update the `@Modifying`
on ProductRankingWeeklyJpaRepository.upsert (and mirror in
ProductRankingMonthlyJpaRepository.upsert) to include flushAutomatically = true
and clearAutomatically = true so the EntityManager is flushed and the
persistence context cleared after the native call, and add a unit/integration
test that performs saveAll → upsert → immediate read within the same transaction
to assert the returned score is the updated value; also add a TODO note to
replace per-row upsert with multi-row VALUES or JDBC batch in future to avoid N
round-trips.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java`:
- Around line 49-60: Add boundary tests to MonthlyRankingTaskletTest to cover
empty, whitespace, and malformed date strings: create new `@Test` methods that use
ReflectionTestUtils.setField(tasklet, "date", "") and setField(..., " ") and
setField(..., "2026-04-14") / "20260230", then call
tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); assert
that empty and whitespace date inputs throw IllegalArgumentException
(hasMessageContaining "date") and that malformed format inputs propagate
DateTimeParseException from LocalDate.parse; reference
MonthlyRankingTaskletTest, tasklet.execute, ReflectionTestUtils.setField, and
LocalDate.parse when adding these assertions.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 61-73: The failing test failsJob_whenDateParameterIsMissing uses
jobLauncherTestUtils.launchJob() with empty JobParameters causing
JobInstanceAlreadyCompleteException due to leftover Spring Batch meta tables;
fix it by launching the job with a unique run.id JobParameter (same pattern used
in isIdempotent_whenRerunWithSameParameters()) so the job instance is unique and
the test exercises the missing date validation path—update the test to build
JobParameters including a generated run.id (or timestamp) before calling
jobLauncherTestUtils.launchJob(), or alternatively ensure
DatabaseCleanUp.truncateAllTables() also clears Spring Batch meta tables
(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION) if you prefer cleanup approach.
---
Outside diff comments:
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`:
- Around line 22-39: ProductMetrics' metric_date can drift between DB CURDATE()
and JVM LocalDate; fix by either specifying serverTimezone (e.g.,
serverTimezone=Asia/Seoul or =UTC) in the application's JDBC URL so DB CURDATE()
and the app JVM share the same TZ, or change the streamer upsert to stop using
CURDATE() and instead pass an application-computed LocalDate parameter into the
upsert (affecting the code that writes metric_date in the streamer/upsert SQL);
additionally add an integration test that injects events at 23:59:59 and
00:00:00 and asserts ProductMetrics entries (productId/metricDate) are recorded
into the same expected date bucket to prevent midnight-boundary mismatches.
---
Duplicate comments:
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java`:
- Around line 77-85: 테스트 격리 문제: WeeklyRankingRepositoryImplIntegrationTest의
returnsEmptyList_whenNoData가 다른 테스트가 같은 SCORE_DATE로 mv_product_rank_weekly에 남긴
데이터 때문에 순서에 따라 실패할 수 있으니, 테스트를 격리하도록 수정하세요; 해결 방법은 (1)
returnsEmptyList_whenNoData 또는 해당 클래스의 테스트에서 readTopRanked 호출에 사용하는 SCORE_DATE를
고유한 값으로 변경하거나 (2) WeeklyRankingRepositoryImplIntegrationTest에 `@BeforeEach` 메서드를
추가해 mv_product_rank_weekly에서 SCORE_DATE 관련 레코드를 삭제하도록 정리하는 것입니다 — 참고로 영향을 주는 심볼:
returnsEmptyList_whenNoData, SCORE_DATE, readTopRanked,
WeeklyRankingRepositoryImplIntegrationTest, BaseIntegrationTest,
mv_product_rank_weekly.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java`:
- Around line 11-13: Add an integration test that verifies the derived query in
MonthlyRankingJpaRepository (findByScoreDateOrderByScoreDesc) actually uses the
idx_score_date_score(index on score_date, score DESC) and does not trigger a
filesort for large datasets: execute an EXPLAIN for the SQL generated by
findByScoreDateOrderByScoreDesc against a populated test table
(ProductRankingMonthly) with realistic volume and assert the EXPLAIN output
shows the index is used and no "filesort" appears; include test setup using
Pageable with PageSize.withMaxSize() to ensure page size limits and run this as
part of CI so index usage is validated automatically.
In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java`:
- Around line 26-36: The API spec methods in RankingV1ApiSpec (getWeeklyRankings
and getMonthlyRankings) do not document the expected date format or
requiredness; update the method signatures' OpenAPI metadata by adding
`@Parameter` annotations (io.swagger.v3.oas.annotations.Parameter) to the date
parameter for both getWeeklyRankings and getMonthlyRankings with
description="yyyyMMdd 형식", required=true, and example="20260414" so the
generated OpenAPI clearly shows the format and that the param is mandatory;
ensure imports are added and that the annotation is applied to the date
parameter so clients see the format/requiredness in docs and spec-driven
clients.
In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java`:
- Line 249: Replace the hardcoded future DATE constant ("20260414") in
RankingV1ApiE2ETest with a dynamic value (e.g.,
LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) or a clearly past
fixed date like "20250101", and update the setUp code that currently uses
LocalDate.of(2026,4,14) to derive its date from the same source so the test
intent remains consistent; then add extra negative test cases in the
WeeklyRankings/MonthlyRankings tests to assert that invalid date strings (e.g.,
"2026-04-14", "20261340") produce a CoreException and the expected error
response.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java`:
- Around line 47-53: Add a Job-level required-parameter validator so the job
fails fast when the "date" parameter is missing: in the monthlyRankingJob() bean
(where JOB_NAME is built with JobBuilder, jobRepository, monthlyRankingStep(),
jobListener) attach a DefaultJobParametersValidator configured with "date" as a
required key before build() so the job throws JobParametersInvalidException at
startup if date is absent; also add an E2E test in MonthlyRankingJobE2ETest
asserting that launching the job without the date parameter results in
JobParametersInvalidException.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java`:
- Around line 57-79: MonthlyRankingTasklet duplicates WeeklyRankingTasklet logic
(only window size and target types differ); extract common flow into an abstract
generic tasklet (e.g., AbstractRankingTasklet<T> implements Tasklet) that
defines protected abstract int windowDays(), protected abstract T toRanking(Long
productId, LocalDate baseDate, Double score), and protected abstract void
save(List<T> rankings), move the shared execute(...) logic (validate date,
compute base/start/end using windowDays(), query repository method call
injection or abstract finder, map projections via toRanking(...), call
save(...), and centralized logging) into AbstractRankingTasklet, then make
MonthlyRankingTasklet and WeeklyRankingTasklet subclasses that only implement
windowDays(), toRanking(), save() (and provide repository/finder instances), and
add parameterized tests that run both tasklets with the same date to assert
start/end bounds and saved counts.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`:
- Around line 38-45: ProductMetrics currently declares
viewCount/likeCount/orderCount as non-nullable fields but lacks defaults and
validation; add a constructor or static factory in ProductMetrics that
initializes these to 0L, enforce non-negative checks there (throw
IllegalArgumentException on negative input), make any public setters
private/remove direct mutation, and expose controlled mutators such as
increaseViewCount(), increaseLikeCount(), increaseOrderCount() that validate and
update counts atomically; finally add unit tests asserting default values are 0
and that constructing or mutating with negative values fails.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java`:
- Line 8: Add explicit Javadoc to the repository method findTopScores(LocalDate
start, LocalDate end, int limit) stating whether the date range is
inclusive/exclusive (e.g., [start, end) or [start, end]) and documenting that
limit has a maximum of 100 and its behavior when exceeded; update any callers
(WeeklyRankingTasklet / MonthlyRankingTasklet) to follow that convention and add
unit/integration tests that assert whether metrics on the boundary dates (start
and end) are included or excluded to catch off-by-one mistakes.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java`:
- Around line 11-23: The Javadoc on ProductRankingWeeklyRepository currently
mentions MySQL-specific syntax; change the doc on the interface
(ProductRankingWeeklyRepository and its saveAll(List<ProductRankingWeekly>
rankings) method) to describe only the intent: that saveAll performs an
idempotent upsert (updates score when a (productId, scoreDate) exists, otherwise
creates), and remove any reference to MySQL or ON DUPLICATE KEY UPDATE; move any
implementation-specific notes about using MySQL upsert syntax into the
infrastructure layer (e.g., ProductRankingWeeklyJpaRepository or its Impl)
Javadoc or implementation comments.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java`:
- Around line 19-25: saveAll currently iterates and calls
productRankingMonthlyJpaRepository.upsert per item causing N round-trips;
replace this with a single batch upsert: add a new method (e.g., upsertAll) in
ProductRankingMonthlyRepositoryImpl that uses JdbcTemplate (or
JdbcTemplate.batchUpdate) to execute one multi-row "INSERT ... VALUES
(...),(...)" with "ON DUPLICATE KEY UPDATE score=VALUES(score)" for all
List<ProductRankingMonthly> entries (or build and execute a single SQL string
with bound params), stop using per-item
productRankingMonthlyJpaRepository.upsert in saveAll and delegate to upsertAll,
and add an integration test (e.g., `@DataJpaTest` using Hibernate Statistics or
P6Spy/datasource-proxy) that persists ~100 ProductRankingMonthly and asserts the
number of executed SQL statements is 1–2.
In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java`:
- Around line 19-25: saveAll currently performs a per-row upsert via
productRankingWeeklyJpaRepository.upsert in a loop which causes N database
round-trips and unnecessary transactions for null/empty input; add a null/empty
guard for rankings and replace the loop with a batch upsert path (either
implement a new repository method like
productRankingWeeklyJpaRepository.upsertBatch(List<ProductRankingWeekly>) that
executes a single native INSERT ... VALUES (...),(... ) ON DUPLICATE KEY UPDATE
or use JDBC batching by setting rewriteBatchedStatements=true and
hibernate.jdbc.batch_size and using a batched PreparedStatement execution), keep
`@Transactional` on saveAll but delegate to the new batch method, and add an
integration test (using DataSourceProxy or hibernate.generate_statistics) that
asserts the number of executed queries for 100 items to prevent regressions.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java`:
- Around line 117-145: The test is missing explicit boundary-date checks for the
monthly window; update the isIdempotent_whenRerunWithSameParameters test to seed
metrics on the window boundaries using saveMetrics (e.g., dates: start-1, start,
end, end+1 for date="20260414" where expected window is 2026-03-15..2026-04-13)
and then assert that only the metrics for the included boundary dates (start and
end) appear in productRankingMonthlyJpaRepository results while the outside
dates (start-1 and end+1) do not; keep the existing idempotency launches
(jobLauncherTestUtils.launchJob with jobParameters1/2) but extend the arrange
and assert sections to check inclusion/exclusion of productIds for those
boundary records.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java`:
- Around line 47-63: The test ProductRankingMonthlyRepositoryIntegrationTest is
flakey because the repository's native upsert (invoked via
productRankingMonthlyRepository.saveAll) may not synchronize the JPA persistence
context before asserting via productRankingMonthlyJpaRepository.findAll; fix by
explicitly synchronizing the context after the saveAll calls (call
entityManager.flush(); entityManager.clear(); in the test) or make the test
non-transactional so the DB state is read fresh, and while here add two extra
assertions/cases: one where saveAll is given two entries with the same productId
in a single list (to validate in-batch idempotency) and one mixed-productId
batch to ensure unaffected rows remain. Ensure you reference the test method
updatesScore_whenDuplicateKey and update it to flush/clear the EntityManager
before calling findAll.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java`:
- Around line 41-86: Add two integration tests to cover the upsert edge cases:
(1) a test that calls ProductRankingWeeklyRepository.saveAll with a mixed list
containing multiple ProductRankingWeekly.create entries having the same
score_date but different productId values and then assert via
productRankingWeeklyJpaRepository.findAll that each productId has its own row
with the correct score (verifying partial updates/inserts), and (2) a test that
calls saveAll with an empty list (e.g., List.of()) and asserts no exception is
thrown and productRankingWeeklyJpaRepository.findAll returns zero changes;
implement these new tests alongside the existing SaveAll nested class to ensure
bulk upsert idempotency and empty-batch handling.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 95-114: Replace exact double equality assertions in
WeeklyRankingJobE2ETest with tolerance-based comparisons: for
ProductRankingWeekly score checks (product1.getScore() and product2.getScore())
use AssertJ's close/offset matcher (e.g., isCloseTo(..., within/offset) or
isCloseTo with Percentage) with a small epsilon (e.g., 1e-6 or 1e-4) instead of
isEqualTo(46.5) and isEqualTo(8.1); keep other assertions (jobExecution exit
status, rankings size, scoreDate) unchanged and update imports if needed.
- Around line 147-162: The saveMetrics helper in WeeklyRankingJobE2ETest
duplicates reflection-based construction used in MonthlyRankingJobE2ETest;
extract that logic into a shared test fixture (e.g.,
ProductMetricsFixture.of(...)) and replace both saveMetrics/inline reflective
blocks to call ProductMetricsFixture.of(productId, metricDate, viewCount,
likeCount, orderCount) then persist via productMetricsJpaRepository.save(...);
alternatively, if feasible add a package-private static factory or `@Builder` to
ProductMetrics and update both tests to use that instead of ReflectionTestUtils
to remove fragile reflection usage.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java`:
- Around line 86-101: 테스트에서 product2의 scoreDate 검증이 빠져 있으므로
WeeklyRankingTaskletTest의 assertions 내 두 번째 람다(상품 조회하는 블록)를 수정해
ProductRankingWeekly product2 = ... 뒤에 product2.getScoreDate()에 대한 단언을 추가하여
product1과 동일한 기대값(LocalDate.of(2026, 4, 13) 또는 도메인에서 정한 기대 날짜)을 검증하도록 하세요; 관련
식별자는 rankings, ProductRankingWeekly, getScore(), getScoreDate()입니다.
In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java`:
- Line 35: CURRENT_METRIC_DATE is set at class load via LocalDate.now() which
can drift across midnight and different timezones causing flaky tests; replace
uses of the static CURRENT_METRIC_DATE with a call to a helper method
currentMetricDate() so the test computes the expected metric date at assertion
time (matching the upsertXxx decision), and update tests to use a fixed Clock or
inject the same Clock used by the production upsert logic (prefer refactoring
production code to accept a java.time.Clock) so both test and upsertXxx compute
metricDate from the same time source.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) | ||
| */ | ||
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | ||
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); |
There was a problem hiding this comment.
잘못된 date 입력 시 500으로 전파된다.
LocalDate.parse(date, DATE_FORMAT)에서 던지는 DateTimeParseException이 어디에서도 잡히지 않아, ApiControllerAdvice를 통해 INTERNAL_ERROR(500)로 응답된다. ErrorType.INVALID_RANKING_DATE_FORMAT(400)이 이미 정의되어 있고 daily/hourly 측은 RankingKeyResolver에서 CoreException으로 변환해 400을 반환하는데, 신규 weekly/monthly 경로만 400이 아닌 500을 반환하게 되어 클라이언트 오용을 장애로 오인하게 된다. 동일한 문제는 ReadWeeklyRankingsUseCase에도 존재할 것으로 보이므로 공통 유틸(예: RankingKeyResolver.parseDate(...))로 일원화하는 것을 권장한다.
추가 테스트로 RankingV1ApiE2ETest에 date=invalid로 요청 시 400과 INVALID_RANKING_DATE_FORMAT 코드가 반환되는 케이스를 추가해야 한다.
♻️ 수정안 예시
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
- LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ LocalDate scoreDate;
+ try {
+ scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT);
+ }
List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size());As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다." 또한 retrieved learnings("enforce unified error handling by routing errors through CoreException to ApiControllerAdvice")에 따라 도메인 오류를 CoreException으로 일관되게 반환해야 한다.
📝 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.
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); | |
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | |
| LocalDate scoreDate; | |
| try { | |
| scoreDate = LocalDate.parse(date, DATE_FORMAT); | |
| } catch (DateTimeParseException e) { | |
| throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); | |
| } | |
| List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.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/application/ranking/ReadMonthlyRankingsUseCase.java`
at line 36, The LocalDate.parse call in ReadMonthlyRankingsUseCase is allowing
DateTimeParseException to bubble up and produce a 500; catch
DateTimeParseException around LocalDate.parse(date, DATE_FORMAT) (and similarly
in ReadWeeklyRankingsUseCase) and instead throw a CoreException with
ErrorType.INVALID_RANKING_DATE_FORMAT, preserving the original exception as the
cause and separating user message from log message; better yet, move parsing
into a shared helper (e.g., RankingKeyResolver.parseDate(...)) that performs
this catch-and-wrap behavior so both weekly/monthly use it, and add an E2E test
in RankingV1ApiE2ETest that requests date=invalid and asserts a 400 with
INVALID_RANKING_DATE_FORMAT.
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | ||
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); | ||
| List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked( | ||
| scoreDate, pageSize.page(), pageSize.size()); |
There was a problem hiding this comment.
LocalDate.parse 실패가 CoreException으로 래핑되지 않아 응답 일관성이 깨진다.
잘못된 date 쿼리 파라미터(예: date=2026-04-14, date=abcd)가 들어오면 DateTimeParseException이 그대로 전파되어 ApiControllerAdvice의 표준 에러 포맷을 벗어난 500 응답이 반환될 위험이 있다. 리포지토리 학습에 따르면 commerce-api는 모든 에러를 CoreException 경로로 통일하도록 규약되어 있다(RankingKeyResolver의 validateDateFormat 참고).
운영 관점에서 API 에러 응답 스키마 불일치는 클라이언트 처리 분기를 늘리고 SLO 지표에도 오탐을 만든다. 주간/월간 UseCase 모두 RankingKeyResolver.validateDateFormat류 검증을 재사용하거나, try/catch (DateTimeParseException) → CoreException(BAD_REQUEST, ...) 래핑을 추가해야 한다. 루트 원인이 동일하므로 Monthly UseCase에도 동일 수정이 필요하다.
추가 테스트: RankingV1ApiE2ETest에 잘못된 date 포맷(예: "invalid", "2026-04-14")에 대해 표준 에러 포맷(4xx)을 반환하는 케이스를 추가하기를 권고한다.
🛡️ 제안 수정
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
- LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ LocalDate scoreDate;
+ try {
+ scoreDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
+ }
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(Based on learnings: "loop-pack-be-l2-vol3-java 프로젝트에서 에러 처리는 CoreException을 통해 ApiControllerAdvice로 라우팅하도록 통일한다."
📝 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.
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | |
| LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); | |
| List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked( | |
| scoreDate, pageSize.page(), pageSize.size()); | |
| public RankingPageResult execute(Long userId, String date, PageSize pageSize) { | |
| LocalDate scoreDate; | |
| try { | |
| scoreDate = LocalDate.parse(date, DATE_FORMAT); | |
| } catch (DateTimeParseException e) { | |
| throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다."); | |
| } | |
| List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked( | |
| scoreDate, pageSize.page(), pageSize.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/application/ranking/ReadWeeklyRankingsUseCase.java`
around lines 35 - 38, The execute method in ReadWeeklyRankingsUseCase parses the
date with LocalDate.parse which can throw DateTimeParseException and bypass API
error normalization; wrap the parse in a try/catch that catches
DateTimeParseException and rethrow a CoreException with BAD_REQUEST (reuse the
message/format style used by RankingKeyResolver.validateDateFormat), then
proceed to call rankingService.readWeeklyTopRanked as before; apply the
identical change to the monthly use case (MonthlyReadRankingsUseCase / similar
execute method) and add an E2E test in RankingV1ApiE2ETest asserting that
invalid date formats (e.g., "invalid", "2026-04-14") return the standardized 4xx
CoreException error response.
| @Entity | ||
| @Table(name = "mv_product_rank_monthly", indexes = { | ||
| @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") | ||
| }) | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Getter | ||
| public class ProductRankingMonthly { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDate scoreDate; | ||
|
|
||
| @Column(nullable = false) | ||
| private Double score; | ||
|
|
||
| public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) { | ||
| ProductRankingMonthly ranking = new ProductRankingMonthly(); | ||
| ranking.productId = productId; | ||
| ranking.scoreDate = scoreDate; | ||
| ranking.score = score; | ||
| return ranking; | ||
| } |
There was a problem hiding this comment.
읽기 전용 뷰 엔티티의 쓰기 경로 차단이 필요하다.
본 엔티티는 "배치가 집계한 mv_product_rank_monthly 테이블의 읽기 전용 매핑"으로 문서화되어 있지만, MonthlyRankingJpaRepository가 JpaRepository<ProductRankingMonthly, Long>를 상속하여 save, saveAll, delete, deleteAll 등 쓰기 메서드가 전부 노출된다. 운영 관점에서 다음 리스크가 있다:
- API 모듈에서 실수로
save(...)를 호출하면 배치가 관리하는 테이블 상태가 오염될 수 있다(동일(product_id, score_date)유니크 제약이 본 엔티티에는 빠져 있어 중복 insert 시 원인 파악도 어려워진다). - 도메인 문서(읽기 전용)와 실제 노출 API 간 계약이 불일치한다.
수정안으로는 다음 중 하나를 권장한다:
MonthlyRankingJpaRepository를JpaRepository대신Repository<ProductRankingMonthly, Long>(또는 커스텀 최소 인터페이스)로 변경해 쓰기 메서드를 노출하지 않는다.- 엔티티에
@Immutable(Hibernate) 또는@Entity(..., readOnly=true)에 준하는 제약을 걸고, 세터/팩토리 노출을 축소한다. - 배치 모듈의 엔티티와 동일하게 API 모듈 엔티티에도
(product_id, score_date)유니크 제약을 선언해 스키마 계약을 일치시킨다(현재 배치 모듈ProductRankingMonthly에는 유니크 제약이 있고, API 모듈에는 없다).
추가 테스트로, MonthlyRankingJpaRepository의 쓰기 경로가 허용되지 않음을 확인하는 아키텍처 테스트(ArchUnit 등) 또는 리포지토리 계약 테스트를 제안한다.
🤖 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/ProductRankingMonthly.java`
around lines 22 - 49, ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.
| @Entity | ||
| @Table(name = "mv_product_rank_weekly", uniqueConstraints = { | ||
| @UniqueConstraint(columnNames = {"product_id", "score_date"}) | ||
| }) | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Getter | ||
| public class ProductRankingWeekly { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private Long productId; | ||
|
|
||
| @Column(nullable = false) | ||
| private LocalDate scoreDate; | ||
|
|
||
| @Column(nullable = false) | ||
| private Double score; | ||
|
|
||
| public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) { | ||
| ProductRankingWeekly ranking = new ProductRankingWeekly(); | ||
| ranking.productId = productId; | ||
| ranking.scoreDate = scoreDate; | ||
| ranking.score = score; | ||
| return ranking; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# naming-strategy 설정과 실제 DDL의 컬럼명 일치 여부 확인
rg -nP '(physical-naming-strategy|implicit-naming-strategy|hibernate\.physical_naming_strategy)' --type=yaml --type=properties --type=java
fd -t f 'mv_product_rank_weekly|mv_product_rank_monthly' -e sql | xargs -I{} cat {}
rg -nP 'mv_product_rank_(weekly|monthly)' -C3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 13478
🏁 Script executed:
# DB 마이그레이션 스크립트 찾기
find . -name "*.sql" -type f | head -20
# mv_product_rank_weekly 테이블 정의 찾기
find . -name "*.sql" -type f -exec grep -l "mv_product_rank_weekly" {} \;
# ProductRankingWeekly가 Set/Map에 사용되는지 확인
rg -n 'Set<ProductRankingWeekly>|Map.*ProductRankingWeekly' --type java
# batch job에서 엔티티 컬렉션 처리 방식 확인
rg -n 'ProductRankingWeekly' --type java -C2 | grep -E '(saveAll|Set|Map|HashSet|HashMap)'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1299
🏁 Script executed:
# 전체 SQL 파일 찾기 및 내용 확인
find . -name "*.sql" -type f
# batch 모듈의 ProductRankingWeekly 정의 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java
# API 모듈의 ProductRankingWeekly 정의 확인 (있다면)
if [ -f "apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java" ]; then
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java
else
echo "API 모듈에는 ProductRankingWeekly 없음"
fi
# Repository 실제 구현 확인
cat apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3364
🏁 Script executed:
# 배치 모듈 ProductRankingMonthly도 확인 (비슷한 패턴인지)
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java
# API 모듈 ProductRankingMonthly도 확인
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java
# 두 모듈 간 entity 정의 차이 정리
echo "=== Comparing batch vs API ProductRankingWeekly ==="
diff -u <(sed -n '/^@Entity/,/^}/p' apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | head -25) <(sed -n '/^@Entity/,/^}/p' apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | head -25)Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2799
엔티티의 equals/hashCode 구현 명시를 고려하고, JPA 제약 선언의 실효성을 확인한다.
-
equals/hashCode구현 부재에 대한 안내: 현재 배치 집계 코드에서는ProductRankingWeekly를List.saveAll(List<ProductRankingWeekly>)형태로만 사용하므로 해시 기반 컬렉션(Set/Map)의 키로 사용되지 않는다. 그러나 향후 코드 변경(예: 중복 제거, 캐싱)에서 Set/Map 사용이 발생할 가능성이 있으므로, 현재 의도를 명확히 하기 위해 의도적 미구현이라면 주석으로 표기하거나, 식별자 기반equals/hashCode를 구현하는 것을 권장한다. -
JPA 제약 선언의 실효성 확인: 이 레포지토리는 외부 DB 마이그레이션 스크립트로 스키마를 관리하므로(ddl-auto 미사용),
@UniqueConstraint선언이 실제 데이터베이스 제약을 생성하지 않는다. 따라서 해당 어노테이션은 의도 문서화 역할만 하며, 실제 유니크 제약은 외부 마이그레이션 스크립트가 보장해야 한다. 배치 모듈의ProductRankingWeekly에만@UniqueConstraint가 있고 API 모듈 버전에는 없는 점을 고려하여, 엔티티 정의 간 일관성을 검토한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java`
around lines 17 - 44, ProductRankingWeekly lacks equals/hashCode and has an
`@UniqueConstraint` that may be only documentation: either implement
identifier-based equals/hashCode (e.g., using id when non-null, otherwise
fallback to business key productId+scoreDate) on ProductRankingWeekly to make
behavior explicit for future Set/Map use, or add a clear class-level comment
stating equals/hashCode are intentionally omitted; additionally, verify that the
`@UniqueConstraint` on the entity is documented as non-enforcing when ddl-auto is
disabled and confirm the actual unique constraint exists in your external
migration scripts and is consistent with other modules' entity definitions (API
module) so schema/behavior remain synchronized.
| @Query("SELECT new com.loopers.domain.metrics.ProductScoreProjection(" | ||
| + "m.productId, " | ||
| + "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) " | ||
| + "FROM ProductMetrics m " | ||
| + "WHERE m.metricDate BETWEEN :start AND :end " | ||
| + "GROUP BY m.productId " | ||
| + "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC") | ||
| List<ProductScoreProjection> findTopScores( | ||
| @Param("start") LocalDate start, | ||
| @Param("end") LocalDate end, | ||
| Pageable pageable | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🌐 Web query:
Hibernate 6.4 JPQL ORDER BY alias referencing aggregate in SELECT with constructor expression
💡 Result:
In Hibernate 6.4 JPQL, you cannot use an ORDER BY alias that references an aggregate expression within a constructor expression (e.g., SELECT new DTO(field, count()) AS cnt ... ORDER BY cnt). This is not supported by the JPQL specification grammar. JPQL constructor expressions (NEW constructor_item ...) do not allow AS aliases on individual constructor_items inside the parentheses, as per JPA spec BNF: constructor_item ::= single_valued_path_expression | scalar_expression | aggregate_expression | identification_variable (no [AS] result_variable allowed). For tuple projections (SELECT field AS f1, count() AS cnt ...), aliases define Tuple element names accessible in Java via tuple.get("cnt"), but these aliases cannot be referenced in ORDER BY. ORDER BY must repeat the expression (e.g., ORDER BY count(*)) or use entity paths. This limitation persists in Hibernate 6.4; official docs show aggregate examples with AS aliases in SELECT but ORDER BY uses repeated expressions or paths. Older Hibernate docs (3.x) showed AS aliases usable in ORDER BY, but modern JPQL/HQL follows JPA spec strictly. Workarounds: 1. Repeat aggregate in ORDER BY: SELECT new DTO(e, count(o)) FROM Entity e JOIN e.orders o GROUP BY e ORDER BY count(o) DESC 2. Use CriteriaQuery for complex sorting. 3. Native SQL query for full SQL alias support. 4. Tuple projection without constructor, sort in Java (inefficient for large results).
Citations:
- 1: https://docs.hibernate.org/orm/6.4/querylanguage/html_single/
- 2: https://docs.jboss.org/hibernate/orm/6.4/querylanguage/html_single/Hibernate_Query_Language.html
- 3: Support for "SELECT ... AS identifier1, ... AS identifier2" in JPQL constructor expressions (to be used by WHERE and/or ORDER BY clauses) jakartaee/persistence#297
- 4: https://stackoverflow.com/questions/25477734/jpa-jpql-select-new-with-count-group-by-and-order-by
- 5: http://www.objectdb.com/java/jpa/query/jpql/order
- 6: https://www.objectdb.com/java/jpa/query/jpql/group
- 7: https://stackoverflow.com/questions/59677453/eclipselink-throws-jpqlexception-on-using-select-as-in-a-constructor-express
가중 합산식이 SELECT·ORDER BY에 중복 정의되어 향후 튜닝 시 불일치 위험이 있다.
운영 관점에서 가중치(0.1/0.2/0.7)를 조정할 때 두 위치 중 한쪽만 수정하면 정렬과 점수가 어긋나 랭킹 품질이 저상된다.
Hibernate 6.4 JPQL 사양에서는 생성자 표현식(NEW constructor_item) 내 개별 항목에 AS 별칭을 붙일 수 없으므로, ORDER BY에서 별칭으로 재사용하는 방식은 동작하지 않는다. 따라서 SELECT와 ORDER BY 양쪽에 동일한 집계식을 반복하거나, CriteriaQuery나 네이티브 SQL을 사용해야 한다.
♻️ 제안 리팩터(집계식 반복)
`@Query`("SELECT new com.loopers.domain.metrics.ProductScoreProjection("
+ "m.productId, "
+ "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) "
+ "FROM ProductMetrics m "
+ "WHERE m.metricDate BETWEEN :start AND :end "
+ "GROUP BY m.productId "
+ "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC")또는 CriteriaQuery를 사용하여 중복을 근본적으로 제거하는 것을 고려한다.
가중치 변경 시 정렬 역전/동률 처리 회귀를 막기 위한 테스트 1건(가중치 경계값, 동점 상품의 productId tie-break 확인)을 추가하기를 권고한다.
🤖 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/metrics/persistence/ProductMetricsJpaRepository.java`
around lines 16 - 27, The weighted-sum expression is duplicated in the JPQL
SELECT and ORDER BY inside ProductMetricsJpaRepository.findTopScores, risking
inconsistency when tuning weights; fix by centralizing the aggregation: either
repeat the exact same SUM(...) expression in both the constructor expression and
ORDER BY (ensure the numeric literals 0.1, 0.2, 0.7 remain identical), or
replace the JPQL query with a CriteriaQuery or native SQL that computes the
weighted score once and maps it to
com.loopers.domain.metrics.ProductScoreProjection; update findTopScores to use
the chosen approach and add a unit test that verifies ordering/tie-break
behavior when weights change.
| @Modifying | ||
| @Query( | ||
| value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) " | ||
| + "VALUES (:productId, :scoreDate, :score) " | ||
| + "ON DUPLICATE KEY UPDATE " | ||
| + "score = :score", | ||
| nativeQuery = true | ||
| ) | ||
| void upsert( | ||
| @Param("productId") Long productId, | ||
| @Param("scoreDate") LocalDate scoreDate, | ||
| @Param("score") Double score | ||
| ); |
There was a problem hiding this comment.
@Modifying 네이티브 upsert에 clearAutomatically/flushAutomatically가 없다.
배치 Tasklet이 트랜잭션 내부에서 saveAll → upsert를 호출하는데, 동일 트랜잭션 내에 같은 엔티티가 관리 상태로 존재하거나 이후 동일 키에 대한 조회가 발생하면, 네이티브 INSERT ... ON DUPLICATE KEY UPDATE는 DB만 갱신하고 1차 캐시는 반영되지 않아 stale read/덮어쓰기 꼬임이 발생할 수 있다. 배치 rerun 및 멱등성 관점에서 위험하므로 다음을 권장한다.
`@Modifying`
+ `@Modifying`(clearAutomatically = true, flushAutomatically = true)
`@Query`(
value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) "
+ "VALUES (:productId, :scoreDate, :score) "
+ "ON DUPLICATE KEY UPDATE "
+ "score = :score",
nativeQuery = true
)또한 루프를 통한 per-row upsert는 상위 100건 기준으로는 무리 없으나 왕복이 100회 발생하므로, 추후 chunk upsert(다중 VALUES) 또는 JDBC batch로 전환하는 것을 로드맵에 둘 것을 권장한다. 추가 테스트로 "같은 트랜잭션 내에서 연속 upsert → 즉시 조회 시 최신 score 반환" 시나리오를 추가해야 한다.
동일한 지적은 ProductRankingMonthlyJpaRepository에도 해당된다.
As per coding guidelines (**/*Repository*.java): "트랜잭션 밖 Lazy 로딩 문제와 영속성 컨텍스트 오염 가능성을 점검한다."
🤖 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/persistence/ProductRankingWeeklyJpaRepository.java`
around lines 14 - 26, The native upsert method
ProductRankingWeeklyJpaRepository.upsert is annotated with `@Modifying` but lacks
flushAutomatically/clearAutomatically, which can leave the persistence context
stale after the native INSERT ... ON DUPLICATE KEY UPDATE; update the `@Modifying`
on ProductRankingWeeklyJpaRepository.upsert (and mirror in
ProductRankingMonthlyJpaRepository.upsert) to include flushAutomatically = true
and clearAutomatically = true so the EntityManager is flushed and the
persistence context cleared after the native call, and add a unit/integration
test that performs saveAll → upsert → immediate read within the same transaction
to assert the returned score is the updated value; also add a TODO note to
replace per-row upsert with multi-row VALUES or JDBC batch in future to avoid N
round-trips.
| @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") | ||
| @Test | ||
| void throwsException_whenDateIsNull() { | ||
| // arrange | ||
| ReflectionTestUtils.setField(tasklet, "date", null); | ||
|
|
||
| // act & assert | ||
| assertThatThrownBy(() -> tasklet.execute( | ||
| mock(StepContribution.class), mock(ChunkContext.class))) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .hasMessageContaining("date"); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# MonthlyRankingTasklet의 date 파라미터 검증 로직 확인
fd -t f 'MonthlyRankingTasklet.java' | xargs -I{} sed -n '1,120p' {}
fd -t f 'WeeklyRankingTasklet.java' | xargs -I{} sed -n '1,120p' {}Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6087
🏁 Script executed:
fd -t f 'MonthlyRankingTaskletTest.java' | xargs cat -nRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5992
date 파라미터의 빈 문자열, 공백, 형식 오류에 대한 경계 테스트가 누락되었다.
현재 테스트는 null 케이스만 검증한다. 운영 환경에서 CLI 파라미터로 "", 공백, 또는 잘못된 형식(예: "2026-04-14", "20260230")이 전달되면 LocalDate.parse()에서 DateTimeParseException이 발생하며, 이는 별도로 처리되지 않아 원인 파악이 어려운 장애로 이어질 수 있다. Tasklet의 입력 검증 계약을 명확히 테스트로 고정하는 것이 필요하다.
♻️ 추가 테스트 예시
+ `@DisplayName`("date 파라미터 형식이 yyyyMMdd가 아니면, 예외가 발생한다.")
+ `@Test`
+ void throwsException_whenDateFormatIsInvalid() {
+ ReflectionTestUtils.setField(tasklet, "date", "2026-04-14");
+
+ assertThatThrownBy(() -> tasklet.execute(
+ mock(StepContribution.class), mock(ChunkContext.class)))
+ .isInstanceOf(DateTimeParseException.class);
+ }📝 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.
| @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") | |
| @Test | |
| void throwsException_whenDateIsNull() { | |
| // arrange | |
| ReflectionTestUtils.setField(tasklet, "date", null); | |
| // act & assert | |
| assertThatThrownBy(() -> tasklet.execute( | |
| mock(StepContribution.class), mock(ChunkContext.class))) | |
| .isInstanceOf(IllegalArgumentException.class) | |
| .hasMessageContaining("date"); | |
| } | |
| `@DisplayName`("date 파라미터가 null이면, 예외가 발생한다.") | |
| `@Test` | |
| void throwsException_whenDateIsNull() { | |
| // arrange | |
| ReflectionTestUtils.setField(tasklet, "date", null); | |
| // act & assert | |
| assertThatThrownBy(() -> tasklet.execute( | |
| mock(StepContribution.class), mock(ChunkContext.class))) | |
| .isInstanceOf(IllegalArgumentException.class) | |
| .hasMessageContaining("date"); | |
| } | |
| `@DisplayName`("date 파라미터 형식이 yyyyMMdd가 아니면, 예외가 발생한다.") | |
| `@Test` | |
| void throwsException_whenDateFormatIsInvalid() { | |
| ReflectionTestUtils.setField(tasklet, "date", "2026-04-14"); | |
| assertThatThrownBy(() -> tasklet.execute( | |
| mock(StepContribution.class), mock(ChunkContext.class))) | |
| .isInstanceOf(DateTimeParseException.class); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java`
around lines 49 - 60, Add boundary tests to MonthlyRankingTaskletTest to cover
empty, whitespace, and malformed date strings: create new `@Test` methods that use
ReflectionTestUtils.setField(tasklet, "date", "") and setField(..., " ") and
setField(..., "2026-04-14") / "20260230", then call
tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); assert
that empty and whitespace date inputs throw IllegalArgumentException
(hasMessageContaining "date") and that malformed format inputs propagate
DateTimeParseException from LocalDate.parse; reference
MonthlyRankingTaskletTest, tasklet.execute, ReflectionTestUtils.setField, and
LocalDate.parse when adding these assertions.
✅ Actions performedReviews paused. |
- UseCase의 DateTimeParseException을 CoreException으로 래핑하여 400 응답 반환 - Tasklet의 DateTimeParseException을 IllegalArgumentException으로 래핑 - 인프라 레이어 @transactional(readOnly=true) 제거 (프로젝트 규칙 준수) - @Modifying에 clearAutomatically/flushAutomatically 추가 - @Index 선언을 batch 모듈(테이블 소유자)로 이동, api 모듈에서 제거 - E2E 테스트 flaky 방지를 위해 run.id 파라미터 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📌 Summary
product_metrics테이블의 일별 집계 데이터를 주간(7일)·월간(30일) 단위로 롤업하고, Materialized View 테이블에 적재하는 배치 Job을 구현한다. 이를 기반으로 주간·월간 랭킹 조회 API를 제공한다.weeklyRankingJob과monthlyRankingJob두 개의 Spring Batch Job을 구현했으며, 각각GET /api/v1/rankings/weekly,GET /api/v1/rankings/monthlyAPI로 조회할 수 있다. 기존 일간·시간 단위 랭킹(Redis)과 주간·월간 랭킹(DB)이 데이터 특성에 맞는 저장소를 사용하는 이원화 구조가 완성되었다.🧭 Context & Decision
1. 왜 Spring Batch인가? — @scheduled로 충분하지 않은가
가장 먼저 고민한 것은 "굳이 Spring Batch를 써야 하는가"였다. 단순히
@Scheduled메서드에서 쿼리를 실행하고 저장하면 되지 않는가?현재 데이터 규모에서는 @scheduled로도 동작하지만, 상품 수가 늘어나면 Chunk 기반 처리와 체크포인트 복구가 필요해진다. 또한 배치의 4가지 핵심 문제(Chunking, Checkpointing, Idempotency, Monitoring)를 프레임워크가 해결해주므로 Spring Batch를 선택했다.
2. Chunk vs Tasklet — 현재 규모에 맞는 선택
Spring Batch의 Step 패턴은 Chunk(Reader → Processor → Writer)와 Tasklet 두 가지가 있다.
핵심 판단 근거: 집계 쿼리가
GROUP BY product_id로 상위 100개만 반환하므로, Reader가 반복적으로 데이터를 가져올 필요가 없다. 한 번의 쿼리로 완결되는 작업에 Chunk 패턴을 적용하면 불필요한 구조 복잡도만 늘어난다. 향후 데이터 규모가 커져서 페이징이 필요해지면 Chunk 패턴으로 전환할 수 있다.3. Materialized View 설계 — MySQL의 한계와 대안
PostgreSQL/Oracle은
CREATE MATERIALIZED VIEW를 지원하지만 MySQL은 지원하지 않는다. 따라서 별도 테이블(mv_product_rank_weekly,mv_product_rank_monthly)을 생성하고 배치가 주기적으로 갱신하는 방식으로 구현했다.Refresh 전략: Upsert (INSERT ... ON DUPLICATE KEY UPDATE)
(product_id, score_date)유니크 제약으로 보장Upsert를 선택한 이유: 같은
date파라미터로 재실행해도 동일한 결과가 보장되어 멱등성을 자연스럽게 달성한다. Full Refresh는 테이블을 비우는 순간 API가 빈 결과를 반환하는 윈도우가 생기지만, Upsert는 기존 데이터를 유지하면서 갱신한다.4. product_metrics 시간 단위 변경 — 시간 단위 → 일 단위
기존
product_metrics는 시간(hour) 단위로 적재되어 있었다. 주간 집계 시 한 상품당 최대 168행(7일 × 24시간)을, 월간 집계 시 최대 720행(30일 × 24시간)을 읽어야 했다.일(day) 단위로 변경하면 주간 7행, 월간 30행으로 줄어든다. 현재 시스템에서 시간 단위 세분화가 필요한 요구사항이 없었으므로, 일 단위로 리팩터링하고
metric_date컬럼에 인덱스를 추가했다.5. 외부 트리거 vs @scheduled — 배치 실행 방식
date=20260401로 즉시 가능외부 트리거를 선택했다. 주간 배치는 일주일에 한 번, 약 수 분 실행된 후 나머지 시간은 유휴 상태다. 또한
date파라미터를 통해 과거 데이터 백필이 자유로워 운영 유연성이 높다.🏗️ Design Overview
변경 범위
product_metrics시간 단위를 일 단위로 변경, 인덱스 추가신규 추가
commerce-batch — Domain Layer
ProductMetrics: 일별 상품 메트릭 엔티티 (viewCount, likeCount, orderCount)ProductMetricsRepository: 가중 스코어 상위 N개 조회 포트ProductScoreProjection: 집계 결과 프로젝션 (productId, score)ProductRankingWeekly/ProductRankingMonthly: 랭킹 엔티티ProductRankingWeeklyRepository/ProductRankingMonthlyRepository: upsert 저장 포트commerce-batch — Infrastructure Layer
ProductMetricsJpaRepository: JPQL 기반 가중 스코어 집계 쿼리ProductRankingWeeklyJpaRepository/ProductRankingMonthlyJpaRepository: Native Query upsert (ON DUPLICATE KEY UPDATE)commerce-batch — Batch Layer
WeeklyRankingJobConfig/MonthlyRankingJobConfig: Job + Step 구성,@ConditionalOnProperty로 Job 선택 실행WeeklyRankingTasklet/MonthlyRankingTasklet: JobParameterdate를 기준으로 직전 7/30일 집계 후 upsertcommerce-api — Domain Layer
WeeklyRankingRepository/MonthlyRankingRepository: 조회 포트ProductRankingWeekly/ProductRankingMonthly: 읽기 전용 엔티티RankingService: 주간·월간 조회 메서드 추가commerce-api — Application Layer
ReadWeeklyRankingsUseCase/ReadMonthlyRankingsUseCase: 랭킹 조회 UseCaseRankingItem.toRankingItems(): 제네릭 변환 유틸리티 (DB 엔티티 → 랭킹 항목)commerce-api — Interfaces Layer
RankingV1Api:GET /api/v1/rankings/weekly,GET /api/v1/rankings/monthly엔드포인트 추가주요 컴포넌트 책임
WeeklyRankingTaskletMonthlyRankingTaskletProductMetricsJpaRepositoryview×0.1 + like×0.2 + order×0.7)RankingServiceRankingItem.toRankingItems()RankingResultAssembler🔁 Flow Diagram
배치 실행 흐름: product_metrics → Materialized View
sequenceDiagram autonumber participant CLI as External Trigger participant Job as WeeklyRankingJob participant Tasklet as WeeklyRankingTasklet participant MetricsRepo as ProductMetricsRepository participant RankingRepo as ProductRankingWeeklyRepository participant DB as MySQL CLI->>Job: bootRun --args="--spring.batch.job.name=weeklyRankingJob date=20260413" Job->>Tasklet: execute(contribution, chunkContext) Tasklet->>Tasklet: date 파라미터 파싱 → start=04/06, end=04/12 Tasklet->>MetricsRepo: findTopScores(start, end, 100) MetricsRepo->>DB: SELECT product_id, SUM(view×0.1 + like×0.2 + order×0.7)<br/>FROM product_metrics WHERE metric_date BETWEEN ... GROUP BY product_id LIMIT 100 DB-->>MetricsRepo: List<ProductScoreProjection> MetricsRepo-->>Tasklet: Top 100 스코어 Tasklet->>RankingRepo: saveAll(rankings) RankingRepo->>DB: INSERT INTO mv_product_rank_weekly ... ON DUPLICATE KEY UPDATE score = :score DB-->>RankingRepo: upsert 완료 Tasklet-->>Job: RepeatStatus.FINISHEDAPI 조회 흐름: 주간/월간 랭킹
sequenceDiagram autonumber participant Client participant API as RankingV1Api participant UseCase as ReadWeeklyRankingsUseCase participant DomainSvc as RankingService participant Repo as WeeklyRankingRepository participant DB as MySQL participant Assembler as RankingResultAssembler Client->>API: GET /api/v1/rankings/weekly?date=20260413&page=0&size=20 API->>UseCase: execute(userId, date, pageSize) UseCase->>DomainSvc: readWeeklyTopRanked(scoreDate, page, size) DomainSvc->>Repo: readTopRanked(scoreDate, page, size) Repo->>DB: SELECT * FROM mv_product_rank_weekly WHERE score_date = ? ORDER BY score DESC DB-->>UseCase: List<ProductRankingWeekly> UseCase->>UseCase: RankingItem.toRankingItems(rankings, offset) UseCase->>Assembler: assemble(userId, rankingItems, pageSize) Assembler-->>API: RankingPageResult (상품 정보 + brandId + 좋아요 여부) API-->>Client: RankingResponse✅ Checklist
🧱 Spring Batch
WeeklyRankingJobConfig,MonthlyRankingJobConfig에서@ConditionalOnProperty로 Job 선택,dateJobParameter로 집계 기준일 전달WeeklyRankingTasklet,MonthlyRankingTasklet에서 Tasklet 패턴으로 구현 (집계 SQL 1회 → 결과 저장 1회로 완결되는 작업이므로 Tasklet 선택)mv_product_rank_weekly,mv_product_rank_monthly테이블에(product_id, score_date)유니크 제약 +ON DUPLICATE KEY UPDATE로 멱등성 보장🧩 Ranking API
🧪 검증
WeeklyRankingTaskletTest,MonthlyRankingTaskletTest(Mockito 기반, 파라미터 검증·집계 결과 저장 검증)WeeklyRankingJobE2ETest,MonthlyRankingJobE2ETest(Testcontainers + @SpringBatchTest, 실제 DB 집계·멱등성 검증)WeeklyRankingRepositoryImplIntegrationTest,MonthlyRankingRepositoryImplIntegrationTest(scoreDate 필터링, 페이지네이션, 정렬 검증)RankingV1ApiE2ETest에 주간·월간 랭킹 테스트 추가 (순위 정렬, 상품 정보 Aggregation, 페이지네이션 검증)🤖 Generated with Claude Code
주간·월간 랭킹 시스템 DB 기반 구현
변경 목적
Redis 기반의 실시간 랭킹에 보완하여 일일 집계 메트릭으로부터 계산된 주간(7일)·월간(30일) 랭킹을 Materialized View 테이블(
mv_product_rank_weekly/monthly)에 사전 계산하고 API로 제공. Spring Batch 기반 배치 작업으로 매일 실행 가능한 체계 구축.핵심 변경점
Spring Batch 작업:
WeeklyRankingJobConfig/MonthlyRankingJobConfig에서 Tasklet 기반 작업 정의.ProductMetricsRepository.findTopScores(start, end, 100)로 상위 100개 상품 조회 후 스코어 공식(view×0.1 + like×0.2 + order×0.7)으로 계산된 스코어를 DB에 Upsert(ON DUPLICATE KEY UPDATE)로 저장하여 멱등성 보장.신규 API 엔드포인트:
/api/v1/rankings/weekly·/monthly추가로 기존 daily/hourly(Redis 기반)와 함께 제공. 동일한RankingPageResult응답 형식 사용으로 클라이언트 호환성 유지.ProductMetrics 단위 변경: 시간(
metricHour) → 일(metricDate)로 데이터 단위 전환. Streamer와 Batch가 각각 별도의ProductMetrics클래스를 정의하고 있으며, 둘 다 이미LocalDate metricDate및metric_date테이블 구조로 변경됨.리스크 및 주의사항
apps/commerce-batch와apps/commerce-streamer에 동일명의 클래스가 분리되어 존재. 향후 스키마 변경 시 두 위치를 동시에 반영해야 함. 스키마 동기화 자동화 메커니즘 필요 여부 검토 권장.date)로만 트리거되며, 스케줄 설정이 포함되지 않음. 정기적 실행을 위한 별도의 스케줄러 또는 워크플로우 구성 필요 여부 확인.테스트 커버리장
Tasklet 단위 테스트(Mockito), Spring Batch E2E 테스트(
@SpringBatchTest), 멱등성 검증(동일 date로 run.id만 변경하여 재실행), Repository 통합 테스트 포함. 약 900+ 줄의 테스트 코드로 주간/월간 집계 로직과 Upsert 동작 검증.