[Volume 10] Spring Batch시스템 구현#408
Conversation
- RankingWeight 가중치 상수 (VIEW=0.01, LIKE=0.3, ORDER=1.0+ε×log) - RankingRepository/Impl: ZINCRBY+EXPIRE, SADD/SREM 좋아요 멱등 - RankingService: 점수 증감 + 날짜 키 resolve + 좋아요 중복 방어 - CatalogEventProcessor: 조회/좋아요 이벤트 → 랭킹 적재 (try-catch) - OrderEventProcessor: 주문 이벤트 → 건수+금액 보너스 적재 (try-catch) - ErrorType.INVALID_DATE_FORMAT 추가 - 단위 테스트 10건 + 통합 테스트 6건 (Testcontainers Redis)
Step 2: commerce-api 랭킹 API
- RankingRepository(읽기 전용) / RankingRepositoryImpl (Replica 우선 읽기)
- RankingService (페이지 계산, 0→1-based 순위 변환)
- RankingFacade (버퍼 초과 요청 + 삭제/숨김 필터링 + 배치 조합)
- GET /api/v1/rankings (date, page, size 파라미터)
- 단위 테스트 4건 + E2E 테스트 2건
Step 3: 상품 상세 순위 확장
- ProductInfo / ProductV1Dto.ProductDetailResponse에 rank 필드 추가
- ProductFacade에서 RankingService.getRank() try-catch (graceful degradation)
- 기존 ProductFacadeTest 하위 호환 + 순위 테스트 3건 추가
Step 4: Nice-to-Have
- RankingCarryOverScheduler — 매일 23:50 Lua ZUNIONSTORE로 10% carry-over
- incrementScoreBatch() — Redis Pipeline 배치 쓰기
- 시간 단위 랭킹 (ranking:hourly:{yyyyMMddHH}, TTL 3h) + /hourly 엔드포인트
- RankingWeightProperties — application.yml 가중치 외부화
Step 5: k6 부하 테스트 (L1~L5 전체 PASS)
- L1 E2E 정확성: checks 100%
- L2 API 부하: p95=14ms, 에러율 0%, 82K req (50VU, 3분)
- L3 이벤트 처리량: 읽기 p95=28ms (쓰기30+읽기10 VU)
- L4 가중치 정확성: checks 99.6%
- L5 운영 시뮬레이션: p95=19ms, 에러율 0%, 127K req (Spike 100VU)
[가중치 변경 — round9.md "총합 1" 요구사항 충족] - RankingWeight: VIEW 0.01→0.1, LIKE 0.3→0.2, ORDER_BASE 1.0→0.7 - 총합: 0.1 + 0.2 + 0.7 = 1.0 - 주문 금액 보너스 안전 마진: 0.08 < 0.7 (8.75배) [죽은 코드 제거] - RankingWeightProperties 삭제 (어디서도 주입되지 않는 미사용 Bean) - application.yml ranking.weights 설정 블록 제거 [DIP 개선 — RankingCarryOverScheduler] - RedisTemplate 직접 의존 → RankingRepository 도메인 인터페이스 경유로 변경 - RankingRepository에 carryOver() 메서드 추가 - RankingRepositoryImpl에 Lua 스크립트 이동, 상수 중복(KEY_PREFIX, TTL) 해소
Kafka Consumer 이벤트 처리 병목 해소 (LAG 46,677 → 0, API p95 917ms → 63ms):
1. Redis Pipeline 배치 처리
- CatalogEventProcessor: 1건씩 → productId별 합산 후 Pipeline 일괄 처리
- RankingRepositoryImpl.incrementScoreBatch(): executePipelined로 N건 1
RTT
2. Streamer @transactional 제거
- CatalogEventProcessor에서 @transactional 제거하여 Redis 호출 중 DB
커넥션 점유 방지
- ProductMetricsService가 자체 트랜잭션 보유하므로 안전
3. DB 조회수 배치 합산 upsert
- incrementViewCountBy(productId, count): N건 이벤트를 productId당 1회
upsert로 축소
- INSERT ... ON DUPLICATE KEY UPDATE view_count = view_count + :count
4. 랭킹 API Caffeine 로컬 캐시 (TTL 10초)
- RankingFacade.getRankings()에 @Cacheable 적용
- rankingCacheManager: maximumSize 100, expireAfterWrite 10s
feat: 주간/월간 랭킹 배치 + MV 기반 API 확장 — Spring Batch, 이중 upsert, period 파라미터
## commerce-batch
- WeeklyRankingJob /
MonthlyRankingJob (3-Step 구조)
- Step 0: tmp 테이블 TRUNCATE
- Step 1: product_metrics_daily
GROUP BY → tmp upsert (Chunk, Cannot
AcquireLock/TransientDataAccess
재시도)
- Step 2: tmp TOP 100 → MV 원자
교체 (DELETE + INSERT…SELECT 단일
TX)
- RankingWeights
(@ConfigurationProperties:
ranking.weights.*)
- RankingScoreCalculator —
streamer의 orderScore와 방향성 일치
(평균 금액 기반 log 근사)
## commerce-streamer
- product_metrics_daily 이중 upsert
파이프라인
- ProductMetricsDailyModel +
JpaRepository (IdClass 복합 PK)
- CatalogEventProcessor: VIEWED
배치 + LIKED/UNLIKED daily 반영
- OrderEventProcessor:
ORDER_CREATED에 metricDate 전달
## commerce-api
- RankingPeriod enum (DAILY / WEEKLY
/ MONTHLY)
- Mv{Weekly,Monthly}RankModel +
@immutable + IdClass (year_month
백틱 이스케이프)
- RankingRepository 확장 — Redis
ZSET + RDB MV 단일 인터페이스
- RankingFacade.getRankings(period,
...) switch 분기 +
mvRankingCacheManager(10분 TTL)
- RankingV1Controller period
파라미터 + PeriodRankingResponse
Session 10 — 주간/월간 랭킹을 Spring Batch + Materialized View로 구현하고
API에 period 파라미터를 추가한다. 시니어 리뷰 P0 2건과 DIP 컨벤션 1건 동시
반영.
## commerce-batch
- WeeklyRankingJob / MonthlyRankingJob (3-Step 구조)
- Step 0: tmp 테이블 TRUNCATE
- Step 1: product_metrics_daily GROUP BY → tmp upsert (Chunk,
CannotAcquireLock/TransientDataAccess 재시도)
- Step 2: tmp TOP 100 → MV 원자 교체 (DELETE + INSERT…SELECT 단일 TX)
- RankingWeights (@ConfigurationProperties: ranking.weights.*)
- RankingScoreCalculator — streamer orderScore(amount)와 방향성 일치 (평균
금액 기반 log 근사)
## commerce-streamer
- product_metrics_daily 이중 upsert 파이프라인
- CatalogEventProcessor VIEWED 배치 + LIKED/UNLIKED daily 반영
- OrderEventProcessor ORDER_CREATED metricDate 전달
## commerce-api
- RankingPeriod enum (DAILY / WEEKLY / MONTHLY)
- Mv{Weekly,Monthly}RankModel + @immutable + IdClass (year_month 백틱
이스케이프)
- RankingRepository 확장 — Redis ZSET + RDB MV 단일 인터페이스
- RankingFacade.getRankings(period, ...) switch 분기 +
mvRankingCacheManager(10분 TTL)
- RankingV1Controller period 파라미터 + PeriodRankingResponse
📝 WalkthroughWalkthroughRedis ZSET 기반 일/주/월간 상품 랭킹 시스템을 구현한다. 스트리머에서 실시간 이벤트를 처리하여 점수를 계산하고, 배치 작업으로 주/월간 집계 뷰를 생성한다. 상품 상세 조회에 랭킹 정보를 통합하며, 랭킹 조회 API를 추가한다. Changes
Sequence DiagramsequenceDiagram
participant User as Client
participant Api as API<br/>(Commerce)
participant Streamer as Streamer<br/>(Kafka→Redis)
participant Redis as Redis<br/>(ZSET)
participant Batch as Batch<br/>(Weekly/Monthly)
participant DB as DB<br/>(JPA)
User->>Api: 1. GET /api/v1/products/{id}<br/>(Detail with rank)
Api->>Api: Fetch ProductFacade
Api->>Redis: getRank(date, productId)
Redis-->>Api: Rank (or null)
Api-->>User: ProductDetail + rank
User->>User: View Product / Like / Order
User->>Streamer: Kafka: PRODUCT_VIEWED<br/>PRODUCT_LIKED<br/>ORDER_CREATED
Streamer->>Streamer: Parse & Aggregate<br/>by (productId, metricDate)
Streamer->>DB: Insert/Update<br/>product_metrics_daily
Streamer->>Redis: ZINCRBY daily ZSET<br/>+ hourly ZSET
Redis-->>Streamer: Updated score
User->>Api: 2. GET /api/v1/rankings<br/>?date=today&page=0&size=20
Api->>Api: RankingFacade.getDailyRankings
Api->>Redis: ZRANGE (cached)
Redis-->>Api: Top rankings
Api->>DB: Hydrate Product/Brand data
DB-->>Api: Enriched metadata
Api-->>User: RankingPage<br/>(items + pagination)
Batch->>DB: 3. Weekly/Monthly Job<br/>(trigger: nightly)
Batch->>DB: Read product_metrics_daily<br/>(7-day / month window)
Batch->>Batch: Aggregate & Calculate<br/>RankingScore
Batch->>DB: Write to<br/>mv_product_rank_weekly<br/>mv_product_rank_monthly
Batch->>Batch: Scheduled at 23:50<br/>CarryOver: Copy<br/>today→tomorrow<br/>with 10% weight
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120분 근거:
Possibly related PRs
운영 관점 주요 지적1. Redis TTL 전략의 명확성 부족문제점:
수정안:
추가 테스트:
2. 배치 작업 실패 및 재시도 정책 미흡문제점:
수정안:
추가 테스트:
3. 이벤트 배치 처리의 부분 실패 처리문제점:
수정안:
추가 테스트:
4. 캐시 일관성 문제문제점:
수정안:
추가 테스트:
5. 가중치 기반 점수 계산의 정밀도문제점:
수정안:
추가 테스트:
6. 파라미터 검증 불완전문제점:
수정안:
추가 테스트:
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 3
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.claude/settings.local.json (1)
223-235:⚠️ Potential issue | 🟠 Major사용자 홈 경로가 고정된 hook 설정은 공유 설정으로 깨진다
/home/mdskkdj/.claude-hooks/notify.sh를 직접 박아 두면 다른 계정, CI, 컨테이너 환경에서 바로 실패한다. 운영 관점에서는 개발자마다 동작이 달라져 장애 재현과 지원이 어려워진다. 이 파일을 로컬 전용으로 버전 관리 대상에서 제외하거나, 저장소 내부 래퍼 스크립트 또는 환경변수로 경로를 주입하도록 바꾸는 편이 안전하다. 추가로 다른$HOME을 가진 깨끗한 환경에서도 hook 부재가 기본 작업을 막지 않는지 확인하는 테스트가 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.claude/settings.local.json around lines 223 - 235, 현재 설정에 하드코딩된 절대경로 "command": "/home/mdskkdj/.claude-hooks/notify.sh" 때문에 다른 계정/CI/컨테이너에서 실패하므로 절대 경로를 제거하고 환경에 따라 동작하도록 수정하세요: .claude/settings.local.json의 해당 "command" 값을 로컬 전용으로 버전관리에서 제외하거나(예: .gitignore에 settings.local.json), 저장소 내부의 래퍼 스크립트(예: repo-level hooks/notify.sh)로 대체하거나 환경변수($HOME 또는 CLAUDE_HOOK_PATH)로 주입하도록 바꾸고, 해당 스크립트가 없을 때는 no-op 또는 안전한 폴백 동작을 하도록 보장해 테스트(깨끗한 환경에서 hook 부재 시 기본 작업이 차단되지 않는지)도 추가하세요.
🟠 Major comments (20)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java-146-158 (1)
146-158:⚠️ Potential issue | 🟠 MajorBean Validation 예외 핸들러 추가는 현재 에러 처리 규약과 충돌한다.
운영 관점에서 검증 실패 경로가
CoreException경로와 Bean Validation 경로로 분기되면, API별 메시지/코드 일관성이 깨져 장애 분석과 클라이언트 대응이 어려워진다. 수정안으로 Line 153-158 핸들러를 제거하고, 입력 검증 실패는 도메인/애플리케이션에서CoreException으로 변환해 기존handle(CoreException e)단일 경로를 유지해야 한다. 추가 테스트로 잘못된page/size/period요청에 대해 항상 동일한 에러 코드/포맷이 반환되는 E2E 케이스를 보강해야 한다.Based on learnings: "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice ... Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling."
🤖 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/ApiControllerAdvice.java` around lines 146 - 158, Remove the Bean Validation exception handler method handleBadRequest(ConstraintViolationException e) from ApiControllerAdvice and ensure all input validation failures are converted to and thrown as CoreException from the domain/application layer so they flow through the existing handle(CoreException e) path; update or add E2E tests to assert invalid page/size/period requests return the same error code/format produced by handle(CoreException e) to enforce a single error schema.apps/commerce-streamer/build.gradle.kts-24-25 (1)
24-25:⚠️ Potential issue | 🟠 Major테스트 의존성 버전을 명시적으로 고정해야 한다.
com.redis:testcontainers-redis는 Spring Cloud BOM에 포함되지 않으므로 중앙 버전 관리 대상이 아니다. 현재 코드는 두 곳(apps/commerce-streamer:25, modules/redis:10)에서 버전 없이 선언되어 있어 의존성 해석이 불확정되고 빌드 재현성이 보장되지 않는다.다음 중 하나의 방식으로 버전을 고정해야 한다.
- gradle.properties에 버전 변수 추가 후 참조
- 각 build.gradle.kts에서 명시 버전 지정
- 루트 build.gradle.kts의 resolutionStrategy를 확장하여 com.redis 그룹도 관리
현재 resolutionStrategy(라인 73-79)는 org.testcontainers만 1.21.4로 강제하고 있으므로 com.redis 그룹은 제어되지 않는다.
수정 방안
방안 A: gradle.properties에 버전 추가 (권장)
### Library versions ### springDocOpenApiVersion=2.7.0 +testcontainersRedisVersion=2.2.2 springMockkVersion=4.0.2apps/commerce-streamer/build.gradle.kts와 modules/redis/build.gradle.kts:
- testImplementation("com.redis:testcontainers-redis") + testImplementation("com.redis:testcontainers-redis:${project.properties["testcontainersRedisVersion"]}")방안 B: 루트 build.gradle.kts resolutionStrategy 확장
configurations.configureEach { resolutionStrategy.eachDependency { if (requested.group == "org.testcontainers") { useVersion("1.21.4") } + if (requested.group == "com.redis") { + useVersion("2.2.2") + } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/build.gradle.kts` around lines 24 - 25, The test dependency com.redis:testcontainers-redis is declared without a version in apps/commerce-streamer (testImplementation("com.redis:testcontainers-redis")), making builds non-reproducible because the Spring Cloud BOM doesn't manage it; fix by pinning the version—preferred: add a property in gradle.properties (e.g., redisTestcontainersVersion) and reference it from apps/commerce-streamer and modules/redis testImplementation declarations; alternatively set the explicit version inline in those build.gradle.kts files, or extend the root build.gradle.kts resolutionStrategy (the block currently forcing org.testcontainers to 1.21.4 around lines 73-79) to also force com.redis:testcontainers-redis to the chosen version so both modules resolve consistently.apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingPage.java-18-29 (1)
18-29:⚠️ Potential issue | 🟠 Major
items를 그대로 보관하면 응답 객체가 외부 변경에 오염된다
List<RankingInfo> items를 방어적 복사 없이 저장하면 호출자가 원본 리스트를 수정하거나page.items()반환값을 수정할 때 record 객체의 상태가 함께 변경된다. 캐시된 응답이나 재사용된 객체가 요청 사이에서 오염되어 재현 어려운 버그로 이어질 수 있다. compact constructor에서items = List.copyOf(items)로 고정하고 null은 즉시 실패시키는 편이 안전하다.제안 수정
public record MvRankingPage( RankingPeriod period, LocalDate baseDate, String yearMonth, Integer windowDays, LocalDateTime aggregatedAt, int page, int size, long totalElements, int totalPages, List<RankingInfo> items ) { + public MvRankingPage { + items = List.copyOf(items); + } public static MvRankingPage daily(LocalDate date, int page, int size, long totalElements, int totalPages, List<RankingInfo> items) {추가로 원본 리스트 변경이 반영되지 않는지와 반환 리스트가 수정 불가인지 확인하는 테스트를 작성한다.
🤖 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/MvRankingPage.java` around lines 18 - 29, The MvRankingPage record stores the List<RankingInfo> items without defensive copying causing external mutation to corrupt the record; add a compact constructor for MvRankingPage that validates items is non-null (fail fast) and assigns items = List.copyOf(items) to make an unmodifiable snapshot, and update any factory/usage sites to rely on the compact constructor; also add tests that mutate the original list after construction and attempt to modify the returned page.items() to assert the record's internal list does not change and that returned list is unmodifiable.apps/commerce-streamer/src/main/java/com/loopers/batch/RankingCarryOverScheduler.java-23-35 (1)
23-35:⚠️ Potential issue | 🟠 Major스케줄러 예외 발생 시 처리 로직이 없다.
rankingRepository.carryOver()호출 중 Redis 연결 실패 등 예외가 발생하면 스케줄러가 조용히 실패한다. 운영 환경에서 장애 인지를 위해 예외 로깅 및 알림 메트릭 발행이 필요하다.🛠️ 수정안
`@Scheduled`(cron = "0 50 23 * * *") public void carryOver() { LocalDate today = LocalDate.now(); LocalDate tomorrow = today.plusDays(1); - boolean success = rankingRepository.carryOver(today, tomorrow, CARRY_OVER_WEIGHT); - - if (success) { - log.info("[RankingCarryOver] 성공: {} → {} (weight={})", today, tomorrow, CARRY_OVER_WEIGHT); - } else { - log.info("[RankingCarryOver] 스킵: 오늘 키 없음 ({})", today); + try { + boolean success = rankingRepository.carryOver(today, tomorrow, CARRY_OVER_WEIGHT); + + if (success) { + log.info("[RankingCarryOver] 성공: {} → {} (weight={})", today, tomorrow, CARRY_OVER_WEIGHT); + } else { + log.info("[RankingCarryOver] 스킵: 오늘 키 없음 ({})", today); + } + } catch (Exception e) { + log.error("[RankingCarryOver] 실패: {} → {}", today, tomorrow, e); } }🤖 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/batch/RankingCarryOverScheduler.java` around lines 23 - 35, The carryOver method in RankingCarryOverScheduler lacks exception handling; wrap the rankingRepository.carryOver(today, tomorrow, CARRY_OVER_WEIGHT) call inside a try-catch that catches Exception, logs the full error (with stack/exception message) via the class logger (log.error) including today/tomorrow/CARRY_OVER_WEIGHT context, and on failure emit or increment an operational metric/alert (e.g., a counter or monitoring client) so failures are visible; ensure the existing success/skip logging remains inside the try and that the catch logs and increments the metric/alert before returning.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java-34-40 (1)
34-40:⚠️ Potential issue | 🟠 MajorprocessBatch 예외 시 무한 재처리 위험
processBatch()에서 예외가 발생하면ack.acknowledge()가 호출되지 않아 동일 배치가 무한 반복 처리될 수 있다. 특정 레코드의 파싱 오류나 DB 연결 실패가 지속되면 컨슈머가 해당 파티션에서 진행하지 못하고 lag이 누적된다.운영 관점에서 장애 전파와 메시지 처리 지연으로 이어진다.
🔧 에러 핸들링 추가 제안
public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) { log.debug("[CatalogConsumer] 수신 {}건", records.size()); - catalogEventProcessor.processBatch(records); + try { + catalogEventProcessor.processBatch(records); + } catch (Exception e) { + log.error("[CatalogConsumer] 배치 처리 실패, {}건 스킵. offset 범위: {}-{}", + records.size(), + records.isEmpty() ? "N/A" : records.get(0).offset(), + records.isEmpty() ? "N/A" : records.get(records.size() - 1).offset(), + e); + // 근사 랭킹 시스템이므로 스킵 후 진행 (at-least-once 허용) + } 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/CatalogEventConsumer.java` around lines 34 - 40, In consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) wrap the call to catalogEventProcessor.processBatch(records) in a try/catch and ensure ack.acknowledge() is always invoked (use finally) so a thrown exception does not block offset commits; on exception log the full error (include the exception) and route the failed batch or offending records to a dead-letter handler (e.g., sendToDlq or catalogEventProcessor.handleFailedBatch) or mark them as skipped, and add/recommend per-record error handling inside catalogEventProcessor.processBatch to avoid whole-batch failures from single-record parse/DB errors.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvWeeklyRankJpaRepository.java-17-22 (1)
17-22:⚠️ Potential issue | 🟠 Major
findByBaseDateOrderByRankNoAsc()메서드의 정렬 성능을 보장하는 인덱스 추가가 필수다.현재 PK는
(base_date, product_id)만 정의되어 있으나,findByBaseDateOrderByRankNoAsc()메서드는base_date조건과rank_no정렬을 조합한다. PK로는rank_no정렬을 커버할 수 없어, 운영 데이터가 쌓일수록 페이징된 조회마다 추가 정렬 비용이 누적된다. 또한findAggregatedAt()쿼리의MAX(aggregatedAt)집계도 최적화 기회가 있다.수정안:
- 엔티티에
@Index어노테이션 추가:@Index(name = "idx_base_date_rank_no", columnList = "base_date,rank_no")aggregatedAt집계 최적화를 위해@Index(name = "idx_base_date_agg_at", columnList = "base_date,aggregated_at")추가- 운영 환경 DDL/마이그레이션 스크립트에도 동일한 인덱스 반영 확인
추가 테스트로
EXPLAIN ANALYZE를 통해 두 쿼리 모두 인덱스 활용을 검증하고, 주간 갱신 배치 실행 후 대량 조회 시나리오에서 응답 시간을 측정하도록 통합 테스트에 포함한다.🤖 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/MvWeeklyRankJpaRepository.java` around lines 17 - 22, Add DB indexes to the MvWeeklyRankModel entity to support findByBaseDateOrderByRankNoAsc and findAggregatedAt: add an `@Index`(name="idx_base_date_rank_no", columnList="base_date,rank_no") to cover the baseDate + rankNo access pattern used by findByBaseDateOrderByRankNoAsc(LocalDate, Pageable) and add an `@Index`(name="idx_base_date_agg_at", columnList="base_date,aggregated_at") to support the MAX(aggregatedAt) aggregation used by findAggregatedAt(LocalDate); update your migration/DDL scripts to create these same indexes in production and run EXPLAIN ANALYZE in tests for both queries to verify index usage and measure paging performance after the weekly batch update.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyTmpAggregateWriter.java-29-35 (1)
29-35:⚠️ Potential issue | 🟠 Majorupsert 동안 재처리 시 배치 실패 가능성이 있다
ON DUPLICATE KEY UPDATE사용 시 동일 값을 재삽입하면 MySQL에서 affected_rows=0을 반환한다. 기본 설정인assertUpdates=true상태에서는 0을 업데이트 실패로 판단하여 배치 재기동 또는 재시도 시나리오에서 예외가 발생한다. WeeklyTmpAggregateWriter의 주석에서 "retry/재시도 시 안전장치로 ON DUPLICATE KEY UPDATE 사용"이라 명시한 것과 배치된다.
.assertUpdates(false)를 명시하여 idempotent 재처리를 허용해야 한다. 추가로 동일 청크를 연속 두 번 처리하는 시나리오로 E2E 테스트를 추가하여 재기동 시 안정성을 검증한다.수정 예시
public static JdbcBatchItemWriter<ProductAggregateWithScore> create(DataSource dataSource) { return new JdbcBatchItemWriterBuilder<ProductAggregateWithScore>() .dataSource(dataSource) .sql(SQL) .itemSqlParameterSourceProvider(MonthlyTmpAggregateWriter::toParams) + .assertUpdates(false) .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/monthly/step/MonthlyTmpAggregateWriter.java` around lines 29 - 35, The JdbcBatchItemWriter returned by MonthlyTmpAggregateWriter.create uses a builder that currently enforces update assertions, which causes failures when ON DUPLICATE KEY UPDATE results in 0 affected rows during idempotent retries; update the JdbcBatchItemWriterBuilder in MonthlyTmpAggregateWriter.create to call .assertUpdates(false) so reprocessing the same values doesn't throw, and add an E2E test that runs the same chunk twice (or restarts the job mid-chunk) to verify idempotent retry behavior for the SQL using ON DUPLICATE KEY UPDATE.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventProcessor.java-132-138 (1)
132-138:⚠️ Potential issue | 🟠 Major
occurredAt파싱 실패를 현재 시각으로 대체하면 기간 집계가 영구 오염된다.
occurredAt가 누락되거나 형식이 달라지면 현재 시각으로 처리되어metric_date와 일간 랭킹 키가 소비 시점 기준으로 기록된다. 운영에서 컨슈머 지연이나 재처리가 발생하면 주문이 잘못된 일자/주차/월간 집계로 들어가고,product_metrics_daily를 원천으로 쓰는 배치 결과까지 틀어져 자동 복구되지 않는다. 파싱 실패 시에는 예외로 처리해 재시도하게 하거나, 정말 폴백이 필요하면ConsumerRecord.timestamp()를Asia/Seoul로 명시 변환한 값만 사용하라. 추가로occurredAt누락/파손 이벤트에서 현재 시각이 아니라 명시된 폴백 또는 실패 경로가 선택되는 테스트를 넣는 것이 필요하다.🤖 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/OrderEventProcessor.java` around lines 132 - 138, The parseOccurredAt method currently swallows parse errors and returns LocalDateTime.now(), which corrupts metric_date; change parseOccurredAt to throw an exception on missing/invalid occurredAt (so the record processing can be retried) or, if a fallback is explicitly required, return the Kafka record timestamp converted to Asia/Seoul using ConsumerRecord.timestamp() instead of LocalDateTime.now(); update the method parseOccurredAt and any callers to propagate/handle the exception path, and add unit tests that cover missing/invalid occurredAt to assert either the exception/retry behavior or the explicit Kafka-timestamp-in-Asia/Seoul fallback.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyTmpAggregateWriter.java-22-33 (1)
22-33:⚠️ Potential issue | 🟠 Major임시 집계 테이블이 실행 스코프로 분리되지 않아 결과가 섞일 수 있다.
현재 writer가
tmp_weekly_aggregate에 실행 식별자 없이 데이터를 upsert하므로, 서로 다른baseDate실행이 겹치면 한 실행의 chunk 결과가 다른 실행의 중간 결과를 덮어쓸 수 있다. 이후 MV 교체 단계가 전역 tmp 테이블을 읽기 때문에 잘못된 주간 랭킹이 저장될 수 있다.tmp_weekly_aggregate에base_date또는job_execution_id를 포함해 PK/UK와 조회 조건을 함께 묶거나, 실행별 임시 테이블을 사용하라. 추가로 서로 다른baseDate두 건을 겹쳐 실행해도 각 MV 결과가 분리되는 E2E 테스트를 추가하는 것이 필요하다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyTmpAggregateWriter.java` around lines 22 - 33, The tmp table upsert in WeeklyTmpAggregateWriter uses a global tmp_weekly_aggregate without an execution scope, so concurrent runs can clobber each other; modify the INSERT/ON DUPLICATE KEY logic and schema usage to include an execution discriminator (e.g., add base_date or job_execution_id columns to tmp_weekly_aggregate and its PK/UK) and ensure all queries in WeeklyTmpAggregateWriter (the SQL constant) and downstream MV swap/read logic filter/join by that execution column, or alternatively create per-execution temp tables; also add an E2E test that runs two different baseDate jobs concurrently and asserts each MV result remains isolated.k6/scripts/session9/ranking-api-load.js-25-26 (1)
25-26:⚠️ Potential issue | 🟠 Major
toISOString()의 UTC 기준 계산으로 KST 요청 날짜가 하루 밀릴 수 있다.
toISOString()은 항상 UTC 기준으로 날짜를 반환하므로, KST 자정 전후에는 실제 운영 날짜와 다른 값을date파라미터로 전송할 수 있다. 예를 들어 KST 오전 8시(UTC 자정 이전)에 요청하면 전날 날짜로 요청되어, 운영 환경에서는 빈 랭킹이나 잘못된 캐시 키를 대상으로 부하 테스트를 실행하게 되어 성능 지표가 왜곡된다.k6의 Goja 엔진은
Intl.DateTimeFormat등 타임존 기능을 지원하지 않으므로, UTC 시간에 9시간을 더하여 KST로 변환하는 방식으로 처리해야 한다. 예를 들어:function seoulDateString(date = new Date()) { const kstTime = date.getTime() + (9 * 60 * 60 * 1000); const kstDate = new Date(kstTime); const year = kstDate.getUTCFullYear(); const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0'); const day = String(kstDate.getUTCDate()).padStart(2, '0'); return `${year}${month}${day}`; }
TODAY를 이 함수로 계산하거나,BASE_DATE환경변수로 주입하는 방식으로 수정하고, 추가로 UTC 00:0009:00 구간(KST 09:0018:00)과 UTC 15:0024:00 구간(KST 자정다음날 09:00)을 포함하는 테스트를 통해 요청 날짜가 KST 기준으로 올바르게 유지되는지 검증해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/scripts/session9/ranking-api-load.js` around lines 25 - 26, TODAY is computed using toISOString() which returns UTC and can yield the wrong KST date; replace the current TODAY definition with a KST-aware calculation (either implement a seoulDateString(date = new Date()) helper that adds 9 hours and formats YYYYMMDD, or accept BASE_DATE via env and fall back to that helper) and use that helper to set TODAY; update any callers expecting TODAY (the TODAY constant in this file) so they use the new KST-derived value and add tests or k6 scenarios that exercise UTC windows (UTC 00:00–09:00 and 15:00–24:00) to verify the date stays KST-correct.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java-156-162 (1)
156-162:⚠️ Potential issue | 🟠 Major
occurredAt파싱 실패를 현재 시각으로 대체하면 날짜 버킷이 오염된다지금처럼
occurredAt오류를LocalDateTime.now()로 바꾸면, 지연 소비나 재처리 중인 과거 이벤트가 오늘 데이터로 적재된다. 운영에서는 이 왜곡이 일간 Redis 랭킹뿐 아니라product_metrics_daily기반의 주/월 배치 집계까지 영구히 오염시킨다. 실패 시 레코드를 드롭하거나, 최소한 Kafka 레코드 timestamp를 fallback으로 사용해 원래 이벤트 시점을 최대한 보존하는 편이 안전하다. 추가로 잘못된occurredAt와 자정 경계 이벤트가 기대한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/interfaces/consumer/CatalogEventProcessor.java` around lines 156 - 162, The parseOccurredAt method currently swallows parsing errors and returns LocalDateTime.now(), which can corrupt time-based buckets; change parseOccurredAt(Map<String,Object> message) so that on parse failure it does NOT default to now but instead either throws an exception to drop the record upstream or returns a fallback based on the Kafka record timestamp (use the ConsumerRecord timestamp if available) to preserve the original event time; update callers of parseOccurredAt (e.g., wherever parseOccurredAt is invoked) to handle the thrown exception by dropping/logging the message or to accept the Kafka-timestamp fallback, and add unit tests for invalid occurredAt and events straddling midnight to assert correct metricDate assignment.k6/scripts/session9/ranking-e2e-accuracy.js-51-80 (1)
51-80:⚠️ Potential issue | 🟠 Major정확성 스크립트가 주문 경로를 실제로 검증하지 않는다
주문 시나리오는 주석만 있고 실제 요청이 없으며, 중복 좋아요 응답도 검사하지 않는다. 이 상태에서는 주문 이벤트 적재가 깨져도 스크립트가 성공해서 운영 배포 전 정확성 회귀를 놓치게 된다.
PRODUCT_3에 대한 실제 주문 생성 호출을 추가하고, 전파 후PRODUCT_3 > PRODUCT_2 > PRODUCT_1같은 기대 순위 또는 점수 관계를 명시적으로 검증해야 한다. 추가로 duplicate like가 허용 상태코드 범위를 벗어나면 즉시 실패하도록 체크를 넣는 것이 좋다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java-31-40 (1)
31-40:⚠️ Potential issue | 🟠 Major비정상 증분값을 허용하면 누적/일별 테이블이 함께 오염된다
incrementViewCountBy와 날짜 기반incrementOrderCount가 입력값을 검증하지 않고 두 저장소에 그대로 반영한다. 소비자 버그나 독성 메시지로count <= 0또는amount < 0가 들어오면 누적 지표와 일별 스냅샷이 동시에 깨지고, 이후 주/월 배치 집계까지 연쇄 오염된다. 서비스 경계에서 값을 선검증하고, 실패 시 repository 호출 전에 예외로 중단하는 편이 안전하다. 추가로 0/음수 입력에서 두 repository 모두 호출되지 않는 테스트를 넣는 것이 좋다.수정 예시다
public void incrementViewCountBy(Long productId, int count, LocalDate metricDate) { + if (count <= 0) { + throw new IllegalArgumentException("count must be positive"); + } productMetricsRepository.incrementViewCountBy(productId, count); productMetricsDailyRepository.incrementViewCountBy(productId, metricDate, count); } @@ public void incrementOrderCount(Long productId, long amount, LocalDate metricDate) { + if (amount < 0) { + throw new IllegalArgumentException("amount must be non-negative"); + } productMetricsRepository.incrementOrderCount(productId, amount); productMetricsDailyRepository.incrementOrderCount(productId, metricDate, amount); }Also applies to: 77-80
🤖 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/ProductMetricsService.java` around lines 31 - 40, Validate input at the service boundary: in both incrementViewCountBy(Long productId, int count) and incrementViewCountBy(Long productId, int count, LocalDate metricDate) (and the analogous incrementOrderCount/amount methods referenced), check that count > 0 (and amount >= 0 for monetary/order-amount methods) and throw an IllegalArgumentException (or a domain-specific exception) before calling productMetricsRepository or productMetricsDailyRepository (and the corresponding order repositories), so neither repository is invoked on invalid input; add unit tests asserting that 0/negative inputs throw and that repositories are not called.apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java-148-168 (1)
148-168:⚠️ Potential issue | 🟠 Major좋아요 계열 이벤트는
userId없으면 파싱 단계에서 버려야 한다현재 구현은
PRODUCT_LIKED/PRODUCT_UNLIKED인데도userId == null인 레코드를 그대로 통과시킨다. 이 경우 운영에서는 DB 좋아요 수가 먼저 반영된 뒤 Redis 멱등 처리에서 실패하거나null사용자로 잘못 집계되어, 일간 랭킹과 주/월 집계용 지표가 서로 어긋날 수 있다. 파싱 단계에서 좋아요 계열 이벤트는userId를 필수값으로 검증하고, 누락 시 실패 메트릭을 남긴 뒤 드롭하는 편이 안전하다. 추가로userId가 없는 LIKE/UNLIKE 레코드가 들어오면ProductMetricsService와RankingService가 호출되지 않는 테스트를 넣는 것이 좋다.수정 예시다
- return new ParsedEvent( - eventType, - productIdNum.longValue(), - parseUserId(message), - parseOccurredAt(message) - ); + Long userId = parseUserId(message); + if (("PRODUCT_LIKED".equals(eventType) || "PRODUCT_UNLIKED".equals(eventType)) && userId == null) { + log.warn("[CatalogProcessor] LIKE 계열 이벤트의 userId 누락: {}", message); + consumerMetrics.recordCatalogFailed(); + return null; + } + + return new ParsedEvent( + eventType, + productIdNum.longValue(), + userId, + parseOccurredAt(message) + );🤖 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/CatalogEventProcessor.java` around lines 148 - 168, Parsing currently allows PRODUCT_LIKED/PRODUCT_UNLIKED events with null userId to pass through; change the parsing logic (where ParsedEvent is constructed and in parseUserId) to treat userId as required for like/unlike events: if eventType == PRODUCT_LIKED or PRODUCT_UNLIKED and parseUserId(...) returns null, record a failure metric (e.g., increment a "missing_user_id_for_like" counter) and abort parsing/return null (so the event is dropped and downstream handlers are not invoked). Update parseUserId to keep returning Long or null, but enforce the check immediately before creating the ParsedEvent (the location that calls new ParsedEvent(...)) and ensure code paths that drop the event do not call ProductMetricsService or RankingService; add tests asserting ProductMetricsService and RankingService are NOT called when a LIKE/UNLIKE record has no userId.k6/scripts/session9/ranking-mixed-load.js-29-32 (1)
29-32:⚠️ Potential issue | 🟠 Major부하 테스트가 UTC 날짜로 실행되어 KST 기반 일간 랭킹 조회와 불일치한다
현재
TODAY는toISOString()으로 생성하므로 UTC 기준이다. 그러나 서버는TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))로 KST를 사용하며, 랭킹 API는date파라미터로 일간 버킷을 결정한다. UTC와 KST 사이에 최대 9시간 차이가 있어 특히 자정 근처에서 이전 날짜의 랭킹 데이터를 조회하게 되므로, 캐시/Redis hit pattern과 응답 지연 시간 측정이 왜곡된다. 부하 테스트는 실제 운영 환경을 정확히 모사해야 하므로 날짜 생성을 수정해야 한다.수정안:
__ENV.TODAY로 KST 기준 날짜를 주입받거나, 클라이언트 기준 로컬 날짜(또는 UTC 오프셋을 적용한 KST 계산)로 변경한다. 추가로 UTC/KST 경계 시각(KST 자정 전후)에 요청 URL의date값이 예상과 일치하는지 검증하는 테스트를 추가한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/scripts/session9/ranking-mixed-load.js` around lines 29 - 32, TODAY is being generated in UTC via new Date().toISOString(), causing date mismatches vs server KST; change the TODAY logic to prefer an injected __ENV.TODAY when present and otherwise compute the KST date (UTC+9) for the local day (or use the runtime's local date) so the test sends the same date bucket the server expects; update the constant named TODAY and any calling code that builds the request URL to use this KST-derived value and add a small assertion in the script to log/verify the resolved date matches expected KST (especially around midnight) before sending requests.k6/scripts/session9/ranking-e2e-accuracy.js-24-25 (1)
24-25:⚠️ Potential issue | 🟠 MajorK6 스크립트의 날짜 계산을 KST 기준으로 수정하고 자정 경계 테스트 추가
toISOString()은 UTC 기준이므로, Asia/Seoul 타임존에서 집계되는 백엔드 랭킹 시스템과 시간차가 발생한다. UTC 15:00~16:00 구간(KST 자정)에서 K6가 실행되면 어제 날짜를 조회하게 되어 테스트가 불안정해진다.수정 방안:
- 테스트 날짜를 환경변수(
__ENV.TODAY)로 주입받거나- KST 오프셋(+9시간)을 반영하여
yyyyMMdd를 계산하는 헬퍼 함수 작성추가로 UTC 15:00~16:00(KST 자정 경계) 구간에서 오늘·어제 날짜 조회 검증 테스트를 추가하면, 운영 중 자정 근처의 날짜 불일치 장애를 미리 방지할 수 있다.
(해당 패턴은 라인 143~145에도 동일하게 적용됨)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/scripts/session9/ranking-e2e-accuracy.js` around lines 24 - 25, TODAY is computed with toISOString() (UTC) causing off-by-one at KST midnight; change to accept an env override (__ENV.TODAY) or compute KST by adding +9h before formatting yyyyMMdd via a helper (e.g., replace current TODAY definition and any duplicate at the second occurrence around lines 143-145), and add an E2E check that runs logic for the UTC 15:00–16:00 boundary to assert both “today” and “yesterday” are returned correctly when the KST date flips.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-196-202 (1)
196-202:⚠️ Potential issue | 🟠 Major
windowDays를 상수 7로 고정하면 주간 메타데이터가 거짓이 된다.초기 적재 직후나 누락 복구 상황에서는 실제 집계 일수가 7 미만일 수 있는데, 지금 응답은 항상 완성된 7일 데이터처럼 보이게 된다. 운영에서는 클라이언트의 “진행 중” 표시와 캐시 판단이 틀어지고, 부분 집계 상태를 구분하기 어려워진다. 실제 집계에 사용한 distinct 일수를 배치 결과에 저장하거나 조회 시 계산해서 내려주고, 3일치 데이터만 있을 때
windowDays=3이 반환되는 테스트를 추가해야 한다.🤖 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 196 - 202, computeWeeklyWindowDays currently returns the constant WEEKLY_WINDOW_DAYS which hides partial aggregation; change computeWeeklyWindowDays(LocalDate baseDate) to compute the actual distinct number of days used in the weekly aggregation (e.g., by reading the stored batch result field that records distinct aggregation days or by querying the underlying aggregation table for distinct dates between baseDate.minusDays(WEEKLY_WINDOW_DAYS-1) and baseDate), return that dynamic count instead of the constant, and ensure any code that sets the response field windowDays uses computeWeeklyWindowDays; also add a unit/integration test that simulates a partial-load (e.g., 3 distinct days) and asserts windowDays == 3.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-61-63 (1)
61-63:⚠️ Potential issue | 🟠 Major빈 페이지여도 이미 계산한 pagination 메타데이터는 유지해야 한다.
현재는
entries가 비면 직전에 계산한totalElements와totalPages를 버리고 둘 다 0으로 내려준다. 운영에서는 범위를 벗어난 페이지 요청이나 필터링으로 content만 비는 케이스에서 클라이언트 페이지네이션이 흔들리고, 동일 조건 응답의 계약도 깨진다. 빈 content만 반환하되 메타데이터는 유지하도록 수정하고,totalCount > 0이지만 현재 페이지 결과가 비는 케이스를 테스트해야 한다.수정 예시를 제안한다
if (entries.isEmpty()) { - return MvRankingPage.daily(date, page, size, 0, 0, List.of()); + return MvRankingPage.daily(date, page, size, totalElements, totalPages, List.of()); }🤖 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 61 - 63, The current early-return in RankingFacade when entries.isEmpty() discards the previously computed pagination metadata and returns zeros; change the logic so pagination metadata (e.g., totalElements/totalPages/totalCount computed earlier) is preserved and only the content is empty: ensure you compute and store totalElements/totalPages (and totalCount if used) before the entries.isEmpty() check and call MvRankingPage.daily(date, page, size, totalElements, totalPages, List.of()) instead of zeros; also add a unit/integration test for the case where totalCount > 0 but the requested page yields empty entries to assert metadata is unchanged while content is empty.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-41-46 (2)
41-46:⚠️ Potential issue | 🟠 Major기간별
@Cacheable캐시가 작동하지 않는다.
getRankings메서드가 같은 빈 내부의getDailyRankings,getWeeklyRankings,getMonthlyRankings를 직접 호출하기 때문에 Spring 프록시를 우회한다. 이 때문에@Cacheable어노테이션이 실제로 적용되지 않으며, 운영 환경에서 동일 조건의 요청이 들어와도 Redis와 MV 조회가 매번 반복 실행된다. 트래픽이 몰릴 때 응답 시간과 저장소 부하가 함께 증가한다.기간별 조회 로직을 별도 빈으로 분리하거나, 프록시된 자신의 메서드를 명시적으로 호출하도록 구조를 변경해야 한다. 동일 파라미터로 두 번 요청했을 때
rankingService가 한 번만 호출되는지 검증하는 캐시 테스트를 추가해야 한다.🤖 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 41 - 46, getRankings in RankingFacade currently calls getDailyRankings/getWeeklyRankings/getMonthlyRankings directly, bypassing Spring's cache proxy so `@Cacheable` isn't applied; fix by either extracting the period-specific methods into a separate `@Service` bean (e.g., RankingPeriodService) and delegating from RankingFacade to that bean, or inject the proxied self (RankingFacade self) and invoke self.getDailyRankings/ self.getWeeklyRankings/ self.getMonthlyRankings so calls go through the Spring proxy; also add a unit/integration cache test that calls getRankings twice with identical params and asserts rankingService (or the underlying repository) was invoked only once.
41-46:⚠️ Potential issue | 🟠 Major공개 조회 메서드에 페이지 파라미터 검증이 없다.
size == 0이면 분모가 0이 되고, 음수page/size는 Redis·DB offset과 rank 계산을 음수로 만든다. 운영에서는 잘못된 요청 하나로 500이 발생하거나 비정상 캐시 키가 쌓일 수 있다. 모든 공개 진입점에서period/date != null,page >= 0,1 <= size <= MAX_RANK를 검증하고CoreException으로 실패시키며, 0·음수·null 입력에 대한 facade/controller 테스트를 추가해야 한다. Based on learnings, unified error handling should route through CoreException to ApiControllerAdvice to ensure a consistent response format.Also applies to: 54-60, 90-95, 109-114
🤖 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 41 - 46, Public entry methods like getRankings, getDailyRankings, getWeeklyRankings and getMonthlyRankings must validate inputs: ensure period/date parameters are not null, enforce page >= 0 and 1 <= size <= MAX_RANK (define or reference a constant MAX_RANK), and throw a CoreException on invalid input so ApiControllerAdvice can produce a consistent error response; add corresponding unit tests for facade and controller to cover null/zero/negative values and out-of-range size, and update callers of getRankings (and the methods at lines noted) to rely on the centralized validation rather than allowing negative/zero values to reach Redis/DB or rank calculations.
🟡 Minor comments (12)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.java-62-65 (1)
62-65:⚠️ Potential issue | 🟡 Minor카운트 기반 메트릭 기록은 0/음수 입력을 방어해야 한다.
운영 관점에서 Line 62, 65에 0 또는 음수가 유입되면 지표 왜곡으로 알람 신뢰도가 떨어질 수 있다. 수정안으로
count < 0은 예외로 즉시 실패시키고count == 0은 no-op 처리하는 방어 로직을 넣는 것이 안전하다. 추가 테스트로count=-1,count=0,count>0각각의 동작을 검증해야 한다.수정 예시
public void recordCatalogProcessed(long count) { - catalogProcessedCounter.increment(count); + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (count == 0) return; + catalogProcessedCounter.increment(count); } @@ public void recordCatalogFailed(long count) { - catalogFailedCounter.increment(count); + if (count < 0) throw new IllegalArgumentException("count must be >= 0"); + if (count == 0) return; + catalogFailedCounter.increment(count); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.java` around lines 62 - 65, The two count-based metric methods recordCatalogProcessed(long) and recordCatalogFailed(long) must guard against non-positive inputs: if count < 0 throw an IllegalArgumentException immediately, if count == 0 return as a no-op, otherwise call the underlying counter.increment(count); update both methods to implement this defensive check and add unit tests that assert IllegalArgumentException for count=-1, no counter change for count=0, and normal increment behavior for count>0.apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java-25-27 (1)
25-27:⚠️ Potential issue | 🟡 Minor기간 파싱의 대소문자 정규화는 Locale 고정이 필요하다.
운영 관점에서 서버 기본 Locale이 비영어권(예: Turkish)일 경우 Line 25의
toUpperCase()는 예기치 않은 변환을 만들어 간헐적 400 오류를 유발할 수 있다. 수정안으로toUpperCase(Locale.ROOT)를 사용해 환경 독립적으로 고정해야 한다. 추가 테스트로 기본 Locale을tr_TR로 설정한 뒤"weekly","monthly"입력이 정상 파싱되는 케이스를 포함해야 한다.수정 예시
+import java.util.Locale; @@ - return RankingPeriod.valueOf(raw.trim().toUpperCase()); + return RankingPeriod.valueOf(raw.trim().toUpperCase(Locale.ROOT));🤖 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/RankingPeriod.java` around lines 25 - 27, The period parsing in RankingPeriod.valueOf currently uses raw.trim().toUpperCase() which is locale-dependent; change it to raw.trim().toUpperCase(Locale.ROOT) to make the normalization environment-independent (update the import to java.util.Locale if needed), and add unit tests for RankingPeriod parsing that set Locale.setDefault(new Locale("tr","TR")) (or use Locale.forLanguageTag("tr-TR")) to assert that inputs like "weekly" and "monthly" still parse correctly via RankingPeriod.valueOf or the parsing method used.k6/seed-session9.sh-15-31 (1)
15-31:⚠️ Potential issue | 🟡 Minor미사용
ADMIN_HEADER는 운영 혼선을 유발하므로 의도를 코드에 반영해야 한다.Line 15의 헤더 변수가 실제 요청(Line 30-31)에 사용되지 않아, 운영 시 “관리자 헤더 기반 시드”로 오해하고 장애 분석 시간을 늘릴 수 있다.
수정안은 (1) 헤더가 불필요하면 변수 삭제, (2) 필요하면 curl에-H "${ADMIN_HEADER}"를 추가하는 방식이다.
추가 테스트로 CI에 ShellCheck(SC2034)를 포함해 미사용 변수 재유입을 차단하는 것이 좋다.수정 예시 diff
-ADMIN_HEADER="X-Loopers-Ldap: loopers.admin"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/seed-session9.sh` around lines 15 - 31, The ADMIN_HEADER variable is defined (ADMIN_HEADER="X-Loopers-Ldap: loopers.admin") but never used, causing possible operational confusion; either remove the ADMIN_HEADER declaration if admin header is not needed, or apply it to the user creation curl requests by adding the header flag (use the ADMIN_HEADER variable) to the curl POST calls in the user-creation loop (look for the curl that posts to "${BASE_URL}/api/v1/users"); also add ShellCheck (or SC2034 check) to CI to prevent reintroducing unused shell variables.apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java-262-284 (1)
262-284:⚠️ Potential issue | 🟡 Minor
LocalDate.now()정확 매칭 스텁은 자정 경계에서 테스트 플래키를 유발한다.Line 262와 Line 284에서 현재 시각값을 정확 매칭으로 스텁하면, CI 실행 시점이 자정을 기점으로 갈릴 때 간헐 실패 위험이 있다. 같은 파일의 Line 59에서는 이미
any()로 유연한 매칭을 사용 중이므로 일관성도 떨어진다.수정안:
any(LocalDate.class)+eq(101L)로 변경하고,verify(rankingService).getRank(any(LocalDate.class), eq(101L))로 호출 검증한다.수정 예시 diff
- when(rankingService.getRank(LocalDate.now(), 101L)).thenReturn(3L); + when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(3L); @@ - when(rankingService.getRank(LocalDate.now(), 101L)).thenReturn(null); + when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(null);🤖 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/product/ProductFacadeTest.java` around lines 262 - 284, The test uses LocalDate.now() as an exact matcher when stubbing rankingService.getRank, which causes flakiness at midnight; update the stub in ProductFacadeTest to use a flexible matcher like any(LocalDate.class) combined with eq(101L) for the product id (i.e., when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(...)) and adjust the verification to verify(rankingService).getRank(any(LocalDate.class), eq(101L)) so the test no longer depends on the exact current date, referencing rankingService.getRank, LocalDate.now(), any(LocalDate.class), eq(101L), and verify(rankingService).getRank.apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/common/RankingScoreCalculatorTest.java-62-69 (1)
62-69:⚠️ Potential issue | 🟡 Minor테스트 설명이 실제 검증 조건과 반대다.
Line 62의 설명은 "고가 상품 소량 주문이 역전할 수 있다"인데, Line 69는 반대로
lowPriceBulk > highPrice만 검증한다. 운영에서는 이 테스트가 점수 정책의 의도를 잘못 문서화해 가중치 조정 시 회귀 판단을 오도할 수 있다. 현재 assertion이 맞다면 표시 이름과 메서드명을 그 의미에 맞게 바꾸고, 정말 역전 가능성을 보장하려는 의도라면 입력값과 비교 방향을 다시 구성하라. 추가로 "건수 우세가 유지되는 경우"와 "다른 지표를 더하면 역전 가능한 경우"를 분리한 테스트를 보강하는 것이 좋다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java-64-69 (1)
64-69:⚠️ Potential issue | 🟡 Minor
parseHourKey도 동일한 silent fallback 문제가 있다.
hour파라미터가 10자리 숫자가 아니면 조용히 현재 시각으로 대체된다.hour=abc를 전달해도 에러 없이 현재 시각 랭킹이 반환된다.🤖 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 64 - 69, parseHourKey silently falls back to current time when the hour param is not a 10-digit numeric string, which hides invalid input; change parseHourKey in RankingV1Controller to validate and fail fast by throwing a clear 400-level exception (e.g., throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid hour format: must be 10 digits") or IllegalArgumentException) instead of returning LocalDateTime.now(), and ensure callers rely on the exception to return a bad-request response.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/WriteMonthlyTopRanksTasklet.java-38-39 (1)
38-39:⚠️ Potential issue | 🟡 Minor
baseDateParamnull 검증이 필요하다.Job 파라미터 누락 시
baseDateParam이 null이 되어 Line 39에서NullPointerException이 발생한다. 명확한 에러 메시지와 함께 실패하도록 검증 로직을 추가해야 한다.🛡️ 제안: null 검증 추가
`@Override` public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (baseDateParam == null || baseDateParam.isBlank()) { + throw new IllegalStateException("Job parameter 'baseDate' is required"); + } LocalDate baseDate = LocalDate.parse(baseDateParam, DateTimeFormatter.BASIC_ISO_DATE);🤖 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/monthly/step/WriteMonthlyTopRanksTasklet.java` around lines 38 - 39, In WriteMonthlyTopRanksTasklet.execute, validate baseDateParam before calling LocalDate.parse: check that baseDateParam is not null (and not blank) and if invalid throw a clear runtime exception (e.g., IllegalArgumentException or JobParametersInvalidException) with a message like "Missing or invalid job parameter: baseDateParam" so the job fails fast instead of NPE; locate this check near the start of execute where baseDateParam is referenced and ensure subsequent LocalDate.parse(baseDateParam, DateTimeFormatter.BASIC_ISO_DATE) only runs after validation.k6/scripts/session9/ranking-weight-accuracy.js-113-139 (1)
113-139:⚠️ Potential issue | 🟡 Minor상품 A/B 미발견 시 테스트가 실패하지 않는다.
Line 135-138에서
productAEntry또는productBEntry가 없으면 WARNING 로그만 출력하고check()가 실행되지 않는다. 이 경우 테스트가 성공으로 끝나 정확성 검증 목적을 달성하지 못한다.🛡️ 제안: 미발견 시 명시적 실패 처리
if (productAEntry && productBEntry) { // ... existing checks } else { console.log('WARNING: Product A or B not found in ranking'); console.log('Content:', JSON.stringify(content.slice(0, 5))); + check(null, { + 'product A found in ranking': () => !!productAEntry, + 'product B found in ranking': () => !!productBEntry, + }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/scripts/session9/ranking-weight-accuracy.js` around lines 113 - 139, If productAEntry or productBEntry is missing the test merely logs a warning and skips check(), causing false positives; update the branch where productAEntry/productBEntry are falsy to explicitly fail the test by invoking k6's fail() with a clear message (or alternately call check(null, { 'missing product A or B': () => false })) so the scenario always fails when either entry is not found; modify the block referencing productAEntry, productBEntry and check() to perform this explicit failure action instead of only logging.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java-71-79 (1)
71-79:⚠️ Potential issue | 🟡 Minor잘못된
date형식에서 조용히 기본값 반환하는 것은 문제가 될 수 있다.
date=invalid를 전달하면 예외 없이 오늘 날짜로 조회된다. 클라이언트가 자신의 입력 오류를 인지하지 못하고 의도와 다른 결과를 받게 된다. 운영 중 디버깅이 어려워질 수 있다.두 가지 선택지가 있다:
- 잘못된 형식에서 400 Bad Request 반환
- 현재 동작 유지하되 응답에 사용된 실제 date를 포함하여 클라이언트가 인지 가능하도록 함
💡 Option 1: 잘못된 형식에서 예외 발생
private LocalDate parseDate(String dateStr) { if (dateStr == null || dateStr.isBlank()) { return LocalDate.now(); } - try { - return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE); - } catch (DateTimeParseException e) { - return LocalDate.now(); - } + return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE); + // DateTimeParseException은 ControllerAdvice에서 처리 }🤖 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 71 - 79, The parseDate method currently swallows parse errors and returns LocalDate.now(), which hides client input mistakes; change it to throw a 400 Bad Request on invalid formats by replacing the catch block to throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format: " + dateStr) (ensure imports for org.springframework.web.server.ResponseStatusException and org.springframework.http.HttpStatus), while keeping the existing behavior of returning LocalDate.now() only when dateStr is null or blank; reference the parseDate method to locate the change.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyProductMetricsReader.java-29-30 (1)
29-30:⚠️ Potential issue | 🟡 Minor
baseDate파라미터에 대한 null 검증이 필요하다.
baseDate가 null로 전달되면 Line 30에서NullPointerException이 발생한다. 배치 Job 파라미터 파싱 실패 시 null이 전달될 수 있으므로 방어 코드가 필요하다.🛡️ 제안: null 검증 추가
public static JdbcPagingItemReader<ProductAggregate> create(DataSource dataSource, LocalDate baseDate) { + if (baseDate == null) { + throw new IllegalArgumentException("baseDate must not be null"); + } LocalDate from = baseDate.minusDays(WINDOW_DAYS - 1L);🤖 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/weekly/step/WeeklyProductMetricsReader.java` around lines 29 - 30, In WeeklyProductMetricsReader.create, add a null check for the baseDate parameter before computing 'from' to prevent a NullPointerException; if baseDate is null, throw a clear IllegalArgumentException (e.g. "baseDate must not be null") or return/handle a sensible default, so the create(DataSource dataSource, LocalDate baseDate) method validates input upfront and avoids using baseDate.minusDays(...) when baseDate is null.apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventProcessorTest.java-76-93 (1)
76-93:⚠️ Potential issue | 🟡 Minor테스트가 재처리 시나리오를 검증하지 않는다.
at-least-once 시맨틱과 DB 중복 증가 위험은 이미 설계에 반영되어 있다(JavaDoc 참고). ProductMetricsService가 자체
@Transactional를소유하므로 각 호출마다 독립 트랜잭션이 시작되고, Redis 실패 후 메시지 재처리 시 DB 중복 증가가 발생한다. 하지만 현재 테스트는 Mock만 검증하며 실제 재처리 시나리오를 다루지 않는다.다음을 추가로 검증하도록 개선하면 좋다:
- 동일 배치를 두 번 처리하여 view count가 실제로 중복 증가하는지 확인
- 운영 관점: view_count 재조정 배치가 존재하는지 확인하고, 없다면 like_count 보정과 유사하게 추가 필요
🤖 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/interfaces/consumer/CatalogEventProcessorTest.java` around lines 76 - 93, Update the CatalogEventProcessorTest to simulate the at-least-once reprocessing by calling catalogEventProcessor.processBatch(records) twice (after configuring doThrow(new RuntimeException("redis down")).when(rankingService).incrementScoreBatch(any()) to persist across calls) and assert that productMetricsService.incrementViewCountBy(100L, 2) and incrementViewCountBy(200L, 1) were effectively applied twice (e.g., total increments reflect duplicate DB updates), and also verify consumerMetrics.recordCatalogFailed was invoked for both attempts while consumerMetrics.recordCatalogProcessed is never called; this ensures the test covers duplicate DB increments on retry due to Redis failures and uses the existing symbols catalogEventProcessor.processBatch, rankingService.incrementScoreBatch, productMetricsService.incrementViewCountBy, and consumerMetrics.recordCatalogFailed/recordCatalogProcessed to locate the logic.k6/scripts/session9/ranking-mixed-load.js-89-100 (1)
89-100:⚠️ Potential issue | 🟡 Minor정렬 검증이 첫 두 항목만 비교해 후반부 역전을 놓친다
현재 체크는 1, 2위만 비교하므로 3위 이하가 뒤섞여도 성공한다. 부하 상황에서 pagination 또는 직렬화 오류로 중간 이후 순서가 깨져도 감지하지 못해 운영 신뢰도가 낮아진다. 응답 배열 전체를 순회하며 인접 항목을 모두 비교하도록 바꾸는 편이 좋다. 추가로 3위 이후만 뒤바뀐 fixture에서도 체크가 실패하는 케이스를 넣는 것이 좋다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@k6/scripts/session9/ranking-mixed-load.js` around lines 89 - 100, The 'ranking sorted' check only compares the first two items so it misses inversions later in the list; update the check inside the check() call (the 'ranking sorted' predicate that parses JSON and reads const items = JSON.parse(r.body).data.content) to iterate the entire items array and verify every adjacent pair satisfies items[i].score >= items[i+1].score, returning false on the first violation and true if the loop completes; also add an additional test case/fixture that exercises a response where the top-2 are correct but a later item is out of order to ensure the new full-array check fails as expected.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: be975b9c-ce65-4ed6-bc60-79b16e9a3c5c
⛔ Files ignored due to path filters (4)
.claude/skills/design-qna/SKILL.mdis excluded by!**/*.mdand included by**.claude/skills/test-report-skill/SKILL.mdis excluded by!**/*.mdand included by**.claude/skills/test-report-skill/references/report-template.mdis excluded by!**/*.mdand included by**k6/README.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (77)
.claude/settings.local.jsonapps/commerce-api/src/main/java/com/loopers/application/event/ProductViewEventHandler.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingPage.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvMonthlyRankId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvMonthlyRankModel.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvWeeklyRankId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/MvWeeklyRankModel.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/domain/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvMonthlyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvWeeklyRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.javaapps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.javaapps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerE2ETest.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/ProductAggregate.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/ProductAggregateWithScore.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/RankingScoreCalculator.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/RankingWeights.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyCleanupTmpTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyProductMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyScoreProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyTmpAggregateWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/WriteMonthlyTopRanksTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyCleanupTmpTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyProductMetricsReader.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyScoreProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyTmpAggregateWriter.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WriteWeeklyTopRanksTasklet.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/common/RankingScoreCalculatorTest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobE2ETest.javaapps/commerce-streamer/build.gradle.ktsapps/commerce-streamer/src/main/java/com/loopers/batch/RankingCarryOverScheduler.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyId.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyModel.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingRepository.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.javaapps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventProcessor.javaapps/commerce-streamer/src/test/java/com/loopers/batch/RankingCarryOverSchedulerTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.javaapps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingWeightTest.javaapps/commerce-streamer/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplIntegrationTest.javaapps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventProcessorTest.javak6/scripts/session9/ranking-api-load.jsk6/scripts/session9/ranking-e2e-accuracy.jsk6/scripts/session9/ranking-event-throughput.jsk6/scripts/session9/ranking-mixed-load.jsk6/scripts/session9/ranking-weight-accuracy.jsk6/seed-session9.sh
| "Bash(grep:*)", | ||
| "Bash(cd:*)", | ||
| "Bash(chmod:*)", | ||
| "Bash(CLAUDE_HOOK_EVENT=Notification /home/ubuntu/.claude-hooks/notify.sh)", | ||
| "Bash(powershell.exe -NoProfile -Command \"[System.Reflection.Assembly]::LoadWithPartialName\\(''''System.Windows.Forms''''\\); [System.Windows.Forms.MessageBox]::Show\\(''''test''''\\)\")", | ||
| "Bash(powershell.exe -NoProfile -Command \"Get-Module -ListAvailable BurntToast 2>&1\")", | ||
| "Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/seed-session8.sh 2>&1)", | ||
| "Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/reset-queue.sh 2>&1)", | ||
| "Bash(xargs -r docker exec -i redis-master redis-cli DEL)", | ||
| "Bash(\\\\ k6:*)", | ||
| "Bash(while ! tail -1 /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bry70duvq.output)", | ||
| "Bash(do tail:*)", | ||
| "Bash(pkill -f CommerceApiApplication)", | ||
| "Bash(xargs -r kill -9)", | ||
| "Bash(while ! grep -q \"BUILD\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bifaewp3g.output)", | ||
| "Bash(do)", | ||
| "Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/seed-session8.sh 5000 2>&1)", | ||
| "Bash(while ! grep -q \"완료\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bp4abvxma.output)", | ||
| "Bash(while ! grep -q \"SCALE=2000 완료\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/b3bscb81m.output)", | ||
| "Bash(pkill -f \"gradlew.*bootRun\")", | ||
| "Bash(while ! grep -q \"BUILD\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/b4y73hks0.output)", | ||
| "Bash(while read:*)", | ||
| "Bash(do grep:*)", | ||
| "Bash(echo \"PID: $!\")", | ||
| "Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/reset-queue.sh)", | ||
| "Bash(k6 run:*)", | ||
| "Bash(tee /tmp/k6-top5-scale-1000.txt)", | ||
| "Bash(tee /tmp/k6-top5-scale-2000.txt)", | ||
| "Bash(pkill -f \"commerce-api\")", | ||
| "Bash(ulimit:*)", | ||
| "Bash(docker.exe ps:*)", | ||
| "Bash(sudo -n docker ps)", | ||
| "Bash(java -version)", | ||
| "Bash(sudo -n echo \"sudo ok\")", | ||
| "Bash(sudo -S echo \"sudo activated\")", | ||
| "Bash(sudo -S apt-get update -qq)", | ||
| "Bash(sudo -S apt-get install -y -qq openjdk-21-jdk-headless redis-tools curl gnupg lsb-release apt-transport-https ca-certificates)", | ||
| "Bash(sudo -S gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg)", | ||
| "Bash(sudo -S tee /etc/apt/sources.list.d/k6.list)", | ||
| "Bash(sudo -S apt-get install -y -qq k6)", | ||
| "Bash(sudo -S:*)", | ||
| "Bash(size=5 2>/dev/null)", | ||
| "Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo '1234')", | ||
| "Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo \"Queue reset done\")", | ||
| "Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo \"reset done\" cd ~/projects/loop-pack-be-l2-vol3-java)", | ||
| "Bash(xargs -r -L 200 sudo docker exec -i redis-master redis-cli DEL echo '1234')", | ||
| "Bash(xargs -r -L 200 sudo docker exec -i redis-master redis-cli DEL cd ~/projects/loop-pack-be-l2-vol3-java)", | ||
| "Bash(CLAUDE_HOOK_EVENT=Notification /home/mdskkdj/.claude-hooks/notify.sh:*)", | ||
| "Bash(CLAUDE_HOOK_EVENT=Stop /home/mdskkdj/.claude-hooks/notify.sh:*)", | ||
| "Bash(echo \"exit: $?\")", | ||
| "Bash(powershell.exe:*)", | ||
| "Bash(size=1 2>/dev/null)", | ||
| "Bash(xargs -r -L 500 sudo docker exec -i redis-master redis-cli DEL sleep 1 k6 run -e POLLERS=500 -e ORDERERS=20 k6/scripts/session8/L3-queue-mixed-load.js)", | ||
| "Bash(xargs -r -L 500 sudo docker exec -i redis-master redis-cli DEL sleep 1 k6 run -e POLLERS=2000 -e ORDERERS=50 k6/scripts/session8/L3-queue-mixed-load.js)", | ||
| "Read(//home/mdskkdj/projects/loop-pack-be-l2-vol3-java/**)", | ||
| "Bash(echo \"bootRun PID: $!\")", | ||
| "Bash(bash k6/scripts/session8/seed-session8.sh 10000)", | ||
| "Bash(xargs -P 200 -I '{}' curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H 'X-Loopers-LoginId: k6user{}' -H 'X-Loopers-LoginPw: Test1234!')", | ||
| "Bash(bash k6/scripts/session8/reset-queue.sh)", | ||
| "Bash(echo \"ZADD 완료: $\\(docker exec redis-master redis-cli ZCARD order:waiting-queue\\)\")", | ||
| "Bash(xargs -r kill sleep 3 bash k6/scripts/session8/reset-queue.sh echo \"Redis queue cleared. Waiting 5s for server to stabilize...\" sleep 5)", | ||
| "Bash(xargs -P 100 -I '{}' curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H 'X-Loopers-LoginId: k6user{}' -H 'X-Loopers-LoginPw: Test1234!')", | ||
| "Bash(xargs -P 100 -I {} curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H \"X-Loopers-LoginId: k6user{}\" -H \"X-Loopers-LoginPw: Test1234!\")", | ||
| "Bash(xargs -r kill)", | ||
| "Bash(echo \"PID=$!\")", | ||
| "Bash(git config:*)", | ||
| "Bash(echo \"API PID: $!\")", | ||
| "Bash(echo \"Streamer PID: $!\")", | ||
| "Bash(bash k6/seed.sh)", | ||
| "Bash(bash k6/seed-session9.sh)", | ||
| "Bash(sed:*)", | ||
| "Bash(pkill -f \"commerce-api:bootRun\")", | ||
| "Bash(pkill -f \"commerce-streamer:bootRun\")", | ||
| "Bash(pkill -f \"GradleDaemon\")", | ||
| "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d['meta']['result'], d['data']['productName']\\)\")", | ||
| "Bash(lsof -ti:8081)", | ||
| "Bash(pkill -9 -f \"commerce-api\")", | ||
| "Bash(pkill -9 -f \"commerce-streamer\")", | ||
| "Bash(pkill -9 -f \"GradleDaemon\")", | ||
| "Bash(git -C /mnt/c/Users/MDSK011/IdeaProjects/loop-pack-be-l2-vol3-java log --oneline -1)" |
There was a problem hiding this comment.
허용 범위가 너무 넓어 사실상 임의 명령 실행 권한이 된다
Bash(grep:*), Bash(cd:*), Bash(chmod:*), Bash(k6 run:*), Bash(powershell.exe:*), Bash(sudo -S:*) 같은 패턴은 세션8/9 로드 테스트를 넘어서 로컬 호스트에서 임의 명령 실행과 권한 상승까지 열어 둔다. 운영 관점에서는 재현성과 감사 가능성이 무너지고, 잘못된 프롬프트 한 번으로 개발 환경 상태가 바뀔 수 있다. 필요한 seed/reset/load 스크립트만 정확한 경로와 고정 인자로 허용하고, 일반화된 sudo -S:* 및 powershell.exe:* 항목은 제거하는 편이 안전하다. 추가로 의도한 k6 시나리오는 계속 실행되고, 무관한 임의 명령은 차단되는지 확인하는 회귀 점검이 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.claude/settings.local.json around lines 134 - 213, The allowed Bash
patterns in .claude/settings.local.json are too broad (e.g., "Bash(grep:*)",
"Bash(cd:*)", "Bash(chmod:*)", "Bash(k6 run:*)", "Bash(powershell.exe:*)",
"Bash(sudo -S:*)") which effectively permit arbitrary command execution;
restrict the entries by removing or replacing wildcard patterns with exact,
fixed command lines for only the intended scripts (e.g., the specific seed/reset
scripts and the exact k6 invocation paths), delete general-purpose patterns like
"Bash(powershell.exe:*)" and "Bash(sudo -S:*)" and any generic "Bash(...:*)"
that match broad shells, and add a regression check to verify the intended k6
seed/reset scripts still run while arbitrary commands are blocked.
| @Bean(JOB_NAME) | ||
| public Job monthlyRankingJob( | ||
| Step monthlyCleanupTmpStep, | ||
| Step monthlyAggregateStep, | ||
| Step writeMonthlyTopRanksStep | ||
| ) { | ||
| return new JobBuilder(JOB_NAME, jobRepository) | ||
| .incrementer(new RunIdIncrementer()) | ||
| .listener(jobListener) | ||
| .start(monthlyCleanupTmpStep) | ||
| .next(monthlyAggregateStep) | ||
| .next(writeMonthlyTopRanksStep) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_CLEANUP) | ||
| public Step monthlyCleanupTmpStep(MonthlyCleanupTmpTasklet tasklet) { | ||
| return new StepBuilder(STEP_CLEANUP, jobRepository) | ||
| .tasklet(tasklet, txManager) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_AGGREGATE) | ||
| public Step monthlyAggregateStep( | ||
| JdbcPagingItemReader<ProductAggregate> monthlyReader, | ||
| MonthlyScoreProcessor processor, | ||
| JdbcBatchItemWriter<ProductAggregateWithScore> monthlyWriter | ||
| ) { | ||
| return new StepBuilder(STEP_AGGREGATE, jobRepository) | ||
| .<ProductAggregate, ProductAggregateWithScore>chunk(CHUNK_SIZE, txManager) | ||
| .reader(monthlyReader) | ||
| .processor(processor) | ||
| .writer(monthlyWriter) | ||
| .faultTolerant() | ||
| .retryLimit(RETRY_LIMIT) | ||
| .retry(CannotAcquireLockException.class) | ||
| .retry(TransientDataAccessResourceException.class) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_WRITE_TOP) | ||
| public Step writeMonthlyTopRanksStep(WriteMonthlyTopRanksTasklet tasklet) { | ||
| return new StepBuilder(STEP_WRITE_TOP, jobRepository) | ||
| .tasklet(tasklet, txManager) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
공유 tmp 테이블 때문에 동일 월간 잡을 동시에 실행하면 결과가 섞일 수 있다.
현재 구성은 RunIdIncrementer로 동일 잡의 재실행을 허용하면서, 첫 step에서 전역 tmp 테이블을 비우고 이후 step들이 그 테이블을 계속 사용한다. 운영에서는 백필이나 수동 재처리 중 다른 실행이 중간 집계 데이터를 지워서 mv_product_rank_monthly가 조용히 오염될 수 있다. 실행을 직렬화하거나, tmp row를 jobExecutionId 또는 baseDate로 분리하고 후속 step이 동일 실행의 row만 읽도록 바꿔야 한다. 서로 다른 baseDate의 두 월간 잡을 병렬로 실행했을 때 하나가 차단되거나 결과가 완전히 분리되는 E2E 테스트도 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java`
around lines 61 - 109, The job allows concurrent runs with RunIdIncrementer
while monthlyCleanupTmpStep empties a shared tmp table, causing cross-run data
contamination; change the flow so tmp rows are namespaced per run (e.g., add
jobExecutionId or baseDate column) and stop truncating the global table in
monthlyCleanupTmpStep; update the producer (monthlyAggregateStep /
MonthlyScoreProcessor) to write tmp rows tagged with jobExecutionId/baseDate and
update the reader used by monthlyAggregateStep
(JdbcPagingItemReader<ProductAggregate>) and writeMonthlyTopRanksStep
(WriteMonthlyTopRanksTasklet) to filter/select only rows for the current
jobExecutionId/baseDate, or alternatively enforce single concurrent execution by
removing RunIdIncrementer and serializing starts; add an E2E test that runs two
monthly jobs with different baseDate in parallel and asserts they either block
or produce fully isolated results (no cross-contamination).
| @Bean(JOB_NAME) | ||
| public Job weeklyRankingJob( | ||
| Step weeklyCleanupTmpStep, | ||
| Step weeklyAggregateStep, | ||
| Step writeWeeklyTopRanksStep | ||
| ) { | ||
| return new JobBuilder(JOB_NAME, jobRepository) | ||
| .incrementer(new RunIdIncrementer()) | ||
| .listener(jobListener) | ||
| .start(weeklyCleanupTmpStep) | ||
| .next(weeklyAggregateStep) | ||
| .next(writeWeeklyTopRanksStep) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_CLEANUP) | ||
| public Step weeklyCleanupTmpStep(WeeklyCleanupTmpTasklet tasklet) { | ||
| return new StepBuilder(STEP_CLEANUP, jobRepository) | ||
| .tasklet(tasklet, txManager) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_AGGREGATE) | ||
| public Step weeklyAggregateStep( | ||
| JdbcPagingItemReader<ProductAggregate> weeklyReader, | ||
| WeeklyScoreProcessor processor, | ||
| JdbcBatchItemWriter<ProductAggregateWithScore> weeklyWriter | ||
| ) { | ||
| return new StepBuilder(STEP_AGGREGATE, jobRepository) | ||
| .<ProductAggregate, ProductAggregateWithScore>chunk(CHUNK_SIZE, txManager) | ||
| .reader(weeklyReader) | ||
| .processor(processor) | ||
| .writer(weeklyWriter) | ||
| .faultTolerant() | ||
| .retryLimit(RETRY_LIMIT) | ||
| .retry(CannotAcquireLockException.class) | ||
| .retry(TransientDataAccessResourceException.class) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean(STEP_WRITE_TOP) | ||
| public Step writeWeeklyTopRanksStep(WriteWeeklyTopRanksTasklet tasklet) { | ||
| return new StepBuilder(STEP_WRITE_TOP, jobRepository) | ||
| .tasklet(tasklet, txManager) | ||
| .listener(stepListener) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
공유 tmp 테이블 때문에 동일 주간 잡을 동시에 실행하면 결과가 섞일 수 있다.
현재 구성은 RunIdIncrementer로 동일 잡의 재실행을 허용하면서, 첫 step에서 전역 tmp 테이블을 비우고 이후 step들이 그 테이블을 계속 사용한다. 운영에서는 백필이나 수동 재처리 중 다른 실행이 중간 집계 데이터를 지워서 mv_product_rank_weekly가 조용히 오염될 수 있다. 실행을 직렬화하거나, tmp row를 jobExecutionId 또는 baseDate로 분리하고 후속 step이 동일 실행의 row만 읽도록 바꿔야 한다. 서로 다른 baseDate의 두 주간 잡을 병렬로 실행했을 때 하나가 차단되거나 결과가 완전히 분리되는 E2E 테스트도 추가해야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java`
around lines 66 - 114, The job currently uses a global tmp table
(mv_product_rank_weekly) and RunIdIncrementer which allows concurrent runs to
clobber each other; change the tmp-row lifecycle to be execution-scoped: update
WeeklyCleanupTmpTasklet (used by weeklyCleanupTmpStep) to NOT truncate the whole
table but to insert/delete only rows tagged with the current jobExecutionId or
the required baseDate job parameter, and update readers/writers (the
JdbcPagingItemReader injected into weeklyAggregateStep and the writer in
WriteWeeklyTopRanksTasklet / writeWeeklyTopRanksStep) to filter/select by that
same jobExecutionId or baseDate so subsequent steps only touch the current run’s
rows; ensure the Job is supplied a mandatory baseDate job parameter (or
derive/propagate jobExecutionId) instead of relying on RunIdIncrementer for
isolation, and add an E2E test that runs two weekly jobs in parallel with
different baseDate values to assert they either serialize or fully isolate
results.
📌 Summary
Spring Batch + Materialized View 패턴으로 주간/월간 랭킹 집계 시스템을 구축하고, 기존 랭킹 API에
period파라미터를 추가하여 DAILY(Redis ZSET) / WEEKLY / MONTHLY(RDB MV) 3종 기간 랭킹을 단일 엔드포인트로 제공한다.product_metrics_daily일별 스냅샷 테이블 도입, ③ API에period분기 추가.RankingScoreCalculator5건 PASS, 기존RankingV1ControllerE2ETest9건 회귀 없음. 시니어 리뷰 P0 2건🧭 Context & Decision
문제 정의
문제 1 — 주간/월간 랭킹 집계 파이프라인이 없다
Session 9에서 Redis ZSET 기반 일간 랭킹을 구축했으나, ZSET TTL 2일로 과거 데이터가 소실되어 주간/월간 기간 랭킹을 구성할 방법이 없다. 또한 DB 기반
GROUP BY + ORDER BY를 매 조회마다 실행하면 상품 누적 시 응답 지연이 발생한다.SUM + ORDER BY→ 10만 상품 이상 시 p95 악화baseDate로 재실행 시 결과 동일문제 2 — DAILY와 WEEKLY/MONTHLY 랭킹이 서로 다른 기준으로 계산된다
Streamer는 주문 1건당
0.7 + 0.01·log₁₀(amount+1)를 Redis ZSET에 적재하지만, Batch는order_count × 0.7만 사용. 같은 사용자가period=DAILY와period=WEEKLY를 보면 순위가 불일치. 설계서 §4.1이 "값이 벗어나면 사용자 혼란" 경고를 명시했는데, 값은 같아도 식(formula)이 달랐다.문제 3 — Facade
@Cacheableself-invocationgetDailyRankings→getRankings(LocalDate)같은 빈 내부 호출로 Spring AOP proxy를 우회 → 내부@Cacheable이 무효. 같은 데이터가 두 개의 캐시 키로 저장되어 메모리 2배 + evict 시 일관성 깨짐.프로세스 흐름도 — 일간(Redis) + 주/월간(MV) 이중 랭킹 파이프라인
flowchart LR U["유저 행동<br>조회/좋아요/주문"] EH["EventHandler + Outbox"] subgraph K["Apache Kafka"] K1["catalog-events"] K2["order-events"] end subgraph STREAM["commerce-streamer"] CP["CatalogEventProcessor"] OP["OrderEventProcessor"] end subgraph DB["MySQL"] PM["product_metrics<br>(누적, Session 7)"] PMD["product_metrics_daily<br>(일별, Session 10 신규)"] MVW["mv_product_rank_weekly<br>(TOP 100)"] MVM["mv_product_rank_monthly<br>(TOP 100)"] end subgraph R["Redis ZSET"] Z1["ranking:all:yyyyMMdd<br>(Session 9)"] end subgraph BATCH["commerce-batch (신규)"] WJ["WeeklyRankingJob<br>03:10 KST"] MJ["MonthlyRankingJob<br>03:20 KST"] end subgraph API["commerce-api"] RF["RankingFacade<br>period 분기"] EP["GET /rankings?period="] end U --> EH EH --> K1 EH --> K2 K1 --> CP K2 --> OP CP -->|이중 upsert same TX| PM CP -->|이중 upsert same TX| PMD OP -->|이중 upsert same TX| PM OP -->|이중 upsert same TX| PMD CP -->|Pipeline ZINCRBY| Z1 OP -->|ZINCRBY orderScore| Z1 PMD -->|GROUP BY product_id<br>7일 윈도우| WJ PMD -->|GROUP BY product_id<br>자연월 윈도우| MJ WJ -->|TOP 100 원자 교체| MVW MJ -->|TOP 100 원자 교체| MVM Z1 -->|DAILY| RF MVW -->|WEEKLY| RF MVM -->|MONTHLY| RF RF --> EP classDef newS10 fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px,color:#000 classDef legacy fill:#eeeeee,stroke:#616161,color:#000 class PMD,MVW,MVM,WJ,MJ newS10 class PM,Z1 legacy범례: 빨간 박스는 Session 10 신규, 회색은 기존. 두 경로(Redis 실시간, MV 배치)는 같은 이벤트를 소비하지만 저장소 독립 → 한쪽 장애 시 다른 쪽 무영향.
관련 소스:
CatalogEventProcessor·OrderEventProcessor·WeeklyRankingJobConfig·MonthlyRankingJobConfig·RankingFacade핵심 결정 요약
product_metrics_daily신규 — Consumer가 누적 테이블과 같은 TX로 이중 upsertDELETE + INSERT…SELECT단일 TX (원자 교체)base_date(롤링 7일 윈도우 끝)year_monthVARCHAR(7) "yyyy-MM" (MySQL 예약어 백틱 필수)LIMIT 100order·(0.7 + 0.01·log₁₀(avgAmount+1))orderScore(amount)와 방향성 일치 → DAILY/WEEKLY/MONTHLY 순위 정합getRankings(LocalDate,…)삭제,getDailyRankings하나로 통합Mv*Model/Id4파일infrastructure/ranking/→domain/ranking/이동결정 1 — product_metrics_daily 이중 upsert: Consumer가 누적 + 일별을 같은 TX로 쓴다
문제 인식: 주간/월간 "기간별" 집계를 하려면 일별 델타가 필요하다. 기존
product_metrics는 누적 테이블이라 "7일 전부터 오늘까지"를 산출할 수 없다. 세 가지 방법을 검토했다.Consumer 쓰기 2배 부담에 대한 방어: 두 테이블을 같은
@Transactional안에서 upsert한다. Catalog 이벤트는 Phase 2의 배치 합산 후 productId × metricDate 단위로 묶여 DB 호출 횟수가 이벤트 수보다 훨씬 적음. 하나 실패 시 둘 다 롤백.Consumer 오프셋 커밋은 트랜잭션 성공 후에만 → at-least-once +
EventHandledModel멱등 (order 이벤트). Catalog 이벤트는 멱등 보장 없이 at-least-once 수용(Session 9 결정 유지).90일 보관 청소 Job은 후속 과제로 분리.
결정 2 & 3 — Spring Batch 3-Step 구조와 원자 MV 교체
왜 3단계인가: Step 1이 실패해도 Step 2는 재실행 가능(tmp 유지). Step 0이 TRUNCATE를 먼저 하는 이유는 이전 실행 잔여물 배제.
왜 Step 2가 단일 TX인가: READ COMMITTED에서 DELETE 후 INSERT 중인 상태를 외부 API가 읽으면 "반쯤 비워진 MV"를 본다. 단일 TX로 감싸면 커밋 전까지 외부는 이전 상태를, 커밋 후는 새 상태만 본다.
MySQL 세션 변수로
rank_no부여:(@rn := @rn + 1)트릭으로 서브쿼리 내 순서 부여.ROW_NUMBER()윈도우 함수가 더 깔끔하지만 이 구문이 호환성 면에서 안정적.결정 7 — 가중치 공식 통일: Batch에 평균 금액 log 보너스 추가
문제 인식: 시니어 리뷰에서 DAILY와 WEEKLY/MONTHLY가 다른 기준으로 계산됨을 확인.
0.7 + 0.01·log₁₀(amount+1)— 개별 주문 금액을 ZSET에 실시간 누적order_count × 0.7— 집계 테이블은 개별 주문 금액을 이미 잃었음정확 일치는 구조적으로 불가능. 집계 테이블에는
order_amount(기간 내 금액 합계)와order_count만 있고, 개별 주문 금액 분포는 소실된다.평균 금액 기반 근사 채택:
편향 분석: 젠센 부등식에 의해
log(avg) ≥ avg(log)이므로 근사는 실제보다 과대평가된다. 즉 batch score ≥ streamer score. 하지만:실제 테스트(E2E)에서 기존
Weekly: 2 > 1 > 3순위가 새 공식 적용 후에도 동일하게 유지됨을 확인.🏗️ Design Overview
변경 범위
batch/job/ranking/common/*(4파일) +weekly/*(6파일) +monthly/*(6파일),application.ymlranking.weights.*ProductMetricsDailyModel/Id/Repository/Impl/JpaRepositoryProductMetricsService(이중 upsert 5개 메서드),CatalogEventProcessor·OrderEventProcessor(metricDate 전달)RankingPeriod,MvWeeklyRankModel/Id,MvMonthlyRankModel/Id,Mv{Weekly,Monthly}RankJpaRepository,MvRankingPage,PeriodRankingResponseRankingRepository(+Impl)·RankingServiceMV 메서드 확장,RankingFacadeperiod switch +getDailyRankings단일 진입점,RankingV1Controllerperiod파라미터,CacheConfigmvRankingCacheManager(10분)session10-ranking-mv-ddl.sql5개 테이블주요 컴포넌트 책임
RankingWeights,RankingScoreCalculator,ProductAggregate(+WithScore)WeeklyRankingJobConfig+ 4 Step 클래스MonthlyRankingJobConfig+ 4 Step 클래스ProductMetricsService,ProductMetricsDailyJpaRepositoryRankingPeriod,MvWeekly·MonthlyRankModel,RankingRepository(+MvRankingEntry)RankingFacade,MvRankingPageRankingV1Controller,PeriodRankingResponseperiod파라미터 파싱 + 응답 DTO🔁 Flow Diagram
Weekly Batch Job 실행
sequenceDiagram autonumber participant CLI as CLI/K8s CronJob participant Job as weeklyRankingJob participant S0 as Step 0 Cleanup participant S1 as Step 1 Chunk participant S2 as Step 2 WriteTopRanks participant DB as MySQL CLI->>Job: baseDate=20260415 Job->>S0: execute S0->>DB: TRUNCATE tmp_weekly_aggregate Job->>S1: execute (chunk size 1000) loop Reader → Processor → Writer S1->>DB: SELECT ... FROM product_metrics_daily<br>WHERE metric_date BETWEEN ? AND ?<br>GROUP BY product_id LIMIT 1000 OFFSET N DB-->>S1: ProductAggregate[1000] S1->>S1: calculate(like, order, view, amount) S1->>DB: INSERT INTO tmp ... ON DUPLICATE KEY UPDATE end Job->>S2: execute (단일 TX) S2->>DB: DELETE FROM mv_product_rank_weekly WHERE base_date=? S2->>DB: INSERT...SELECT t.*, @rn:=@rn+1 FROM tmp<br>ORDER BY score DESC LIMIT 100 S2-->>Job: COMMIT Job-->>CLI: COMPLETEDAPI Period 분기 조회
sequenceDiagram autonumber participant Client participant C as RankingV1Controller participant F as RankingFacade participant S as RankingService participant Z as Redis ZSET participant MV as MV Tables participant E as Product/Brand Client->>C: GET /rankings?period=WEEKLY&date=20260415 C->>C: RankingPeriod.fromOrDefault("WEEKLY") C->>F: getRankings(WEEKLY, date, page, size) F->>F: switch(period) alt period = DAILY F->>S: getTopRankings(date, page, size+5) S->>Z: ZREVRANGE ranking:all:yyyyMMdd else period = WEEKLY F->>S: getWeeklyTop(baseDate, page, size) S->>MV: SELECT ... FROM mv_product_rank_weekly else period = MONTHLY F->>S: getMonthlyTop(yearMonth, page, size) S->>MV: SELECT ... FROM mv_product_rank_monthly end F->>E: findAllByIds (products + brands) F->>F: 필터(del_yn, display_status) + enrich F-->>C: MvRankingPage C-->>Client: PeriodRankingResponse✅ Checklist
Step 0 — 설계
Step 1 — DDL + Streamer 이중 upsert
session10-ranking-mv-ddl.sql5개 테이블ProductMetricsDailyModel/Id/Repository/Impl/JpaRepositoryProductMetricsService5개 메서드 daily 호출 추가CatalogEventProcessorPhase 2/3metricDate전달OrderEventProcessormetricDate전달Step 2 — Batch Job 구현
RankingWeights(@ConfigurationProperties) + ymlRankingScoreCalculator+ 평균 금액 log 보너스 (P0 반영)WeeklyRankingJobConfig+ 3 Step + Reader/Processor/Writer/TaskletMonthlyRankingJobConfig+ 동일 구조 복제Step 3 — API Period 분기
RankingPeriodenum (domain/ranking)MvWeeklyRankModel/Id,MvMonthlyRankModel/Id(domain/ranking — DIP 반영)MvWeeklyRankJpaRepository,MvMonthlyRankJpaRepository(infrastructure/ranking)RankingRepository인터페이스 MV 메서드 확장 +MvRankingEntryrecordRankingFacade.getRankings(period, ...)switch 분기RankingFacadeself-invocation 제거 →getDailyRankings단일 진입점 (P0)MvRankingPageDTO +mvRankingCacheManager(10분 TTL)RankingV1Controllerperiod파라미터 +PeriodRankingResponseStep 4 — 테스트
RankingScoreCalculatorTest5건 (소프트 위계 포함)WeeklyRankingJobE2ETest2건 (집계 + 멱등 재실행)MonthlyRankingJobE2ETest1건 (year_month 키 + 윈도우 필터)RankingFacadeTest2건 (self-invocation 제거 후 시그니처 이전)RankingV1ControllerE2ETest9건 회귀 없음 확인Step 5 — 시니어 리뷰 P0 반영
RankingFacadeself-invocation 제거 → 단일 @Cacheable 진입점RankingScoreCalculatordomain레이어 이동 (DIP)📊 테스트 결과
RankingScoreCalculatorTestWeeklyRankingJobE2ETestMonthlyRankingJobE2ETestRankingFacadeTestRankingV1ControllerE2ETest실동작 검증 로그 (Weekly Job)
변경 목적 및 핵심 기능
Spring Batch를 활용한 주간/월간 랭킹 집계 시스템 구현으로, 실시간 Redis ZSET 기반의 일일 랭킹을 Materialized View 패턴으로 확장.
product_metrics_daily테이블을 신규 생성하여 배치 작업이 기반할 일별 스냅샷을 제공하며, API는 통합된RankingPeriod파라미터(DAILY/WEEKLY/MONTHLY)로 단일 엔드포인트 지원.핵심 변경점
배치 작업:
WeeklyRankingJob및MonthlyRankingJob은 3단계 구성(Cleanup TMP → Chunk 집계 → Top 100 MV 원자 교체)으로,product_metrics_daily에서 기간별 점수를 계산하고mv_product_rank_weekly/mv_product_rank_monthly테이블에 작성. 스코어 계산: 통일된 공식orderCount × (0.7 + 0.01·log₁₀(avgAmount+1)) + likeCount×0.2 + viewCount×0.1적용(RankingScoreCalculator— 배치는 평균 금액 기반 근사 사용으로 젠센 부등식상 과대평가 가능성 있음). 이중 upsert: 같은 트랜잭션 내에서 누적 메트릭과 일별 메트릭을 동시 업데이트하여 배치 집계 기반 확보(ProductMetricsService). 캐시:rankingCacheManager(10초, TOP 100용),mvRankingCacheManager(10분, MV용) 분리 구성 (CacheConfig). 재시도 정책: 배치는 청크 단위 3회 재시도(CannotAcquireLockException,TransientDataAccessResourceException).리스크 및 주의사항
배치 근사 공식(avgAmount 기반)이 streamer의 실제 per-order 점수와 정확히 일치하지 않을 수 있어 DAILY 대비 WEEKLY/MONTHLY 상대 순위 편차 발생 가능. 또한
product_metrics_daily가 batch 모듈 엔티티로 선언되지 않아 수동 테이블 생성 필요한지 확인 필요(session10-ranking-mv-ddl.sql참조). MV는 일 1회 갱신 기준으로 설계되었으므로 배치 스케줄 변경 시 캐시 TTL(10분) 조정 검토 필요.테스트 및 검증
RankingScoreCalculator단위 테스트 5개,WeeklyRankingJobE2ETest/MonthlyRankingJobE2ETest(Testcontainers 기반),RankingFacade단위 테스트 2개,RankingV1ControllerE2ETest9개 케이스로 파라미터 검증 및 빈 응답 처리 확인. K6 부하 테스트 스크립트 4종(load/accuracy/throughput/mixed-load)과 seed 스크립트 제공. 모든 표시된 테스트 PASS 상태.🔍 리뷰 포인트
1. MV 갱신 직후 10분간 구 캐시 반환 — UX에 허용 가능한지 판단을 구하고 싶습니다
mvRankingCacheManagerTTL 10분 고정, evict endpoint 미구현. 배치가 03:10 KST에 끝나면 03:20까지 이전 캐시 반환 가능성. 배치 완료 이벤트로 자동 evict을 구현할지, 10분 stale 허용으로 둘지 판단을 구합니다.2. Reader가 전체 상품을 읽어 tmp 테이블에 저장하는 구조 — 상품 수 10만 이상일 때 확장 판단
현재 Reader(
WeeklyProductMetricsReader/MonthlyProductMetricsReader)는GROUP BY product_id로 기간 내 활동한 모든 상품을 읽고 Processor가 전부 score 계산. tmp 테이블이 전체 상품 수와 같아집니다. score가 Processor 계산이라 Reader에LIMIT 100을 걸 수 없어 불가피한 구조지만, 10만 상품 규모에서 1~2분 완주 가능성을 판단을 구합니다.