Skip to content

[volume-10] Batch 기반 주간·월간 랭킹 시스템 구현 - 양권모#413

Open
Praesentia-YKM wants to merge 8 commits intoLoopers-dev-lab:Praesentia-YKMfrom
Praesentia-YKM:volume-10
Open

[volume-10] Batch 기반 주간·월간 랭킹 시스템 구현 - 양권모#413
Praesentia-YKM wants to merge 8 commits intoLoopers-dev-lab:Praesentia-YKMfrom
Praesentia-YKM:volume-10

Conversation

@Praesentia-YKM
Copy link
Copy Markdown

@Praesentia-YKM Praesentia-YKM commented Apr 17, 2026

📌 Summary

배경: volume-9에서 Redis Sorted Set 기반의 일간 랭킹 시스템을 구축했지만, 주간·월간처럼 긴 시간 범위의 랭킹은 Redis에 적재하기 비효율적이었습니다. Redis ZSET은 TTL이 짧은 실시간 데이터에 적합하지만, 7일·30일 단위 집계는 배치로 사전 계산하여 DB의 Materialized View에 적재하는 것이 합리적이라고 판단했습니다.

목표: Spring Batch 를 활용하여 product_metrics_daily 테이블의 일별 집계를 주간(7일)·월간(30일) 단위로 롤업하고, MV 테이블(mv_product_rank_weekly / mv_product_rank_monthly)에 적재합니다. 버전 기반 스왑 전략으로 조회 무중단을 확보하고, 단일 엔드포인트에서 period 파라미터로 일간·주간·월간을 분기합니다.

결과: weeklyRankingJob, monthlyRankingJob 두 개의 Spring Batch Job을 추가했으며, GET /api/v1/rankings?period=WEEKLY|MONTHLY 로 조회 가능합니다. 일간(Redis ZSET) + 주간·월간(DB MV) 이원화 구조가 완성되었고, 랭킹 집계 점수 계산 로직(RankingScoreCalculator)을 modules/jpa 공유 모듈로 이동하여 api/batch/streamer 3개 앱이 동일 공식을 사용합니다.


🧭 Context & Decision

1. 왜 Spring Batch인가? — @Scheduled 로는 부족한 지점

기준 @Scheduled Spring Batch
소규모 데이터 충분 과잉
대규모 상품 × 7/30일 집계 OOM / 수동 복구 Chunk + 체크포인트
실패 복구 전체 재실행 Step 단위 재개
중복 실행 방지 수동 구현 JobInstance + RunIdIncrementer
실행 이력·모니터링 수동 로깅 메타 테이블 자동 적재

현재 데이터 규모에서는 @Scheduled 로도 돌아가지만, 상품 수가 늘어나면 Chunk 기반 처리 + 재개 + 모니터링이 필요해질 것으로 보았습니다. commerce-batch 앱이 이미 분리되어 있고 JobListener, StepMonitorListener, ChunkListener 공통 인프라가 있어 Batch로 일관되게 실었습니다.

2. Chunk vs Tasklet — 하이브리드 3-Step 선택

예시 구현처럼 "단일 Tasklet + Upsert" 도 가능했지만, 버전 기반 스왑 전략을 선택하면서 3-Step 하이브리드로 분리했습니다.

Step 1: ClearOldVersionTasklet         ← Tasklet (1회 실행)
Step 2: Aggregate Chunk                ← Chunk(chunkSize=100)
        Reader → Processor → Writer
Step 3: ActivateVersionTasklet         ← Tasklet (1회 실행)
Step 패턴 이유
Clear Tasklet "중단된 과거 버전 찌꺼기 정리" 단일 트랜잭션 작업
Aggregate Chunk Reader 가 N개 상품을 흘려보내므로 100건 단위 커밋으로 메모리 안전 확보
Activate Tasklet "버전 포인터 교체 + 구 버전 삭제" 단일 원자 작업

핵심 판단 근거: 집계는 상품 수에 비례하는 반복이므로 Chunk가 적합하다고 보았습니다. 반면 구버전 정리/활성화는 1회성 관리 작업이라 Tasklet이 자연스러웠습니다. 두 종류를 같은 Job 안에 조립할 수 있다는 점이 Spring Batch의 강점이라고 생각합니다.

3. Materialized View — MySQL 한계와 버전 기반 스왑

MySQL은 CREATE MATERIALIZED VIEW 를 지원하지 않으므로 일반 테이블 + 배치 갱신으로 구현합니다. Refresh 전략을 고민했습니다.

전략 Full Refresh (TRUNCATE + INSERT) Upsert (ON DUPLICATE KEY UPDATE) 버전 스왑 (채택)
조회 무중단 ❌ (비는 구간 발생)
멱등성 자연스러움 unique 제약으로 보장 version 컬럼 + Step 재실행 가능
구현 복잡도 낮음 중간 중간 (버전 관리 필요)
롤백·핫스왑 불가 불가 이전 버전이 DELETE 전까지 살아있어 즉시 롤백 가능

버전 스왑 동작

  1. Clear Step: ProductRankWeeklyJpaRepository.deleteByVersion(nextVersion) 으로 작업 중이던 다음 버전 찌꺼기 제거
  2. Aggregate Step: nextVersion 값으로 새 랭킹 row 들을 INSERT (읽기 쿼리는 여전히 currentVersion 만 봄)
  3. Activate Step: RankingVersionManager.activateWeeklyVersion(nextVersion) 으로 포인터 교체 → 과거 버전 DELETE

조회 API는 현재 모든 row 를 읽지만, Activate 직후 deleteByVersion(oldVersion) 으로 구 버전이 제거되므로 결과적으로 활성 버전 row 만 남습니다.

4. product_metrics_daily — 시간 단위 → 일 단위 집계 테이블

기존 streamer 쪽에는 메트릭이 일 단위로 없었습니다. 주간 집계 시 한 상품당 최대 7행, 월간 30행으로 읽도록 ProductMetricsDaily 엔티티를 modules/jpa 공유 모듈에 신설했습니다.

  • Streamer: CatalogEventConsumer, OrderEventConsumer 가 상품 조회/좋아요/판매 이벤트를 받아 일자별로 누적
  • Batch: ProductMetricsDailyBatchRepository.findByDateRange(start, end) 로 조회
  • 공유 키: ProductMetricsDailyId(productId, metricDate) 복합키

5. RankingScoreCalculator 공유 모듈로 이동

volume-9의 랭킹 점수 계산기가 commerce-streamer 안에만 있었습니다. 이번에 batch 쪽에서도 동일 공식이 필요해 modules/jpa 로 이동하여 api/batch/streamer 3앱이 단일 구현을 공유합니다. (공식: view×0.1 + like×0.2 + order×0.7)

6. API — 단일 엔드포인트 + period enum

경로 분기(/rankings/weekly, /rankings/monthly) 대신 단일 GET /api/v1/rankings + period=DAILY|WEEKLY|MONTHLY 을 택했습니다. 호출자 입장에서 같은 응답 스키마를 받고, Service 레이어(RankingService) 내부에서만 Redis / MV Repository 라우팅이 일어납니다. 추후 HOURLY, REALTIME 등 확장이 필요해도 enum 만 늘리면 됩니다.


🏗️ Design Overview

변경 범위

  • commerce-batch: 주간·월간 랭킹 배치 Job (Chunk + Tasklet 3-Step)
  • commerce-api: 주간·월간 MV 엔티티/Repository, RankingService 라우팅 확장, API enum 추가
  • commerce-streamer: CatalogEventConsumer / OrderEventConsumer 에서 일별 지표(ProductMetricsDaily) 누적
  • modules/jpa (공유): ProductMetricsDaily, RankingScoreCalculator 이전 + 단위 테스트

신규/변경 컴포넌트

modules/jpa (공유)

  • ProductMetricsDaily, ProductMetricsDailyId — 일별 상품 지표 엔티티 (view/like/sale count)
  • RankingScoreCalculator — 점수 공식 (streamer 전용 → 공유로 이전)

commerce-batch — Domain / Infrastructure

  • ProductMetricsAggregation — Reader → Processor 사이 DTO
  • ProductRankWeekly / ProductRankMonthly — MV 테이블 쓰기용 엔티티 (view/like/sale count + version 포함)
  • RankingVersionManager — 활성 버전 관리 (in-memory AtomicLong)
  • ProductMetricsDailyBatchRepository — 기간별 일 지표 조회
  • ProductRankWeeklyJpaRepository / ProductRankMonthlyJpaRepository — MV 저장 + deleteByVersion

commerce-batch — Batch Layer

  • WeeklyRankingJobConfig / MonthlyRankingJobConfig@ConditionalOnProperty(spring.batch.job.name=...) 로 Job 선택, RunIdIncrementer + requestDate JobParameter
  • WeeklyRankingReader / MonthlyRankingReader — 기간 내 ProductMetricsDaily 를 읽어 상품별 in-memory 합산 → 점수 계산 → 점수 내림차순 Iterator
  • WeeklyRankingProcessor / MonthlyRankingProcessor — 순위(ranking) 부여 + ProductRankWeekly/Monthly 변환
  • WeeklyRankingWriter / MonthlyRankingWriter — Chunk 단위 JPA 저장
  • ClearOldVersionTasklet / MonthlyClearOldVersionTasklet — 다음 버전 찌꺼기 제거 + ExecutionContextnextWeeklyVersion 저장
  • ActivateVersionTasklet / MonthlyActivateVersionTasklet — 버전 포인터 교체 + 구 버전 DELETE

commerce-api — Domain / Application / Interfaces

  • MvProductRankWeekly / MvProductRankMonthly — 같은 MV 테이블의 읽기 전용 엔티티
  • MvRankingRepository / MvRankingRepositoryImplranking ASC 페이징 조회
  • RankingPeriod enum — DAILY / WEEKLY / MONTHLY
  • RankingService#getTopRankingsswitch(period) 로 Redis(RankingRepository) vs DB(MvRankingRepository) 라우팅
  • RankingV1ControllerGetMappingperiod RequestParam 추가 (기본 DAILY)

commerce-streamer

  • CatalogEventConsumer / OrderEventConsumer — 기존 시간 단위 지표 외에 ProductMetricsDaily upsert 추가

주요 책임 요약

컴포넌트 모듈 책임
WeeklyRankingReader batch requestDate 기준 직전 7일의 ProductMetricsDaily 를 상품별 합산 + 점수 계산 후 Iterator 반환
WeeklyRankingProcessor batch ProductMetricsAggregationProductRankWeekly 변환 (순위 부여)
WeeklyRankingWriter batch Chunk 단위로 새 버전 랭킹 row INSERT
ActivateVersionTasklet batch RankingVersionManager 포인터 교체 + 구 버전 row DELETE
RankingService api period 에 따라 Redis ZSET / MV Repository 라우팅
MvRankingRepositoryImpl api ranking ASC 페이징 조회 후 RankingEntry 변환
RankingScoreCalculator modules/jpa 공통 점수 공식 — streamer/batch 모두 사용

🔁 Flow Diagram

배치 실행 흐름 — weeklyRankingJob (monthlyRankingJob 도 대칭)

$ java -Dspring.batch.job.name=weeklyRankingJob \
       -DrequestDate=2026-04-17 ...
                │
                ▼
┌─────────────────────────────────────────────────────┐
│ Step 1: ClearOldVersionTasklet                      │
│  - nextVersion = versionManager.getNextWeeklyVersion()│
│  - deleteByVersion(nextVersion)  // 찌꺼기 제거     │
│  - ExecutionContext.put("nextWeeklyVersion", n)     │
└─────────────────────────────────────────────────────┘
                │
                ▼
┌─────────────────────────────────────────────────────┐
│ Step 2: Aggregate Chunk (chunkSize=100)             │
│  Reader:    ProductMetricsDaily[7days] → in-memory │
│             groupBy(productId) → score 계산 →      │
│             score DESC Iterator                     │
│  Processor: ranking 부여 → ProductRankWeekly 변환   │
│  Writer:    JPA saveAll (100건씩)                   │
└─────────────────────────────────────────────────────┘
                │
                ▼
┌─────────────────────────────────────────────────────┐
│ Step 3: ActivateVersionTasklet                      │
│  - oldVersion = versionManager.getCurrentWeekly()   │
│  - versionManager.activate(nextVersion)             │
│  - if (oldVersion > 0) deleteByVersion(oldVersion)  │
└─────────────────────────────────────────────────────┘

API 조회 흐름 — GET /api/v1/rankings?period=...

Client
  │  GET /api/v1/rankings?period=WEEKLY&page=1&size=20
  ▼
RankingV1Controller
  │
  ▼
RankingFacade.getTopRankings(date, page, size, period)
  │
  ▼
