[VOLUME-10] Spring Batch 를 이용한 주간, 월간 랭킹 구현 - 조용민#391
Conversation
AS-IS: - Kafka 토픽 설정이 단일 브로커/클러스터 구분 없이 하나로 존재 - VIEWED 이벤트 발행/랭킹 조회 실패 시 상품 조회 전체 실패 - ORDERED 이벤트 Outbox 저장 실패 시 주문 전체 롤백 - LocalDate.now() 시스템 기본 타임존 사용 (서버 UTC 시 날짜 불일치) - RankingService safeOrderValue overflow 가능성 TO-BE: - @Profile("cluster") 분리 — replicas=3, min.isr=2 (클러스터 전용) - acks-one 프로파일 추가 (메시지 유실 실험용) - 비핵심 부수효과 fail-open 처리 (log.warn + 정상 응답) - ZoneId.of("Asia/Seoul") 명시적 적용 (RankingService, CarryOver) - Math.log1p + double 캐스팅으로 안전한 점수 계산 - CatalogEvent.of()에 ORDERED 가드 추가 - BYPASS_OUTBOX, SLOW_ACK 실험 플래그 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS: - CatalogEventConsumer가 product_metrics(실시간)만 갱신 - 일별 스냅샷 데이터가 없어 주간/월간 배치 집계 불가 TO-BE: - ProductMetricsDaily 엔티티 신규 (@IdClass 복합 PK: productId + metricDate) - record() 메서드로 이벤트 타입별 카운트 증감 (VIEWED/LIKED/UNLIKED/ORDERED) - CatalogEventConsumer에 upsertDailyMetrics() 추가 — 이벤트 처리 시 daily 테이블 동시 적재 - 원천 데이터(카운트)만 저장, 점수 가공은 배치에서 수행 (원본/가공 분리) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS: - 일간 랭킹만 Redis ZSET으로 제공 - 주간/월간 집계 데이터 없음 TO-BE: - weeklyRankingJob: product_metrics_daily 7일치 합산 → mv_product_rank_weekly TOP 100 적재 - monthlyRankingJob: product_metrics_daily 월간 합산 → mv_product_rank_monthly TOP 100 적재 - 2-Step 구조: Cleanup(Tasklet, 기존 데이터 삭제) → Aggregate(Chunk, 집계 + 적재) - JdbcCursorItemReader로 SQL 직접 작성 (모듈 간 JPA 엔티티 의존 회피) - @ConditionalOnProperty로 Job별 빈 생성 격리 - @StepScope + JobParameter로 실행 날짜 외부 주입 - 점수 산출: view*1 + like*2 + order*7 - 재실행 시 멱등 보장 (DELETE → INSERT) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS: - GET /api/v1/rankings → 일간(Redis ZSET)만 조회 가능 TO-BE: - GET /api/v1/rankings?period=daily → Redis ZSET (기존) - GET /api/v1/rankings?period=weekly → mv_product_rank_weekly (MV 테이블) - GET /api/v1/rankings?period=monthly → mv_product_rank_monthly (MV 테이블) - RankingPeriod enum, ProductRankEntry VO 신규 - commerce-api 전용 조회 엔티티 (@immutable) — commerce-batch 의존 없이 독립 - RankingRepositoryImpl로 MV 테이블 조회 → 도메인 VO 변환 - RankingFacade에 enrichRankEntries() 헬퍼로 상품/브랜드 정보 공통 조합 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS - 주간/월간 배치 Job과 period 확장 API가 단위 테스트만 존재 TO-BE - WeeklyRankingJobE2ETest: 7일치 daily 데이터 집계 + 멱등성 검증 (2건) - MonthlyRankingJobE2ETest: 월간 daily 집계 + 다른 달 배제 + 멱등성 검증 (2건) - RankingV1ApiE2ETest: weekly/monthly 조회 + period/date 파라미터 검증 (4건) 설계 포인트 - @TestPropertySource에 spring.batch.job.enabled=false 추가 → auto-launch가 weekStartDate 없이 job을 먼저 실행해 NPE 내는 것을 차단 (@ConditionalOnProperty 는 spring.batch.job.name만 보므로 Job 빈 로딩에는 영향 없음) - product_metrics_daily 엔티티는 streamer 모듈에 있으므로 배치 테스트에서 @BeforeAll로 CREATE TABLE 수동 생성 + @AfterEach로 DELETE - commerce-api의 @immutable MV 엔티티에는 count 컬럼이 없으므로 API 테스트 INSERT에서 view/like/order count 컬럼 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS - FakeProductRepository 3곳에 findAllByIds() 미구현 (9주차 랭킹 조회 API 도입 시 인터페이스 확장됨) - OrderServiceTest가 OutboxEventRepository/ObjectMapper 없이 OrderService 생성자 호출 (7주차 Outbox 도입 시 시그니처 변경됨) TO-BE - FakeProductRepository (OrderServiceTest/LikeServiceTest/OrderDomainServiceTest) 에 findAllByIds 스텁 추가 - OrderServiceTest setUp에 FakeOutboxEventRepository + ObjectMapper 주입 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS - 일간/주간/월간 랭킹 조회 API의 성능 비교 수단 없음 TO-BE - tests/k6/get-rankings.js: period 파라미터로 daily/weekly/monthly 각각 부하 - 프로필 4종 (smoke/load/stress/spike) 기준 정의 - 로드 프로필(VU 100, 30s)로 p95<500ms, p99<1000ms, 에러율<1% threshold Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
랭킹 조회 시 Brand 정보를 채우는 fetchBrandMap이 distinct brand_id마다 findById를 개별 호출하여 N+1 발생 (요청당 5회 round trip). findAllByIds로 IN 절 배치 조회로 변경. [k6 부하테스트 결과 (warmup 동일 적용, 100 VU 30s)] - weekly p99: 74.10ms → 11.82ms (-84%) - monthly p99: 59.47ms → 14.26ms (-76%) - daily p99: 45.45ms → 30.95ms (-32%) AS-IS: brandRepository.findById(id) × N TO-BE: brandRepository.findAllByIds(brandIds) × 1
배치로 생성한 MV 테이블 + Redis ZSET을 일괄 시드하고, daily/weekly/monthly 3개 period를 순차 측정하는 자동화 스크립트. - seed.sh: Brand 5 + Product 100 UPSERT + MV 100 INSERT + ZSET ZADD - run-all.sh: health 체크 후 ORDER/LABEL/WARMUP 옵션으로 k6 일괄 실행 - ORDER: 실행 순서 변경 (cold start 영향 격리 실험용) - LABEL: 결과 파일 접미사 (실험 구분) - WARMUP: 메인 측정 전 warmup 단계 (cold start 제거 실험용) .gitignore: tests/k6/results/, .claude/, infra.md 제외
📝 WalkthroughWalkthrough순위 기능을 주간 및 월간 집계로 확장하고, 배치 작업으로 MV 테이블 자동 집계하며, 제품 일일 메트릭 수집을 추가한다. 부분 오류 허용과 Kafka 클러스터 프로필 설정을 도입한다. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
운영 관점 지적사항1. 오류 처리의 무음적(silent) 실패 위험문제점: 권장사항:
추가 테스트: 데이터베이스 다운, 타임아웃 시나리오에서 일관성 검증 테스트 추가 2. 타임존 고정 적용의 불완전성문제점: 권장사항:
추가 테스트: 다양한 타임존에서 날짜 경계 케이스 테스트 (자정 근처) 3. Outbox 우회 플래그의 테스트 범위 제한문제점: 권장사항:
추가 테스트: 우회 모드에서 4. 배치 작업의 멱등성 검증 부족문제점: 권장사항:
추가 테스트: 집계 중 데이터베이스 다운 시나리오, 부분 쓰기 시나리오 재현 5. Kafka 클러스터 프로필 설정의 검증 미흡문제점: 권장사항:
추가 테스트: Kafka 브로커 수가 부족한 환경에서의 배포 실패 테스트 6. 메트릭 수집의 타이밍 불일치문제점: 권장사항:
추가 테스트: 메트릭 저장 시 데이터베이스 오류 시나리오 재현 🚥 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 |
AS-IS - Weekly/Monthly Reader SQL에 가중치(1, 2, 7) 리터럴이 흩어져 있어 공식 변경 시 두 곳을 동시에 고쳐야 했고, 한쪽만 바뀌면 SQL과 Java 계산값이 어긋나도 적재까지 통과되는 구조였음. TO-BE - VIEW/LIKE/ORDER 가중치 상수와 calculate()를 RankingScoreCalculator에 응집. - Reader SQL은 .formatted()로 동일한 상수를 주입해 가중치 단일 소스 확보. - Processor에서 assertConsistent()로 SQL 결과와 Calculator 공식이 drift되면 즉시 실패시켜 잘못된 랭킹 적재를 차단.
There was a problem hiding this comment.
Actionable comments posted: 18
🧹 Nitpick comments (26)
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java (1)
7-29: serialVersionUID 누락은 직렬화 호환성 위험이 있다.운영 관점 문제:
클래스 구조가 변경되면 기존 직렬화된 데이터와의 호환성이 깨질 수 있다. JPA 복합키에서 직렬화가 직접 사용되는 경우는 드물지만, 분산 캐시나 세션 클러스터링 환경에서 문제가 될 수 있다.수정안:
public class MvProductRankWeeklyId implements Serializable { + private static final long serialVersionUID = 1L; private Long productId;추가 테스트:
- 현재 환경에서 직렬화가 사용되지 않는다면 낮은 우선순위로 처리해도 된다.
🤖 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/MvProductRankWeeklyId.java` around lines 7 - 29, Add a serialVersionUID to the Serializable class MvProductRankWeeklyId to ensure serialization compatibility; declare a private static final long serialVersionUID (e.g., 1L) inside the class (alongside productId and weekStartDate) so future structural changes don't break serialized forms used by caches or session replication, and update the value if you intentionally change the serialized contract.apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java (1)
54-78: drift 검증에 경계값 테스트를 추가하는 편이 안전하다.운영 관점에서 배치 안정성은 오탐/미탐 경계에서 깨지기 쉽다.
수정안으로 근접값(아주 작은 차이)과 명확한 차이(큰 오차)를 분리한 테스트를 추가하는 것이 좋다.
추가 테스트 예시는565.0 + 1e-10허용,565.0 + 1e-3예외 발생 검증이다.🤖 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/domain/ranking/RankingScoreCalculatorTest.java` around lines 54 - 78, Add boundary drift tests around RankingScoreCalculator.assertConsistent: keep the existing "consistent" and "drift" cases, and add two more tests that verify a tiny numerical difference is allowed and a larger difference triggers an exception — e.g., call RankingScoreCalculator.assertConsistent with sqlScore = 565.0 + 1e-10 and expect no exception, and with sqlScore = 565.0 + 1e-3 and expect IllegalStateException (message containing "drift"); place them alongside the existing Drift nested tests to ensure both near-equal tolerance and clear-drift failure are covered.apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)
47-53: fail-open 정책은 Controller 밖으로 이동하는 편이 낫다.현재 Controller가 viewed 이벤트 발행과 랭킹 조회의 예외 흡수 정책까지 직접 결정하고 있다. 운영 중 비슷한 폴백이 다른 엔드포인트로 늘어나면 로그 기준과 장애 대응 방식이 흩어져 일관성이 깨진다.
ProductFacade또는 별도 application service가상품 조회 + viewed 이벤트 fail-open + rank fail-open을 한곳에서 처리하고, Controller는 응답 조립만 담당하도록 정리하는 편이 낫다. 추가로 viewed 이벤트 실패와 rank 조회 실패 각각에서 200 응답이 유지되는 서비스 단위 테스트, Controller는 DTO 매핑만 검증하는 슬라이스 테스트를 보강하는 것이 좋다.As per coding guidelines "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다."
🤖 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/product/ProductV1Controller.java` around lines 47 - 53, Move the fail-open logic out of ProductV1Controller into the service/facade layer: create or extend ProductFacade (or a new ProductService) with a single method (e.g., getProductDetailWithFailOpen or fetchProductForDetail) that performs the product retrieval, calls productFacade.publishViewedEvent inside its own try/catch, calls rankingFacade.getRank inside its own try/catch, and returns a DTO or value object containing product + nullable rank; then update ProductV1Controller to call that new facade/service method and only perform DTO mapping/response assembly. Also add unit tests at the service level that assert viewed-event failure and rank lookup failure both still return the product (fail-open), and keep controller slice tests focused on mapping/validation only.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java (1)
43-58:PK클래스에serialVersionUID미정의이다.
Serializable구현체에서serialVersionUID를 명시하지 않으면 직렬화 호환성 문제가 발생할 수 있다. 특히 분산 캐시나 세션 복제 환경에서 문제가 될 수 있다.♻️ serialVersionUID 추가 제안
public static class PK implements Serializable { + private static final long serialVersionUID = 1L; + private Long productId; private LocalDate monthStartDate;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java` around lines 43 - 58, The PK inner class implements Serializable but lacks a serialVersionUID; add a private static final long serialVersionUID (e.g. 1L) to the PK class in MvProductRankMonthlyEntity to ensure serialization compatibility across deployments and avoid default compiler-generated IDs.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
66-75:ZoneId.of("Asia/Seoul")이 인라인으로 사용되어 유지보수성이 떨어진다.
CatalogEventConsumer에서는ZONE상수로 정의했으나, 이 컨트롤러에서는 인라인 문자열로 사용한다. 타임존 정책 변경 시 여러 파일을 수정해야 하며, 오타 위험도 있다.공통 상수 클래스로 추출하거나, 최소한 클래스 내 상수로 정의하라.
♻️ 상수 추출 제안
`@RequiredArgsConstructor` `@RestController` `@RequestMapping`("/api/v1/rankings") public class RankingV1Controller implements RankingV1ApiSpec { private static final int MAX_SIZE = 100; + private static final ZoneId ZONE_SEOUL = ZoneId.of("Asia/Seoul"); private final RankingFacade rankingFacade; // ... private LocalDate resolveDate(String date) { if (date == null) { - return LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + return LocalDate.now(ZONE_SEOUL); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java` around lines 66 - 75, The controller currently uses ZoneId.of("Asia/Seoul") inline inside resolveDate, reducing maintainability; extract that ZoneId into a constant and reuse it (or reference the existing shared ZONE constant used in CatalogEventConsumer) so time zone changes are centralized. Add a class-level constant (e.g., private static final ZoneId ZONE = ZoneId.of("Asia/Seoul")) or import the shared constant, then update resolveDate to use ZONE instead of calling ZoneId.of(...) directly.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java (1)
26-39:monthStartDate파싱을 캐싱하여 불필요한 반복 연산을 줄일 수 있다.
process()메서드가 아이템마다 호출되는데,LocalDate.parse(monthStartDate, ...)가 매번 수행된다.LIMIT 100이므로 성능 영향은 크지 않으나, 명확한 의도 표현과 일관성을 위해@PostConstruct또는 lazy initialization으로 캐싱하는 것이 좋다.♻️ 파싱 결과 캐싱 제안
`@StepScope` `@ConditionalOnProperty`(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) `@Component` public class MonthlyRankingProcessor implements ItemProcessor<ProductScoreRow, MvProductRankMonthly> { private int rankCounter; + private LocalDate parsedMonthStartDate; `@Value`("#{jobParameters['monthStartDate']}") private String monthStartDate; + private LocalDate getMonthStartDate() { + if (parsedMonthStartDate == null) { + parsedMonthStartDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); + } + return parsedMonthStartDate; + } + `@Override` public MvProductRankMonthly process(ProductScoreRow item) { RankingScoreCalculator.assertConsistent( item.getTotalScore(), item.getViewCount(), item.getLikeCount(), item.getOrderCount() ); - LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); return MvProductRankMonthly.of( - item.getProductId(), startDate, item.getTotalScore(), + item.getProductId(), getMonthStartDate(), item.getTotalScore(), item.getViewCount(), item.getLikeCount(), item.getOrderCount(), ++rankCounter ); } }🤖 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/MonthlyRankingProcessor.java` around lines 26 - 39, The method process() currently parses monthStartDate on every call; cache the parsed LocalDate in the MonthlyRankingProcessor to avoid repeated parsing. Add a private LocalDate field (e.g., parsedMonthStartDate) and initialize it once (either in a `@PostConstruct` method or via lazy initialization when first needed) instead of calling LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE) inside process(); then use parsedMonthStartDate when constructing MvProductRankMonthly and keep existing calls to RankingScoreCalculator.assertConsistent and rankCounter unchanged.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java (1)
31-37: Weekly Tasklet과 동일한 개선이 필요하다.
WeeklyRankingCleanupTasklet과 동일하게:
monthStartDatenull/blank 체크 추가- 삭제 건수 반환 및 로깅
운영 관점에서 월간 배치는 데이터량이 더 많으므로, 삭제 건수 모니터링이 더욱 중요하다.
♻️ 방어 코드 및 로깅 개선 제안
`@Override` public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (monthStartDate == null || monthStartDate.isBlank()) { + throw new IllegalStateException("monthStartDate job parameter is required"); + } LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); - repository.deleteByMonthStartDate(startDate); - log.info("[MonthlyCleanup] deleted monthStartDate={}", startDate); + int deletedCount = repository.deleteByMonthStartDate(startDate); + log.info("[MonthlyCleanup] deleted monthStartDate={}, count={}", startDate, deletedCount); return RepeatStatus.FINISHED; }🤖 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/MonthlyRankingCleanupTasklet.java` around lines 31 - 37, MonthlyRankingCleanupTasklet.execute currently parses monthStartDate unguarded and calls repository.deleteByMonthStartDate without checking input or logging the delete count; add the same defensive checks and logging as WeeklyRankingCleanupTasklet: validate monthStartDate for null/blank before parsing in execute, parse only when valid, call the repository delete method that returns the number of rows deleted (or modify repository.deleteByMonthStartDate to return int/long if needed), and log the deleted count with context (e.g., "deletedCount" and "monthStartDate") and return RepeatStatus.FINISHED. Ensure references: MonthlyRankingCleanupTasklet.execute, monthStartDate, repository.deleteByMonthStartDate.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java (1)
31-37: 삭제 건수 로깅 미포함으로 운영 모니터링이 어렵다.
deleteByWeekStartDate의 반환 타입이void이므로 삭제된 레코드 수를 알 수 없다. 배치 운영 시 멱등성 검증, 이상 탐지(예: 예상보다 많거나 적은 삭제)에 삭제 건수가 필요하다.Repository의
@Modifying메서드가int를 반환하도록 수정하거나, 삭제 전 카운트 쿼리를 추가하는 방안을 고려하라.추가로,
weekStartDate가null이거나 파싱 실패 시 명확한 에러 메시지와 함께 실패하도록 방어 코드를 추가하는 것이 좋다.♻️ 방어 코드 및 로깅 개선 제안
`@Override` public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (weekStartDate == null || weekStartDate.isBlank()) { + throw new IllegalStateException("weekStartDate job parameter is required"); + } LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); - repository.deleteByWeekStartDate(startDate); - log.info("[WeeklyCleanup] deleted weekStartDate={}", startDate); + int deletedCount = repository.deleteByWeekStartDate(startDate); + log.info("[WeeklyCleanup] deleted weekStartDate={}, count={}", startDate, deletedCount); return RepeatStatus.FINISHED; }Repository 메서드도 반환 타입 변경 필요:
`@Modifying` `@Query`("DELETE FROM MvProductRankWeekly m WHERE m.weekStartDate = :weekStartDate") int deleteByWeekStartDate(`@Param`("weekStartDate") LocalDate weekStartDate);🤖 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/WeeklyRankingCleanupTasklet.java` around lines 31 - 37, The execute method in WeeklyRankingCleanupTasklet currently calls repository.deleteByWeekStartDate(weekStartDate) which returns void so you can't log deleted row count or detect anomalies; change the repository method signature (deleteByWeekStartDate) to return int (annotated `@Modifying`) or, if you cannot change the repo, call a count query first, then perform the delete, capture the returned int/count and log it via log.info including weekStartDate and deletedCount; additionally add defensive checks in WeeklyRankingCleanupTasklet.execute to validate weekStartDate is non-null and that LocalDate.parse(...) is surrounded by try/catch to log and rethrow a clear error on parse failure.apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java (1)
7-17:serialVersionUID미정의로 직렬화 호환성 위험이 있다.
Serializable구현체에서serialVersionUID를 명시하지 않으면, JVM 버전 또는 클래스 구조 변경 시 역직렬화 실패(InvalidClassException)가 발생할 수 있다. 운영 환경에서 배치 재시작이나 세션 복원 시 문제가 될 수 있다.♻️ serialVersionUID 추가 제안
public class ProductMetricsDailyId implements Serializable { + + private static final long serialVersionUID = 1L; private Long productId; private LocalDate metricDate;🤖 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/ProductMetricsDailyId.java` around lines 7 - 17, The class ProductMetricsDailyId implements Serializable but lacks a serialVersionUID; add a private static final long serialVersionUID field (e.g. 1L) to ProductMetricsDailyId to ensure stable serialization across JVM/class changes. Place the serialVersionUID declaration near the top of the class (above the instance fields) so the JVM uses it during serialization for the ProductMetricsDailyId type.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java (1)
14-16: 삭제 건수 반환을 위해int반환 타입을 권장한다.
void반환 시 Tasklet에서 삭제량 모니터링이 불가하다. Spring Data JPA의@Modifying쿼리는int또는Integer반환을 지원한다.♻️ 반환 타입 변경 제안
`@Modifying` `@Query`("DELETE FROM MvProductRankMonthly m WHERE m.monthStartDate = :monthStartDate") -void deleteByMonthStartDate(`@Param`("monthStartDate") LocalDate monthStartDate); +int deleteByMonthStartDate(`@Param`("monthStartDate") LocalDate monthStartDate);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java` around lines 14 - 16, Change the repository method deleteByMonthStartDate in MvProductRankMonthlyJpaRepository to return the number of rows deleted instead of void: update the signature of deleteByMonthStartDate(`@Param`("monthStartDate") LocalDate monthStartDate) from void to int (or Integer) so the `@Modifying` `@Query` can return the delete count that the Tasklet can monitor; keep the existing `@Modifying` and `@Query` annotations and adjust any callers (e.g., the Tasklet) to use the returned int result.apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java (2)
53-65:@BeforeAll에서 테이블 생성만 하고 스키마 변경 시 DROP이 없어 테스트 실패 가능성이 있다.
CREATE TABLE IF NOT EXISTS는 테이블 존재 시 스키마 변경을 반영하지 않는다. 컬럼 추가/변경 시 테스트 환경에서 불일치가 발생할 수 있으므로DROP TABLE IF EXISTS후 생성하거나, 별도 마이그레이션 스크립트를 사용하는 것을 권장한다.♻️ 개선안
`@BeforeAll` static void createTable(`@Autowired` JdbcTemplate jdbcTemplate) { + jdbcTemplate.execute("DROP TABLE IF EXISTS product_metrics_daily"); jdbcTemplate.execute(""" CREATE TABLE IF NOT EXISTS product_metrics_daily (🤖 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 53 - 65, The test setup in MonthlyRankingJobE2ETest's `@BeforeAll` createTable method uses CREATE TABLE IF NOT EXISTS which won't update schema changes and can cause failures; modify createTable to first execute a DROP TABLE IF EXISTS product_metrics_daily using the provided JdbcTemplate, then recreate the table (or alternatively invoke your migration runner) so the test starts with a deterministic schema; update the method that references JdbcTemplate and ensure the SQL sequence (DROP then CREATE) runs before other tests.
73-114: 경계값 테스트 케이스 추가를 권장한다.현재 테스트는 정상 집계와 멱등성만 검증한다. 운영 안정성을 위해 다음 케이스 추가를 권장한다:
- 해당 월에 데이터가 없는 경우 (빈 결과 반환)
- 단일 상품만 있는 경우 (rank=1 검증)
- 월 경계 날짜 (말일 데이터 포함 여부)
🤖 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 73 - 114, Add extra boundary-focused test methods to MonthlyRankingJobE2ETest to cover empty-month, single-product, and month-boundary cases: create three new `@Test` methods that reuse insertDailyMetrics, jobLauncherTestUtils.launchJob(...) and mvRepository.findAll() to assert (1) when no daily metrics exist for the given month the job completes and mvRepository.findAll() is empty, (2) when only one product has metrics the result size is 1 and that product has rankPosition == 1 and correct totalScore, and (3) when metrics exist on the month’s last day (and the day before) they are included/excluded according to the monthStartDate parameter (use monthStart/date arithmetic and BASIC_ISO_DATE job parameter as in the existing monthlyRankingJob_aggregatesDailyMetrics test). Ensure each test sets a unique run.id and asserts jobExecution.getExitStatus() == ExitStatus.COMPLETED.apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java (2)
45-59:LocalDateTime.now()직접 호출은 테스트 격리를 어렵게 만든다.
aggregatedAt필드에LocalDateTime.now()를 직접 사용하면 단위 테스트에서 시간 검증이 불가능하다. 배치 실행 시점의 타임스탬프를 외부에서 주입받거나,Clock을 사용하는 방식을 권장한다. 운영 환경에서 배치 재실행 시 aggregatedAt 값이 달라져 데이터 분석 시 혼란을 줄 수 있다.♻️ 개선안: aggregatedAt을 파라미터로 받도록 수정
public static MvProductRankMonthly of( Long productId, LocalDate monthStartDate, double totalScore, - long viewCount, long likeCount, long orderCount, int rankPosition + long viewCount, long likeCount, long orderCount, int rankPosition, + LocalDateTime aggregatedAt ) { MvProductRankMonthly entity = new MvProductRankMonthly(); entity.productId = productId; entity.monthStartDate = monthStartDate; entity.viewCount = viewCount; entity.likeCount = likeCount; entity.orderCount = orderCount; entity.totalScore = totalScore; entity.rankPosition = rankPosition; - entity.aggregatedAt = LocalDateTime.now(); + entity.aggregatedAt = aggregatedAt; return entity; }🤖 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/MvProductRankMonthly.java` around lines 45 - 59, The factory method MvProductRankMonthly.of currently calls LocalDateTime.now() directly for the aggregatedAt field which breaks testability; change the method signature of MvProductRankMonthly.of to accept an aggregatedAt (LocalDateTime) parameter (or alternatively a Clock) and assign that value to entity.aggregatedAt instead of calling LocalDateTime.now(), updating all call sites to pass the desired timestamp (or a Clock-derived time) so tests can inject fixed times; keep references to the MvProductRankMonthly class and the of(...) factory method when making the changes.
12-69:equals/hashCode미구현 시 컬렉션 내 동작이 불안정할 수 있다.
@IdClass사용 시에도 엔티티 자체에equals/hashCode가 없으면Set,Map등에서 예기치 않은 동작이 발생할 수 있다. 복합키 기반으로 구현하거나,productId + monthStartDate조합으로 식별자 기반 equals/hashCode를 추가하는 것을 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java` around lines 12 - 69, The entity MvProductRankMonthly is missing equals and hashCode which can break collection behavior; implement both on the MvProductRankMonthly class using the composite identifier fields productId and monthStartDate (the same logical key as MvProductRankMonthlyId) so equality/hash are consistent with `@IdClass`; ensure equals checks class/type and compares productId and monthStartDate, and hashCode is derived from those two fields.apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java (1)
56-64:eventType문자열 직접 비교는 타입 안전성이 낮고, 예외 메시지에 입력값 노출은 로그 오염 위험이 있다.
record()메서드에서 문자열을 직접 비교하면 오타나 대소문자 불일치 시 런타임 오류가 발생한다. 또한IllegalArgumentException메시지에eventType을 그대로 포함하면 악의적 입력이 로그에 기록될 수 있다.♻️ 개선안: Enum 활용 및 예외 메시지 정제
+import com.loopers.domain.event.CatalogEventType; // 또는 적절한 enum 위치 + public void record(String eventType) { - switch (eventType) { - case "VIEWED" -> this.viewCount++; - case "LIKED" -> this.likeCount++; - case "UNLIKED" -> { if (this.likeCount > 0) this.likeCount--; } - case "ORDERED" -> this.orderCount++; - default -> throw new IllegalArgumentException("알 수 없는 이벤트 타입: " + eventType); + CatalogEventType type; + try { + type = CatalogEventType.valueOf(eventType); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("지원하지 않는 이벤트 타입"); + } + switch (type) { + case VIEWED -> this.viewCount++; + case LIKED -> this.likeCount++; + case UNLIKED -> { if (this.likeCount > 0) this.likeCount--; } + case ORDERED -> this.orderCount++; } }🤖 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/ProductMetricsDaily.java` around lines 56 - 64, The record method in ProductMetricsDaily currently compares raw eventType strings which is error-prone and exposes input in exceptions; define an enum EventType { VIEWED, LIKED, UNLIKED, ORDERED } and change ProductMetricsDaily.record to either accept EventType or safely map the incoming String to EventType using EventType.valueOf(...) inside a try/catch that handles IllegalArgumentException and null by throwing a new IllegalArgumentException("Unsupported event type") (no raw input echoed); keep the existing increment/decrement logic (e.g., this.likeCount = Math.max(0, this.likeCount - 1) for UNLIKED) and update any call sites to pass EventType or let the method perform the safe mapping.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
118-142:enrichRankEntries에서 삭제된 상품을 건너뛰면 반환 결과 수가 요청size보다 적을 수 있다.
product == null시continue로 건너뛰면 요청한 페이지 크기보다 적은 결과가 반환된다. 클라이언트 관점에서 예상 결과 수와 실제 반환 수 불일치로 인한 혼란 가능성이 있다. MV 테이블 집계 시점에 존재했던 상품이 이후 삭제된 경우 발생할 수 있으며, 운영 환경에서 이 케이스에 대한 모니터링 또는 문서화를 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` around lines 118 - 142, The current enrichRankEntries method silently skips entries when product == null, causing the returned list size to be smaller than the requested page (ProductRankEntry -> RankingInfo mismatch). Change the loop in enrichRankEntries so that instead of continue it creates a placeholder RankingInfo for missing products (use entry.rankPosition(), entry.productId(), null for name/price/brand, entry.totalScore()) to preserve the original entries count; also emit a metric/log when a missing product is encountered (e.g., processLogger or a counter) so deletions can be monitored. Keep references: enrichRankEntries, ProductRankEntry, RankingInfo, fetchProductMap, fetchBrandMap.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (2)
89-93:monthStartDate파싱 실패 시 예외 처리 부재
WeeklyRankingJobConfig와 동일한 문제다.LocalDate.parse가 형식 불일치 시DateTimeParseException을 발생시키며, 운영 로그에서 원인 파악이 어렵다. null 체크와 명확한 예외 메시지를 추가해야 한다.🔧 명확한 예외 메시지 추가
`@StepScope` `@Bean` public JdbcCursorItemReader<ProductScoreRow> monthlyReader( `@Value`("#{jobParameters['monthStartDate']}") String monthStartDate ) { + if (monthStartDate == null || monthStartDate.isBlank()) { + throw new IllegalArgumentException("monthStartDate 파라미터가 필요합니다"); + } + LocalDate start; + try { + start = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "monthStartDate 형식 오류 (expected: yyyyMMdd, actual: %s)".formatted(monthStartDate), e); + } - LocalDate start = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE); LocalDate end = YearMonth.from(start).atEndOfMonth();🤖 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 89 - 93, monthlyReader currently calls LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE) without null or format error handling; add a null/blank check for the monthStartDate parameter and wrap the parse in a try/catch for DateTimeParseException, throwing a clear IllegalArgumentException (or JobParametersInvalidException if used elsewhere) that includes the invalid monthStartDate value and expected format; modify the monthlyReader method to validate monthStartDate before parsing and to surface a descriptive error message so logs show the bad input (mirror the handling used in WeeklyRankingJobConfig).
35-128:WeeklyRankingJobConfig와 높은 코드 중복두 Job 설정의 구조가 거의 동일하다(Job/Step 빌드, Reader/Writer 구성, Listener 연결). 날짜 범위 계산(
plusDays(6)vsatEndOfMonth())과 엔티티 타입만 다르다. 공통 추상 클래스나 팩토리 패턴으로 중복을 줄일 수 있으나, 현재 규모에서는 유지보수 부담이 크지 않으므로 선택 사항이다.🤖 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 35 - 128, MonthlyRankingJobConfig largely duplicates WeeklyRankingJobConfig (job/step construction, listeners, reader/writer wiring) — extract the shared logic into a common base or factory: move Job/Step creation and listener attachment into an abstract superclass or helper (referencing monthlyRankingJob, aggregateStep, cleanupStep, JOB_NAME, STEP_AGGREGATE, STEP_CLEANUP) and parameterize the differences (reader logic: monthlyReader vs weeklyReader date-range calculation and entity type for monthlyWriter). Implement shared methods for creating the chunk step and JpaItemWriter, and provide small overrides or lambdas for the date-range and entity-specific parts to eliminate the repeated code.apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java (2)
54-71:static tableCreated플래그는 병렬 테스트 실행 시 경쟁 조건 발생 가능JUnit 5의 병렬 테스트 실행이 활성화되면 여러 테스트 클래스가 동시에
createTable을 호출할 수 있다.tableCreated플래그가 동기화 없이 공유되어 race condition이 발생할 수 있다.CREATE TABLE IF NOT EXISTS가 멱등하므로 실제 문제는 적지만,tableCreated체크 자체가 의미 없는 코드가 된다.🔧 플래그 제거 또는 동기화 추가
- private static boolean tableCreated = false; - `@BeforeAll` static void createTable(`@Autowired` JdbcTemplate jdbcTemplate) { - if (!tableCreated) { - jdbcTemplate.execute(""" - CREATE TABLE IF NOT EXISTS product_metrics_daily ( - ... - ) ENGINE=InnoDB - """); - tableCreated = true; - } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics_daily ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_count BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, metric_date) + ) ENGINE=InnoDB + """); }🤖 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 54 - 71, The static tableCreated flag introduces a race when JUnit runs tests in parallel—remove the flag and the conditional in the createTable(`@Autowired` JdbcTemplate jdbcTemplate) method and always run the idempotent jdbcTemplate.execute(...) call to create product_metrics_daily (or alternatively replace the flag with a thread-safe AtomicBoolean or synchronize createTable if you prefer synchronization); update references to tableCreated accordingly so createTable unconditionally executes the CREATE TABLE IF NOT EXISTS statement.
79-124: 경계값 및 실패 케이스 테스트 누락현재 테스트는 정상 케이스와 멱등성만 검증한다. 운영 안정성을 위해 다음 케이스 추가를 권장한다:
- 빈 데이터: 해당 주간에
product_metrics_daily데이터가 없을 때 Job이 정상 완료되고 MV가 비어 있는지- LIMIT 경계: 101개 이상의 상품이 있을 때 정확히 100개만 적재되는지
- 동점 처리:
total_score가 동일한 상품의rank_position결정 로직🤖 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 79 - 124, Add three new tests in WeeklyRankingJobE2ETest: one "emptyData" test that uses jobLauncherTestUtils.launchJob with a weekStart that has no insertDailyMetrics calls and asserts job completes and mvRepository.findAll() is empty; one "limitBoundary" test that inserts >100 distinct products via insertDailyMetrics (e.g., loop 1..101) for the same weekStart, launches the job and asserts mvRepository.findAll().size() == 100 and the top entries correspond to the highest-scoring 100 products; and one "tieHandling" test that inserts multiple products with identical total scores (use insertDailyMetrics to create equal view/like/order sums), runs the job and asserts deterministic rank_position ordering (e.g., by productId or expected tie-break rule) by examining mvRepository.findAll() and rank_position for those products; use existing helpers (insertDailyMetrics, jobLauncherTestUtils, mvRepository, MvProductRankWeekly) and the same JobParametersBuilder pattern to run the job and verify assertions.tests/k6/seed.sh (3)
36-43: 변수 인용(quoting) 누락으로 잠재적 오류 발생 가능
${TOP_N},${i}등 변수가 따옴표 없이 사용되어 word splitting이나 globbing 문제가 발생할 수 있다. 특히 루프 범위 지정 시$(seq 1 ${TOP_N})에서TOP_N이 공백이나 특수문자를 포함할 경우 예기치 않은 동작이 발생한다.🔧 수정 제안
-for i in $(seq 1 ${TOP_N}); do +for i in $(seq 1 "${TOP_N}"); do BRAND_ID=$(( (i - 1) % 5 + 1 )) PRICE=$(( 10000 + (i * 2971) % 290000 ))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/k6/seed.sh` around lines 36 - 43, The shell loop and SQL build use unquoted variables which can cause word-splitting/globbing bugs; update usages of TOP_N and i in the loop header and any expansions (e.g., $(seq 1 ${TOP_N}), ${i}, ${PRICE}, ${BRAND_ID}, ${SQL}, ${MYSQL}) to use quoted expansions where appropriate (e.g., "${TOP_N}", "${i}") and ensure SQL concatenation safely handles values (SQL variable) to avoid accidental splitting; locate the for loop and the SQL/MYSQL invocations in tests/k6/seed.sh (symbols: TOP_N, i, PRICE, BRAND_ID, SQL, MYSQL, seq) and add proper quoting around variable expansions and the final ${MYSQL} call.
43-43: MySQL 에러 출력 억제로 인한 디버깅 어려움
2>/dev/null로 stderr를 완전히 무시하면 INSERT 실패, 연결 오류, 권한 문제 등이 발생해도 알 수 없다.set -e가 있어 비정상 종료는 되지만, 원인 파악이 어렵다. 운영 환경에서 시드 실패 시 빠른 원인 분석이 불가능하다.🔧 수정 제안: 에러 로그 파일로 리다이렉트
-${MYSQL} "${SQL}" 2>/dev/null +${MYSQL} "${SQL}" 2>> /tmp/seed-mysql-errors.log || { cat /tmp/seed-mysql-errors.log; exit 1; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/k6/seed.sh` at line 43, The current command `${MYSQL} "${SQL}" 2>/dev/null` silences all MySQL stderr which prevents diagnosing failures; change it so stderr is preserved or persisted (e.g., remove the `2>/dev/null` redirection or redirect stderr to a log file and/or tee it to the console) so that when `set -e` triggers on failure you can see the underlying MySQL error; update the invocation that uses `${MYSQL}` and `"${SQL}"` to write stderr to a persistent log (and optionally to stdout) instead of discarding it.
75-76: Redis ZADD 명령어 변수 미인용
${ZADD_CMD}변수가 공백으로 구분된 여러 인자를 포함하므로 의도적으로 word splitting이 필요하지만, ShellCheck 경고 억제를 명시하거나 배열을 사용하는 것이 안전하다.🔧 배열 사용 제안
-ZADD_CMD="ZADD ${ZSET_KEY}" +ZADD_ARGS=("ZADD" "${ZSET_KEY}") for i in $(seq 1 ${TOP_N}); do SCORE=$(( (TOP_N - i + 1) * 10 )) - ZADD_CMD+=" ${SCORE} ${i}" + ZADD_ARGS+=("${SCORE}" "${i}") done -${REDIS} ${ZADD_CMD} > /dev/null +${REDIS} "${ZADD_ARGS[@]}" > /dev/null🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/k6/seed.sh` around lines 75 - 76, The ZADD_CMD variable is unquoted and contains multiple space-separated arguments causing ShellCheck warnings and potential word-splitting bugs; update the script to define ZADD_CMD as an array (e.g., ZADD_CMD=(...)) and invoke it with "${ZADD_CMD[@]}" in the call that currently uses ${ZADD_CMD}, or if keeping a string form, explicitly suppress ShellCheck SC2086 with a comment and document why word-splitting is intentional; ensure calls using REDIS and ZADD_CMD use proper quoting for ZSET_KEY (e.g., "${ZSET_KEY}") and use the array expansion form when referencing ZADD_CMD.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (2)
42-43:CHUNK_SIZE=500이지만 SQLLIMIT 100으로 실질적으로 단일 청크청크 크기가 500이지만 쿼리가 최대 100건만 반환하므로 청크 처리의 이점이 없다. 현재 동작에 문제는 없으나, 향후 LIMIT 증가 시 청크 크기와의 관계를 재검토해야 한다. 문서화 주석 추가를 권장한다.
🤖 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/WeeklyRankingJobConfig.java` around lines 42 - 43, CHUNK_SIZE is set to 500 but the SQL used elsewhere returns at most 100 rows, so chunking is effectively single-chunk; update the code by adding a clear inline comment next to the CHUNK_SIZE constant (reference CHUNK_SIZE in WeeklyRankingJobConfig) explaining that the query currently uses LIMIT 100 and therefore only one chunk will be processed, and note that if the query LIMIT is increased in future the CHUNK_SIZE should be reviewed or kept in sync (or adjust the query to fetch by CHUNK_SIZE) to preserve chunking behavior.
88-92:weekStartDate파싱 실패 시 예외 처리 부재
LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE)는 형식이 맞지 않으면DateTimeParseException을 발생시킨다. 현재 예외가 그대로 전파되어 Job 실패로 이어지지만, 운영 로그에서 원인 파악이 어려울 수 있다. 명확한 메시지와 함께 예외를 래핑하거나, Job 파라미터 검증 단계를 분리하는 것이 운영 관점에서 유리하다.🔧 명확한 예외 메시지 추가
`@StepScope` `@Bean` public JdbcCursorItemReader<ProductScoreRow> weeklyReader( `@Value`("#{jobParameters['weekStartDate']}") String weekStartDate ) { + if (weekStartDate == null || weekStartDate.isBlank()) { + throw new IllegalArgumentException("weekStartDate 파라미터가 필요합니다"); + } + LocalDate start; + try { + start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "weekStartDate 형식 오류 (expected: yyyyMMdd, actual: %s)".formatted(weekStartDate), e); + } - LocalDate start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE); LocalDate end = start.plusDays(6);🤖 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/WeeklyRankingJobConfig.java` around lines 88 - 92, 주간 파라미터 파싱에서 발생하는 DateTimeParseException을 그대로 흘려보내지 말고 weeklyReader 메서드의 LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE) 호출을 try-catch로 감싸서 입력값(weekStartDate)과 기대 포맷(BASIC_ISO_DATE)을 포함한 명확한 메시지로 예외를 래핑(예: IllegalArgumentException 또는 JobParametersInvalidException)해 던지거나, 파라미터 검증 로직을 분리해 유효성 검사 실패 시 동일한 명확한 예외를 발생시키도록 변경하세요; 이때 예외 처리 대상은 함수명 weeklyReader와 LocalDate.parse 호출부를 수정하면 됩니다.apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)
54-85:dailyperiod 테스트 누락API가
period=daily/weekly/monthly를 지원하는데,daily케이스 테스트가 없다. Redis ZSET 기반 일간 랭킹 조회가 MV 테이블 조회와 다른 경로를 타므로 별도 검증이 필요하다.🤖 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/RankingV1ApiE2ETest.java` around lines 54 - 85, Add a new E2E test for the daily period in RankingV1ApiE2ETest: create a Nested class (e.g., DailyRanking) with a test method returnsDailyRankings that mirrors returnsWeeklyRankings but uses the Redis ZSET path instead of insertWeeklyMv — create brand/products, populate the daily ranking Redis ZSET via a new helper (e.g., insertDailyZset(date, productId, score) or use the existing Redis client), call exchange(ENDPOINT + "?period=daily&date=20260415&size=10&page=1") and assert response status and ordered data (rank, productName, score) like the weekly test; add the insertDailyZset helper alongside insertWeeklyMv so the test setup is clear and reusable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d60af69f-b9fc-4450-b853-d7404d9357a4
📒 Files selected for processing (54)
.gitignoreapps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderService.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.javaapps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.javaapps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductScoreRow.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.javaapps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.javaapps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.javaapps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/application/RankingService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaapps/commerce-streamer/src/main/resources/application.ymldocker/kafka-cluster-compose.ymlmodules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.javamodules/kafka/src/main/java/com/loopers/kafka/event/CatalogEvent.javamodules/kafka/src/main/resources/kafka.ymltests/k6/get-rankings.jstests/k6/profiles.jsontests/k6/run-all.shtests/k6/seed.sh
| private static final boolean BYPASS_OUTBOX = | ||
| Boolean.parseBoolean(System.getenv().getOrDefault("BYPASS_OUTBOX", "false")); | ||
|
|
||
| private static final boolean CRASH_BEFORE_SEND = | ||
| Boolean.parseBoolean(System.getenv().getOrDefault("CRASH_BEFORE_SEND", "false")); | ||
|
|
||
| private final CouponTemplateRepository couponTemplateRepository; | ||
| private final IssuedCouponRepository issuedCouponRepository; | ||
| private final OutboxEventRepository outboxEventRepository; | ||
| private final KafkaTemplate<Object, Object> kafkaTemplate; |
There was a problem hiding this comment.
Outbox 우회 경로를 운영 서비스 빈에 직접 두면 배포 단위로 전달 보장이 달라진다.
환경변수 한 번으로 동일한 CouponService가 Outbox 보장 경로와 비보장 경로를 오가면, 롤링 배포 중 일부 인스턴스만 다른 전달 의미를 갖게 된다. 운영에서는 동일 요청이 Pod마다 다르게 처리되어 유실 분석과 장애 재현이 어려워진다. 실험 경로는 @Profile 기반 별도 빈이나 테스트 전용 구성으로 분리하고, 기본 서비스는 항상 Outbox만 타도록 고정하는 편이 안전하다. 추가로 기본 프로필에서는 Outbox 저장만 수행되는 통합 테스트와, 실험 프로필에서만 direct publish 구현이 로딩되는 구성 테스트를 넣어 두는 것이 좋다.
Also applies to: 74-94
🤖 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/coupon/CouponService.java`
around lines 36 - 45, The CouponService currently toggles outbox bypassing via
static env flags (BYPASS_OUTBOX, CRASH_BEFORE_SEND) causing mixed delivery
semantics across rolling deployments; instead remove these environment-driven
conditionals from CouponService and provide two distinct beans: a default
production CouponService (or implementation) that always writes to Outbox, and a
separate experimental/test implementation activated via `@Profile`("experiment")
or a dedicated test profile that performs direct publish/crash behavior;
relocate CRASH_BEFORE_SEND and bypass logic into that experimental bean/config
and add integration tests ensuring the default profile only saves Outbox events
and the experiment profile loads the direct-publish behavior.
| try { | ||
| kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.readTree(payload)); | ||
| log.info("[Bypass] direct Kafka send eventId={}", eventId); | ||
| } catch (Exception e) { | ||
| log.error("[Bypass] Kafka send failed eventId={}", eventId, e); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find and examine CouponService.java
find . -type f -name "CouponService.java" | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 168
🏁 Script executed:
#!/bin/bash
# Read the file to see context around lines 85-90
if [ -f "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" ]; then
wc -l "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java"
fiRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 169
🏁 Script executed:
#!/bin/bash
# Check the context around the problematic code
cat -n "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" | sed -n '70,105p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1966
🏁 Script executed:
#!/bin/bash
# Search for kafkaTemplate usage patterns in the codebase
rg "kafkaTemplate\.send" --type java -B 2 -A 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1568
🏁 Script executed:
#!/bin/bash
# Check if Future callbacks are used anywhere in the codebase
rg "addCallback|ListenableFuture|SendResult" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check Spring Kafka configuration for this project
find . -type f \( -name "*Kafka*.java" -o -name "*kafka*.properties" -o -name "*kafka*.yml" \) | grep -E "config|properties|yml"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 164
🏁 Script executed:
#!/bin/bash
# Check OutboxPublisher for similar patterns
cat -n "apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxPublisher.java" | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2164
🏁 Script executed:
#!/bin/bash
# Check Kafka configuration for error handling or default error handler
cat "modules/kafka/src/main/resources/kafka.yml"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1940
🏁 Script executed:
#!/bin/bash
# Search for any KafkaTemplate configuration or bean setup
rg "KafkaTemplate|kafkaTemplate" --type java -B 3 -A 3 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 6512
🏁 Script executed:
#!/bin/bash
# Check if there are any error handlers or listeners registered
rg "@KafkaListener|KafkaListenerErrorHandler|ErrorHandler" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 598
kafkaTemplate.send() 반환값(Future)을 확인하지 않아 비동기 브로커 실패를 감지하지 못한다.
kafkaTemplate.send(...)는 비동기 호출이므로 현재 try/catch는 직렬화 오류 같은 동기 예외만 잡는다. 브로커 미가용, 토픽 미존재, 할당량 초과 같은 원격 실패는 반환된 Future 콜백에서만 드러나는데, 이 코드는 Future를 무시하고 바로 성공 로그를 남긴다. 그 결과 실제 브로커 ACK 상태와 무관하게 로그와 모니터링 지표가 낙관적으로 왜곡되어 장애를 감지하기 어려워진다.
반환된 Future에 success/failure 콜백을 등록하거나, future.get(timeout, TimeUnit.MILLISECONDS)로 일정 시간 내 ACK 확인 후에만 성공으로 기록하도록 변경하자. 추가로 브로커 미가용, 잘못된 토픽 같은 조건에서 실제로 실패 로그가 남고 성공 로그가 기록되지 않는 통합 테스트를 작성하자. OutboxPublisher도 동일한 패턴을 가지고 있으므로 함께 개선하자.
🤖 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/coupon/CouponService.java`
around lines 85 - 90, The current kafkaTemplate.send call in CouponService (and
the similar pattern in OutboxPublisher) ignores the returned ListenableFuture,
so remote broker failures are missed; update the send usage to capture the
returned ListenableFuture from
kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS,
String.valueOf(couponTemplateId), objectMapper.readTree(payload)) and either
attach success/failure callbacks (addCallback) to log success only on onSuccess
and log errors on onFailure, or call future.get(timeout, TimeUnit.MILLISECONDS)
to synchronously confirm ACK before emitting the success log; ensure the catch
blocks only handle synchronous exceptions (e.g., JSON serialization) while
remote failures are handled by the future callback or get() exception handling,
and add integration tests asserting that broker-unavailable / missing-topic
scenarios produce failure logs and no success log for both CouponService and
OutboxPublisher.
| // 랭킹은 보조 지표이므로 실패해도 주문을 롤백하지 않는다 (fail-open) | ||
| try { | ||
| for (Order.OrderLineEntity ol : order.getOrderLines()) { | ||
| String eventId = UUID.randomUUID().toString(); | ||
| CatalogEvent event = CatalogEvent.ordered( | ||
| eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(), | ||
| ol.getUnitPrice(), ol.getQuantity() | ||
| ); | ||
| outboxEventRepository.save(OutboxEvent.create( | ||
| eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()), | ||
| objectMapper.writeValueAsString(event) | ||
| )); | ||
| } | ||
| } catch (Exception e) { | ||
| log.warn("[Order] ORDERED 이벤트 Outbox 저장 실패 orderId={}", order.getId(), e); | ||
| } |
There was a problem hiding this comment.
Outbox 패턴의 핵심 보장과 상충하는 fail-open 전략이다.
운영 관점 문제:
- Outbox 패턴의 본질은 "저장됐으면 반드시 발행된다"인데, try/catch로 저장 실패를 무시하면 이벤트가 영구 유실된다. OutboxPublisher는 저장된 이벤트만 재발행하므로, 저장 자체가 실패하면 복구 경로가 없다.
- LikeService, ProductFacade는 동일한 outbox save를 try/catch 없이 수행한다. 서비스 간 일관성이 깨지면 장애 분석 시 혼란을 야기한다.
수정안:
- (A) fail-open 유지 시: 별도 DLQ 테이블이나 로컬 파일에 실패 이벤트를 기록하여 수동 복구 경로를 확보하라.
- (B) 일관성 우선 시: try/catch를 제거하고 다른 서비스와 동일하게 처리하라. 랭킹 정합성이 중요하다면 주문 롤백이 더 안전하다.
추가 테스트:
- outboxEventRepository.save() 예외 발생 시 주문이 정상 커밋되는지, 로그에 orderId가 남는지 검증하는 테스트 케이스가 필요하다.
🤖 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/order/OrderService.java`
around lines 143 - 158, The current fail-open try/catch around creating/saving
outbox events in OrderService (loop over Order.OrderLineEntity calling
outboxEventRepository.save and OutboxEvent.create) allows permanent event loss
which violates the outbox guarantee; either remove the try/catch to treat save
failures like other services (e.g., LikeService/ProductFacade) so the exception
bubbles and the order transaction rolls back, or keep fail-open but implement a
durable failure sink (DLQ table or local file) inside the catch that records the
event payload, eventId, topic (KafkaTopics.CATALOG_EVENTS) and orderId for
manual replay; also add a test that simulates outboxEventRepository.save
throwing and asserts the chosen behavior (rollback or DLQ entry and order
commit/logging).
| @Immutable | ||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @IdClass(MvProductRankMonthlyEntity.PK.class) | ||
| public class MvProductRankMonthlyEntity { |
There was a problem hiding this comment.
JPA 엔티티에 기본 생성자가 누락되었다.
JPA 명세에 따르면 엔티티 클래스는 public 또는 protected 접근 수준의 기본 생성자가 필요하다. Hibernate가 일부 경우 동작할 수 있으나, 다른 JPA 구현체나 버전 업그레이드 시 InstantiationException이 발생할 수 있다.
🛡️ 기본 생성자 추가 제안
`@Immutable`
`@Entity`
`@Table`(name = "mv_product_rank_monthly")
`@IdClass`(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
+
+ protected MvProductRankMonthlyEntity() {}
`@Id`
`@Column`(name = "product_id")📝 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.
| @Immutable | |
| @Entity | |
| @Table(name = "mv_product_rank_monthly") | |
| @IdClass(MvProductRankMonthlyEntity.PK.class) | |
| public class MvProductRankMonthlyEntity { | |
| `@Immutable` | |
| `@Entity` | |
| `@Table`(name = "mv_product_rank_monthly") | |
| `@IdClass`(MvProductRankMonthlyEntity.PK.class) | |
| public class MvProductRankMonthlyEntity { | |
| protected MvProductRankMonthlyEntity() {} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 15 - 19, Add a no-argument constructor with public or protected
visibility to the JPA entity class MvProductRankMonthlyEntity so Hibernate/JPA
can instantiate it; locate the class declaration for MvProductRankMonthlyEntity
(which uses `@Entity` and IdClass(MvProductRankMonthlyEntity.PK.class)) and add a
protected (or public) default constructor alongside existing constructors to
satisfy the JPA spec and avoid InstantiationException in other JPA
implementations.
| @Override | ||
| public List<Product> findAllByIds(List<Long> ids) { | ||
| return ids.stream() | ||
| .map(store::get) | ||
| .filter(java.util.Objects::nonNull) | ||
| .toList(); | ||
| } |
There was a problem hiding this comment.
FakeProductRepository의 batch 조회 계약이 실제 저장소와 다르다.
findAllByIds()는 store의 key로 조회하지만, 같은 클래스의 save()는 상품 ID가 아니라 nextId를 key로 넣고 있다. 상품 ID와 삽입 순서가 어긋나는 순간 잘못된 상품 목록이 반환되므로, 이번 PR의 batch 조회 경로가 깨져도 테스트가 놓치거나 거짓 실패를 낼 수 있다. 테스트 더블을 실제 계약에 맞추어 product.getId() 기준으로 저장/조회하도록 맞추는 편이 안전하다. 추가로 10L, 30L처럼 비연속 ID 상품을 저장한 뒤 findAllByIds(List.of(30L, 10L))가 둘 다 정확히 반환하는 케이스를 넣어 회귀를 막아야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java`
around lines 208 - 214, FakeProductRepository's findAllByIds(List<Long>) uses
the map key (store::get) but its save(...) stores products under nextId rather
than product.getId(), causing mismatches; update the fake so save(...) uses
product.getId() as the key and make findAllByIds remain based on those IDs, and
add a test that saves products with non-sequential IDs (e.g., 10L and 30L) then
asserts findAllByIds(List.of(30L, 10L)) returns both products exactly to prevent
regression; reference FakeProductRepository, save, findAllByIds, store, nextId,
and product.getId() when making the changes.
| // [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현 | ||
| if (SLOW_ACK) { | ||
| log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!"); | ||
| try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} | ||
| } | ||
| ack.acknowledge(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 인터럽트 처리 누락 및 ack 호출 위치를 확인한다.
rg -n -C2 'InterruptedException|ack\.acknowledge\(\)' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 350
🏁 Script executed:
# SLOW_ACK 플래그가 어디에서 정의되고 사용되는지 확인
rg -n "SLOW_ACK" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 392
🏁 Script executed:
# CouponIssueConsumer의 전체 구조와 메서드 확인
head -110 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java | tail -40Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2146
🏁 Script executed:
# 파일 전체 라인 수 확인
wc -l apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 182
🏁 Script executed:
# 관련된 테스트 파일 검색
fd -e java -path "*test*" -o -path "*Test*" | grep -i "coupon" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 253
🏁 Script executed:
# CouponIssueConsumer 관련 테스트 파일 검색
find . -name "*Test.java" -o -name "*Tests.java" | xargs grep -l "CouponIssueConsumer" 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
# 테스트 디렉토리 구조 확인
find . -path "*/test/java/*" -name "*Coupon*.java" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 335
🏁 Script executed:
# CouponIssueConsumer 클래스 전체 라인 수와 주요 메서드 확인
head -20 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 963
InterruptedException 무시로 인한 ack() 호출로 재전달 테스트 신뢰도 저하
SLOW_ACK 실험의 목적은 shutdown 시점에 kill을 유도하여 offset 미커밋 상태에서 재시작 후 메시지 재전달을 검증하는 것이다. 그러나 InterruptedException을 무시하고 계속 실행하면 kill 신호로 인한 인터럽트가 발생해도 ack.acknowledge()가 호출되어 offset이 커밋되므로, 재전달 실험이 정상 동작하지 않는다.
인터럽트 발생 시 다음과 같이 처리해야 한다:
- 인터럽트 상태 복구 (
Thread.currentThread().interrupt()) - ack 호출 없이 반환하여 offset 미커밋 보장
- 적절한 로깅으로 재전달 시나리오 추적 가능
추가로 모의 Acknowledgment를 사용한 단위 테스트를 작성하여 인터럽트 시 ack가 호출되지 않음을 검증해야 한다.
수정안
if (SLOW_ACK) {
log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!");
- try { Thread.sleep(10_000); } catch (InterruptedException ignored) {}
+ try {
+ Thread.sleep(10_000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.warn("[CouponIssue][SlowAck] interrupted before ack. skip ack for redelivery.");
+ return;
+ }
}
ack.acknowledge();📝 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.
| // [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현 | |
| if (SLOW_ACK) { | |
| log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!"); | |
| try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} | |
| } | |
| ack.acknowledge(); | |
| // [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현 | |
| if (SLOW_ACK) { | |
| log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!"); | |
| try { | |
| Thread.sleep(10_000); | |
| } catch (InterruptedException e) { | |
| Thread.currentThread().interrupt(); | |
| log.warn("[CouponIssue][SlowAck] interrupted before ack. skip ack for redelivery."); | |
| return; | |
| } | |
| } | |
| ack.acknowledge(); |
🤖 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/interfaces/consumer/CouponIssueConsumer.java`
around lines 99 - 104, In CouponIssueConsumer's SLOW_ACK block, don't ignore
InterruptedException; instead catch it, call Thread.currentThread().interrupt()
to restore the interrupt status, log that an interrupt occurred during the
sleep, and return from the method without calling ack.acknowledge() so the
offset remains uncommitted; update the catch around Thread.sleep(...)
accordingly (referencing SLOW_ACK, Thread.sleep, InterruptedException, and
ack.acknowledge()). Also add a unit test using a mocked Acknowledgment to
simulate an interrupt during the sleep and assert that ack.acknowledge() is not
invoked and the interrupt status is restored.
| ports: | ||
| - "19092:19092" | ||
| environment: | ||
| - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Bitnami Kafka 3.5.1 KRaft 환경에서 KAFKA_KRAFT_CLUSTER_ID가 기존 /bitnami/kafka 볼륨의 meta.properties cluster.id와 다를 때 브로커 기동이 실패하는지 공식 문서/이슈로 확인해 달라
💡 Result:
네, Bitnami Kafka 3.5.1 KRaft 환경에서 KAFKA_KRAFT_CLUSTER_ID가 기존 /bitnami/kafka 볼륨의 meta.properties cluster.id와 다르면 브로커 기동이 실패합니다. 이는 Apache Kafka KRaft의 표준 동작으로, Bitnami 이미지도 이를 따릅니다.
Citations:
- 1: [bitnami/kafka] Cannot easily scale up kafka brokers - INCONSISTENT_CLUSTER_ID bitnami/charts#31404
- 2: https://dev.to/jayesh_shinde/fixing-kafka-kraft-cluster-id-mismatch-on-kubernetes-2943
- 3: https://kafka.apache.org/37/operations/kraft/
- 4: https://kafka.apache.org/36/operations/kraft/
- 5: https://kafka.apache.org/34/operations/kraft/
🏁 Script executed:
# Verify the file exists and contains the referenced lines
git ls-files | grep -E 'docker/kafka-cluster-compose.yml'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 117
하드코딩된 KRaft Cluster ID는 기존 볼륨 재사용 시 브로커 기동을 방해한다
9번 라인의 cluster ID가 기존 /bitnami/kafka 볼륨의 meta.properties에 기록된 ID와 다르면, 브로커가 기동 실패한다. 이는 Apache Kafka KRaft의 표준 동작으로, 35번, 61번 라인의 다른 kafka 서비스들도 동일한 문제를 갖고 있다. 이로 인해 개발/운영 환경에서 볼륨을 재사용할 때 불필요한 복구 시간이 발생한다.
수정안으로 cluster ID를 환경 변수로 분리하고, 최초 적용 시 볼륨 초기화 절차를 명시해야 한다.
수정 예시
services:
kafka-1:
environment:
- - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+ - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}
@@
kafka-2:
environment:
- - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+ - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}
@@
kafka-3:
environment:
- - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+ - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}수정 후 다음 3가지 시나리오를 검증해야 한다.
- 새 볼륨에서
up후 3개 브로커의 healthcheck 통과 여부 확인 - 동일한
KAFKA_KRAFT_CLUSTER_ID로 재기동 시 정상적으로 재합류하는지 확인 - 기존 볼륨에 다른 cluster ID가 기록되어 있는 경우 기동 실패를 재현한 후, 문서화된 복구 절차(
down -v또는 볼륨 교체)로 정상 복구되는지 확인
📝 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.
| - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv | |
| - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docker/kafka-cluster-compose.yml` at line 9, The hardcoded KRaft cluster ID
(KAFKA_KRAFT_CLUSTER_ID) in the compose file should be replaced with a
configurable environment variable and initialization guidance: remove the
literal "abcdefghijklmnopqrstuv" and use an env substitution (e.g.,
${KAFKA_KRAFT_CLUSTER_ID}) for each kafka service entry (the occurrences around
the current KAFKA_KRAFT_CLUSTER_ID and the other kafka services mentioned), add
a comment or note in the compose file describing that the cluster ID must match
any existing /bitnami/kafka/meta.properties or a fresh volume must be
initialized, and update the repo docs or compose comments to include the three
verification steps (initial bring-up healthchecks for 3 brokers, restart with
same KAFKA_KRAFT_CLUSTER_ID to ensure rejoin, and reproduce failure/describe
recovery via down -v or replacing volume). Ensure all kafka service definitions
that set KAFKA_KRAFT_CLUSTER_ID are changed consistently.
| spring.config.activate.on-profile: acks-one | ||
|
|
||
| spring: | ||
| kafka: | ||
| producer: | ||
| acks: 1 | ||
| properties: | ||
| enable.idempotence: false |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Spring Boot에서 spring.config.activate.on-profile에 acks-one & (local | test) 같은 profile expression을 공식적으로 지원하는지 확인해 달라.
💡 Result:
결론: Spring Boot의 spring.config.activate.on-profile는 profile expression(복합 조건) 문법을 “공식적으로” 지원하며, 예시로 든 acks-one & (local | test) 같은 AND/OR(및 필요 시 NOT) 표현을 사용할 수 있습니다. 근거 요약: 1) Spring Boot 문서에서 on-profile을 “profile expression(프로필 표현식)”이며 문서 활성 조건으로 사용한다고 명시합니다. 2) 해당 profile expression에서 지원하는 연산자(&, |, !)와 괄호 규칙(예: &와 |를 괄호 없이 혼합할 수 없음을 포함)은 Spring Framework의 profile expression 정의(javadoc)가 명확히 설명합니다. 따라서 spring.config.activate.on-profile: "acks-one & (local | test)" 형태는, 괄호를 사용해 &와 |의 우선순위/혼합 규칙을 만족하므로 문서 활성 조건으로 평가될 수 있습니다. 추가로 참고할 점: - spring.profiles.active/spring.profiles.include는 spring.config.activate.on-profile로 활성화되는 프로필-특정 문서 안에서는 사용할 수 없다는 제약이 문서에 따로 존재합니다(다만 이번 질문의 핵심인 on-profile expression 지원 여부와는 별개입니다).
Citations:
- 1: https://docs.spring.io/spring-boot/3.4.11/reference/features/profiles.html
- 2: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/env/Profiles.html
acks-one 프로필을 비운영 환경으로 제한하고, 금지 조합을 검증하는 테스트를 추가해야 한다.
현재 설정에서 acks-one 프로필이 제약 없이 활성화되면, cluster/qa/prd 프로필과 함께 동작할 경우 의도치 않게 Kafka 프로듀서의 내구성 설정이 acks: 1로 약화될 수 있다. 이는 메시지 손실 위험을 증가시켜 운영 환경의 데이터 신뢰성을 저하시킨다.
spring.config.activate.on-profile에 profile expression 문법(&, | 연산자)을 사용해 다음과 같이 수정한다:
수정안
-spring.config.activate.on-profile: acks-one
+spring.config.activate.on-profile: acks-one & (local | test)이와 함께 CI 단계에서 금지된 조합(acks-one + cluster/qa/prd)을 검증하는 설정 테스트를 필수로 추가해야 한다.
📝 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.
| spring.config.activate.on-profile: acks-one | |
| spring: | |
| kafka: | |
| producer: | |
| acks: 1 | |
| properties: | |
| enable.idempotence: false | |
| spring.config.activate.on-profile: acks-one & (local | test) | |
| spring: | |
| kafka: | |
| producer: | |
| acks: 1 | |
| properties: | |
| enable.idempotence: false |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@modules/kafka/src/main/resources/kafka.yml` around lines 53 - 60, The current
kafka profile activation allows `acks-one` to run alongside production profiles,
weakening producer durability; update the `spring.config.activate.on-profile`
expression for the `acks-one` block so it only activates in non-production
contexts (use profile expression logic to exclude `cluster`, `qa`, and `prd`
when `acks-one` is applied) and add a CI configuration test that
programmatically loads `kafka.yml` (or uses Spring's Environment/Profile
resolution) to assert the forbidden combination (`acks-one` + any of `cluster`,
`qa`, `prd`) is not allowed, failing the build if detected; references: the
`spring.config.activate.on-profile` key and the `acks-one` profile name in
kafka.yml.
| WARMUP_END=$(( $(date +%s) + WARMUP_SEC )) | ||
| WARMUP_COUNT=0 | ||
| while [ $(date +%s) -lt ${WARMUP_END} ]; do | ||
| curl -s -o /dev/null "${WARMUP_URL}" & | ||
| curl -s -o /dev/null "${WARMUP_URL}" & | ||
| curl -s -o /dev/null "${WARMUP_URL}" & | ||
| curl -s -o /dev/null "${WARMUP_URL}" | ||
| WARMUP_COUNT=$(( WARMUP_COUNT + 4 )) | ||
| done | ||
| wait | ||
| echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)" |
There was a problem hiding this comment.
$(date +%s) 커맨드 치환에 따옴표가 필요하다.
Line 49의 $(date +%s)는 word splitting 방지를 위해 따옴표로 감싸야 한다. 또한 warmup 단계에서 background curl(&)이 무제한으로 생성되어 서버에 과부하를 줄 수 있다. rate limiting 추가를 권장한다.
🔧 수정안
- while [ $(date +%s) -lt ${WARMUP_END} ]; do
+ while [ "$(date +%s)" -lt "${WARMUP_END}" ]; do
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}"
WARMUP_COUNT=$(( WARMUP_COUNT + 4 ))
+ sleep 0.1 # rate limiting
done📝 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.
| WARMUP_END=$(( $(date +%s) + WARMUP_SEC )) | |
| WARMUP_COUNT=0 | |
| while [ $(date +%s) -lt ${WARMUP_END} ]; do | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" | |
| WARMUP_COUNT=$(( WARMUP_COUNT + 4 )) | |
| done | |
| wait | |
| echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)" | |
| WARMUP_END=$(( $(date +%s) + WARMUP_SEC )) | |
| WARMUP_COUNT=0 | |
| while [ "$(date +%s)" -lt "${WARMUP_END}" ]; do | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" & | |
| curl -s -o /dev/null "${WARMUP_URL}" | |
| WARMUP_COUNT=$(( WARMUP_COUNT + 4 )) | |
| sleep 0.1 # rate limiting | |
| done | |
| wait | |
| echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)" |
🧰 Tools
🪛 Shellcheck (0.11.0)
[warning] 49-49: Quote this to prevent word splitting.
(SC2046)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/k6/run-all.sh` around lines 47 - 57, The while-loop using unquoted
command substitution should wrap date calls in quotes (use "$(date +%s)") to
prevent word-splitting when comparing against WARMUP_END; update occurrences in
the condition and when computing WARMUP_END if any. Also prevent unbounded
background curls spawned from the loop: throttle concurrency by limiting the
number of background jobs (e.g., track a job counter and call wait when it
reaches a threshold) or insert a short sleep between iterations so WARMUP_COUNT
and WARMUP_URL usage doesn’t spawn infinite background processes; modify the
loop that references WARMUP_END, WARMUP_COUNT, and WARMUP_URL accordingly.
| SQL="USE loopers; INSERT INTO mv_product_rank_weekly (week_start_date, product_id, rank_position, total_score, aggregated_at) VALUES " | ||
| for i in $(seq 1 ${TOP_N}); do | ||
| SCORE=$(( (TOP_N - i + 1) * 10 )) | ||
| SQL+="('${WEEK_START}', ${i}, ${i}, ${SCORE}.0, NOW())" | ||
| [ ${i} -lt ${TOP_N} ] && SQL+=", " | ||
| done | ||
| SQL+=";" | ||
| ${MYSQL} "${SQL}" 2>/dev/null |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# MV 테이블의 실제 DDL을 확인하여 NOT NULL 컬럼과 DEFAULT 값 존재 여부 점검
ast-grep --pattern 'CREATE TABLE IF NOT EXISTS mv_product_rank_weekly'
rg -n "view_count|like_count|order_count" --glob "*MvProductRank*.java" -A2 -B2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2617
🏁 Script executed:
#!/bin/bash
# DDL 및 마이그레이션 파일 검색
find . -type f \( -name "*.sql" -o -name "*schema*" -o -name "*migration*" \) | head -20
# mv_product_rank_weekly 테이블 생성 구문 검색
rg -n "mv_product_rank_weekly" -i -A 10 -B 2
# 스키마 디렉토리 확인
fd -t f -e sql | xargs grep -l "mv_product_rank_weekly" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7495
🏁 Script executed:
#!/bin/bash
# DDL/마이그레이션 파일 찾기
find . -type d -name "db" -o -name "migration" -o -name "migrations" -o -name "schema"
# SQL 파일 모두 찾기
find . -name "*.sql" -type f
# commerce-api, commerce-batch의 resources 디렉토리 확인
find apps -type d -name "resources"
# schema.sql, ddl.sql 같은 파일 확인
find . -name "*schema*.sql" -o -name "*ddl*.sql" -o -name "V*.sql"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 202
🏁 Script executed:
#!/bin/bash
# resources 디렉토리 내 SQL 파일 확인
ls -la apps/commerce-api/src/main/resources/
ls -la apps/commerce-batch/src/main/resources/
# 프로퍼티 파일 확인
find apps -name "application*.yml" -o -name "application*.properties" | xargs grep -l "jpa\|hibernate\|ddl"
# 모든 SQL 관련 파일 찾기
find . -path "./.*" -prune -o -type f -name "*.sql" -print
# commerce-batch에서 엔티티 정의 전체 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java
# commerce-api에서 엔티티 정의 전체 확인
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4435
🏁 Script executed:
#!/bin/bash
# application.yml 파일들 확인
cat apps/commerce-api/src/main/resources/application.yml
echo "---"
cat apps/commerce-batch/src/main/resources/application.yml
# 테이블이 실제로 어떻게 생성되는지 확인하기 위해 CREATE TABLE 구문 검색
rg -n "CREATE TABLE" --type sql -A 20
# H2 또는 다른 임베디드 DB 스키마 파일 확인
find . -name "*.sql" -o -name "schema*" -o -name "data*" 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3499
🏁 Script executed:
#!/bin/bash
# jpa.yml 파일 확인
find . -name "jpa.yml" -type f | xargs cat
# 테스트에서 실제 사용하는 INSERT 구문 다시 확인
rg -n "INSERT INTO mv_product_rank_weekly" -A 3 -B 3
# 배치에서 엔티티 저장하는 로직 확인
rg -n "MvProductRankWeekly" --type java -A 5 -B 5 | grep -A 5 -B 5 "save\|insert"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3776
🏁 Script executed:
#!/bin/bash
# commerce-batch에서 ranking job 코드 검색
find apps/commerce-batch -name "*.java" -type f | xargs grep -l "MvProductRankWeekly"
# ranking 배치 job 파일들 확인
find apps/commerce-batch -path "*ranking*" -type f -name "*.java"
# MvProductRankWeekly.save() 또는 저장 로직 확인
rg -n "MvProductRankWeekly" apps/commerce-batch --type java -A 10 | grep -A 10 "save\|insert\|persist"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3896
🏁 Script executed:
#!/bin/bash
# WeeklyRankingProcessor에서 MvProductRankWeekly 객체 생성 방식 확인
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java
# MvProductRankWeeklyJpaRepository 확인
cat apps/commerce-batch/src/main/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
# 배치 테스트에서 실제 데이터 검증 방식 확인
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 8457
🏁 Script executed:
#!/bin/bash
# MvProductRankWeeklyJpaRepository 위치 찾기
find apps/commerce-batch -name "*MvProductRankWeekly*" -type f
# 실제 경로 확인
find apps/commerce-batch -path "*/ranking/*" -name "*.java" | grep -i repository
# 테이블이 실제로 어떤 필드를 가지고 생성되는지 확인
# Hibernate 로그나 generated DDL 확인
rg -n "DEFAULT" apps/commerce-batch --type java -B 5
# 혹은 MySQL에서 show create table 결과를 보자
# MvProductRankMonthly도 같은 구조인지 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5040
🏁 Script executed:
#!/bin/bash
# 실제 프로덕션에서 사용할 DDL이 있는지 확인
find . -path "*/flyway*" -o -path "*/db/migration*" -o -path "*/liquibase*" 2>/dev/null
# seed.sh 실행 결과나 에러 로그가 있는지 확인
find . -name "*.log" -o -name "*seed*" | grep -E "(log|result)" 2>/dev/null
# 최종 확인: 두 엔티티가 같은 테이블을 매핑하는지 명확히
rg "@Table\(name = \"mv_product_rank_weekly\"\)" -A 1
# 배치 job이 보내는 INSERT 시점에 테이블이 어떤 구조인지 추적
rg -n "INSERT INTO mv_product_rank" apps --type java -B 5 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3435
두 엔티티의 스키마 정의 불일치로 인한 테이블 생성 실패 위험
같은 테이블 mv_product_rank_weekly에 대해 상충하는 엔티티 정의가 존재한다:
commerce-batch/MvProductRankWeekly: 8개 필드 (view_count, like_count, order_count nullable=false)commerce-api/MvProductRankWeeklyEntity: 5개 필드만 정의, view_count/like_count/order_count 제외 (@Immutable)
Hibernate의 ddl-auto=create (local/test 프로필)에서 어느 엔티티를 먼저 스캔하는지에 따라:
- API 엔티티 우선 → 5개 컬럼만 생성 → 배치 Job의 INSERT 실패 (컬럼 누락)
- 배치 엔티티 우선 → 8개 컬럼 생성 (DEFAULT값 없음) → seed.sh의 INSERT 실패 (필수 컬럼 미포함)
스키마 정의를 통일하고, 필수 컬럼에 대한 DEFAULT값을 명시하거나 INSERT 구문을 완성해야 한다. 또한 프로덕션(ddl-auto=none)에서 사용할 명시적 마이그레이션 스크립트가 두 엔티티 정의와 일치하는지 검증이 필수다.
🧰 Tools
🪛 Shellcheck (0.11.0)
[info] 48-48: Double quote to prevent globbing and word splitting.
(SC2086)
[info] 51-51: Double quote to prevent globbing and word splitting.
(SC2086)
[info] 51-51: Double quote to prevent globbing and word splitting.
(SC2086)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/k6/seed.sh` around lines 47 - 54, The test seed INSERT in seed.sh risks
failing because two conflicting JPA entities define mv_product_rank_weekly
differently (commerce-batch/MvProductRankWeekly includes view_count, like_count,
order_count as non-nullable; commerce-api/MvProductRankWeeklyEntity defines only
five columns and is `@Immutable`), so fix by reconciling schemas and/or making the
seed INSERT match the stricter entity: either update
commerce-api/MvProductRankWeeklyEntity to include the missing non-nullable
fields (view_count, like_count, order_count) with appropriate defaults or change
commerce-batch/MvProductRankWeekly to allow NULL/defaults, and then update
seed.sh’s SQL string construction to insert values for view_count, like_count,
order_count (or ensure DB columns have DEFAULTs) so the INSERT into
mv_product_rank_weekly always supplies all required columns; also ensure
migration scripts used in prod reflect the unified schema.
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
📌 Summary
commerce-streamer가 적재한product_metrics_daily(SOT)를 Spring Batch로 사전 집계해 MV 테이블에 적재하고,period파라미터로 일간/주간/월간을 단일 엔드포인트에서 분기했습니다. 점수 가중치는 SQL과 Java 양쪽에 흩어지지 않도록RankingScoreCalculator로 응집하고, Processor에서 drift가 감지되면 즉시 실패하도록 가드를 두었습니다.🧭 Context & Decision
일간 ZSET과 주간/월간 MV로 갈라진 이유
원래는 일간 ZSET을 7일치로 누적해 그대로 보여주는 방안도 떠올렸습니다. 그런데 일간과 주간/월간은 같은 도메인 개념이지만 요구가 너무 달랐습니다. 일간은 이벤트마다 갱신이 필요하고 갱신 지연 허용도가 거의 0인 반면, 주간/월간은 하루 1회 사전 집계로 충분하고 수 시간의 지연도 허용됩니다. 누적 데이터 크기도 일간은 1일치인데 주간/월간은 7일/30일치를 합산해야 하고, 정합성 요구는 일간이 휘발 가능(TTL 2일)인 반면 주간/월간은 누적을 보존해야 합니다.
이런 차이를 같은 저장소로 묶으면 한쪽이 무리한 비용을 지게 됩니다. ZSET으로 통일하면 누적 보존이 어려워지고, MV로 통일하면 일간 실시간성을 포기해야 합니다. 일간은 실시간성이 우선이라 ZSET이 자연스럽고, 주간/월간은 정합성·비용이 우선이라 MV 테이블에 사전 집계하는 쪽으로 갈랐습니다.
Chunk-Oriented를 택한 이유
주간/월간 집계는 데이터를 한 번에 읽어 가공한 뒤 적재하는 작업이라 단순 Tasklet으로도 충분히 굴러갑니다. Chunk-Oriented를 택한 건 두 가지 때문이었습니다.
지금은 GROUP BY 결과가 100건이라 단발 처리가 가능하지만, 운영 데이터가 늘어나 LIMIT 없이 가져갈 일이 생기면 Tasklet 단발 처리는 메모리 부담이 커집니다. Chunk size 500으로 나누면 메모리 상한을 통제할 수 있습니다. 또 Chunk-Oriented는 청크 단위로 트랜잭션이 커밋되어, 실패해도 마지막 성공한 청크 다음부터 재시작이 가능합니다.
Reader 선택 — JpaPagingItemReader 대신 JdbcCursorItemReader
JpaPagingItemReader도 후보였는데, 집계 쿼리에GROUP BY가 있어 페이징과 궁합이 좋지 않았습니다. 페이지마다 GROUP BY 전체 쿼리가 다시 실행되면서 정렬 순서가 흔들릴 수 있고, OFFSET이 커질수록 비용도 누적됩니다.JdbcCursorItemReader는 단일 쿼리를 한 번 실행하고 커서로 row를 흘려보내는 방식이라 GROUP BY 결과를 안전하게 단일 패스로 처리할 수 있었습니다.점수 가중치를 한 곳으로 모은 이유
원래 Reader SQL 안에
(SUM(view) * 1 + SUM(like) * 2 + SUM(order) * 7)처럼 리터럴로 박아두었습니다. 가중치를 자주 바꿀 일은 없으니 큰 문제는 아니지만, 시간이 지나 가중치가 SQL과 Java 양쪽에 흩어지면 한쪽만 바꿨을 때 SQL 결과와 Java 계산이 어긋나도 적재까지 통과되는 구조가 됩니다. 이게 마음에 걸렸습니다.RankingScoreCalculator한 곳에 상수와 계산 로직을 두고, Reader SQL은 같은 상수를.formatted()로 주입합니다. Processor에서 SQL이 계산한 점수와 Calculator가 계산한 점수가 일치하는지 매 row마다 검증하고, 어긋나면 즉시 실패시켜 잘못된 랭킹이 적재되는 걸 막는 구조입니다.🏗️ Design Overview
변경 범위
RankingScoreCalculator+ 단위 테스트MvProductRankWeekly,MvProductRankMonthly, RepositoryfindAllByIds도메인 메서드 추가 (RP-2 참조)product_metrics_daily엔티티 + Consumer 적재seed.sh,run-all.sh,WARMUPenv, 시나리오별 결과전체 아키텍처
🔁 Flow Diagram
Weekly Job 실행 흐름
sequenceDiagram autonumber participant Launcher as JobLauncher participant Cleanup as Cleanup Tasklet participant Reader as JdbcCursorItemReader participant Processor as WeeklyRankingProcessor participant Calculator as RankingScoreCalculator participant Writer as JpaItemWriter participant DB as MySQL Launcher->>Cleanup: weekStartDate=20260413, runId=... Cleanup->>DB: DELETE FROM mv_product_rank_weekly WHERE week_start = ? Note over Cleanup,DB: 멱등성 보장 — 동일 키 재실행 안전 Launcher->>Reader: aggregateStep 시작 Reader->>DB: SELECT product_id, SUM(view), SUM(like), SUM(order),<br/>(SUM*1 + SUM*2 + SUM*7) AS total_score<br/>FROM product_metrics_daily<br/>WHERE metric_date BETWEEN ? AND ?<br/>GROUP BY product_id ORDER BY total_score DESC LIMIT 100 loop chunk size = 500 Reader-->>Processor: ProductScoreRow Processor->>Calculator: assertConsistent(sqlScore, view, like, order) Note over Processor,Calculator: SQL 결과와 Calculator 결과<br/>일치 검증 (drift fail-fast) Processor-->>Writer: MvProductRankWeekly end Writer->>DB: persist (chunk 단위 commit)Ranking API period 분기
sequenceDiagram autonumber participant Client participant Facade as RankingFacade participant Redis as Redis (ZSET) participant Repo as RankingRepository participant DB as MySQL Client->>Facade: GET /api/v1/rankings?period=weekly&date=20260413 alt period = daily Facade->>Redis: ZREVRANGE ranking:all:{yyyyMMdd} Redis-->>Facade: [(productId, score), ...] else period = weekly | monthly Facade->>Repo: findWeeklyRankings(weekStart, page, size) Repo->>DB: SELECT * FROM mv_product_rank_weekly<br/>WHERE week_start = ? ORDER BY rank_position DB-->>Repo: [ProductRankEntry] Repo-->>Facade: entries end Facade->>DB: productRepository.findAllByIds(productIds) Facade->>DB: brandRepository.findAllByIds(brandIds) Note over Facade,DB: distinct 후 일괄 조회<br/>(RP-2: N+1 회피) Facade-->>Client: [{rank, productId, productName, price, brandName, score}]Step 1. Spring Batch Weekly/Monthly Job
Job 구성 — Cleanup Tasklet + Aggregate Step
—
WeeklyRankingJobConfig.javaCleanup → Aggregate 두 Step 구조로, Cleanup이 동일 (period, date) row를 선삭제해 동일 키 재실행이 안전합니다. UPSERT로도 멱등성을 보장할 수 있는데, DELETE + INSERT는 어떤 상태에서 실행해도 같은 결과로 수렴한다는 의도가 가장 명확하게 드러납니다.
Cleanup Tasklet — 동일 키 선삭제
—
WeeklyRankingCleanupTasklet.javaJob 파라미터의
weekStartDate로 해당 주 row를 모두 삭제하고 끝납니다. 백필이나 재처리로 같은 키가 들어와도 항상 같은 결과가 나옵니다.Reader — GROUP BY + LIMIT 100, JdbcCursorItemReader
가중치는
.formatted()로RankingScoreCalculator상수를 주입합니다. 매직 리터럴(1, 2, 7)을 SQL에 박지 않고 단일 소스에서 가져오는 게 Step 3의 drift assertion이 작동하기 위한 전제입니다.Job 활성화 —
@ConditionalOnProperty로 선택적 등록Spring Batch 5는
spring.batch.job.name으로 실행할 Job을 선택합니다. Weekly/Monthly가 같은 컨텍스트에 모두 등록되면 매번 같이 실행되어버려서,@ConditionalOnProperty로 실행 시점에 지정된 Job만 등록되도록 했습니다.Step 2. Ranking API period 분기
단일 엔드포인트 + RankingFacade 분기
—
RankingFacade.javaperiod별 엔드포인트를 분리하는 방식도 떠올랐는데, 클라이언트가 라우팅을 신경 써야 한다는 점이 걸렸습니다. 단일 엔드포인트 +
periodenum이면 OpenAPI 스펙으로 enum 제약이 검증되어 오타도 막을 수 있습니다.Brand N+1 회피 —
findAllByIds로 일괄 조회원래는 Brand를 productMap.values()를 돌면서 단건 조회했는데, 부하 측정 단계에서 N+1이 드러났습니다 (RP-2 참조). productMap 확정 후 brandIds를 distinct로 모아
findAllByIds로 한 번에 가져오는 구조로 바꿔서 weekly p99가 74ms → 12ms로 떨어졌습니다.@BatchSize나JOIN FETCH도 후보였습니다. Brand가 Product의 lazy 연관이 아니라 직접 조회 경로라@BatchSize는 적용 지점 자체가 없었고,JOIN FETCH는 Product 조회 쿼리를 무겁게 만들어 적합하지 않았습니다. 도메인 메서드로 명시적으로 가져가는 쪽이 호출자가 의도를 읽기 쉽다는 판단이었습니다.Step 3. 점수 가중치 단일 소스화
RankingScoreCalculator — 상수 + 계산 + drift assertion
—
RankingScoreCalculator.java상수와 계산 로직을 한 클래스에 모았습니다. SQL과 Java 양쪽이 같은 상수를 참조하므로 가중치는 한 곳에서만 바뀝니다.
Processor — 매 row drift 검증
—
WeeklyRankingProcessor.javaReader가 SQL로 계산한
total_score와 Calculator가 Java로 계산한 점수가 매 row마다 일치하는지 검증합니다. 어긋나면IllegalStateException으로 즉시 실패해서 잘못된 랭킹이 적재되는 걸 막습니다.drift가 실제로 발생하는 시나리오는 가중치 변경 시 한쪽만 깜빡 잊은 경우입니다. SQL에서 가중치를 바꾸고
RankingScoreCalculator상수를 안 바꾸면(또는 그 반대), 첫 번째 row에서 즉시 fail-fast됩니다. 잘못된 랭킹이 노출되기 전에 Job 자체가 실패하는 안전한 가드입니다.✅ 요구사항 체크리스트
🧪 k6 부하테스트 결과
부하 프로파일: 100 VU, 30s (ramp 9s + sustain 15s + down 6s), 약 1640 req / TPS ~53.
Step 1. 베이스라인 — weekly가 가장 느린 게 어색했던 시점
monthly가 같은 MV 구조인데 weekly보다 빠른 점이 가장 먼저 눈에 들어왔습니다. 데이터 양으로 따지면 monthly가 더 큰데 더 빠르다는 게 어색했고, 쿼리 비용이 원인이라는 가설은 이 시점에 기각되었습니다.
Step 2. 호출 순서 가설 (Exp A) → 웜업 효과 검증 (Exp B)
다음으로 호출 순서를 의심해 daily → weekly → monthly 순서를 monthly → weekly → daily로 뒤집었습니다 (Exp A).
cold spike가 weekly에서 monthly로 그대로 옮겨갔습니다. 첫 번째 호출이 항상 느리다는 건 호출 순서 = 콜드 효과라는 뜻이었습니다. JIT 미컴파일, JPA 메타데이터 lazy load, HikariCP idle pool 셋이 동시에 작용한다고 보고, 측정 전에 10초 웜업을 끼웠습니다 (Exp B).
cold start 가설은 입증됐습니다. 다만 정상화 구간에서 여전히 weekly(74ms) > monthly(59ms) 역전이 남아있는 게 새로 눈에 들어왔습니다.
Step 3. Brand N+1 발견 (Exp C)
weekly가 monthly보다 느리다는 정황으로 Hibernate
show_sql을 켰습니다.통합 테스트에서는 N+1을 잡지 못했습니다. 테스트 데이터가 Brand 1개라 단건 SELECT × 1과 IN 절 SELECT × 1이 같아 보였던 게 원인입니다. 부하 측정까지 가서야 발견한 셈이라, 정석 가드를 두지 않은 책임이 있습니다.
findAllByIds로 일괄 조회로 바꾼 뒤:weekly < monthly로 순서가 정상화되면서 N+1 가설도 입증됐습니다.
📁 파일 변경 요약
🤔 고민한 점
1. N+1을 부하 측정까지 가서야 발견한 점
가장 아쉬운 부분입니다. 통합 테스트에서 N+1을 잡지 못한 이유는 단순합니다 — 테스트 데이터가 Brand 1개라 단건 SELECT × 1과 IN 절 SELECT × 1이 같아 보였습니다. monthly만 봤으면 71ms라 절대값으로는 정상이라 의심도 못 했을 텐데, weekly가 더 느린 정황이 생긴 뒤에야
show_sql을 켰습니다.이 경험으로 N+1 가드는 통합 테스트 시점에 시스템 레벨로 들어가야 한다는 생각이 들었습니다. Hibernate Statistics로
getQueryExecutionCount()임계치를 단언하는 방식은 기본 제공이라 손은 가볍지만 카운트 기반이라 N+1 자체보다는 쿼리 수 회귀 감지에 가깝고, p6spy/dataSource-proxy로 쿼리를 인터셉트해 단언하는 방식은 추적이 정확한 대신 별도 의존성과 테스트 인프라 셋업이 필요합니다. 가장 단순하게는 fixture를 Brand 여러 개로 다양화하는 방법도 있는데, 모든 케이스를 사람이 챙겨야 한다는 한계가 있습니다.2. 호출 순서가 결과를 흔들었다는 점
Step 1에서 weekly p99가 261ms로 튀었을 때, 처음에는 weekly 쿼리 자체를 의심했습니다. monthly가 같은 MV 구조인데 더 빨랐다는 점이 첫 번째 단서였고, 호출 순서를 뒤집었더니 cold spike가 그대로 옮겨갔다는 점이 결정적이었습니다.
이 경험으로, 부하 측정에서는 무엇을 비교하고 싶은가와 측정 환경 자체의 편향을 분리해서 보지 않으면 잘못된 결론에 도달할 수 있다는 걸 체감했습니다. 웜업은 측정 편향을 제거하는 도구일 뿐 운영 솔루션은 아니라는 것도 같은 맥락에서 정리했습니다.
💬 리뷰 포인트
1. 일간 ZSET vs 주간/월간 MV — 저장소 분리 비용은 감수할 만한가
저장소가 둘이 되면서 모니터링·백업·장애 복구 매뉴얼이 분리됩니다. 이 운영 비용은 무시할 수준은 아닙니다. 같은 도메인 개념이 두 코드 경로로 갈라진 점은
RankingFacade에서 한 번만 분기하도록 흡수했지만, 새 period가 추가되면 다시 검토해야 하는 구조입니다.질문: 운영 관점에서 저장소 두 개를 가져가는 비용은 감수할 만한 결정인지 궁금합니다. 실제 현업이라면 ClickHouse 같은 분석형 DB로 단일화하는 시점을 어떤 신호로 판단하시는지도 함께 들어보고 싶습니다.
2. Brand 조회 N+1 — 정석 가드는 어떻게 두는지
본 PR은 부하 측정 단계에서 N+1을 발견해 도메인 메서드 추가로 해결했지만, 시스템 가드는 두지 못했습니다. 도메인 메서드 추가 방식은 의도가 명시적이긴 하지만 호출자가 IN 호출을 잊으면 다시 회귀할 수 있다는 약점이 있습니다.
질문 1: N+1을 부하 측정 단계까지 가지 않고 단위/통합 테스트 단계에서 잡으려면 정석 가드가 무엇인지 궁금합니다 (Hibernate Statistics, p6spy/dataSource-proxy의 쿼리 카운트 단언 등 중에서).
질문 2:
findAllByIds같은 도메인 메서드 추가와@EntityGraph/JOIN FETCH같은 JPA 어노테이션 중 어느 쪽을 기본 정책으로 두시는지도 함께 들어보고 싶습니다.3. Cold start 운영 적용 — ApplicationRunner / startupProbe / 점진 배포
본 PR의 웜업은 부하 측정의 전제 조건이지 운영 솔루션이 아닙니다. 어떤 옵션이 정석인지 본 PR에서 결정하지 않았습니다.
질문: 운영에서는 ApplicationRunner / startupProbe / 점진 배포 중 어떤 조합이 정석으로 자리잡는지 궁금합니다.