RankingService.getTopRankings
  │
  ├─ period=DAILY   → RankingRepository (Redis ZREVRANGE)
  ├─ period=WEEKLY  → MvRankingRepository.getWeeklyTopN(offset, size)
  │                     └─ MvProductRankWeeklyJpaRepository
  │                          .findAllByOrderByRankingAsc(pageable)
  └─ period=MONTHLY → MvRankingRepository.getMonthlyTopN(...)
                        └─ MvProductRankMonthlyJpaRepository ...
  │
  ▼
RankingInfo[] → RankingResponse[] → ApiResponse<RankingListResponse>

⚠️ 리스크 / 주의사항

  1. MV 테이블 이중 엔티티 매핑mv_product_rank_weekly 테이블을 api 쪽 MvProductRankWeekly(읽기 전용, 축약 컬럼) 와 batch 쪽 ProductRankWeekly(view/like/sale count + version 포함, 쓰기용) 가 각각 매핑합니다. 스키마 변경 시 두 곳을 동시에 반영해야 하므로 공유 모듈로의 추출이 후속 과제로 남아있습니다.
  2. RankingVersionManager 가 in-memory AtomicLong — 배치 JVM 이 재기동되면 currentVersion = 0 으로 리셋됩니다. 현 구현에서는 Activate Step 종료 시 구 버전을 DELETE 하므로 테이블엔 활성 버전 row 만 남고, api 는 전체를 읽어 우회되고 있습니다. 멀티 인스턴스 배치나 재기동 내성이 필요하면 DB 저장형 version 으로 승격이 필요해 보입니다.
  3. Reader 가 메모리에 전체 집계 결과 적재ProductMetricsDaily 를 기간 전체로 findByDateRange 해 HashMap 에 합산한 뒤 Iterator 반환. 상품 수 × 일수 만큼 메모리 사용. Chunk 의 장점(스트리밍 처리)이 이 지점에서는 희석되는 면이 있습니다. 대규모에서는 SQL GROUP BY + Pageable Reader 로 전환이 정석이라고 보고 있습니다.
  4. 스키마 마이그레이션 스크립트 부재mv_product_rank_weekly/monthly, product_metrics_daily 테이블 DDL 이 Flyway/Liquibase 로 버저닝돼 있지 않습니다. 환경 배포 순서 정립이 필요합니다.
  5. 배치 실행 트리거 외부화@ConditionalOnProperty(spring.batch.job.name=...) + CLI 파라미터로만 실행됩니다. 정기 스케줄은 외부 워크플로우(Airflow / K8s CronJob 등) 에 위임하는 것을 전제로 했습니다.

✅ Checklist

🧱 Spring Batch

  • Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다
    WeeklyRankingJobConfig, MonthlyRankingJobConfig 에서 @ConditionalOnProperty(spring.batch.job.name=...) 로 Job 선택, requestDate JobParameter 로 집계 기준일 전달, RunIdIncrementer 로 재실행 허용
  • Chunk Oriented Processing (Reader/Processor/Writer) + Tasklet 기반의 배치 처리를 구현했다
    Aggregate Step 은 Chunk(chunkSize=100), Clear/Activate Step 은 Tasklet 으로 각 작업 성격에 맞는 패턴 조합
  • 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다
    mv_product_rank_weekly / mv_product_rank_monthlyversion 컬럼 + 버전 스왑 전략으로 조회 무중단·멱등성·롤백 여지 확보

🧩 Ranking API

  • API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다
    단일 엔드포인트 GET /api/v1/rankings?period=DAILY|WEEKLY|MONTHLY — 일간은 Redis ZSET, 주간·월간은 MV 테이블. RankingService#getTopRankingsswitch(period) 로 라우팅

🧪 검증

  • 도메인 단위 테스트RankingScoreCalculatorTest, ProductMetricsDailyModelTest, ProductRankWeeklyModelTest (점수 공식·엔티티 유효성 검증)
  • Job E2E 테스트WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest (@SpringBatchTest, 실제 DB 집계 + 버전 스왑 검증)
  • CommerceBatchApplicationTest 컨텍스트 로딩 검증



개인적으로 직접 구현해본 배치 적채 처리 비교 프로젝트 관련 내용을 검토받고 싶어 추가했습니다.

📌 Summary

  • 배경: Java 배치 처리 기술(Raw JDBC ~ Kafka/ForkJoinPool)의 성능 차이를 정량적으로 비교할 수 있는 벤치마크 프로젝트가 필요했습니다.
  • 목표: 동일한 데이터셋(주문+주문아이템)에 대해 8가지 배치 기술을 단일 실행으로 비교하는 Gradle 멀티모듈 프로젝트를 구현합니다.
  • 결과: L0(Raw JDBC) ~ L7(ForkJoinPool) 까지 8개 레벨을 구현하고, 콘솔 테이블 + Chart.js HTML 리포트를 자동 생성하는 벤치마크 러너를 완성했습니다.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 배치 기술 선택 시 "JPA는 느리다", "Spring Batch가 좋다" 같은 정성적 판단에 의존해 왔던 것 같습니다.
  • 문제: 기술별 성능 차이를 동일 조건에서 정량적으로 비교한 자료를 찾기 어려워, 프로젝트 상황에 맞는 기술 선택 근거가 부족하다고 느꼈습니다.
  • 성공 기준: java -jar runner.jar --size=N 한 줄로 전 레벨 벤치마크가 실행되고, TPS/메모리/소요시간 비교 테이블이 출력됩니다.

선택지와 결정

  • 고려한 대안:
    • A: JMH 마이크로벤치마크만 사용 → DB I/O가 포함된 매크로 벤치마크에는 부적합하다고 판단
    • B: 레벨별 독립 모듈 + 공통 인터페이스 + 통합 러너 → 동일 데이터·동일 스키마에서 공정 비교 가능
  • 최종 결정: B안 채택. BatchBenchmark 인터페이스를 정의하고, Spring의 List<BatchBenchmark> 자동 주입으로 모든 구현체를 순차 실행합니다.
  • 트레이드오프: 모든 레벨이 하나의 Spring Context에 공존하므로 Bean/엔티티 이름 충돌 해결이 필요합니다.
  • 추후 개선 여지: Testcontainers 도입으로 Docker 수동 기동 제거, JMH 연동으로 워밍업/반복 자동화.

🏗️ Design Overview

프로젝트 구조

batch-benchmark-java/
├── common/                    # 도메인(OrderRaw), 벤치마크 인터페이스, 스키마 초기화
├── level0-raw-jdbc/           # Raw JDBC addBatch/executeBatch
├── level1-jdbc-template/      # JdbcTemplate.batchUpdate + BatchPreparedStatementSetter
├── level2-jpa-batch/          # JPA EntityManager.persist + flush/clear (batch=50)
├── level3-scheduled-tasklet/  # L2와 동일 JPA 구조, @Scheduled 스케줄링 대응 구조
├── level4-spring-batch/       # Spring Batch Job/Step chunk=500
├── level5-batch-partition/    # Spring Batch Partitioning (4 thread, gridSize=4)
├── level6-kafka-driven/       # Kafka Producer/Consumer + batch listener
├── level7-spark-local/        # ForkJoinPool(4) 병렬 Raw JDBC
├── runner/                    # 통합 러너 + ConsoleReporter + HtmlReporter
└── docker/                    # MySQL 8.0 + Kafka 3.7.0 (KRaft) 인프라

핵심 인터페이스

public interface BatchBenchmark {
    String name();
    BenchmarkResult runInsert(List<OrderRaw> data);
    BenchmarkResult runAggregate();
}

모든 레벨이 이 인터페이스를 구현하며, BenchmarkApplicationList<BatchBenchmark>를 주입받아 순차 실행합니다.

주요 컴포넌트 책임

컴포넌트 책임
OrderDataGenerator 시드 기반 결정적 테스트 데이터 생성 (주문당 1~5개 아이템)
SchemaInitializer DDL 자동 생성 + 레벨 간 테이블 초기화 (DELETE + AUTO_INCREMENT 리셋)
BenchmarkRunner GC 호출 + 메모리 측정 + 실행 시간 측정 유틸리티
ConsoleReporter ASCII 테이블 + 복잡도 등급 + Winner 표시
HtmlReporter Chart.js 기반 Bar/Radar 차트 + 정렬 가능 테이블 HTML 생성

레벨별 기술 스택

Level 기술 Batch Size 스레드 핵심 특징
L0 Raw JDBC addBatch/executeBatch 1,000 1 수동 트랜잭션, getGeneratedKeys()
L1 JdbcTemplate.batchUpdate 1,000 1 BatchPreparedStatementSetter, MAX(id) 방식
L2 JPA EntityManager.persist 50 1 flush()/clear() 패턴, Cascade insert
L3 L2 + @Scheduled 구조 50 1 스케줄링 프레임워크 대응 구조적 분리
L4 Spring Batch Job/Step chunk 500 1 재시작 가능, 메타데이터 관리, LAST_INSERT_ID()
L5 Spring Batch + Partitioner 500 4 SimpleAsyncTaskExecutor, gridSize=4
L6 Kafka Producer/Consumer 1,000 N/A @KafkaListener(batchListener=true), @Profile("with-kafka")
L7 ForkJoinPool 병렬 1,000 4 2단계(Order→Item) 병렬 파티셔닝, 독립 Connection

🔁 Flow Diagram

Main Flow

sequenceDiagram
  autonumber
  participant Runner as BenchmarkApplication
  participant Schema as SchemaInitializer
  participant Bench as BatchBenchmark (L0~L7)
  participant DB as MySQL 8.0
  participant Reporter as Console/HtmlReporter

  Runner->>Schema: createTables()
  Runner->>Runner: generate(N) 테스트 데이터 생성

  loop 각 벤치마크 레벨
    Runner->>Schema: truncateTables() (DELETE + AUTO_INCREMENT)
    Runner->>Bench: runInsert(data)
    Bench->>DB: batch INSERT (orders + order_item)
    DB-->>Bench: 완료
    Runner->>Bench: runAggregate()
    Bench->>DB: INSERT INTO daily_sales_summary SELECT ... GROUP BY
    DB-->>Bench: 완료
    Bench-->>Runner: BenchmarkResult (시간, TPS, 메모리)
  end

  Runner->>Reporter: print(insertResults, aggregateResults)
  Reporter-->>Runner: 콘솔 ASCII 테이블 + HTML 차트 리포트
Loading

Error Handling Flow

flowchart TD
  A[벤치마크 루프 시작] --> B{benchmark.runInsert}
  B -->|성공| C[runAggregate 실행]
  B -->|Exception| D["[Failed] 로그 출력 → 다음 레벨로"]
  C -->|성공| E["[Done] 결과 수집"]
  C -->|Exception| D
  D --> F[다음 벤치마크]
  E --> F
  F --> B
Loading

📊 벤치마크 결과 (10,000건 Bulk Insert)

Level 기술 시간(s) TPS 메모리(MB) 복잡도
L0 Raw JDBC Batch 0.68 14,663 13.3 ★☆☆☆☆
L1 JdbcTemplate Batch 0.44 22,779 7.9 ★★☆☆☆
L2 JPA flush/clear 21.23 471 22.9 ★★☆☆☆
L3 @scheduled Tasklet 18.48 541 4.1 ★★☆☆☆
L4 Spring Batch Chunk 9.57 1,045 19.7 ★★★☆☆
L5 Batch + Partition 2.82 3,544 27.8 ★★★★☆
L7 Parallel ForkJoinPool 0.14 71,942 17.2 ★★★★★

L6 (Kafka-Driven)은 @Profile("with-kafka") 활성화 시 별도 실행 가능


🧪 추가 실험 — 100,000건 3회 반복 측정

구현 완료 후 실제로 돌려보며 스케일업 시 성능 변화를 확인해 보았습니다. 100,000건을 3회 반복 실행하여 중앙값(median)을 측정했습니다.

추가 튜닝 환경

SET GLOBAL innodb_redo_log_capacity = 1073741824;   -- redo log 1GB
SET GLOBAL innodb_flush_log_at_trx_commit = 2;      -- 매 커밋마다 fsync 생략
SET GLOBAL sync_binlog = 0;                         -- binlog 동기화 비활성화

결과 (100K, median of 3)

Level 방식 Elapsed (ms) TPS Memory (MB) N
L0 Raw JDBC Batch 5,570 17,950 96.3 3
L1 JdbcTemplate Batch 5,830 17,156 52.6 2
L2 JPA flush/clear 257,840 388 68.2 1
L4 Spring Batch Chunk 350 289,017 25.0 3
L5 Batch + Partition (4T) 30,530 3,276 123.5 3
L6 Kafka-Driven 301,390 332 157.1 1
L7 Parallel ForkJoinPool (4T) 1,750 57,274 173.2 3

스케일업 시 순위가 뒤집혔습니다

10K에서는 L7(ForkJoinPool)이 71,942 TPS로 1위였지만, 100K에서는 L4(Spring Batch Chunk)가 289,017 TPS로 역전하는 결과가 나왔습니다.

비교 배율 해석
L4 vs L1 16.6배 빠름 chunk-oriented Writer의 DB 왕복 최적화가 드러난 지점으로 보입니다
L7 vs L4 5배 느림 4스레드가 있어도 단일 스레드의 chunk 최적화가 이기는 구간이 존재
L5 vs L4 87배 느림 Partitioning 오버헤드(스레드 조율, 트랜잭션 경합)가 이득을 잠식한 것으로 해석

Spring Batch의 Job/Step 초기화 비용이 소규모(10K)에서는 전체 시간의 상당 부분을 차지하지만, 대규모(100K)에서는 상각되면서 chunk 처리의 효율이 드러난 것으로 보입니다. (이 해석이 맞는지 검토 부탁드립니다.)

안정성 기록

  • L0, L4, L5, L7: n=3 안정적
  • L1: n=2 (1회 누락)
  • L2, L6: n=1 — JVM hang 또는 측정 누락으로 단회 관찰값
  • L3: 3회 모두 Report 미출력 (@Transactional AOP proxy 미작용 추정, 원인 규명은 후속 과제)
  • 1M 측정 시도: L2 JPA 단계에서 15분 hard timeout → 초선형 비용 증가 확인

재현 방법

사전 준비

  • JDK 21+
  • Docker Desktop
  • Gradle 8.13+ (wrapper 포함)

1. 프로젝트 클론 및 빌드

git clone https://github.com/Praesentia-YKM/batch-benchmark-java.git
cd batch-benchmark-java
./gradlew :runner:bootJar

2. 인프라 기동

docker-compose -f docker/infra-compose.yml up -d

# MySQL 컨테이너 정상 기동 확인 (약 10초 대기)
docker exec benchmark-mysql mysql -uroot -proot -e "SELECT 1"

3. 기본 실행 (10K, L0~L5 + L7)

java -Xmx2g -XX:+UseG1GC -jar runner/build/libs/runner.jar --size=10000

실행 완료 후 콘솔에 ASCII 테이블이 출력되고, reports/benchmark-result.html에 차트 리포트가 생성됩니다.

4. Kafka 포함 실행 (L6 추가)

java -Xmx2g -XX:+UseG1GC \
  -Dspring.profiles.active=with-kafka \
  -jar runner/build/libs/runner.jar --size=10000

5. 대규모 테스트 (100K, MySQL 튜닝 적용)

# insert 집중 워크로드용 MySQL 튜닝
docker exec benchmark-mysql mysql -uroot -proot -e "
  SET GLOBAL innodb_redo_log_capacity = 1073741824;
  SET GLOBAL innodb_flush_log_at_trx_commit = 2;
  SET GLOBAL sync_binlog = 0;
"

# 100K 실행
java -Xmx2g -XX:+UseG1GC -jar runner/build/libs/runner.jar --size=100000

⚠️ L2(JPA)가 100K에서 약 4분 소요되므로 전체 완료까지 5~7분 정도 걸립니다. JVM OOM 발생 시 -Xmx4g로 증가.

6. 인프라 정리

docker-compose -f docker/infra-compose.yml down -v

💡 스스로 내려본 결론

트레이드 오프

이번 벤치마크에서 얻은 인상은 데이터 규모와 운영 요구사항에 따라 적정 기술이 달라진다는 점이었습니다. 스케일에 따라 최적 기술이 바뀔 수 있다는 걸 수치로 확인한 것이 가장 큰 수확이었습니다.

시나리오별 추천

시나리오 추천 이유
소규모 단발 작업 (< 5만건) L1 — JdbcTemplate 단일스레드에서 좋은 성능 + 코드 간결 + Spring 통합
대규모 정기 배치 (> 10만건) L4 — Spring Batch Chunk 스케일업 시 TPS 역전 + 재시작/모니터링
대규모 + 병렬 필요 L5 — Spring Batch + Partition L4의 운영성 + 수평 확장 (단, 튜닝 필수)
극한 처리량 ETL L7 — ForkJoinPool 높은 TPS, 운영 편의는 직접 구현 필요
이벤트 기반 / 시스템 간 연동 L6 — Kafka-Driven 비동기 디커플링, Consumer 수평 확장
기존 JPA 프로젝트 내 부분 배치 L1 (JPA 대신 JdbcTemplate 병행) JPA는 대량 삽입에 불리하다고 판단

결론

  1. JPA는 대량 삽입에 신중히 쓰는 편이 나아 보입니다

    • L2(JPA)는 L1(JdbcTemplate) 대비 48배 느리게 측정됐고, 5만건에서 JVM OOM이 발생했습니다
    • 이미 JPA 프로젝트여도 배치 작업에는 JdbcTemplate 병행을 고려할 만합니다
    • (다만 튜닝 부족 때문일 가능성도 있어, 이 해석도 검토 부탁드립니다)
  2. "병렬 = 빠르다"는 항상 성립하지는 않는 듯합니다

    • 100K 기준 L5(4스레드 Partition)가 L4(단일스레드 Chunk)보다 87배 느리게 측정됐습니다
    • 병렬화의 이득 < 스레드 조율 + DB 커넥션 경합 비용인 구간이 존재할 수 있습니다
  3. 스케일에 따라 적정 기술이 바뀔 수 있습니다

    • 10K: L7(71,942 TPS) > L1(22,779) > L4(1,045)
    • 100K: L4(289,017 TPS) > L7(57,274) > L1(17,156)
    • 소규모에서의 벤치마크만으로 대규모 의사결정을 내리기는 위험할 수 있다는 점이 가장 인상적이었습니다

🔍 리뷰 포인트

1. 구조적으로는 허술하지만 운영 흐름상 문제 없는 설계를 해야할 때 트레이드오프를 지어내려가는 과정이 궁금합니다.

과제를 하면서 RankingVersionManager 의 in-memory AtomicLong어디까지 허용 가능한가 에 대한 트레이드 오프를 고민했습니다.
활성 버전을 배치 JVM 의 AtomicLong 로 관리합니다. 배치 재기동 시 currentVersion = 0 으로 휘발되지만, Activate Step 에서 구 버전을 DELETE 하므로 "테이블엔 활성 버전 row 만 남는다" 는 전제로 API 는 전체 조회로 우회하도록 설계했습니다.

전제가 깨지는 시나리오

  • 배치가 Aggregate 와 Activate 사이에서 실패 → 두 버전 공존
  • 배치 JVM ≠ API JVM → 버전 공유 불가 (이미 현재 구조에서 발생)
  • 배치 멀티 인스턴스 운영

대안

  • (A) ranking_version DB 메타 테이블
  • (B) 두 벌 테이블 + RENAME 스왑
  • (C) 현 구조 유지 (휘발성 + DELETE 정돈 전제)

질문

과제를 하면서 "휘발성 in-memory + DELETE 로 테이블 정돈" 전략을 선택했는데 이러한 구현은 어쩌면 규모에 맞는 편의적 단순화라는 생각이 들었습니다. 멘토님께서는 실무에서 이런 "구조적으로는 허술하지만 운영 흐름상 문제 없는 설계" 를 해야할때 스스로 내리는 지표나 문서가 있는지 궁금합니다.


2. MV 테이블의 이중 엔티티 매핑을 진행하면 CQRS 분리로 적합한 분리라고 볼 수 있는지 아니면 과한 분리인지 판단 기준이 궁금합니다.

같은 mv_product_rank_weekly 테이블을 두 앱이 각자 매핑합니다.

클래스 필드
commerce-api MvProductRankWeekly 읽기 전용, 축약(id/productId/ranking/score/date/version)
commerce-batch ProductRankWeekly 쓰기용, view/like/sale count + version 전체

공유 모듈(modules/jpa) 이전이 자연스러워 보임에도 안 한 이유

  • 읽기에는 필요 없는 count 컬럼들이 노출됨
  • "쓰기 주체는 batch" 라는 의도가 흐려짐

현 방식의 비용

  • 스키마 변경 시 두 곳 동시 수정
  • "진짜 엔티티" 가 어디인지 애매하다고 생각

질문

이 분리가 CQRS 의 자연스러운 물리 표현으로 읽어야 할지, 아니면 공유 가능한 것을 중복 관리하는 기술 부채로 읽어야 할지 경계가 헷갈립니다. 실무에선 보통 어느 쪽으로 판단하시는지 궁금합니다.


3. (학습프로젝트에 대한 질문깃허브 링크) 배치 데이터 적재 성능 측정 방식의 테스트 방식 질문

현재 측정 방식 요약

항목 채택한 방식 위치
실행 컨테이너 단일 JVM, 단일 Spring Context BenchmarkApplication.java
레벨 주입 List<BatchBenchmark> 자동 주입 후 순차 실행 L26
데이터 생성 시드 42L 1회 생성, 모든 레벨 공유 L38
초기화 DELETE FROM ... + ALTER TABLE ... AUTO_INCREMENT = 1 SchemaInitializer.truncateTables
반복 측정 각 레벨 1회 실행 (반복은 셸 스크립트로 외부 N회) run-100k-batch.sh
Warm-up 없음
JVM 옵션 -Xmx2g -XX:+UseG1GC 실행 스크립트
예외 처리 try { ... } catch (Exception e) { System.out.printf("[Failed]") } L48~54
메모리 측정 Runtime.totalMemory() - freeMemory() BenchmarkRunner

<테스트 설계 관련 질문>

멘토님이시라면 이런 상황에서 아래 세 가지를 어떻게 가져가실지 거 같으신가요?

  • 비교군(baseline) 을 무엇으로 잡으시는지
  • 테스트 환경 을 어떻게 통제하시는지
  • 어떤 지표를 몇 회 반복 측정하실 것 같으신지

<기술 적합성 판단 방법론 관련 질문>

저는 직접 해보지 않으면 이해가 잘 안 되는 타입이라, 기술 검토 시 항상 실제 구현 테스트를 해보는 편인데요. 관련해서 궁금증이 생겼습니다. 비교군 테스트를 어떻게 해보시는 편이신가요?

  • 기술 선택 시에도 이렇게 비교군 테스트를 주로 활용하시는지
  • 아니면 프로파일링 · 사례 조사 · POC 같은 다른 접근을 먼저 쓰시는지
  • 멘토님만의 판단 팁이 있으신지

4. 제가 테스트한 결과에 대한 결론을 작성해보았습니다. 각 기술의 강점 + 트레이드오프에 대한 견해에 대한 검토를 받아보고 싶습니다!

강점·대가·적정 사용처 매트릭스

Level 강점 (제 견해) 대가로 포기하는 것 적정 사용처 측정 근거 (TPS)
L0 Raw JDBC DB 드라이버 동작에 가장 가까움. 외부 의존성 0 트랜잭션·예외·자원 해제 모두 수동 → 운영 리스크 ↑ 학습, 드라이버 검증, 1회성 마이그레이션 10K: 14,663 / 100K: 17,950
L1 JdbcTemplate 소규모(<5만)에서 단일스레드 최고 TPS. 코드량/성능/Spring 통합의 균형점 도메인 모델·낙관적 락·Cascade 같은 ORM 이점 없음 기존 Spring 프로젝트 부분 배치, 일회성 ETL 10K: 22,779 / 100K: 17,156
L2 JPA flush/clear 도메인 객체 그대로 영속화, CRUD 코드와 동일 모델 재사용 1차 캐시 관리 + identity 전략의 batch insert 무력화 → L1 대비 44~48배 느림 대량 INSERT 부적합. CRUD 화면 백엔드용 10K: 471 / 100K: 388
L3 @scheduled Tasklet 외부 인프라 없이 인스턴스 내부 스케줄링 분산 환경 중복 실행/락 직접 구현. 트리거일 뿐 처리 엔진 아님 단일 인스턴스 단순 주기 작업 10K: 541 (100K 측정 누락)
L4 Spring Batch Chunk 대규모(≥10만)에서 역전 우승. chunk Writer가 multi-value INSERT로 DB 왕복 최적화. 재시작/Skip/Retry/메타 무료 Job/Step 초기화 비용 → 소규모 오버헤드. 학습 곡선 가파름 정기 ETL, 재처리 가능성 있는 모든 배치 10K: 1,045 / 100K: 289,017
L5 Partition 수평 확장 + 부분 재시작. 파티션별 진행률 추적 스레드 조율·트랜잭션 경합·메타테이블 쓰기 → 단일 chunk 대비 87배 느림 (튜닝 부재 시) DB 외 시스템(파일·API) 호출이 병목인 작업 10K: 3,544 / 100K: 3,276
L6 Kafka-Driven Producer/Consumer 디커플링. Consumer 수평 확장. throughput·lag 독립 축 1회 처리량 비교 시 가장 느림 (네트워크+브로커+적재 포함) 시스템 간 비동기 연동, 이벤트 스트리밍 100K: 332 (n=1)
L7 ForkJoinPool 소규모(1만) 압도적 1위 (71,942 TPS). CPU-bound 변환에 강점 추정 운영성(재시작·모니터링) 직접 구현. Connection pool 한도에 민감 단발 고처리량 ETL, CPU 변환 워크로드 10K: 71,942 / 100K: 57,274

변경 목적

일간 랭킹(실시간 Redis ZSET)과 달리, 주간·월간 랭킹을 Spring Batch로 사전 계산하여 DB 기반 Materialized View에 적재하고, 버전 스왑 전략으로 조회 무중단을 보장. 동시에 이벤트 기반 아키텍처(ApplicationEvent → Outbox → Kafka)와 Redis 기반 큐 시스템을 확대 구현하여 주문 흐름 보호 및 메시지 전달 보장.

핵심 변경점

  1. 배치 랭킹 Job (apps/commerce-batch): WeeklyRankingJob, MonthlyRankingJob 3-step 구조 (ClearOldVersion Tasklet → Aggregate chunk step 100건 → ActivateVersion Tasklet), RankingVersionManager로 버전 관리
  2. 공유 모듈화 (modules/jpa): RankingScoreCalculator, ProductMetricsDaily 이동 → batch/streamer 동일 점수 계산 공식 보장
  3. MV 엔티티·레포지토리 (commerce-api/batch): MvProductRankWeekly, MvProductRankMonthly, ProductRankWeekly/Monthly 엔티티, 관련 JpaRepository 및 MvRankingRepositoryImpl 추가
  4. 단일 랭킹 API: GET /api/v1/rankings?period=DAILY|WEEKLY|MONTHLY로 기간별 데이터소스(Redis/MV) 자동 라우팅 (RankingV1Controller, RankingFacade, RankingService)
  5. 이벤트 파이프라인: 도메인 이벤트(OrderPlaced, PaymentCompleted, LikeToggled, ProductViewed, CouponIssueRequested) → OutboxEventListener로 Outbox 저장 → OutboxRelayService로 Kafka 비동기 발행 (PENDING→PROCESSING→PUBLISHED, 실패/재시도/정리 로직 포함)
  6. Redis 기반 큐 (apps/commerce-api): QueueV1Controller, QueueFacade, QueueService, QueueTokenService, QueueEntryScheduler (3초 배치), QueueTokenInterceptor (/api/v1/orders 보호), QueueMetrics (Micrometer 통합)
  7. 이벤트 소비자 (apps/commerce-streamer): CatalogEventConsumer (LIKED/UNLIKED/PRODUCT_VIEWED 처리, 메트릭 갱신), CouponIssueConsumer (쿠폰 수량 체크, 중복 발급 방지), OrderEventConsumer (주문 상품별 점수 계산, 일일 메트릭 갱신)

리스크/주의사항

  • 버전 관리 인메모리 문제: RankingVersionManager가 AtomicLong 사용 → 멀티 인스턴스/서버 재기동 시 동기화 안됨 (확인: DB 기반 버전 관리(e.g., version_tracking 테이블) 계획 있는가?)
  • 배치 Reader 메모리 사용: ProductMetricsDailyBatchRepository.findByDateRange()로 기간 전체 레코드 메모리 집계 후 정렬 → 대규모 데이터셋에서 OOM 위험 (SQL GROUP BY + Pageable 청크 처리 권장)
  • MV 테이블 생성 DDL 부재: Flyway/Liquibase 마이그레이션 스크립트 없음 → 배포 전 mv_product_rank_weekly/monthly 테이블 수동/별도 생성 필요
  • 배치 실행 트리거 외부화: Job 실행 메커니즘이 명시되지 않음 (외부 스케줄러 가정) (확인: Quartz/APScheduler 등으로 weeklyRankingJob/monthlyRankingJob 언제 실행하는가?)
  • 이벤트 중복 방지: Kafka 소비자의 EventHandled 엔티티로만 방어 → Outbox 발행 중복 가능성 (생산자 멱등성 강화 검토)

테스트/검증 방법

  • 배치 Job E2E: WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest로 ProductMetricsDaily 인입 → reader/processor 집계 → writer 저장 → version 활성화 검증
  • API E2E: RankingV1ApiE2ETest (Redis 직접 주입), QueueV1ApiE2ETest (진입→위치→토큰 흐름)
  • 큐 성능/동시성: QueueRepositoryImplIntegrationTest, QueueConcurrencyIntegrationTest (중복 진입 방지), QueueThroughputIntegrationTest (배치 처리량), QueueTokenExpiryIntegrationTest (TTL), k6 부하 테스트(queue-spike.js, queue-scheduler.js, queue-ttl.js)
  • 이벤트 소비: OutboxEventListenerTest, OutboxRelayService 재시도/DLQ 로직, 스트리머 Consumer의 트랜잭션·멱등성 검증
  • 통합 검증: 컨텍스트 로딩 테스트(CommerceBatchApplicationTest)로 배치 Job bean 등록 확인

Praesentia-YKM and others added 8 commits March 27, 2026 09:22
- 좋아요/주문/결제 도메인에 이벤트 record 정의 (LikeToggledEvent, OrderPlacedEvent, PaymentCompletedEvent)
- LikeTransactionService에서 직접 호출 대신 이벤트 발행으로 전환
- LikeMetricsEventListener: AFTER_COMMIT + REQUIRES_NEW로 좋아요 집계 처리
- OrderFacade.placeOrder(), PaymentFacade.handleCallback()에서 이벤트 발행
- UserActivityEventListener: 모든 도메인 이벤트를 구독하여 유저 행동 로깅
- 캐시 evict 실패 시에도 DB 업데이트가 롤백되지 않도록 try-catch 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Step 2: Transactional Outbox Pattern + Kafka Producer/Consumer
- OutboxEvent 엔티티 (2-Phase: PENDING → PROCESSING → PUBLISHED/FAILED)
- OutboxEventListener (BEFORE_COMMIT으로 도메인 TX 원자성 보장)
- OutboxRelayService (전용 ExecutorService + @scheduled 폴링 + Recovery/Cleanup)
- ProductViewedEvent 추가 및 ProductFacade 연동
- Kafka 설정: acks=all, idempotence=true
- CatalogEventConsumer (LIKED/UNLIKED/PRODUCT_VIEWED → ProductMetrics 집계)
- OrderEventConsumer (ORDER_PLACED/PAYMENT_COMPLETED 로깅)
- EventHandled 테이블 기반 Consumer 멱등 처리
- @Version 낙관적 락 충돌 시 재시도 + DLQ 전송

Step 3: Kafka 기반 선착순 쿠폰 발급
- CouponModel에 maxQuantity/issuedCount 추가
- CouponFacade.requestCouponIssue() → Kafka 비동기 위임
- CouponIssueConsumer (비관적 락 + 수량 제한 + UK 중복 방지)
- 발급 결과 확인 Polling API (/issue-status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Step 1: Redis Sorted Set 대기열 진입/순번 조회 API
- Step 2: 스케줄러 기반 입장 토큰 발급/검증/TTL + HandlerInterceptor
- Step 3: Prometheus 메트릭(Counter/Gauge) + k6 부하테스트 시나리오

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Redis 기반 대기열 시스템 전체 구현 (Step 1~3) 머지
- QueueTokenInterceptor.afterCompletion()에서 주문 성공 시 토큰 자동 삭제
- QueueTokenService.removeToken() 위임 메서드 추가
- 토큰 TTL 만료 통합 테스트 추가 (QueueTokenExpiryIntegrationTest)
- 처리량 초과 통합 테스트 추가 (QueueThroughputIntegrationTest)
- Interceptor 토큰 삭제 단위 테스트 3건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 이벤트별 가중치 점수 계산 (조회 0.1, 좋아요 0.2, 주문 0.7*log10)
- Kafka Consumer에서 ZSET ZINCRBY로 실시간 랭킹 갱신
- 랭킹 조회 API (GET /api/v1/rankings) + 상품 상세에 rank 필드 추가
- OrderPlacedEvent에 items 확장하여 상품별 주문 랭킹 반영
- E2E 테스트 및 .http 파일 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- RankingScoreCalculator를 modules/jpa로 이동 (api/batch 공유)
- ProductMetricsDaily 엔티티 추가 (일별 상품 지표 집계)
- MvProductRankWeekly/Monthly MV 엔티티 + Repository 추가
- WeeklyRankingJob / MonthlyRankingJob Spring Batch 구현
  (Reader/Processor/Writer + Activate/ClearOldVersion Tasklet)
- Ranking API에 주간/월간 조회 엔드포인트 확장
- Catalog/OrderEventConsumer 에서 일별 지표 반영
- 관련 단위/E2E 테스트 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

Queue 대기 시스템, Ranking 순위 산출, Event-driven Outbox 패턴, Coupon 비동기 발급, Like 토글 메트릭 추적, 주간/월간 Batch 순위 계산, Product 상세조회 순위 통합, Kafka 스트리밍 컨슈머를 구현한다. Redis 정렬 집합, Spring Batch 작업, 트랜잭션 이벤트 리스너, Kafka 토픽 관리, 토큰 라이프사이클 관리를 포함한다.


Changes

Cohort / File(s) Summary
Queue System - Domain & Core
apps/commerce-api/src/main/java/com/loopers/application/queue/QueueConstants, QueueService, QueueInfo, QueueFacade, QueueEntryScheduler, QueueTokenService, domain/queue/QueueRepository, QueueTokenRepository
Redis 기반 대기열 진입, 위치 조회, 토큰 발급 및 검증 기능을 제공한다. QueueEntryScheduler는 3초 간격으로 10명씩 처리한다. 토큰 TTL 300초로 고정되어 있다.
Queue System - Infrastructure
infrastructure/queue/QueueRepositoryImpl, QueueTokenRepositoryImpl
Redis 정렬 집합으로 FIFO 대기열을 구현하고, 토큰을 Redis 문자열로 TTL과 함께 저장한다.
Queue System - Interface & Config
interfaces/api/queue/QueueV1Controller, QueueV1ApiSpec, QueueV1Dto, config/QueueTokenInterceptor, config/WebMvcConfig
POST /api/v1/queue/{eventId}/enter와 GET /api/v1/queue/{eventId}/position 엔드포인트를 제공한다. QueueTokenInterceptor는 /api/v1/orders/** 요청 시 X-Queue-Token, X-User-Id, X-Event-Id 헤더를 검증하고 요청 후 토큰을 제거한다.
Queue System - Tests
src/test/java/.../queue/QueueServiceTest, QueueFacadeTest, QueueTokenServiceTest, QueueEntrySchedulerTest, QueueRepositoryImplIntegrationTest, QueueConcurrencyIntegrationTest, QueueTokenExpiryIntegrationTest, QueueThroughputIntegrationTest, interfaces/api/.../QueueV1ApiE2ETest, config/QueueTokenInterceptorTest
대기열 동시 진입 시 정확성, 토큰 TTL 만료, 스케줄러 배치 처리, E2E 시나리오를 검증한다.
Ranking System - Domain & Service
domain/ranking/RankingKeyGenerator, RankingPeriod, RankingInfo, RankingEntry, RankingWithProduct, RankingRepository, RankingService, application/ranking/RankingFacade
Redis ZSET을 사용하여 일간/주간/월간 순위를 조회한다. RankingKeyGenerator는 ranking:all:yyyyMMdd 형식의 키를 생성한다.
Ranking System - Infrastructure
infrastructure/ranking/RankingRepositoryImpl, MvRankingRepositoryImpl, MvProductRankWeeklyJpaRepository, MvProductRankMonthlyJpaRepository
Redis와 MySQL Materialized View를 통해 순위 데이터를 읽는다. 오프셋/사이즈 기반 페이징을 지원한다.
Ranking System - Interface & Integration
interfaces/api/ranking/RankingV1Controller, RankingV1ApiSpec, RankingV1Dto, application/product/ProductDetail, application/product/ProductFacade
GET /api/v1/rankings 엔드포인트를 제공하고 ProductDetail에 rank 필드를 추가한다. ProductFacade는 상품 조회 시 RankingFacade를 통해 순위를 조회한다.
Ranking System - Batch Jobs
apps/commerce-batch/src/main/java/.../ranking/WeeklyRankingJobConfig, MonthlyRankingJobConfig, step/WeeklyRankingReader, WeeklyRankingProcessor, WeeklyRankingWriter, step/MonthlyRankingReader, MonthlyRankingProcessor, MonthlyRankingWriter, step/ClearOldVersionTasklet, ActivateVersionTasklet, step/MonthlyClearOldVersionTasklet, MonthlyActivateVersionTasklet, RankingVersionManager
7일/30일 윈도우로 메트릭을 집계하고 MySQL 뷰에 순위를 저장한다. 버전 관리를 통해 활성 순위를 전환한다.
Ranking System - Tests
src/test/java/.../ranking/RankingKeyGeneratorTest, RankingScoreCalculatorTest, interfaces/api/.../RankingV1ApiE2ETest, apps/commerce-batch/src/test/java/.../ranking/WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest
순위 키 생성, 점수 계산, 배치 작업 E2E를 검증한다.
Event-Driven Architecture - Kafka & Outbox
domain/outbox/OutboxEvent, OutboxStatus, OutboxEventEnvelope, OutboxRepository, config/KafkaTopicConfig, application/outbox/OutboxEventListener, infrastructure/outbox/OutboxJpaRepository, OutboxRepositoryImpl, OutboxRelayService, config/OutboxRelayConfig
Kafka 토픽 3개(catalog-events, order-events, coupon-issue-requests)를 정의하고, 트랜잭션 이벤트를 Outbox 테이블에 저장한 후 스케줄된 릴레이 서비스가 Kafka로 발행한다. 실패한 이벤트는 DLT로 라우팅되며, 최대 재시도 3회까지 수행한다.
Event Records
domain/like/event/LikeToggledEvent, domain/order/event/OrderPlacedEvent, domain/payment/event/PaymentCompletedEvent, domain/product/event/ProductViewedEvent, domain/coupon/event/CouponIssueRequestedEvent
ApplicationEventPublisher를 통해 발행되는 5가지 도메인 이벤트를 정의한다. Outbox 리스너가 이벤트를 감지하여 Kafka로 발행한다.
Coupon Async Issuance
application/coupon/CouponFacade, application/coupon/CouponIssueService, domain/coupon/CouponModel, interfaces/api/coupon/CouponV1Controller, interfaces/api/coupon/CouponV1Dto
POST /api/v1/coupons/{couponId}/issue-async로 비동기 쿠폰 발급을 요청하고, GET /api/v1/coupons/{couponId}/issue-status로 발급 상태를 조회한다. CouponModel에 maxQuantity와 issuedCount를 추가하여 발급 수량을 제한한다.
Like Toggle Event Handling
application/like/LikeMetricsEventListener, application/like/LikeTransactionService
LikeToggledEvent를 수신하여 ProductService의 좋아요 수를 업데이트하고 캐시를 무효화한다. LikeTransactionService는 직접 업데이트 대신 이벤트를 발행한다.
User Activity Logging
application/logging/UserActivityEventListener
OrderPlacedEvent, PaymentCompletedEvent, LikeToggledEvent를 수신하여 [UserActivity] 로그를 기록한다.
Product Metrics & Daily Aggregation
modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsDaily, ProductMetricsDailyId, domain/ranking/RankingScoreCalculator, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics
조회수, 좋아요, 판매 메트릭을 일일 단위로 추적한다. 다양한 가중치(조회 0.1, 좋아요 0.2, 판매 0.7log10(가격수량))를 적용하여 순위 점수를 계산한다.
Kafka Streaming Consumers
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer, CouponIssueConsumer, OrderEventConsumer
Kafka 토픽을 배치로 소비하여 메트릭을 업데이트하고, 멱등성을 위해 EventHandled 테이블을 사용한다. 실패 시 DLT로 라우팅한다. 낙관적 잠금 재시도는 3회까지 수행한다.
Streamer Domain Entities
apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponEntity, CouponIssueEntity, idempotency/EventHandled, metrics/ProductMetrics, ranking/RankingKeyGenerator, infrastructure/coupon/CouponIssueJpaRepository, CouponJpaRepository, idempotency/EventHandledJpaRepository, metrics/ProductMetricsJpaRepository, metrics/ProductMetricsDailyJpaRepository, ranking/RankingRedisRepository
Streamer 애플리케이션의 도메인 엔티티와 리포지토리를 정의한다. EventHandled를 통해 중복 처리를 방지한다.
Test Suite - Comprehensive Coverage
src/test/java/.../outbox/OutboxEventListenerTest, application/logging/UserActivityEventListenerTest, application/like/LikeMetricsEventListenerTest, LikeTransactionServiceTest, application/order/OrderFacadeTest, application/payment/PaymentFacadeTest, application/product/ProductFacadeTest, application/coupon/CouponModelQuantityTest, domain/like/event/LikeToggledEventTest, domain/outbox/OutboxEventTest, apps/commerce-batch/src/test/java/.../CommerceBatchApplicationTest, domain/ranking/ProductRankWeeklyModelTest, modules/jpa/src/test/java/.../ProductMetricsDailyModelTest, apps/commerce-streamer/src/test/java/.../ProductMetricsTest, RankingKeyGeneratorTest, RankingScoreCalculatorTest
이벤트 리스너, 도메인 모델, 배치 작업, 메트릭 추적의 단위 및 통합 테스트를 제공한다.
Load Testing & Documentation
.http/queue.http, .http/ranking.http, docs/queue-simulator.html, supports/k6/queue-scheduler.js, queue-spike.js, queue-ttl.js, apps/commerce-api/build.gradle.kts (modules:kafka 추가)
HTTP 테스트 시나리오, HTML 시뮬레이터, k6 로드 테스트 스크립트를 제공한다. Kafka 모듈 의존성을 추가한다.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant QueueAPI as Queue API
    participant QueueService
    participant QueueScheduler
    participant TokenService
    participant OrderAPI as Order API
    participant QueueInterceptor
    
    User->>QueueAPI: POST /api/v1/queue/enter<br/>(eventId, userId)
    QueueAPI->>QueueService: enter(eventId, userId)
    QueueService->>QueueService: add to Redis ZSET<br/>calculate position
    QueueService-->>QueueAPI: QueueStatus(position, totalWaiting)
    QueueAPI-->>User: 200 OK {position: 1}
    
    loop Every 3 seconds
        QueueScheduler->>QueueService: popFront(eventId, BATCH_SIZE=10)
        QueueService->>QueueService: pop 10 users from ZSET
        loop For each user
            QueueScheduler->>TokenService: issueToken(eventId, userId)
            TokenService->>TokenService: generate UUID token<br/>store in Redis w/ 300s TTL
            TokenService-->>QueueScheduler: token
        end
    end
    
    User->>QueueAPI: GET /api/v1/queue/position<br/>(eventId, userId)
    QueueAPI->>QueueService: getPosition(eventId, userId)
    QueueService-->>QueueAPI: QueueStatus(..., token)
    QueueAPI-->>User: 200 OK {token: "uuid"}
    
    User->>OrderAPI: POST /api/v1/orders<br/>Headers: X-Queue-Token, X-User-Id, X-Event-Id
    OrderAPI->>QueueInterceptor: preHandle()
    QueueInterceptor->>TokenService: validateToken(eventId, userId, token)
    TokenService-->>QueueInterceptor: valid
    QueueInterceptor-->>OrderAPI: request allowed
    OrderAPI->>OrderAPI: process order
    OrderAPI-->>User: 200 OK {orderId: 123}
    
    OrderAPI->>QueueInterceptor: afterCompletion(status=200)
    QueueInterceptor->>TokenService: removeToken(eventId, userId)
    TokenService->>TokenService: delete from Redis
Loading
sequenceDiagram
    participant App as Application
    participant EventPub as Event Publisher
    participant OutboxListener as Outbox Listener
    participant OutboxDB as Outbox Table
    participant RelayService as Relay Service
    participant Kafka
    participant Consumer as Streamer Consumer
    participant MetricsDB as Metrics DB
    
    App->>App: process business logic
    App->>EventPub: publish LikeToggledEvent<br/>(productId, liked)
    
    EventPub->>OutboxListener: onApplicationEvent()
    OutboxListener->>OutboxDB: save OutboxEvent<br/>(status=PENDING)
    OutboxDB-->>OutboxListener: eventId saved
    
    loop Every 1 second
        RelayService->>OutboxDB: findPendingEventsForUpdate(BATCH_SIZE)
        OutboxDB-->>RelayService: [OutboxEvent]
        
        par Async Publish
            RelayService->>OutboxDB: markProcessing()
            loop For each event
                RelayService->>Kafka: send ProducerRecord<br/>(topic, payload, headers)
                Kafka-->>RelayService: ack (10s timeout)
            end
            RelayService->>OutboxDB: markPublished()
        end
    end
    
    Kafka-->>Consumer: consume batch<br/>(catalog-events)
    Consumer->>Consumer: extract X-Event-Id<br/>check EventHandled
    Consumer->>MetricsDB: increment like_count<br/>increment ranking score
    Consumer->>MetricsDB: save EventHandled<br/>(eventId, eventType)
    Consumer->>Kafka: commit batch
Loading
sequenceDiagram
    participant Batch as Batch Job
    participant Reader as Reader (DB)
    participant Processor as Processor
    participant RedisRanking as Redis Ranking
    participant Writer as Writer (DB)
    participant VersionMgr as Version Manager
    
    Batch->>Batch: start WeeklyRankingJob
    
    Batch->>Batch: clearOldVersionStep
    Batch->>VersionMgr: getNextWeeklyVersion()
    VersionMgr-->>Batch: nextVersion=2
    Batch->>Writer: deleteByVersion(2)
    Batch->>Batch: store nextVersion in context
    
    Batch->>Batch: aggregateStep (chunk=100)
    loop Read batches
        Batch->>Reader: findByDateRange(startDate, endDate)
        Reader-->>Batch: [ProductMetricsDaily]
        
        loop Process items
            Batch->>Processor: process(metricsDaily)
            Processor->>Processor: aggregate per product<br/>calculate score
            Processor->>Processor: assign rank=counter++
            Processor-->>Batch: ProductRankWeekly
        end
        
        loop Write batch
            Batch->>Writer: saveAll([ProductRankWeekly])
        end
    end
    
    Batch->>Batch: activateVersionStep
    Batch->>VersionMgr: getCurrentWeeklyVersion()
    VersionMgr-->>Batch: oldVersion=1
    Batch->>VersionMgr: activateWeeklyVersion(2)
    Batch->>Writer: deleteByVersion(1)
    Batch-->>Batch: version 1 → 2
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes


Possibly related PRs

  • [volume-8] 대기열 시스템 구현  #319, #310, #302: Redis 기반 Queue/Token 시스템의 핵심 구현으로, QueueService, QueueRepository, 스케줄러, 토큰 라이프사이클 관리가 동일하게 변경된다.
  • [Volume-9] Redis ZSET 기반 랭킹 시스템 구현  #362, #346, #343: Ranking 시스템의 완전한 구현으로, RankingFacade, RankingService, Redis ZSET 리포지토리, ProductDetail 순위 통합이 중복된다.
  • Week7 #288, #274, #293: Kafka Outbox 패턴 기반의 Event-driven 아키텍처로, OutboxEvent, OutboxRelayService, 이벤트 리스너, Kafka 토픽 설정이 동일하게 구성된다.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java (1)

70-86: ⚠️ Potential issue | 🟠 Major

statusSUCCESS/FAILED 어느 쪽도 아닐 때도 이벤트가 발행된다.

현재 코드는 if/else if로 상태를 분기한 뒤, 분기 밖에서 무조건 PaymentCompletedEvent를 발행한다. PG가 PENDING/CANCELED/UNKNOWN 등 예기치 못한 값으로 콜백을 보내면 결제/주문 상태는 그대로인 채 success=false인 "완료 이벤트"만 유포되어, 이를 소비하는 Outbox/활동 로깅/후속 Kafka 컨슈머가 실패로 오인하고 사용자 알림·보상 로직이 잘못 실행될 수 있다. 운영상 콜백 재시도·리플레이와 겹치면 영향이 확장된다.

수정안:

  • 유효 상태에서만 발행한다.
  • 알 수 없는 상태는 예외로 전환해 PG 콜백 재시도/관측을 유도한다.
🛠️ 제안 diff
-        if ("SUCCESS".equals(status)) {
-            payment.markSuccess();
-            order.confirmPayment();
-        } else if ("FAILED".equals(status)) {
-            payment.markFailed(failureReason);
-            order.failPayment();
-        }
-
-        eventPublisher.publishEvent(new PaymentCompletedEvent(
-            payment.getId(), payment.orderId(), payment.userId(), "SUCCESS".equals(status)
-        ));
+        final boolean success;
+        if ("SUCCESS".equals(status)) {
+            payment.markSuccess();
+            order.confirmPayment();
+            success = true;
+        } else if ("FAILED".equals(status)) {
+            payment.markFailed(failureReason);
+            order.failPayment();
+            success = false;
+        } else {
+            log.warn("알 수 없는 PG 콜백 상태 - transactionKey: {}, status: {}", transactionKey, status);
+            throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 결제 콜백 상태다.");
+        }
+
+        eventPublisher.publishEvent(new PaymentCompletedEvent(
+            payment.getId(), payment.orderId(), payment.userId(), success
+        ));

추가 테스트:

  • status=SUCCESS/FAILED 각각에서 이벤트 발행 1회 및 success 플래그 검증.
  • status=UNKNOWN/null에서 예외 발생 및 이벤트 미발행, 결제/주문 상태 불변 검증.
  • 동일 transactionKey로 재콜백 수신 시 중복 상태 전이 방지(멱등) 회귀 테스트.
🤖 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/payment/PaymentFacade.java`
around lines 70 - 86, The handleCallback method currently always publishes
PaymentCompletedEvent regardless of status; change it to only act and publish
when status is a known terminal value ("SUCCESS" or "FAILED"): inside
handleCallback (using paymentService.getByTransactionKey, payment.markSuccess,
payment.markFailed, order.confirmPayment, order.failPayment) validate the
incoming status, throw an exception for unknown/null statuses to trigger PG
retry/observation, and only call eventPublisher.publishEvent(new
PaymentCompletedEvent(...)) when you have executed a valid state transition;
additionally ensure idempotency by checking payment’s current state before
applying markSuccess/markFailed so repeated callbacks for the same
transactionKey do not re-emit events or re-transition terminal payments.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java (1)

102-130: ⚠️ Potential issue | 🟡 Minor

새 의존성(ApplicationEventPublisher, RankingFacade) 행위 검증 누락.

ProductFacadeRankingFacadeApplicationEventPublisher가 추가되었음에도 GetProduct#returnsProductDetail는 기존 필드(name/brandName/stockStatus)만 검증하며, 신규 행위인 다음 두 가지를 검증하지 않는다.

  • ProductViewedEvent 발행 여부 및 payload(productId 일치)
  • RankingFacade에서 가져온 rank 값이 ProductDetail에 반영되는지

운영 관점에서 이 둘이 회귀하면 아웃박스 미발행(→ 랭킹 스코어 누락)과 상세 응답의 rank 누락으로 이어져 사용자 체감 품질이 저하된다. 아래와 같이 verify/assert를 추가할 것을 권장한다.

♻️ 추가 테스트 예시
+            when(rankingFacade.getRank(productId)).thenReturn(/* 기대 rank */);
+
             ProductDetail result = productFacade.getProduct(productId);
 
             assertAll(
                 () -> assertThat(result.name()).isEqualTo("에어맥스"),
                 () -> assertThat(result.brandName()).isEqualTo("나이키"),
-                () -> assertThat(result.stockStatus()).isEqualTo(StockStatus.IN_STOCK)
+                () -> assertThat(result.stockStatus()).isEqualTo(StockStatus.IN_STOCK),
+                () -> verify(eventPublisher).publishEvent(any(ProductViewedEvent.class))
             );

As per coding guidelines: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다" 및 "Mock 남용으로 의미가 약해지면 테스트 방향을 재정렬하도록 제안한다" — 주입만 하고 검증하지 않는 mock은 테스트 의미를 약화시킨다.

🤖 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 102 - 130, The test GetProduct#returnsProductDetail needs to verify
the new side-effects introduced to ProductFacade: assert that
ApplicationEventPublisher.publishEvent was called with a ProductViewedEvent
whose productId equals productId, and assert that RankingFacade.getRank (or the
mocked ranking call used by productFacade) is invoked and its returned rank is
reflected in the returned ProductDetail (e.g., result.rank() equals the mocked
rank). Update the test to mock RankingFacade to return a known rank, add
verify(applicationEventPublisher).publishEvent(argThat(event ->
((ProductViewedEvent)event).getProductId().equals(productId))) and
verify(rankingFacade).getRank(productId) (or the concrete method used), and add
an assertion for result.rank() matching the mocked value while keeping existing
assertions for name/brandName/stockStatus.
♻️ Duplicate comments (1)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java (1)

181-198: ⚠️ Potential issue | 🟠 Major

OrderEventConsumer.sendToDlq와 동일한 getMessage() NPE 리스크이다.

exception.getMessage()가 null인 경우(대표적으로 NPE) .getBytes(...) 단계에서 다시 NPE가 발생해 DLQ 전송이 실패하고 메시지가 유실된다. 해당 파일의 수정안과 동일하게 null 안전 처리 및 예외 클래스명 fallback을 적용해야 한다. 테스트도 message가 null인 예외로 sendToDlq 호출 시 DLT 발행에 성공함을 검증하는 단위 테스트를 공통 helper로 추출해 두는 것을 권장한다.

🤖 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 181 - 198, The sendToDlq method risks an NPE when
exception.getMessage() is null; update sendToDlq to null-safe the error header
by computing a non-null string (e.g., String errMsg = exception.getMessage() !=
null ? exception.getMessage() : exception.getClass().getSimpleName()) before
calling getBytes, use that errMsg for the "X-Error-Message" RecordHeader, and
keep the existing copy of original headers and "X-Original-Topic" logic; also
add/adjust a unit test (reusable helper) that calls
CatalogEventConsumer.sendToDlq with an Exception whose getMessage() returns null
and asserts the DLT publish succeeds and contains the fallback error header.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c061c31f-42da-4682-876a-52b9ac8d6b9d

📥 Commits

Reviewing files that changed from the base of the PR and between 54a63a6 and 6799edb.

⛔ Files ignored due to path filters (6)
  • docs/plans/2026-03-27-step1-application-event.md is excluded by !**/*.md and included by **
  • docs/plans/2026-04-03-queue-system-design.md is excluded by !**/*.md and included by **
  • docs/plans/2026-04-03-step1-queue-system.md is excluded by !**/*.md and included by **
  • docs/plans/2026-04-03-step2-token-scheduler.md is excluded by !**/*.md and included by **
  • docs/plans/2026-04-03-step3-monitoring-k6.md is excluded by !**/*.md and included by **
  • docs/requirements/round7-requirement.md is excluded by !**/*.md and included by **
📒 Files selected for processing (137)
  • .http/queue.http
  • .http/ranking.http
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeMetricsEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java
  • apps/commerce-api/src/main/java/com/loopers/application/logging/UserActivityEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueConstants.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueEntryScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueService.java
  • apps/commerce-api/src/main/java/com/loopers/application/queue/QueueTokenService.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingWithProduct.java
  • apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java
  • apps/commerce-api/src/main/java/com/loopers/config/OutboxRelayConfig.java
  • apps/commerce-api/src/main/java/com/loopers/config/QueueTokenInterceptor.java
  • apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponIssueRequestedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeToggledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderPlacedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventEnvelope.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/queue/QueueTokenRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvRankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueTokenRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeMetricsEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/logging/UserActivityEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueEntrySchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/queue/QueueTokenServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/config/QueueTokenInterceptorTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelQuantityTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/event/LikeToggledEventTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueConcurrencyIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueRepositoryImplIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueThroughputIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueTokenExpiryIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueTokenRepositoryImplIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ActivateVersionTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ClearOldVersionTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyActivateVersionTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyClearOldVersionTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregation.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankMonthly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankWeekly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingVersionManager.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyBatchRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/ranking/ProductRankWeeklyModelTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponEntity.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueEntity.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingKeyGeneratorTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java
  • docs/queue-simulator.html
  • modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsDaily.java
  • modules/jpa/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyId.java
  • modules/jpa/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java
  • modules/jpa/src/test/java/com/loopers/domain/metrics/ProductMetricsDailyModelTest.java
  • modules/jpa/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java
  • modules/kafka/src/main/resources/kafka.yml
  • supports/k6/queue-scheduler.js
  • supports/k6/queue-spike.js
  • supports/k6/queue-ttl.js

Comment on lines 45 to 54
@Cacheable(cacheNames = "productDetail", key = "#productId")
@Transactional(readOnly = true)
@Transactional
public ProductDetail getProduct(Long productId) {
ProductModel product = productService.getById(productId);
String brandName = getBrandName(product.getBrandId());
StockModel stock = stockService.getByProductId(productId);
return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity()));
Long rank = rankingFacade.getProductRank(productId);
eventPublisher.publishEvent(new ProductViewedEvent(productId, null));
return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity()), rank);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

@Cacheable 메서드 내부에서 이벤트 발행과 rank 포함이 결합되어 있다. 치명적 결함이다.

운영/기능 관점 이슈 3건이 한 메서드에 얽혀 있다.

  1. 캐시 히트 시 ProductViewedEvent가 발행되지 않는다. @Cacheable은 캐시 적중 시 메서드 본문을 건너뛰므로, 인기 상품일수록(=캐시 히트율이 높을수록) 조회 이벤트가 유실된다. 이 이벤트는 commerce-streamer의 ZINCRBY로 실시간 랭킹을 만드는 입력이므로, 결과적으로 "인기 상품일수록 랭킹에 덜 잡히는" 역전 현상이 생긴다. 일간 Redis ZSET 설계의 근간이 무너진다.

  2. rank가 캐시에 포함된다. ProductDetail에 실린 rank는 본 메서드 반환값과 함께 productDetail 캐시로 들어가지만, 실제 랭킹은 이벤트 수신에 따라 초 단위로 변동한다. 그 결과 캐시 TTL 동안 오래된 순위 값이 반환되어 사용자에게 보인다. 캐시 무효화 주기와 랭킹 갱신 주기가 완전히 어긋난다.

  3. @TransactionalreadOnly에서 쓰기 트랜잭션으로 바꾼 의도가 불명확하다. 본 메서드는 상태를 변경하지 않으며, 이벤트 발행도 AFTER_COMMIT 리스너로 동작한다면 읽기 트랜잭션으로 충분하다. 불필요한 커밋 경로·락 경합이 생긴다.

  4. new ProductViewedEvent(productId, null)null이 userId라면 트래킹·개인화 집계에서 사용자 식별이 영구 불가능해진다. 필요 시 SecurityContext 또는 헤더에서 사용자 식별자를 주입해야 한다.

수정 방향을 권장한다.

  • 조회와 이벤트 발행을 분리해, 이벤트 발행은 @Cacheable이 걸리지 않은 별도 퍼블릭 메서드(또는 컨트롤러 어드바이스)에서 수행한다.
  • rank는 캐시 대상에서 제외하고, 캐시된 ProductDetail에 조회 시점에 rank를 덧붙여 반환한다(데코레이션).
  • @Transactional(readOnly = true)로 환원한다.
  • 사용자 식별자는 가능한 경우 실제 값을 전달하고, 익명 조회 정책을 주석으로 명시한다.

추가 테스트:

  • 동일 상품을 2회 연속 조회 시 이벤트가 2회 발행되는지 검증(캐시 히트에도 발행).
  • 캐시 TTL 내에서 Redis ZSET 점수가 변경된 뒤 다시 조회 시 rank가 최신값으로 반영되는지 검증.
🛠 제안 diff (요지)
-    `@Cacheable`(cacheNames = "productDetail", key = "#productId")
-    `@Transactional`
-    public ProductDetail getProduct(Long productId) {
-        ProductModel product = productService.getById(productId);
-        String brandName = getBrandName(product.getBrandId());
-        StockModel stock = stockService.getByProductId(productId);
-        Long rank = rankingFacade.getProductRank(productId);
-        eventPublisher.publishEvent(new ProductViewedEvent(productId, null));
-        return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity()), rank);
-    }
+    public ProductDetail getProduct(Long productId, Long userId) {
+        ProductDetail cached = getProductCached(productId);
+        eventPublisher.publishEvent(new ProductViewedEvent(productId, userId));
+        Long rank = rankingFacade.getProductRank(productId);
+        return cached.withRank(rank);
+    }
+
+    `@Cacheable`(cacheNames = "productDetail", key = "#productId")
+    `@Transactional`(readOnly = true)
+    public ProductDetail getProductCached(Long productId) {
+        ProductModel product = productService.getById(productId);
+        String brandName = getBrandName(product.getBrandId());
+        StockModel stock = stockService.getByProductId(productId);
+        return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity()), null);
+    }

As per coding guidelines ("엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다" 및 "도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다").

🤖 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/product/ProductFacade.java`
around lines 45 - 54, The getProduct method currently combines caching, event
publishing and rank inclusion causing missed events on cache hits, stale rank in
cache, and unnecessary write transaction; split responsibilities by making
getProduct a readOnly cached method that only returns cacheable product details
without rank or event (adjust `@Transactional` to readOnly = true and remove event
publish and rankingFacade usage from ProductFacade.getProduct), add a separate
public non-cached method (e.g., publishProductView or recordProductView called
from controller or advice) that always publishes ProductViewedEvent (use real
userId from SecurityContext if available instead of null), and decorate the
cached ProductDetail at response time by fetching current rank from
rankingFacade and attaching it before returning to clients (so ProductDetail
stays cacheable but rank is computed dynamically).

Comment on lines +53 to +82
public void relay() {
// Phase 1: PENDING → PROCESSING
List<OutboxEvent> events = fetchAndMarkProcessing();
if (events.isEmpty()) {
return;
}

// Phase 2: partitionKey별 그루핑 → Kafka 발행
Map<String, List<OutboxEvent>> grouped = events.stream()
.collect(Collectors.groupingBy(OutboxEvent::getPartitionKey));

List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Map.Entry<String, List<OutboxEvent>> entry : grouped.entrySet()) {
CompletableFuture<Void> future = CompletableFuture.runAsync(
() -> publishEvents(entry.getValue()),
outboxRelayExecutor
);
futures.add(future);
}

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("Outbox relay 완료: {}건 처리", events.size());
}

@Transactional
public List<OutboxEvent> fetchAndMarkProcessing() {
List<OutboxEvent> events = outboxRepository.findPendingEventsForUpdate(BATCH_SIZE);
events.forEach(OutboxEvent::markProcessing);
return events;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Self-invocation으로 @Transactional이 적용되지 않아 FOR UPDATE SKIP LOCKED 락이 소실된다.

운영 관점에서 치명적이다. relay()가 같은 클래스의 fetchAndMarkProcessing()this 참조로 직접 호출하므로 Spring AOP 프록시를 거치지 않고, @Transactional이 무효화된다. 그 결과 다음 문제가 연쇄적으로 발생한다.

  • findPendingEventsForUpdate(..)FOR UPDATE SKIP LOCKED가 트랜잭션 범위를 가지지 못해 쿼리 종료 즉시 락이 해제되고, 멀티 인스턴스/다중 스케줄 환경에서 동일 이벤트가 두 번 PROCESSING으로 마킹되어 중복 Kafka 발행이 발생할 수 있다.
  • events.forEach(OutboxEvent::markProcessing)의 엔티티 상태 변경이 dirty checking으로 커밋되지 않아 DB에는 여전히 PENDING으로 남을 수 있다. 이 경우 recoverStalledEvents도 탐지할 수 없는 "유령 처리중" 상태가 된다.
  • Phase 2의 Kafka 발행은 트랜잭션이 열려 있지 않은 상태에서 진행되므로, 그룹 단위 예외/타임아웃 시 상태 정합성 보장이 약하다.

수정안은 두 가지다.

🔧 수정안 1: 분리된 빈(권장) — 프록시 경계를 강제한다
-    `@Transactional`
-    public List<OutboxEvent> fetchAndMarkProcessing() {
-        List<OutboxEvent> events = outboxRepository.findPendingEventsForUpdate(BATCH_SIZE);
-        events.forEach(OutboxEvent::markProcessing);
-        return events;
-    }
+    // 별도 `@Component` (예: OutboxRelayTxService)로 분리하여 주입받아 호출한다.
+    // 예: this.outboxRelayTxService.fetchAndMarkProcessing();

별도 트랜잭셔널 빈으로 분리하면 자기호출 문제를 구조적으로 제거할 수 있고, 조회+상태전이+flush를 하나의 커밋 경계 안에 둘 수 있다.

🔧 수정안 2: 진입점(`relay`)에 트랜잭션 경계 지정은 지양

relay()@Transactional을 붙이면 Phase 2의 Kafka I/O까지 단일 트랜잭션에 묶여 장시간 락/커넥션 점유가 발생한다. 반드시 Phase 1(조회+상태전이)만 트랜잭션으로 감싸야 한다.

추가 테스트가 필요하다.

  • 두 개 이상의 인스턴스(혹은 두 스레드)에서 동시에 relay()를 호출해도 동일 OutboxEventPROCESSING으로 한 번만 마킹되는지 Testcontainers MySQL 기반 통합 테스트로 검증한다.
  • fetchAndMarkProcessing() 리턴 직후 DB에서 재조회하여 상태가 PROCESSING으로 커밋되어 있는지 단정한다.

As per coding guidelines ("트랜잭션 경계(@Transactional) 위치와 전파, readOnly, 롤백 조건을 점검한다").

🤖 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/outbox/OutboxRelayService.java`
around lines 53 - 82, The issue: self-invocation in OutboxRelayService.relay()
calls the `@Transactional` method fetchAndMarkProcessing() directly so the FOR
UPDATE SKIP LOCKED transaction is not applied; fix by moving the transactional
phase into a separate Spring bean (e.g., OutboxFetcherService) with a
`@Transactional` method fetchAndMarkProcessing() that calls
outboxRepository.findPendingEventsForUpdate(BATCH_SIZE), calls
OutboxEvent::markProcessing, and flushes/commits before returning; then have
OutboxRelayService.relay() call that new bean's fetchAndMarkProcessing(), keep
publishEvents(...) and outboxRelayExecutor usage unchanged, and avoid annotating
relay() with `@Transactional` to prevent long-lived transactions during Kafka I/O.

Comment on lines +9 to +11
public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> {
List<MvProductRankMonthly> findAllByOrderByRankingAsc(Pageable pageable);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 주간 Repository도 동일 패턴인지 확인
fd -t f MvProductRankWeeklyJpaRepository.java -x cat {}
# MvRankingRepositoryImpl에서 version 인자를 어떻게 사용하는지 확인
fd -t f MvRankingRepositoryImpl.java -x cat {}
# 활성 버전 주입 경로 확인
rg -nP -C3 'RankingVersionManager|activeVersion|currentVersion' --type=java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 10648


🏁 Script executed:

# Check the entity models to confirm 'version' field exists
fd -t f -E '*.class' \( -name 'MvProductRankMonthly.java' -o -name 'MvProductRankWeekly.java' \) -x cat {}

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 315


🏁 Script executed:

# Check the tasklet implementations for actual version handling
fd -t f \( -name 'ClearOldVersionTasklet.java' -o -name 'ActivateVersionTasklet.java' \) | head -2 | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 315


🏁 Script executed:

# Look for any integration tests related to ranking batch or version management
fd -t f -path '*/test/*' -name '*Rank*Test*.java' -o -path '*/test/*' -name '*Version*Test*.java' | xargs -l1 sh -c 'wc -l "$1" 2>/dev/null | awk "{print \$1, \"$1\"}"' _ | sort -rn | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 253


🏁 Script executed:

# Find and display entity models
find . -type f -name 'MvProductRankMonthly.java' -o -name 'MvProductRankWeekly.java' | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1825


🏁 Script executed:

# Check tasklet implementations
find . -type f \( -name 'ClearOldVersionTasklet.java' -o -name 'ActivateVersionTasklet.java' \) | xargs cat

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3440


🏁 Script executed:

# Search for test files related to ranking
find . -path '*/test/*' -type f \( -name '*Rank*Test*.java' -o -name '*Version*Test*.java' \) | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 732


🏁 Script executed:

# Check if there are any other query methods on the MV repositories
rg 'findAll|findBy' apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ --type java -A 1

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1365


🏁 Script executed:

# Check if deleteByVersion method exists on repositories
rg 'deleteByVersion' apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ --type java -B2 -A2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check the E2E tests to see if they validate version isolation
cat ./apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4311


🏁 Script executed:

# Check if RankingVersionManager has getCurrentWeeklyVersion/Monthly methods
rg 'getCurrentWeekly|getCurrentMonth|getNextWeekly|getNextMonth' --type java -B1 -A3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4937


활성 version 필터가 누락되어 버전 스왑 전략이 깨진다

PR 설계상 MV는 "버전 기반 스왑으로 무중단 조회"를 전제로 하며, ClearOldVersionAggregate(INSERT)ActivateVersion 3-step으로 동작한다. 그러나 findAllByOrderByRankingAsc(Pageable)version 컬럼을 조건에 전혀 사용하지 않아, 배치 실행 도중에는 이전 버전 + 신규 적재 중인 버전이 섞여 ranking 값이 중복/누락되어 정렬되고, 조회 API가 순위를 왜곡 반환한다. Pageable로 top-N만 반환해도 서로 다른 버전의 동일 ranking=1 행이 섞이면 운영상 치명적이다.

동일 이슈가 주간 Repository(MvProductRankWeeklyJpaRepository)에도 존재한다. 활성 버전을 받아 필터링하는 쿼리로 교체하고, RankingVersionManager에서 활성 버전을 주입받도록 해야 한다. 또한 버전이 섞여 들어오는 회귀를 잡는 통합 테스트(구 버전 + 신 버전 동시 INSERT 상태에서 조회 결과가 단일 버전으로 한정되는지)를 추가해야 한다.

🐛 제안 수정
-public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> {
-    List<MvProductRankMonthly> findAllByOrderByRankingAsc(Pageable pageable);
-}
+public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, Long> {
+    List<MvProductRankMonthly> findAllByVersionOrderByRankingAsc(long version, Pageable pageable);
+}

MvRankingRepositoryImplgetWeeklyTopN(), getMonthlyTopN() 메서드도 함께 수정하여 RankingVersionManager에서 현재 활성 버전을 주입받아 쿼리를 호출해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java`
around lines 9 - 11, The repository method findAllByOrderByRankingAsc(Pageable)
in MvProductRankMonthlyJpaRepository (and the analogous method in
MvProductRankWeeklyJpaRepository) is missing an active version filter; update
these JPA query methods to accept a version parameter and filter on the version
column (e.g., findAllByVersionAndOrderByRankingAsc) and ensure
MvRankingRepositoryImpl.getMonthlyTopN() and getWeeklyTopN() obtain the current
active version from RankingVersionManager and pass it into the repository call;
finally add an integration test that inserts rows for both old and new versions
concurrently and asserts that reads return only rows for the single active
version to prevent mixed-version results.

Comment on lines +16 to +21
@Slf4j
@StepScope
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
@RequiredArgsConstructor
@Component
public class MonthlyClearOldVersionTasklet implements Tasklet {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

in-memory 버전 매니저의 멀티인스턴스·재기동 내성을 확인한다.

PR 설명에 명시된 대로 RankingVersionManagerAtomicLong 기반이면, 배치 인스턴스가 재기동되거나 API/Batch가 서로 다른 JVM에서 동작할 경우 버전이 초기화되거나 어긋난다. 이는 "무중단 스왑"이라는 설계 목적 자체를 무너뜨리므로, 단기라도 다음 중 하나를 적용해야 한다.

  • DB 기반 버전 테이블(활성/후보 버전 레코드)
  • Redis INCR 기반 버전
  • 최소한 기동 시 DB MAX(version)+1로 워밍업

추가 테스트: 컨텍스트 재기동 후 이전 버전과의 충돌/중복 적재가 없는지 통합 테스트에서 검증한다.

🤖 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/MonthlyClearOldVersionTasklet.java`
around lines 16 - 21, The in-memory RankingVersionManager currently using an
AtomicLong is not multi-instance or restart-safe: update RankingVersionManager
(and/or MonthlyClearOldVersionTasklet) to persist or seed the version from a
shared store—either implement a DB-backed version table (active/candidate
records) or use Redis INCR for atomic global increments, or, at minimum, on
startup set the AtomicLong to (SELECT MAX(version)+1 FROM
<ranking_version_table>) to warm up the in-memory counter; ensure the logic
lives in the RankingVersionManager initialization path and add integration tests
that restart the application/context to validate there is no version collision
or duplicate ingestion across JVMs.

Comment on lines +17 to +33
@StepScope
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
@Component
public class MonthlyRankingProcessor implements ItemProcessor<ProductMetricsAggregation, ProductRankMonthly>, StepExecutionListener {

private final AtomicInteger rankCounter = new AtomicInteger(0);
private long nextVersion;

@Value("#{jobParameters['requestDate']}")
private LocalDate requestDate;

@Override
public void beforeStep(StepExecution stepExecution) {
this.nextVersion = stepExecution.getJobExecution()
.getExecutionContext()
.getLong("nextMonthlyVersion");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Step 구성에서 processor가 listener로 등록되는지 확인한다.
fd -t f 'MonthlyRankingJobConfig.java' | xargs -I{} sed -n '1,250p' {}
echo '---'
fd -t f 'WeeklyRankingJobConfig.java' | xargs -I{} sed -n '1,250p' {}

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 7940


🏁 Script executed:

fd -t f 'MonthlyRankingProcessor.java' | xargs cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2379


MonthlyRankingProcessor가 Step에 listener로 명시 등록되지 않으면 nextVersion=0으로 데이터가 적재된다.

MonthlyRankingJobConfig.aggregateStep()에서 processor는 .processor(processor)로만 등록되고 .listener()로는 등록되지 않는다. Spring Batch에서 .processor()ItemProcessor 인터페이스만 작동하며, StepExecutionListenerbeforeStep()은 호출되지 않는다. 따라서 nextVersion은 선언부의 기본값 0으로 유지되어 모든 월간 랭킹이 version=0으로 적재된다. activate tasklet이 swap할 때 원하지 않는 버전이 노출되거나 unique 제약 위반으로 Job이 실패하므로, 운영상 첫 배치 실행 시 전량 오염되는 치명적 케이스다.

수정안:

  1. MonthlyRankingJobConfig.aggregateStep()에서 .listener((Object) processor) 또는 .listener(processor) 추가 등록
  2. beforeStep()에서 nextMonthlyVersion 부재 시 IllegalStateException 발생하도록 명시적 실패 처리 추가
  3. E2E 테스트에 activate된 버전의 실제 레코드 수를 검증하는 assertion 추가
🤖 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 17 - 33, The processor's StepExecutionListener.beforeStep is never
invoked because MonthlyRankingJobConfig.aggregateStep registers the processor
only via .processor(processor); update aggregateStep to also register the
listener (e.g., .listener((Object) processor) or .listener(processor)) so
MonthlyRankingProcessor.beforeStep runs and sets nextVersion; additionally, in
MonthlyRankingProcessor.beforeStep check for presence of "nextMonthlyVersion" in
the StepExecution executionContext and throw an IllegalStateException if missing
instead of leaving nextVersion as 0; finally add an E2E test assertion that
verifies the activated monthly version's record count matches expected to catch
this regression.

Comment on lines +65 to +81
/**
* @Version 낙관적 락 충돌 시 최대 MAX_RETRY 재시도.
* 동시 업데이트로 version이 맞지 않으면 재조회 후 재시도한다.
*/
private void processWithRetry(ConsumerRecord<String, String> record) {
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
processRecord(record);
return;
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("낙관적 락 충돌 (attempt {}/{}): offset={}", attempt, MAX_RETRY, record.offset());
if (attempt == MAX_RETRY) {
throw e;
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

낙관적 락 재시도 패턴이 자기 호출로 완전히 무력화된다.

processWithRetry → this.processRecord 호출은 프록시를 통하지 않아 @Transactional이 적용되지 않는다. 결과적으로:

  1. 각 시도가 새 트랜잭션/새 영속성 컨텍스트에서 실행되지 않아 ObjectOptimisticLockingFailureException 이후 재시도해도 같은 1차 캐시의 스테일 엔티티로 다시 저장을 시도하게 되어 재시도의 의미가 사라진다.
  2. 실제 DB 반영이 트랜잭션 없이 auto-commit으로 흩어져, ProductMetrics/ProductMetricsDaily/EventHandled 간 정합성이 깨질 수 있다(예: 카운트 증가는 됐는데 EventHandled가 안 들어가 재소비 시 이중 증가).
  3. 추가로 재시도 간 백오프가 없어, 경합이 심해지면 짧은 시간 안에 MAX_RETRY를 소진해 곧바로 DLQ로 빠진다.

수정안: 처리 로직을 별도 @Transactional(propagation=REQUIRES_NEW) 빈으로 분리하고, 재시도마다 해당 빈의 public 메서드를 호출하여 프록시를 경유시키며, 시도 간 지수 백오프(예: 20ms·80ms·240ms)를 적용한다. 테스트는 (a) 동일 상품에 대해 LIKED 이벤트 2건을 병렬 처리해 실제 재시도가 발생하고 최종 카운트가 +2 인지, (b) MAX_RETRY 도달 시 DLT로 넘어가는지 검증해야 한다.

🤖 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 65 - 81, processWithRetry currently calls this.processRecord
directly so the `@Transactional` proxy on processRecord is bypassed, nullifying
retry semantics and causing stale persistence context, inconsistent writes to
ProductMetrics/ProductMetricsDaily/EventHandled, and immediate MAX_RETRY
exhaustion; refactor by extracting the core processing into a separate
`@Component` bean with a public method annotated `@Transactional`(propagation =
REQUIRES_NEW) (e.g., CatalogEventProcessor.processRecordPublic) and change
processWithRetry to invoke that bean's public method through the proxy on each
attempt, add exponential backoff between attempts (e.g., 20ms, 80ms, 240ms), and
add tests that (a) run two parallel LIKED events for the same product to force
optimistic lock retries and verify final count is +2 and (b) confirm behavior
when MAX_RETRY is reached sends the record to the DLT.

Comment on lines +39 to +51
public void consume(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
for (ConsumerRecord<String, String> record : records) {
try {
processRecord(record);
} catch (Exception e) {
log.error("coupon-issue-requests 처리 실패: offset={}, error={}", record.offset(), e.getMessage(), e);
}
}
ack.acknowledge();
}

@Transactional
public void processRecord(ConsumerRecord<String, String> record) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

@Transactional이 자기 호출(self-invocation)로 무효화되고 있다.

consume(...)은 프록시를 통해 호출되지만, 내부에서 this.processRecord(record)를 직접 호출하므로 Spring AOP 프록시가 개입하지 못하여 processRecord@Transactional이 적용되지 않는다. 결과적으로 멱등 저장·쿠폰 발급·EventHandled 저장이 단일 트랜잭션으로 묶이지 않아, incrementIssuedCount 이후 couponIssueRepository.save가 실패하더라도 쿠폰 수량은 이미 증가한 상태로 커밋될 수 있다. 또한 findByIdForUpdate의 비관적 락도 트랜잭션이 없으므로 의미를 잃는다. 운영 환경에서 재고 초과 발급·데이터 정합성 깨짐이 발생한다.

수정안은 처리 로직을 별도 빈(CouponIssueHandler)으로 분리하여 주입받거나, 동일 클래스 내라면 ApplicationContext로 프록시를 받아 호출하는 방식이다. 테스트는 (1) processRecord 중간 예외 발생 시 couponRepository.issuedCount가 롤백되는지, (2) 동시 발급 요청에 대해 findByIdForUpdate 락이 실제로 동작하는지 확인하는 통합 테스트를 추가해야 한다.

🐛 제안 수정(분리 빈)
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class CouponIssueHandler {
+    private final CouponJpaRepository couponRepository;
+    private final CouponIssueJpaRepository couponIssueRepository;
+    private final EventHandledJpaRepository eventHandledRepository;
+
+    `@Transactional`
+    public void handle(String eventId, Long couponId, Long userId) { /* ... */ }
+}
 `@Component`
 public class CouponIssueConsumer {
-    private final CouponJpaRepository couponRepository;
-    private final CouponIssueJpaRepository couponIssueRepository;
-    private final EventHandledJpaRepository eventHandledRepository;
+    private final CouponIssueHandler handler;
     private final ObjectMapper objectMapper;
🤖 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 39 - 51, 현재 CouponIssueConsumer.processRecord(...)에 붙은
`@Transactional이` 자기 호출로 무효화되어 트랜잭션과 비관적 락이 적용되지 않으므로, processRecord 로직을 별도
빈(CouponIssueHandler 같은 클래스)으로 옮기고 그 빈을 CouponIssueConsumer에 주입받아
consumer.consume(...)에서 handler.processRecord(record)를 호출하도록 변경하세요; 또는 같은 클래스
내에서 ApplicationContext.getBean(CouponIssueConsumer.class).processRecord(record)
방식으로 프록시를 통해 호출하도록 변경해 트랜잭션 프록시가 적용되게 하세요. 또한 새 빈(CouponIssueHandler) 또는 프록시 호출
지점에서 멱등 처리·incrementIssuedCount·couponIssueRepository.save·EventHandled 저장이 단일
트랜잭션에 묶이도록 유지하고, 통합 테스트를 추가해(processRecord 도중 예외 발생 시 issuedCount 롤백 확인, 동시 발급 시
findByIdForUpdate의 락 동작 확인) 수정이 올바른지 검증하세요.

Comment on lines +100 to +113
// 발급 처리
try {
coupon.incrementIssuedCount();
couponRepository.save(coupon);

CouponIssueEntity issue = new CouponIssueEntity(couponId, userId, coupon.getExpiredAt());
couponIssueRepository.save(issue);

eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED"));
log.info("쿠폰 발급 성공: couponId={}, userId={}", couponId, userId);
} catch (DataIntegrityViolationException e) {
log.info("쿠폰 중복 발급 방지 (UK): couponId={}, userId={}", couponId, userId);
eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED"));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

같은 트랜잭션 내에서 DataIntegrityViolationException 캐치 후 save 호출은 커밋되지 않는다.

(@Transactional이 정상 적용된다는 전제에서) DB 제약 위반이 발생한 시점에 해당 트랜잭션은 rollback-only 로 마킹되므로, 같은 트랜잭션 안에서 eventHandledRepository.save(...)를 호출해도 커밋 시점에 UnexpectedRollbackException이 발생하며 EventHandled는 저장되지 않는다. 이 경우 해당 이벤트가 재소비될 때 멱등 테이블에 기록이 없어 중복 처리 로직이 다시 돌게 되고, 운영 관점에서 UK 충돌 로그와 재시도가 반복적으로 쌓인다.

수정안은 UK 충돌 기록용 EventHandled 저장을 REQUIRES_NEW로 분리한 별도 서비스에 위임하거나, 선(先)-존재확인 흐름에서 잡아내도록 하는 것이다. 테스트는 CouponIssueEntity에 UK 충돌을 강제로 유발한 뒤 event_handled가 실제로 커밋되는지 검증하는 통합 테스트가 필요하다.

🤖 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 100 - 113, The DataIntegrityViolationException occurs after the
transaction is marked rollback-only so eventHandledRepository.save(...) inside
the same transaction (in CouponIssueConsumer) will not commit; extract the
EventHandled persistence into a separate transactional boundary with propagation
REQUIRES_NEW (e.g., create a new service method like
EventHandledService.saveAsNewTransaction(EventHandled) annotated with
`@Transactional`(propagation = Propagation.REQUIRES_NEW)) and call that from both
the success path and the catch(DataIntegrityViolationException) path in
CouponIssueConsumer instead of calling eventHandledRepository.save(...)
directly; also add an integration test that forces a UK conflict on
CouponIssueEntity and verifies EventHandled is actually committed.

Comment on lines +48 to +87
public void consume(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
for (ConsumerRecord<String, String> record : records) {
try {
processRecord(record);
} catch (Exception e) {
log.error("order-events 처리 실패 → DLQ 전송: offset={}, error={}", record.offset(), e.getMessage(), e);
sendToDlq(record, e);
}
}
ack.acknowledge();
}

@Transactional
public void processRecord(ConsumerRecord<String, String> record) {
String eventId = getHeader(record, "X-Event-Id");
String eventType = getHeader(record, "X-Event-Type");

if (eventId == null || eventType == null) {
log.warn("이벤트 헤더 누락: offset={}", record.offset());
return;
}

if (eventHandledRepository.existsById(eventId)) {
log.debug("이미 처리된 이벤트: eventId={}", eventId);
return;
}

JsonNode envelope = parsePayload(record.value());
if (envelope == null) return;

JsonNode data = envelope.get("data");

switch (eventType) {
case "ORDER_PLACED" -> handleOrderPlaced(data);
case "PAYMENT_COMPLETED" -> handlePaymentCompleted(data);
default -> log.warn("알 수 없는 이벤트 타입: {}", eventType);
}

eventHandledRepository.save(new EventHandled(eventId, eventType));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

@Transactional이 자기 호출로 무효화된다(동일 패턴, 반복).

CouponIssueConsumer와 동일하게 consume → this.processRecord는 프록시를 우회하므로 processRecord@Transactional이 적용되지 않는다. 현 구현에서는 Redis ZINCRBY(incrementScore)와 ProductMetricsDaily upsert, EventHandled 저장이 서로 다른 리소스/비트랜잭션에서 실행되어, DB 저장 실패 시에도 Redis 랭킹은 이미 증가한 채로 남아 랭킹이 과대 집계된다. 또한 같은 배치 내 동일 이벤트가 두 번 들어온 경우 existsById 체크와 save 사이 TX 격리가 없어 이중 처리 가능성이 있다.

수정안은 처리 로직을 별도 @Transactional 빈으로 분리하거나, DB 트랜잭션 커밋 이후에 Redis를 증가시키도록 순서를 재배치(트랜잭션 커밋 후 리스너)하는 것이다. 테스트는 DB 저장 실패를 주입했을 때 Redis 스코어가 증가하지 않는지, 동일 eventId를 배치 내 두 번 넣었을 때 메트릭이 한 번만 반영되는지 확인해야 한다.

🤖 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/OrderEventConsumer.java`
around lines 48 - 87, The processRecord method is currently annotated
`@Transactional` but is invoked directly from consume (this.processRecord), so the
transaction is not applied; separate the transactional work into a
Spring-managed bean/method and call it via the proxy (or move DB-related logic
into a new `@Service` with a method like transactionalProcessRecord) so that the
DB operations (existsById, upsert ProductMetricsDaily, save EventHandled) run
inside a single transaction; ensure Redis incrementScore is executed only after
the DB transaction commits (either by moving Redis logic out of the
transactional bean to be invoked after the proxy call returns or by publishing
an event/using TransactionSynchronizationManager.afterCommit); update consume to
call the proxied transactional bean (e.g., transactionalProcessRecord) and
adjust sendToDlq usage accordingly; add tests that simulate DB save failure to
assert Redis ZINCRBY is not called and a duplicate eventId in the same batch is
only applied once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant