Skip to content

[VOLUME-10] Spring Batch 를 이용한 주간, 월간 랭킹 구현 - 조용민#391

Open
simoncho91 wants to merge 10 commits into
Loopers-dev-lab:ymchofrom
simoncho91:feature/week10
Open

[VOLUME-10] Spring Batch 를 이용한 주간, 월간 랭킹 구현 - 조용민#391
simoncho91 wants to merge 10 commits into
Loopers-dev-lab:ymchofrom
simoncho91:feature/week10

Conversation

@simoncho91
Copy link
Copy Markdown

@simoncho91 simoncho91 commented Apr 16, 2026

📌 Summary

  • R9에서 일간 랭킹은 Redis ZSET으로 실시간 누적을 구축했지만, 주간/월간 랭킹은 부재했습니다. ZSET을 7일/30일치로 누적해 풀 수도 있지만, 쓰기 빈도와 정합성 요구가 일간과 너무 달라서 같은 저장소로 묶으면 한쪽이 무리한 비용을 지게 됩니다.
  • commerce-streamer가 적재한 product_metrics_daily(SOT)를 Spring Batch로 사전 집계해 MV 테이블에 적재하고, period 파라미터로 일간/주간/월간을 단일 엔드포인트에서 분기했습니다. 점수 가중치는 SQL과 Java 양쪽에 흩어지지 않도록 RankingScoreCalculator로 응집하고, Processor에서 drift가 감지되면 즉시 실패하도록 가드를 두었습니다.
  • 3단계로 진행했습니다. ① Spring Batch Weekly/Monthly Job 구현, ② Ranking API period 분기, ③ 점수 가중치 단일 소스화. 이후 k6 부하테스트로 cold start와 Brand N+1을 발견·해결해 weekly p99를 261ms에서 12ms까지 내렸습니다.

🧭 Context & Decision

일간 ZSET과 주간/월간 MV로 갈라진 이유

원래는 일간 ZSET을 7일치로 누적해 그대로 보여주는 방안도 떠올렸습니다. 그런데 일간과 주간/월간은 같은 도메인 개념이지만 요구가 너무 달랐습니다. 일간은 이벤트마다 갱신이 필요하고 갱신 지연 허용도가 거의 0인 반면, 주간/월간은 하루 1회 사전 집계로 충분하고 수 시간의 지연도 허용됩니다. 누적 데이터 크기도 일간은 1일치인데 주간/월간은 7일/30일치를 합산해야 하고, 정합성 요구는 일간이 휘발 가능(TTL 2일)인 반면 주간/월간은 누적을 보존해야 합니다.

이런 차이를 같은 저장소로 묶으면 한쪽이 무리한 비용을 지게 됩니다. ZSET으로 통일하면 누적 보존이 어려워지고, MV로 통일하면 일간 실시간성을 포기해야 합니다. 일간은 실시간성이 우선이라 ZSET이 자연스럽고, 주간/월간은 정합성·비용이 우선이라 MV 테이블에 사전 집계하는 쪽으로 갈랐습니다.

Chunk-Oriented를 택한 이유

주간/월간 집계는 데이터를 한 번에 읽어 가공한 뒤 적재하는 작업이라 단순 Tasklet으로도 충분히 굴러갑니다. Chunk-Oriented를 택한 건 두 가지 때문이었습니다.

지금은 GROUP BY 결과가 100건이라 단발 처리가 가능하지만, 운영 데이터가 늘어나 LIMIT 없이 가져갈 일이 생기면 Tasklet 단발 처리는 메모리 부담이 커집니다. Chunk size 500으로 나누면 메모리 상한을 통제할 수 있습니다. 또 Chunk-Oriented는 청크 단위로 트랜잭션이 커밋되어, 실패해도 마지막 성공한 청크 다음부터 재시작이 가능합니다.

Reader 선택 — JpaPagingItemReader 대신 JdbcCursorItemReader

JpaPagingItemReader도 후보였는데, 집계 쿼리에 GROUP BY가 있어 페이징과 궁합이 좋지 않았습니다. 페이지마다 GROUP BY 전체 쿼리가 다시 실행되면서 정렬 순서가 흔들릴 수 있고, OFFSET이 커질수록 비용도 누적됩니다. JdbcCursorItemReader는 단일 쿼리를 한 번 실행하고 커서로 row를 흘려보내는 방식이라 GROUP BY 결과를 안전하게 단일 패스로 처리할 수 있었습니다.

점수 가중치를 한 곳으로 모은 이유

원래 Reader SQL 안에 (SUM(view) * 1 + SUM(like) * 2 + SUM(order) * 7)처럼 리터럴로 박아두었습니다. 가중치를 자주 바꿀 일은 없으니 큰 문제는 아니지만, 시간이 지나 가중치가 SQL과 Java 양쪽에 흩어지면 한쪽만 바꿨을 때 SQL 결과와 Java 계산이 어긋나도 적재까지 통과되는 구조가 됩니다. 이게 마음에 걸렸습니다.

RankingScoreCalculator 한 곳에 상수와 계산 로직을 두고, Reader SQL은 같은 상수를 .formatted()로 주입합니다. Processor에서 SQL이 계산한 점수와 Calculator가 계산한 점수가 일치하는지 매 row마다 검증하고, 어긋나면 즉시 실패시켜 잘못된 랭킹이 적재되는 걸 막는 구조입니다.


🏗️ Design Overview

변경 범위

구분 모듈 내용
신규 모듈 apps/commerce-batch Spring Batch 구성, Weekly/Monthly Job, Listener
점수 도메인 apps/commerce-batch RankingScoreCalculator + 단위 테스트
MV 도메인 apps/commerce-batch MvProductRankWeekly, MvProductRankMonthly, Repository
API 확장 apps/commerce-api period enum, RankingPeriod, RankingRepository, MV 조회 분기
Brand N+1 제거 apps/commerce-api findAllByIds 도메인 메서드 추가 (RP-2 참조)
적재 로직 apps/commerce-streamer product_metrics_daily 엔티티 + Consumer 적재
k6 자동화 tests/k6 seed.sh, run-all.sh, WARMUP env, 시나리오별 결과

전체 아키텍처

[commerce-streamer]
  CatalogEventConsumer
    ├── R9: ZINCRBY ranking:all:{yyyyMMdd} (일간)
    └── R10 신규: product_metrics_daily upsert
         (productId, metricDate, viewCount, likeCount, orderCount)
                            │
                            ▼ (SOT)
[apps/commerce-batch] ─ 신규 모듈
  WeeklyRankingJob (스케줄러: 매일 N시)
    ├── Step 1: Cleanup Tasklet
    │   └── DELETE FROM mv_product_rank_weekly WHERE week_start = :weekStart
    └── Step 2: Aggregate Step (Chunk size = 500)
        ├── Reader   : JdbcCursorItemReader (GROUP BY + LIMIT 100)
        ├── Processor: drift assertion (SQL ↔ Calculator) + ProductScoreRow → MV
        └── Writer   : JpaItemWriter

  MonthlyRankingJob — 동일 패턴 (period_start = month start)
                            │
                            ▼ (적재)
[commerce-api]
  GET /api/v1/rankings?period={daily|weekly|monthly}&date=...
    ├── daily   → Redis ZSET (ZREVRANGE)
    └── weekly/monthly → MV 테이블 (ORDER BY rank_position)
                  └── productMap + brandMap (findAllByIds로 N+1 회피)

🔁 Flow Diagram

Weekly Job 실행 흐름

sequenceDiagram
  autonumber
  participant Launcher as JobLauncher
  participant Cleanup as Cleanup Tasklet
  participant Reader as JdbcCursorItemReader
  participant Processor as WeeklyRankingProcessor
  participant Calculator as RankingScoreCalculator
  participant Writer as JpaItemWriter
  participant DB as MySQL

  Launcher->>Cleanup: weekStartDate=20260413, runId=...
  Cleanup->>DB: DELETE FROM mv_product_rank_weekly WHERE week_start = ?
  Note over Cleanup,DB: 멱등성 보장 — 동일 키 재실행 안전

  Launcher->>Reader: aggregateStep 시작
  Reader->>DB: SELECT product_id, SUM(view), SUM(like), SUM(order),<br/>(SUM*1 + SUM*2 + SUM*7) AS total_score<br/>FROM product_metrics_daily<br/>WHERE metric_date BETWEEN ? AND ?<br/>GROUP BY product_id ORDER BY total_score DESC LIMIT 100
  loop chunk size = 500
    Reader-->>Processor: ProductScoreRow
    Processor->>Calculator: assertConsistent(sqlScore, view, like, order)
    Note over Processor,Calculator: SQL 결과와 Calculator 결과<br/>일치 검증 (drift fail-fast)
    Processor-->>Writer: MvProductRankWeekly
  end
  Writer->>DB: persist (chunk 단위 commit)
Loading

Ranking API period 분기

sequenceDiagram
  autonumber
  participant Client
  participant Facade as RankingFacade
  participant Redis as Redis (ZSET)
  participant Repo as RankingRepository
  participant DB as MySQL

  Client->>Facade: GET /api/v1/rankings?period=weekly&date=20260413

  alt period = daily
    Facade->>Redis: ZREVRANGE ranking:all:{yyyyMMdd}
    Redis-->>Facade: [(productId, score), ...]
  else period = weekly | monthly
    Facade->>Repo: findWeeklyRankings(weekStart, page, size)
    Repo->>DB: SELECT * FROM mv_product_rank_weekly<br/>WHERE week_start = ? ORDER BY rank_position
    DB-->>Repo: [ProductRankEntry]
    Repo-->>Facade: entries
  end

  Facade->>DB: productRepository.findAllByIds(productIds)
  Facade->>DB: brandRepository.findAllByIds(brandIds)
  Note over Facade,DB: distinct 후 일괄 조회<br/>(RP-2: N+1 회피)
  Facade-->>Client: [{rank, productId, productName, price, brandName, score}]
Loading

Step 1. Spring Batch Weekly/Monthly Job

Job 구성 — Cleanup Tasklet + Aggregate Step

WeeklyRankingJobConfig.java

@Bean(JOB_NAME)
public Job weeklyRankingJob() {
    return new JobBuilder(JOB_NAME, jobRepository)
        .incrementer(new RunIdIncrementer())
        .start(cleanupStep())     // Step 1: 동일 키 row 선삭제
        .next(aggregateStep())     // Step 2: GROUP BY → chunk 적재
        .listener(jobListener)
        .build();
}

@JobScope
@Bean(STEP_AGGREGATE)
public Step aggregateStep() {
    return new StepBuilder(STEP_AGGREGATE, jobRepository)
        .<ProductScoreRow, MvProductRankWeekly>chunk(CHUNK_SIZE, transactionManager)
        .reader(weeklyReader(null))
        .processor(processor)
        .writer(weeklyWriter())
        .listener(stepMonitorListener)
        .listener(chunkListener)
        .build();
}

Cleanup → Aggregate 두 Step 구조로, Cleanup이 동일 (period, date) row를 선삭제해 동일 키 재실행이 안전합니다. UPSERT로도 멱등성을 보장할 수 있는데, DELETE + INSERT는 어떤 상태에서 실행해도 같은 결과로 수렴한다는 의도가 가장 명확하게 드러납니다.

Cleanup Tasklet — 동일 키 선삭제

WeeklyRankingCleanupTasklet.java

@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
    repository.deleteByWeekStartDate(startDate);
    log.info("[WeeklyCleanup] deleted weekStartDate={}", startDate);
    return RepeatStatus.FINISHED;
}

Job 파라미터의 weekStartDate로 해당 주 row를 모두 삭제하고 끝납니다. 백필이나 재처리로 같은 키가 들어와도 항상 같은 결과가 나옵니다.

Reader — GROUP BY + LIMIT 100, JdbcCursorItemReader

@StepScope
@Bean
public JdbcCursorItemReader<ProductScoreRow> weeklyReader(
    @Value("#{jobParameters['weekStartDate']}") String weekStartDate
) {
    LocalDate start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
    LocalDate end = start.plusDays(6);

    return new JdbcCursorItemReaderBuilder<ProductScoreRow>()
        .name("weeklyRankingReader")
        .dataSource(dataSource)
        .sql("""
            SELECT product_id,
                   SUM(view_count) AS view_count,
                   SUM(like_count) AS like_count,
                   SUM(order_count) AS order_count,
                   (SUM(view_count) * %d + SUM(like_count) * %d + SUM(order_count) * %d) AS total_score
            FROM product_metrics_daily
            WHERE metric_date BETWEEN ? AND ?
            GROUP BY product_id
            ORDER BY total_score DESC
            LIMIT 100
            """.formatted(
            RankingScoreCalculator.VIEW_WEIGHT,
            RankingScoreCalculator.LIKE_WEIGHT,
            RankingScoreCalculator.ORDER_WEIGHT
        ))
        .preparedStatementSetter(ps -> {
            ps.setObject(1, start);
            ps.setObject(2, end);
        })
        .beanRowMapper(ProductScoreRow.class)
        .build();
}

가중치는 .formatted()RankingScoreCalculator 상수를 주입합니다. 매직 리터럴(1, 2, 7)을 SQL에 박지 않고 단일 소스에서 가져오는 게 Step 3의 drift assertion이 작동하기 위한 전제입니다.

Job 활성화 — @ConditionalOnProperty로 선택적 등록

@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME)

Spring Batch 5는 spring.batch.job.name으로 실행할 Job을 선택합니다. Weekly/Monthly가 같은 컨텍스트에 모두 등록되면 매번 같이 실행되어버려서, @ConditionalOnProperty로 실행 시점에 지정된 Job만 등록되도록 했습니다.


Step 2. Ranking API period 분기

단일 엔드포인트 + RankingFacade 분기

RankingFacade.java

// daily — Redis ZSET
public List<RankingInfo> getTopRankings(String date, int page, int size) {
    String key = rankingKey(date);
    Set<TypedTuple<String>> tuples = redisTemplate.opsForZSet()
        .reverseRangeWithScores(key, start, end);
    // ... ZSET → productIds → enrichment
}

// weekly — MV 테이블
public List<RankingInfo> getWeeklyRankings(LocalDate date, int page, int size) {
    LocalDate weekStart = date.with(DayOfWeek.MONDAY);
    List<ProductRankEntry> entries = rankingRepository.findWeeklyRankings(weekStart, page, size);
    return enrichRankEntries(entries);
}

period별 엔드포인트를 분리하는 방식도 떠올랐는데, 클라이언트가 라우팅을 신경 써야 한다는 점이 걸렸습니다. 단일 엔드포인트 + period enum이면 OpenAPI 스펙으로 enum 제약이 검증되어 오타도 막을 수 있습니다.

Brand N+1 회피 — findAllByIds로 일괄 조회

private Map<Long, Brand> fetchBrandMap(Map<Long, Product> productMap) {
    List<Long> brandIds = productMap.values().stream()
        .map(Product::getBrandId)
        .filter(id -> id != null)
        .distinct()
        .toList();
    if (brandIds.isEmpty()) return Collections.emptyMap();
    return brandRepository.findAllByIds(brandIds).stream()
        .collect(Collectors.toMap(Brand::getId, b -> b));
}

원래는 Brand를 productMap.values()를 돌면서 단건 조회했는데, 부하 측정 단계에서 N+1이 드러났습니다 (RP-2 참조). productMap 확정 후 brandIds를 distinct로 모아 findAllByIds로 한 번에 가져오는 구조로 바꿔서 weekly p99가 74ms → 12ms로 떨어졌습니다.

@BatchSizeJOIN FETCH도 후보였습니다. Brand가 Product의 lazy 연관이 아니라 직접 조회 경로라 @BatchSize는 적용 지점 자체가 없었고, JOIN FETCH는 Product 조회 쿼리를 무겁게 만들어 적합하지 않았습니다. 도메인 메서드로 명시적으로 가져가는 쪽이 호출자가 의도를 읽기 쉽다는 판단이었습니다.


Step 3. 점수 가중치 단일 소스화

RankingScoreCalculator — 상수 + 계산 + drift assertion

RankingScoreCalculator.java

public final class RankingScoreCalculator {

    public static final int VIEW_WEIGHT  = 1;
    public static final int LIKE_WEIGHT  = 2;
    public static final int ORDER_WEIGHT = 7;

    public static double calculate(long viewCount, long likeCount, long orderCount) {
        return viewCount * VIEW_WEIGHT
             + likeCount * LIKE_WEIGHT
             + orderCount * ORDER_WEIGHT;
    }

    public static void assertConsistent(double scoreFromSql, long viewCount, long likeCount, long orderCount) {
        double expected = calculate(viewCount, likeCount, orderCount);
        if (scoreFromSql != expected) {
            throw new IllegalStateException(
                "Ranking score drift detected: SQL=%s Calculator=%s (view=%d like=%d order=%d)"
                    .formatted(scoreFromSql, expected, viewCount, likeCount, orderCount)
            );
        }
    }
}

상수와 계산 로직을 한 클래스에 모았습니다. SQL과 Java 양쪽이 같은 상수를 참조하므로 가중치는 한 곳에서만 바뀝니다.

Processor — 매 row drift 검증

WeeklyRankingProcessor.java

@Override
public MvProductRankWeekly process(ProductScoreRow item) {
    RankingScoreCalculator.assertConsistent(
        item.getTotalScore(),
        item.getViewCount(), item.getLikeCount(), item.getOrderCount()
    );

    LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
    return MvProductRankWeekly.of(
        item.getProductId(), startDate, item.getTotalScore(),
        item.getViewCount(), item.getLikeCount(), item.getOrderCount(),
        ++rankCounter
    );
}

Reader가 SQL로 계산한 total_score와 Calculator가 Java로 계산한 점수가 매 row마다 일치하는지 검증합니다. 어긋나면 IllegalStateException으로 즉시 실패해서 잘못된 랭킹이 적재되는 걸 막습니다.

drift가 실제로 발생하는 시나리오는 가중치 변경 시 한쪽만 깜빡 잊은 경우입니다. SQL에서 가중치를 바꾸고 RankingScoreCalculator 상수를 안 바꾸면(또는 그 반대), 첫 번째 row에서 즉시 fail-fast됩니다. 잘못된 랭킹이 노출되기 전에 Job 자체가 실패하는 안전한 가드입니다.


✅ 요구사항 체크리스트

카테고리 요구사항 상태
배치 주간/월간 랭킹 사전 집계 Job 구현 ✅ Spring Batch Chunk-Oriented (size=500)
동일 (period, date) 키 재실행 멱등성 ✅ Cleanup Tasklet (DELETE + INSERT)
점수 공식 단일 소스화 + drift 가드 ✅ RankingScoreCalculator + assertConsistent
API period 파라미터 도입 (daily/weekly/monthly) ✅ enum + RankingFacade 분기
weekly/monthly MV 조회 ✅ RankingRepository.findWeeklyRankings 등
상품 + 브랜드 Aggregation ✅ findAllByIds로 N+1 회피
검증 E2E 테스트 (Weekly/Monthly Job) ✅ WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest
k6 부하테스트 자동화 ✅ seed.sh + run-all.sh + WARMUP env

🧪 k6 부하테스트 결과

부하 프로파일: 100 VU, 30s (ramp 9s + sustain 15s + down 6s), 약 1640 req / TPS ~53.

Step 1. 베이스라인 — weekly가 가장 느린 게 어색했던 시점

period avg p95 p99
daily 33.71ms 75.79ms 156.11ms
weekly 35.53ms 95.25ms 261.45ms
monthly 25.80ms 51.16ms 71.61ms

monthly가 같은 MV 구조인데 weekly보다 빠른 점이 가장 먼저 눈에 들어왔습니다. 데이터 양으로 따지면 monthly가 더 큰데 더 빠르다는 게 어색했고, 쿼리 비용이 원인이라는 가설은 이 시점에 기각되었습니다.

Step 2. 호출 순서 가설 (Exp A) → 웜업 효과 검증 (Exp B)

다음으로 호출 순서를 의심해 daily → weekly → monthly 순서를 monthly → weekly → daily로 뒤집었습니다 (Exp A).

period p99 (Step 1) p99 (Exp A 순서 reverse)
daily 156.11ms 98.39ms
weekly 261.45ms 93.01ms
monthly 71.61ms 261.59ms

cold spike가 weekly에서 monthly로 그대로 옮겨갔습니다. 첫 번째 호출이 항상 느리다는 건 호출 순서 = 콜드 효과라는 뜻이었습니다. JIT 미컴파일, JPA 메타데이터 lazy load, HikariCP idle pool 셋이 동시에 작용한다고 보고, 측정 전에 10초 웜업을 끼웠습니다 (Exp B).

period p99 (Step 1) p99 (Exp B 웜업) Δ
daily 156.11ms 45.45ms -71%
weekly 261.45ms 74.10ms -72%
monthly 71.61ms 59.47ms -17%

cold start 가설은 입증됐습니다. 다만 정상화 구간에서 여전히 weekly(74ms) > monthly(59ms) 역전이 남아있는 게 새로 눈에 들어왔습니다.

Step 3. Brand N+1 발견 (Exp C)

weekly가 monthly보다 느리다는 정황으로 Hibernate show_sql을 켰습니다.

select ... from mv_product_rank_weekly ...   -- 1회
select ... from product where id in (...)    -- 1회
select ... from brand where id = ?           -- 5회 ← N+1

통합 테스트에서는 N+1을 잡지 못했습니다. 테스트 데이터가 Brand 1개라 단건 SELECT × 1과 IN 절 SELECT × 1이 같아 보였던 게 원인입니다. 부하 측정까지 가서야 발견한 셈이라, 정석 가드를 두지 않은 책임이 있습니다.

findAllByIds로 일괄 조회로 바꾼 뒤:

period p99 (Exp B) p99 (Exp C, N+1 제거) Δ
daily 45.45ms 30.95ms -32%
weekly 74.10ms 11.82ms -84%
monthly 59.47ms 14.26ms -76%

weekly < monthly로 순서가 정상화되면서 N+1 가설도 입증됐습니다.


📁 파일 변경 요약

신규 (apps/commerce-batch — 신규 모듈)
├── batch/job/ranking/
│   ├── WeeklyRankingJobConfig.java         # Job 정의 + Reader SQL + Chunk Step
│   ├── MonthlyRankingJobConfig.java        # 동일 패턴
│   ├── ProductScoreRow.java                # JDBC row mapping POJO
│   └── step/
│       ├── WeeklyRankingProcessor.java     # drift assertion + 변환
│       ├── MonthlyRankingProcessor.java    # 동일 패턴
│       ├── WeeklyRankingCleanupTasklet.java # DELETE WHERE week_start = ?
│       └── MonthlyRankingCleanupTasklet.java
├── batch/listener/                         # JobListener, StepMonitorListener, ChunkListener
└── domain/ranking/
    ├── RankingScoreCalculator.java         # 가중치 + 계산 + drift assertion
    ├── MvProductRankWeekly.java            # MV 엔티티
    └── MvProductRankMonthly.java

신규 (apps/commerce-streamer)
└── domain/ProductMetricsDaily.java         # 일별 (productId, metricDate) 누적 SOT

수정 (apps/commerce-api)
├── application/ranking/RankingFacade.java  # period 분기 + Brand N+1 회피
├── domain/ranking/
│   ├── RankingPeriod.java                  # daily/weekly/monthly enum
│   ├── RankingRepository.java              # MV 조회 인터페이스
│   └── ProductRankEntry.java               # MV → API DTO
├── domain/product/BrandRepository.java     # findAllByIds 추가
├── infrastructure/product/BrandRepositoryImpl.java
└── interfaces/api/ranking/RankingV1Controller.java # period 파라미터

신규 (tests/k6)
├── seed.sh                                 # 시드 데이터 자동화
├── run-all.sh                              # 시나리오별 일괄 실행 + WARMUP env
├── profiles.json                           # 시나리오 정의
└── results/                                # 실험별 결과 텍스트

🤔 고민한 점

1. N+1을 부하 측정까지 가서야 발견한 점

가장 아쉬운 부분입니다. 통합 테스트에서 N+1을 잡지 못한 이유는 단순합니다 — 테스트 데이터가 Brand 1개라 단건 SELECT × 1과 IN 절 SELECT × 1이 같아 보였습니다. monthly만 봤으면 71ms라 절대값으로는 정상이라 의심도 못 했을 텐데, weekly가 더 느린 정황이 생긴 뒤에야 show_sql을 켰습니다.

이 경험으로 N+1 가드는 통합 테스트 시점에 시스템 레벨로 들어가야 한다는 생각이 들었습니다. Hibernate Statistics로 getQueryExecutionCount() 임계치를 단언하는 방식은 기본 제공이라 손은 가볍지만 카운트 기반이라 N+1 자체보다는 쿼리 수 회귀 감지에 가깝고, p6spy/dataSource-proxy로 쿼리를 인터셉트해 단언하는 방식은 추적이 정확한 대신 별도 의존성과 테스트 인프라 셋업이 필요합니다. 가장 단순하게는 fixture를 Brand 여러 개로 다양화하는 방법도 있는데, 모든 케이스를 사람이 챙겨야 한다는 한계가 있습니다.

2. 호출 순서가 결과를 흔들었다는 점

Step 1에서 weekly p99가 261ms로 튀었을 때, 처음에는 weekly 쿼리 자체를 의심했습니다. monthly가 같은 MV 구조인데 더 빨랐다는 점이 첫 번째 단서였고, 호출 순서를 뒤집었더니 cold spike가 그대로 옮겨갔다는 점이 결정적이었습니다.

이 경험으로, 부하 측정에서는 무엇을 비교하고 싶은가와 측정 환경 자체의 편향을 분리해서 보지 않으면 잘못된 결론에 도달할 수 있다는 걸 체감했습니다. 웜업은 측정 편향을 제거하는 도구일 뿐 운영 솔루션은 아니라는 것도 같은 맥락에서 정리했습니다.


💬 리뷰 포인트

1. 일간 ZSET vs 주간/월간 MV — 저장소 분리 비용은 감수할 만한가

저장소가 둘이 되면서 모니터링·백업·장애 복구 매뉴얼이 분리됩니다. 이 운영 비용은 무시할 수준은 아닙니다. 같은 도메인 개념이 두 코드 경로로 갈라진 점은 RankingFacade에서 한 번만 분기하도록 흡수했지만, 새 period가 추가되면 다시 검토해야 하는 구조입니다.

질문: 운영 관점에서 저장소 두 개를 가져가는 비용은 감수할 만한 결정인지 궁금합니다. 실제 현업이라면 ClickHouse 같은 분석형 DB로 단일화하는 시점을 어떤 신호로 판단하시는지도 함께 들어보고 싶습니다.

2. Brand 조회 N+1 — 정석 가드는 어떻게 두는지

본 PR은 부하 측정 단계에서 N+1을 발견해 도메인 메서드 추가로 해결했지만, 시스템 가드는 두지 못했습니다. 도메인 메서드 추가 방식은 의도가 명시적이긴 하지만 호출자가 IN 호출을 잊으면 다시 회귀할 수 있다는 약점이 있습니다.

질문 1: N+1을 부하 측정 단계까지 가지 않고 단위/통합 테스트 단계에서 잡으려면 정석 가드가 무엇인지 궁금합니다 (Hibernate Statistics, p6spy/dataSource-proxy의 쿼리 카운트 단언 등 중에서).

질문 2: findAllByIds 같은 도메인 메서드 추가와 @EntityGraph/JOIN FETCH 같은 JPA 어노테이션 중 어느 쪽을 기본 정책으로 두시는지도 함께 들어보고 싶습니다.

3. Cold start 운영 적용 — ApplicationRunner / startupProbe / 점진 배포

본 PR의 웜업은 부하 측정의 전제 조건이지 운영 솔루션이 아닙니다. 어떤 옵션이 정석인지 본 PR에서 결정하지 않았습니다.

질문: 운영에서는 ApplicationRunner / startupProbe / 점진 배포 중 어떤 조합이 정석으로 자리잡는지 궁금합니다.

simoncho91 and others added 9 commits April 15, 2026 17:07
AS-IS:
- Kafka 토픽 설정이 단일 브로커/클러스터 구분 없이 하나로 존재
- VIEWED 이벤트 발행/랭킹 조회 실패 시 상품 조회 전체 실패
- ORDERED 이벤트 Outbox 저장 실패 시 주문 전체 롤백
- LocalDate.now() 시스템 기본 타임존 사용 (서버 UTC 시 날짜 불일치)
- RankingService safeOrderValue overflow 가능성

TO-BE:
- @Profile("cluster") 분리 — replicas=3, min.isr=2 (클러스터 전용)
- acks-one 프로파일 추가 (메시지 유실 실험용)
- 비핵심 부수효과 fail-open 처리 (log.warn + 정상 응답)
- ZoneId.of("Asia/Seoul") 명시적 적용 (RankingService, CarryOver)
- Math.log1p + double 캐스팅으로 안전한 점수 계산
- CatalogEvent.of()에 ORDERED 가드 추가
- BYPASS_OUTBOX, SLOW_ACK 실험 플래그 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS:
- CatalogEventConsumer가 product_metrics(실시간)만 갱신
- 일별 스냅샷 데이터가 없어 주간/월간 배치 집계 불가

TO-BE:
- ProductMetricsDaily 엔티티 신규 (@IdClass 복합 PK: productId + metricDate)
- record() 메서드로 이벤트 타입별 카운트 증감 (VIEWED/LIKED/UNLIKED/ORDERED)
- CatalogEventConsumer에 upsertDailyMetrics() 추가 — 이벤트 처리 시 daily 테이블 동시 적재
- 원천 데이터(카운트)만 저장, 점수 가공은 배치에서 수행 (원본/가공 분리)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS:
- 일간 랭킹만 Redis ZSET으로 제공
- 주간/월간 집계 데이터 없음

TO-BE:
- weeklyRankingJob: product_metrics_daily 7일치 합산 → mv_product_rank_weekly TOP 100 적재
- monthlyRankingJob: product_metrics_daily 월간 합산 → mv_product_rank_monthly TOP 100 적재
- 2-Step 구조: Cleanup(Tasklet, 기존 데이터 삭제) → Aggregate(Chunk, 집계 + 적재)
- JdbcCursorItemReader로 SQL 직접 작성 (모듈 간 JPA 엔티티 의존 회피)
- @ConditionalOnProperty로 Job별 빈 생성 격리
- @StepScope + JobParameter로 실행 날짜 외부 주입
- 점수 산출: view*1 + like*2 + order*7
- 재실행 시 멱등 보장 (DELETE → INSERT)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS:
- GET /api/v1/rankings → 일간(Redis ZSET)만 조회 가능

TO-BE:
- GET /api/v1/rankings?period=daily   → Redis ZSET (기존)
- GET /api/v1/rankings?period=weekly  → mv_product_rank_weekly (MV 테이블)
- GET /api/v1/rankings?period=monthly → mv_product_rank_monthly (MV 테이블)
- RankingPeriod enum, ProductRankEntry VO 신규
- commerce-api 전용 조회 엔티티 (@immutable) — commerce-batch 의존 없이 독립
- RankingRepositoryImpl로 MV 테이블 조회 → 도메인 VO 변환
- RankingFacade에 enrichRankEntries() 헬퍼로 상품/브랜드 정보 공통 조합

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS
- 주간/월간 배치 Job과 period 확장 API가 단위 테스트만 존재

TO-BE
- WeeklyRankingJobE2ETest: 7일치 daily 데이터 집계 + 멱등성 검증 (2건)
- MonthlyRankingJobE2ETest: 월간 daily 집계 + 다른 달 배제 + 멱등성 검증 (2건)
- RankingV1ApiE2ETest: weekly/monthly 조회 + period/date 파라미터 검증 (4건)

설계 포인트
- @TestPropertySource에 spring.batch.job.enabled=false 추가 → auto-launch가
  weekStartDate 없이 job을 먼저 실행해 NPE 내는 것을 차단 (@ConditionalOnProperty
  는 spring.batch.job.name만 보므로 Job 빈 로딩에는 영향 없음)
- product_metrics_daily 엔티티는 streamer 모듈에 있으므로 배치 테스트에서
  @BeforeAll로 CREATE TABLE 수동 생성 + @AfterEach로 DELETE
- commerce-api의 @immutable MV 엔티티에는 count 컬럼이 없으므로 API 테스트
  INSERT에서 view/like/order count 컬럼 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS
- FakeProductRepository 3곳에 findAllByIds() 미구현 (9주차 랭킹 조회 API 도입 시 인터페이스 확장됨)
- OrderServiceTest가 OutboxEventRepository/ObjectMapper 없이 OrderService 생성자 호출 (7주차 Outbox 도입 시 시그니처 변경됨)

TO-BE
- FakeProductRepository (OrderServiceTest/LikeServiceTest/OrderDomainServiceTest) 에 findAllByIds 스텁 추가
- OrderServiceTest setUp에 FakeOutboxEventRepository + ObjectMapper 주입

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AS-IS
- 일간/주간/월간 랭킹 조회 API의 성능 비교 수단 없음

TO-BE
- tests/k6/get-rankings.js: period 파라미터로 daily/weekly/monthly 각각 부하
- 프로필 4종 (smoke/load/stress/spike) 기준 정의
- 로드 프로필(VU 100, 30s)로 p95<500ms, p99<1000ms, 에러율<1% threshold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
랭킹 조회 시 Brand 정보를 채우는 fetchBrandMap이 distinct brand_id마다
findById를 개별 호출하여 N+1 발생 (요청당 5회 round trip).
findAllByIds로 IN 절 배치 조회로 변경.

[k6 부하테스트 결과 (warmup 동일 적용, 100 VU 30s)]
- weekly  p99: 74.10ms → 11.82ms (-84%)
- monthly p99: 59.47ms → 14.26ms (-76%)
- daily   p99: 45.45ms → 30.95ms (-32%)

AS-IS: brandRepository.findById(id) × N
TO-BE: brandRepository.findAllByIds(brandIds) × 1
배치로 생성한 MV 테이블 + Redis ZSET을 일괄 시드하고,
daily/weekly/monthly 3개 period를 순차 측정하는 자동화 스크립트.

- seed.sh: Brand 5 + Product 100 UPSERT + MV 100 INSERT + ZSET ZADD
- run-all.sh: health 체크 후 ORDER/LABEL/WARMUP 옵션으로 k6 일괄 실행
  - ORDER: 실행 순서 변경 (cold start 영향 격리 실험용)
  - LABEL: 결과 파일 접미사 (실험 구분)
  - WARMUP: 메인 측정 전 warmup 단계 (cold start 제거 실험용)

.gitignore: tests/k6/results/, .claude/, infra.md 제외
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

📝 Walkthrough

Walkthrough

순위 기능을 주간 및 월간 집계로 확장하고, 배치 작업으로 MV 테이블 자동 집계하며, 제품 일일 메트릭 수집을 추가한다. 부분 오류 허용과 Kafka 클러스터 프로필 설정을 도입한다.

Changes

Cohort / File(s) Summary
Ranking 도메인 및 리포지토리 확장
apps/commerce-api/src/main/java/com/loopers/domain/ranking/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/*
순위 조회 기능을 주간/월간으로 확장. RankingRepository 인터페이스 및 RankingRepositoryImpl 구현 추가. ProductRankEntry VO, RankingPeriod enum 신규 추가. MvProductRankWeeklyEntity, MvProductRankMonthlyEntity JPA 엔티티 및 대응 JpaRepository 추가.
RankingFacade 리팩토링
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
생성자에 RankingRepository 의존성 추가. 주간/월간 순위 조회 메서드 신규 추가. 기존 일일 순위 로직을 공통 enrichment 전략으로 리팩토링. LocalDate.now(ZoneId.of("Asia/Seoul")) 고정 타임존 적용.
Ranking API 업데이트
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
period 쿼리 파라미터 추가. 날짜 파싱을 LocalDate 기반으로 변경. 페이지/크기 검증 강화 (page ≥ 1, 1 ≤ size ≤ 100). CoreException 발생 시 정상 처리.
배치 작업 - 주간/월간 집계
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/*
WeeklyRankingJobConfig, MonthlyRankingJobConfig 신규 추가. 각각 cleanup tasklet 및 aggregate chunk-oriented step 포함. JdbcCursorItemReaderproduct_metrics_daily 집계, RankingScoreCalculator로 가중치 계산.
배치 도메인 엔티티
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/*
MvProductRankWeekly, MvProductRankMonthly, MvProductRankWeeklyId, MvProductRankMonthlyId JPA 엔티티 및 composite key 클래스 추가. RankingScoreCalculator 유틸리티 클래스로 점수 계산 및 검증 로직 제공.
배치 인프라 저장소
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/*
MvProductRankWeeklyJpaRepository, MvProductRankMonthlyJpaRepository 추가. 각각 bulk delete 메서드 포함.
Coupon 및 Order 서비스 부분 오류 처리
apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java, apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java
CouponServiceBYPASS_OUTBOX, CRASH_BEFORE_SEND 환경 플래그 추가. Outbox 패턴 우회 시 TransactionSynchronization.afterCommit() 콜백으로 직접 Kafka 전송. OrderService의 Outbox 저장 실패를 try/catch로 처리하여 트랜잭션 롤백 방지.
BrandRepository 배치 조회
apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java
findAllByIds(List<Long> ids) 메서드 추가로 배치 조회 지원.
Product 상세 조회 오류 처리
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
publishViewedEventgetRank 호출을 try/catch로 감싸 실패 시 계속 진행. ranknull이어도 응답 반환.
메트릭 수집 기능
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
ProductMetricsDaily JPA 엔티티로 제품별 일일 메트릭 (view/like/order count) 저장. CatalogEventConsumer에서 이벤트별 메트릭 upsert 로직 추가. 고정 타임존 Asia/Seoul 적용.
Streamer 컴포넌트 개선
apps/commerce-streamer/src/main/java/com/loopers/application/RankingCarryOverScheduler.java, apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java
타임존 고정 적용, 로그 점수 계산 개선 (Math.log1p 사용), SLOW_ACK 플래그로 Kafka ack 지연 테스트 옵션 추가.
Kafka 설정 및 이벤트
docker/kafka-cluster-compose.yml, modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java, modules/kafka/src/main/resources/kafka.yml, modules/kafka/src/main/java/com/loopers/kafka/event/CatalogEvent.java
Kafka broker 이미지 업데이트 (bitnamilegacy), KRAFT 클러스터 ID 추가. 클러스터 프로필에서 topic 복제 계수 3, min.insync.replicas 2로 설정. acks-one 프로필 추가. CatalogEvent.of() 검증 강화 (ORDERED 타입 거부).
배치 및 통합 테스트
apps/commerce-batch/src/test/java/com/loopers/job/ranking/*, apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java, apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java, apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java, apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java
주간/월간 배치 작업 E2E 테스트, 점수 계산기 단위 테스트, Ranking V1 API E2E 테스트 추가. 기존 fake repository들에 findAllByIds() 구현 추가.
k6 로드 테스트 인프라
tests/k6/get-rankings.js, tests/k6/profiles.json, tests/k6/run-all.sh, tests/k6/seed.sh, .gitignore
순위 엔드포인트 로드 테스트 스크립트 추가. 프로파일 설정, 전체 실행 스크립트, 데이터 시드 스크립트 신규. .gitignoretests/k6/results/, .claude/, infra.md 추가.
Configuration
apps/commerce-streamer/src/main/resources/application.yml
spring.jpa.hibernate.ddl-auto: update 설정 추가 (로컬, 테스트 프로필).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs


운영 관점 지적사항

1. 오류 처리의 무음적(silent) 실패 위험

문제점: OrderService의 Outbox 저장 실패와 ProductV1ControllergetRank 실패가 try/catch로 무음 처리되고 있다. 이는 메트릭 누락이나 순위 데이터 불일치로 이어질 수 있다.

권장사항:

  • 실패 원인에 따라 circuit breaker 또는 재시도 메커니즘 도입
  • 실패 경우 외부 모니터링/알림 시스템과 연계
  • 무음 처리하기 전에 비즈니스 영향도 재검토

추가 테스트: 데이터베이스 다운, 타임아웃 시나리오에서 일관성 검증 테스트 추가

2. 타임존 고정 적용의 불완전성

문제점: Asia/Seoul 타임존을 여러 곳에 하드코딩했으나, 설정으로 관리되지 않아 향후 지역 변경 시 코드 수정이 필수다. 또한 배치 작업의 job parameter monthStartDate 형식 검증이 부족하다.

권장사항:

  • 타임존을 application.yml 또는 application-{profile}.yml에서 관리하는 설정 프로퍼티로 도입
  • job parameter의 날짜 포맷 검증 및 파싱 예외 처리 강화

추가 테스트: 다양한 타임존에서 날짜 경계 케이스 테스트 (자정 근처)

3. Outbox 우회 플래그의 테스트 범위 제한

문제점: CouponServiceBYPASS_OUTBOXCRASH_BEFORE_SEND 플래그는 환경 변수 기반으로만 제어되며, 이들 코드 경로가 충분히 테스트되지 않았을 가능성이 있다.

권장사항:

  • 두 플래그의 모든 조합 (bypass on/off, crash on/off)에 대한 단위 테스트 추가
  • 실제 Kafka 전송 실패 시나리오에 대한 통합 테스트
  • 기존 Outbox 경로와 우회 경로 간 메시지 일관성 검증

추가 테스트: 우회 모드에서 afterCommit() 콜백이 정말 트랜잭션 커밋 후 실행됨을 보증하는 테스트

4. 배치 작업의 멱등성 검증 부족

문제점: WeeklyRankingJobConfigMonthlyRankingJobConfig에서 cleanup 단계가 먼저 실행되지만, 중복 실행 시 최신 데이터만 유지됨을 보장한다. 그러나 부분 실패 시 데이터 일관성이 깨질 수 있다.

권장사항:

  • cleanup 단계 실패 후 재시도 정책 명시
  • 부분 실패 시 자동 롤백 또는 보상 트랜잭션 설계
  • 배치 실행 이력 로깅 및 감시 강화

추가 테스트: 집계 중 데이터베이스 다운 시나리오, 부분 쓰기 시나리오 재현

5. Kafka 클러스터 프로필 설정의 검증 미흡

문제점: KafkaConfig에서 cluster 프로필 시 replicas=3, min.insync.replicas=2로 설정하지만, 실제 브로커 수가 3 미만인 환경에서는 오류가 발생한다.

권장사항:

  • 브로커 수 자동 감지 또는 설정값 검증
  • 프로필 적용 시 선행 체크리스트 문서화
  • 개발/스테이징/프로덕션 환경별 권장 설정 명시

추가 테스트: Kafka 브로커 수가 부족한 환경에서의 배포 실패 테스트

6. 메트릭 수집의 타이밍 불일치

문제점: CatalogEventConsumer에서 upsertDailyMetricsevent.occurredAt() 기반으로 날짜를 계산하는데, 이벤트 발생 시간과 처리 시간 사이의 시간대 변화는 고려되지 않았다. 또한 메트릭 저장 실패 시 이벤트 처리가 계속되므로 데이터 누락이 발생할 수 있다.

권장사항:

  • 메트릭 저장 실패 시 이벤트 처리 실패로 변경 (Kafka offset 미커밋)
  • 또는 메트릭 저장을 비동기 DLQ로 처리

추가 테스트: 메트릭 저장 시 데이터베이스 오류 시나리오 재현

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 Spring Batch를 이용한 주간, 월간 랭킹 구현이라는 핵심 내용을 명확하게 전달하고 있다.
Description check ✅ Passed PR 설명이 구조적이고 상세하며, 📌 Summary, 🧭 Context & Decision, 🏗️ Design Overview, 🔁 Flow Diagram, 단계별 구현, ✅ 요구사항 체크리스트, 🧪 k6 테스트 결과, 💬 리뷰 포인트 등 요구 템플릿의 모든 섹션을 포함하고 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

AS-IS
- Weekly/Monthly Reader SQL에 가중치(1, 2, 7) 리터럴이 흩어져 있어
  공식 변경 시 두 곳을 동시에 고쳐야 했고, 한쪽만 바뀌면 SQL과
  Java 계산값이 어긋나도 적재까지 통과되는 구조였음.

TO-BE
- VIEW/LIKE/ORDER 가중치 상수와 calculate()를 RankingScoreCalculator에 응집.
- Reader SQL은 .formatted()로 동일한 상수를 주입해 가중치 단일 소스 확보.
- Processor에서 assertConsistent()로 SQL 결과와 Calculator 공식이
  drift되면 즉시 실패시켜 잘못된 랭킹 적재를 차단.
@simoncho91 simoncho91 marked this pull request as ready for review April 16, 2026 14:24
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: 18

🧹 Nitpick comments (26)
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java (1)

7-29: serialVersionUID 누락은 직렬화 호환성 위험이 있다.

운영 관점 문제:
클래스 구조가 변경되면 기존 직렬화된 데이터와의 호환성이 깨질 수 있다. JPA 복합키에서 직렬화가 직접 사용되는 경우는 드물지만, 분산 캐시나 세션 클러스터링 환경에서 문제가 될 수 있다.

수정안:

 public class MvProductRankWeeklyId implements Serializable {
+    private static final long serialVersionUID = 1L;
 
     private Long productId;

추가 테스트:

  • 현재 환경에서 직렬화가 사용되지 않는다면 낮은 우선순위로 처리해도 된다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java`
around lines 7 - 29, Add a serialVersionUID to the Serializable class
MvProductRankWeeklyId to ensure serialization compatibility; declare a private
static final long serialVersionUID (e.g., 1L) inside the class (alongside
productId and weekStartDate) so future structural changes don't break serialized
forms used by caches or session replication, and update the value if you
intentionally change the serialized contract.
apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java (1)

54-78: drift 검증에 경계값 테스트를 추가하는 편이 안전하다.

운영 관점에서 배치 안정성은 오탐/미탐 경계에서 깨지기 쉽다.
수정안으로 근접값(아주 작은 차이)과 명확한 차이(큰 오차)를 분리한 테스트를 추가하는 것이 좋다.
추가 테스트 예시는 565.0 + 1e-10 허용, 565.0 + 1e-3 예외 발생 검증이다.

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

In
`@apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.java`
around lines 54 - 78, Add boundary drift tests around
RankingScoreCalculator.assertConsistent: keep the existing "consistent" and
"drift" cases, and add two more tests that verify a tiny numerical difference is
allowed and a larger difference triggers an exception — e.g., call
RankingScoreCalculator.assertConsistent with sqlScore = 565.0 + 1e-10 and expect
no exception, and with sqlScore = 565.0 + 1e-3 and expect IllegalStateException
(message containing "drift"); place them alongside the existing Drift nested
tests to ensure both near-equal tolerance and clear-drift failure are covered.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)

47-53: fail-open 정책은 Controller 밖으로 이동하는 편이 낫다.

현재 Controller가 viewed 이벤트 발행과 랭킹 조회의 예외 흡수 정책까지 직접 결정하고 있다. 운영 중 비슷한 폴백이 다른 엔드포인트로 늘어나면 로그 기준과 장애 대응 방식이 흩어져 일관성이 깨진다. ProductFacade 또는 별도 application service가 상품 조회 + viewed 이벤트 fail-open + rank fail-open을 한곳에서 처리하고, Controller는 응답 조립만 담당하도록 정리하는 편이 낫다. 추가로 viewed 이벤트 실패와 rank 조회 실패 각각에서 200 응답이 유지되는 서비스 단위 테스트, Controller는 DTO 매핑만 검증하는 슬라이스 테스트를 보강하는 것이 좋다.

As per coding guidelines "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다."

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

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java`
around lines 47 - 53, Move the fail-open logic out of ProductV1Controller into
the service/facade layer: create or extend ProductFacade (or a new
ProductService) with a single method (e.g., getProductDetailWithFailOpen or
fetchProductForDetail) that performs the product retrieval, calls
productFacade.publishViewedEvent inside its own try/catch, calls
rankingFacade.getRank inside its own try/catch, and returns a DTO or value
object containing product + nullable rank; then update ProductV1Controller to
call that new facade/service method and only perform DTO mapping/response
assembly. Also add unit tests at the service level that assert viewed-event
failure and rank lookup failure both still return the product (fail-open), and
keep controller slice tests focused on mapping/validation only.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java (1)

43-58: PK 클래스에 serialVersionUID 미정의이다.

Serializable 구현체에서 serialVersionUID를 명시하지 않으면 직렬화 호환성 문제가 발생할 수 있다. 특히 분산 캐시나 세션 복제 환경에서 문제가 될 수 있다.

♻️ serialVersionUID 추가 제안
 public static class PK implements Serializable {
+    private static final long serialVersionUID = 1L;
+
     private Long productId;
     private LocalDate monthStartDate;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 43 - 58, The PK inner class implements Serializable but lacks a
serialVersionUID; add a private static final long serialVersionUID (e.g. 1L) to
the PK class in MvProductRankMonthlyEntity to ensure serialization compatibility
across deployments and avoid default compiler-generated IDs.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)

66-75: ZoneId.of("Asia/Seoul")이 인라인으로 사용되어 유지보수성이 떨어진다.

CatalogEventConsumer에서는 ZONE 상수로 정의했으나, 이 컨트롤러에서는 인라인 문자열로 사용한다. 타임존 정책 변경 시 여러 파일을 수정해야 하며, 오타 위험도 있다.

공통 상수 클래스로 추출하거나, 최소한 클래스 내 상수로 정의하라.

♻️ 상수 추출 제안
 `@RequiredArgsConstructor`
 `@RestController`
 `@RequestMapping`("/api/v1/rankings")
 public class RankingV1Controller implements RankingV1ApiSpec {

     private static final int MAX_SIZE = 100;
+    private static final ZoneId ZONE_SEOUL = ZoneId.of("Asia/Seoul");
     private final RankingFacade rankingFacade;
     
     // ...
     
     private LocalDate resolveDate(String date) {
         if (date == null) {
-            return LocalDate.now(java.time.ZoneId.of("Asia/Seoul"));
+            return LocalDate.now(ZONE_SEOUL);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 66 - 75, The controller currently uses ZoneId.of("Asia/Seoul")
inline inside resolveDate, reducing maintainability; extract that ZoneId into a
constant and reuse it (or reference the existing shared ZONE constant used in
CatalogEventConsumer) so time zone changes are centralized. Add a class-level
constant (e.g., private static final ZoneId ZONE = ZoneId.of("Asia/Seoul")) or
import the shared constant, then update resolveDate to use ZONE instead of
calling ZoneId.of(...) directly.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java (1)

26-39: monthStartDate 파싱을 캐싱하여 불필요한 반복 연산을 줄일 수 있다.

process() 메서드가 아이템마다 호출되는데, LocalDate.parse(monthStartDate, ...)가 매번 수행된다. LIMIT 100이므로 성능 영향은 크지 않으나, 명확한 의도 표현과 일관성을 위해 @PostConstruct 또는 lazy initialization으로 캐싱하는 것이 좋다.

♻️ 파싱 결과 캐싱 제안
 `@StepScope`
 `@ConditionalOnProperty`(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
 `@Component`
 public class MonthlyRankingProcessor implements ItemProcessor<ProductScoreRow, MvProductRankMonthly> {

     private int rankCounter;
+    private LocalDate parsedMonthStartDate;

     `@Value`("#{jobParameters['monthStartDate']}")
     private String monthStartDate;

+    private LocalDate getMonthStartDate() {
+        if (parsedMonthStartDate == null) {
+            parsedMonthStartDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE);
+        }
+        return parsedMonthStartDate;
+    }
+
     `@Override`
     public MvProductRankMonthly process(ProductScoreRow item) {
         RankingScoreCalculator.assertConsistent(
             item.getTotalScore(),
             item.getViewCount(), item.getLikeCount(), item.getOrderCount()
         );

-        LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE);
         return MvProductRankMonthly.of(
-            item.getProductId(), startDate, item.getTotalScore(),
+            item.getProductId(), getMonthStartDate(), item.getTotalScore(),
             item.getViewCount(), item.getLikeCount(), item.getOrderCount(),
             ++rankCounter
         );
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingProcessor.java`
around lines 26 - 39, The method process() currently parses monthStartDate on
every call; cache the parsed LocalDate in the MonthlyRankingProcessor to avoid
repeated parsing. Add a private LocalDate field (e.g., parsedMonthStartDate) and
initialize it once (either in a `@PostConstruct` method or via lazy initialization
when first needed) instead of calling LocalDate.parse(monthStartDate,
DateTimeFormatter.BASIC_ISO_DATE) inside process(); then use
parsedMonthStartDate when constructing MvProductRankMonthly and keep existing
calls to RankingScoreCalculator.assertConsistent and rankCounter unchanged.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java (1)

31-37: Weekly Tasklet과 동일한 개선이 필요하다.

WeeklyRankingCleanupTasklet과 동일하게:

  1. monthStartDate null/blank 체크 추가
  2. 삭제 건수 반환 및 로깅

운영 관점에서 월간 배치는 데이터량이 더 많으므로, 삭제 건수 모니터링이 더욱 중요하다.

♻️ 방어 코드 및 로깅 개선 제안
 `@Override`
 public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+    if (monthStartDate == null || monthStartDate.isBlank()) {
+        throw new IllegalStateException("monthStartDate job parameter is required");
+    }
     LocalDate startDate = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE);
-    repository.deleteByMonthStartDate(startDate);
-    log.info("[MonthlyCleanup] deleted monthStartDate={}", startDate);
+    int deletedCount = repository.deleteByMonthStartDate(startDate);
+    log.info("[MonthlyCleanup] deleted monthStartDate={}, count={}", startDate, deletedCount);
     return RepeatStatus.FINISHED;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingCleanupTasklet.java`
around lines 31 - 37, MonthlyRankingCleanupTasklet.execute currently parses
monthStartDate unguarded and calls repository.deleteByMonthStartDate without
checking input or logging the delete count; add the same defensive checks and
logging as WeeklyRankingCleanupTasklet: validate monthStartDate for null/blank
before parsing in execute, parse only when valid, call the repository delete
method that returns the number of rows deleted (or modify
repository.deleteByMonthStartDate to return int/long if needed), and log the
deleted count with context (e.g., "deletedCount" and "monthStartDate") and
return RepeatStatus.FINISHED. Ensure references:
MonthlyRankingCleanupTasklet.execute, monthStartDate,
repository.deleteByMonthStartDate.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java (1)

31-37: 삭제 건수 로깅 미포함으로 운영 모니터링이 어렵다.

deleteByWeekStartDate의 반환 타입이 void이므로 삭제된 레코드 수를 알 수 없다. 배치 운영 시 멱등성 검증, 이상 탐지(예: 예상보다 많거나 적은 삭제)에 삭제 건수가 필요하다.

Repository의 @Modifying 메서드가 int를 반환하도록 수정하거나, 삭제 전 카운트 쿼리를 추가하는 방안을 고려하라.

추가로, weekStartDatenull이거나 파싱 실패 시 명확한 에러 메시지와 함께 실패하도록 방어 코드를 추가하는 것이 좋다.

♻️ 방어 코드 및 로깅 개선 제안
 `@Override`
 public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+    if (weekStartDate == null || weekStartDate.isBlank()) {
+        throw new IllegalStateException("weekStartDate job parameter is required");
+    }
     LocalDate startDate = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
-    repository.deleteByWeekStartDate(startDate);
-    log.info("[WeeklyCleanup] deleted weekStartDate={}", startDate);
+    int deletedCount = repository.deleteByWeekStartDate(startDate);
+    log.info("[WeeklyCleanup] deleted weekStartDate={}, count={}", startDate, deletedCount);
     return RepeatStatus.FINISHED;
 }

Repository 메서드도 반환 타입 변경 필요:

`@Modifying`
`@Query`("DELETE FROM MvProductRankWeekly m WHERE m.weekStartDate = :weekStartDate")
int deleteByWeekStartDate(`@Param`("weekStartDate") LocalDate weekStartDate);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingCleanupTasklet.java`
around lines 31 - 37, The execute method in WeeklyRankingCleanupTasklet
currently calls repository.deleteByWeekStartDate(weekStartDate) which returns
void so you can't log deleted row count or detect anomalies; change the
repository method signature (deleteByWeekStartDate) to return int (annotated
`@Modifying`) or, if you cannot change the repo, call a count query first, then
perform the delete, capture the returned int/count and log it via log.info
including weekStartDate and deletedCount; additionally add defensive checks in
WeeklyRankingCleanupTasklet.execute to validate weekStartDate is non-null and
that LocalDate.parse(...) is surrounded by try/catch to log and rethrow a clear
error on parse failure.
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java (1)

7-17: serialVersionUID 미정의로 직렬화 호환성 위험이 있다.

Serializable 구현체에서 serialVersionUID를 명시하지 않으면, JVM 버전 또는 클래스 구조 변경 시 역직렬화 실패(InvalidClassException)가 발생할 수 있다. 운영 환경에서 배치 재시작이나 세션 복원 시 문제가 될 수 있다.

♻️ serialVersionUID 추가 제안
 public class ProductMetricsDailyId implements Serializable {
+
+    private static final long serialVersionUID = 1L;
 
     private Long productId;
     private LocalDate metricDate;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java`
around lines 7 - 17, The class ProductMetricsDailyId implements Serializable but
lacks a serialVersionUID; add a private static final long serialVersionUID field
(e.g. 1L) to ProductMetricsDailyId to ensure stable serialization across
JVM/class changes. Place the serialVersionUID declaration near the top of the
class (above the instance fields) so the JVM uses it during serialization for
the ProductMetricsDailyId type.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java (1)

14-16: 삭제 건수 반환을 위해 int 반환 타입을 권장한다.

void 반환 시 Tasklet에서 삭제량 모니터링이 불가하다. Spring Data JPA의 @Modifying 쿼리는 int 또는 Integer 반환을 지원한다.

♻️ 반환 타입 변경 제안
 `@Modifying`
 `@Query`("DELETE FROM MvProductRankMonthly m WHERE m.monthStartDate = :monthStartDate")
-void deleteByMonthStartDate(`@Param`("monthStartDate") LocalDate monthStartDate);
+int deleteByMonthStartDate(`@Param`("monthStartDate") LocalDate monthStartDate);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java`
around lines 14 - 16, Change the repository method deleteByMonthStartDate in
MvProductRankMonthlyJpaRepository to return the number of rows deleted instead
of void: update the signature of deleteByMonthStartDate(`@Param`("monthStartDate")
LocalDate monthStartDate) from void to int (or Integer) so the `@Modifying` `@Query`
can return the delete count that the Tasklet can monitor; keep the existing
`@Modifying` and `@Query` annotations and adjust any callers (e.g., the Tasklet) to
use the returned int result.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java (2)

53-65: @BeforeAll에서 테이블 생성만 하고 스키마 변경 시 DROP이 없어 테스트 실패 가능성이 있다.

CREATE TABLE IF NOT EXISTS는 테이블 존재 시 스키마 변경을 반영하지 않는다. 컬럼 추가/변경 시 테스트 환경에서 불일치가 발생할 수 있으므로 DROP TABLE IF EXISTS 후 생성하거나, 별도 마이그레이션 스크립트를 사용하는 것을 권장한다.

♻️ 개선안
 `@BeforeAll`
 static void createTable(`@Autowired` JdbcTemplate jdbcTemplate) {
+    jdbcTemplate.execute("DROP TABLE IF EXISTS product_metrics_daily");
     jdbcTemplate.execute("""
         CREATE TABLE IF NOT EXISTS product_metrics_daily (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java`
around lines 53 - 65, The test setup in MonthlyRankingJobE2ETest's `@BeforeAll`
createTable method uses CREATE TABLE IF NOT EXISTS which won't update schema
changes and can cause failures; modify createTable to first execute a DROP TABLE
IF EXISTS product_metrics_daily using the provided JdbcTemplate, then recreate
the table (or alternatively invoke your migration runner) so the test starts
with a deterministic schema; update the method that references JdbcTemplate and
ensure the SQL sequence (DROP then CREATE) runs before other tests.

73-114: 경계값 테스트 케이스 추가를 권장한다.

현재 테스트는 정상 집계와 멱등성만 검증한다. 운영 안정성을 위해 다음 케이스 추가를 권장한다:

  • 해당 월에 데이터가 없는 경우 (빈 결과 반환)
  • 단일 상품만 있는 경우 (rank=1 검증)
  • 월 경계 날짜 (말일 데이터 포함 여부)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java`
around lines 73 - 114, Add extra boundary-focused test methods to
MonthlyRankingJobE2ETest to cover empty-month, single-product, and
month-boundary cases: create three new `@Test` methods that reuse
insertDailyMetrics, jobLauncherTestUtils.launchJob(...) and
mvRepository.findAll() to assert (1) when no daily metrics exist for the given
month the job completes and mvRepository.findAll() is empty, (2) when only one
product has metrics the result size is 1 and that product has rankPosition == 1
and correct totalScore, and (3) when metrics exist on the month’s last day (and
the day before) they are included/excluded according to the monthStartDate
parameter (use monthStart/date arithmetic and BASIC_ISO_DATE job parameter as in
the existing monthlyRankingJob_aggregatesDailyMetrics test). Ensure each test
sets a unique run.id and asserts jobExecution.getExitStatus() ==
ExitStatus.COMPLETED.
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java (2)

45-59: LocalDateTime.now() 직접 호출은 테스트 격리를 어렵게 만든다.

aggregatedAt 필드에 LocalDateTime.now()를 직접 사용하면 단위 테스트에서 시간 검증이 불가능하다. 배치 실행 시점의 타임스탬프를 외부에서 주입받거나, Clock을 사용하는 방식을 권장한다. 운영 환경에서 배치 재실행 시 aggregatedAt 값이 달라져 데이터 분석 시 혼란을 줄 수 있다.

♻️ 개선안: aggregatedAt을 파라미터로 받도록 수정
 public static MvProductRankMonthly of(
     Long productId, LocalDate monthStartDate, double totalScore,
-    long viewCount, long likeCount, long orderCount, int rankPosition
+    long viewCount, long likeCount, long orderCount, int rankPosition,
+    LocalDateTime aggregatedAt
 ) {
     MvProductRankMonthly entity = new MvProductRankMonthly();
     entity.productId = productId;
     entity.monthStartDate = monthStartDate;
     entity.viewCount = viewCount;
     entity.likeCount = likeCount;
     entity.orderCount = orderCount;
     entity.totalScore = totalScore;
     entity.rankPosition = rankPosition;
-    entity.aggregatedAt = LocalDateTime.now();
+    entity.aggregatedAt = aggregatedAt;
     return entity;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java`
around lines 45 - 59, The factory method MvProductRankMonthly.of currently calls
LocalDateTime.now() directly for the aggregatedAt field which breaks
testability; change the method signature of MvProductRankMonthly.of to accept an
aggregatedAt (LocalDateTime) parameter (or alternatively a Clock) and assign
that value to entity.aggregatedAt instead of calling LocalDateTime.now(),
updating all call sites to pass the desired timestamp (or a Clock-derived time)
so tests can inject fixed times; keep references to the MvProductRankMonthly
class and the of(...) factory method when making the changes.

12-69: equals/hashCode 미구현 시 컬렉션 내 동작이 불안정할 수 있다.

@IdClass 사용 시에도 엔티티 자체에 equals/hashCode가 없으면 Set, Map 등에서 예기치 않은 동작이 발생할 수 있다. 복합키 기반으로 구현하거나, productId + monthStartDate 조합으로 식별자 기반 equals/hashCode를 추가하는 것을 권장한다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java`
around lines 12 - 69, The entity MvProductRankMonthly is missing equals and
hashCode which can break collection behavior; implement both on the
MvProductRankMonthly class using the composite identifier fields productId and
monthStartDate (the same logical key as MvProductRankMonthlyId) so equality/hash
are consistent with `@IdClass`; ensure equals checks class/type and compares
productId and monthStartDate, and hashCode is derived from those two fields.
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java (1)

56-64: eventType 문자열 직접 비교는 타입 안전성이 낮고, 예외 메시지에 입력값 노출은 로그 오염 위험이 있다.

record() 메서드에서 문자열을 직접 비교하면 오타나 대소문자 불일치 시 런타임 오류가 발생한다. 또한 IllegalArgumentException 메시지에 eventType을 그대로 포함하면 악의적 입력이 로그에 기록될 수 있다.

♻️ 개선안: Enum 활용 및 예외 메시지 정제
+import com.loopers.domain.event.CatalogEventType; // 또는 적절한 enum 위치
+
 public void record(String eventType) {
-    switch (eventType) {
-        case "VIEWED" -> this.viewCount++;
-        case "LIKED" -> this.likeCount++;
-        case "UNLIKED" -> { if (this.likeCount > 0) this.likeCount--; }
-        case "ORDERED" -> this.orderCount++;
-        default -> throw new IllegalArgumentException("알 수 없는 이벤트 타입: " + eventType);
+    CatalogEventType type;
+    try {
+        type = CatalogEventType.valueOf(eventType);
+    } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException("지원하지 않는 이벤트 타입");
+    }
+    switch (type) {
+        case VIEWED -> this.viewCount++;
+        case LIKED -> this.likeCount++;
+        case UNLIKED -> { if (this.likeCount > 0) this.likeCount--; }
+        case ORDERED -> this.orderCount++;
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java`
around lines 56 - 64, The record method in ProductMetricsDaily currently
compares raw eventType strings which is error-prone and exposes input in
exceptions; define an enum EventType { VIEWED, LIKED, UNLIKED, ORDERED } and
change ProductMetricsDaily.record to either accept EventType or safely map the
incoming String to EventType using EventType.valueOf(...) inside a try/catch
that handles IllegalArgumentException and null by throwing a new
IllegalArgumentException("Unsupported event type") (no raw input echoed); keep
the existing increment/decrement logic (e.g., this.likeCount = Math.max(0,
this.likeCount - 1) for UNLIKED) and update any call sites to pass EventType or
let the method perform the safe mapping.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

118-142: enrichRankEntries에서 삭제된 상품을 건너뛰면 반환 결과 수가 요청 size보다 적을 수 있다.

product == nullcontinue로 건너뛰면 요청한 페이지 크기보다 적은 결과가 반환된다. 클라이언트 관점에서 예상 결과 수와 실제 반환 수 불일치로 인한 혼란 가능성이 있다. MV 테이블 집계 시점에 존재했던 상품이 이후 삭제된 경우 발생할 수 있으며, 운영 환경에서 이 케이스에 대한 모니터링 또는 문서화를 권장한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 118 - 142, The current enrichRankEntries method silently skips
entries when product == null, causing the returned list size to be smaller than
the requested page (ProductRankEntry -> RankingInfo mismatch). Change the loop
in enrichRankEntries so that instead of continue it creates a placeholder
RankingInfo for missing products (use entry.rankPosition(), entry.productId(),
null for name/price/brand, entry.totalScore()) to preserve the original entries
count; also emit a metric/log when a missing product is encountered (e.g.,
processLogger or a counter) so deletions can be monitored. Keep references:
enrichRankEntries, ProductRankEntry, RankingInfo, fetchProductMap,
fetchBrandMap.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (2)

89-93: monthStartDate 파싱 실패 시 예외 처리 부재

WeeklyRankingJobConfig와 동일한 문제다. LocalDate.parse가 형식 불일치 시 DateTimeParseException을 발생시키며, 운영 로그에서 원인 파악이 어렵다. null 체크와 명확한 예외 메시지를 추가해야 한다.

🔧 명확한 예외 메시지 추가
     `@StepScope`
     `@Bean`
     public JdbcCursorItemReader<ProductScoreRow> monthlyReader(
         `@Value`("#{jobParameters['monthStartDate']}") String monthStartDate
     ) {
+        if (monthStartDate == null || monthStartDate.isBlank()) {
+            throw new IllegalArgumentException("monthStartDate 파라미터가 필요합니다");
+        }
+        LocalDate start;
+        try {
+            start = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE);
+        } catch (DateTimeParseException e) {
+            throw new IllegalArgumentException(
+                "monthStartDate 형식 오류 (expected: yyyyMMdd, actual: %s)".formatted(monthStartDate), e);
+        }
-        LocalDate start = LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE);
         LocalDate end = YearMonth.from(start).atEndOfMonth();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java`
around lines 89 - 93, monthlyReader currently calls
LocalDate.parse(monthStartDate, DateTimeFormatter.BASIC_ISO_DATE) without null
or format error handling; add a null/blank check for the monthStartDate
parameter and wrap the parse in a try/catch for DateTimeParseException, throwing
a clear IllegalArgumentException (or JobParametersInvalidException if used
elsewhere) that includes the invalid monthStartDate value and expected format;
modify the monthlyReader method to validate monthStartDate before parsing and to
surface a descriptive error message so logs show the bad input (mirror the
handling used in WeeklyRankingJobConfig).

35-128: WeeklyRankingJobConfig와 높은 코드 중복

두 Job 설정의 구조가 거의 동일하다(Job/Step 빌드, Reader/Writer 구성, Listener 연결). 날짜 범위 계산(plusDays(6) vs atEndOfMonth())과 엔티티 타입만 다르다. 공통 추상 클래스나 팩토리 패턴으로 중복을 줄일 수 있으나, 현재 규모에서는 유지보수 부담이 크지 않으므로 선택 사항이다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java`
around lines 35 - 128, MonthlyRankingJobConfig largely duplicates
WeeklyRankingJobConfig (job/step construction, listeners, reader/writer wiring)
— extract the shared logic into a common base or factory: move Job/Step creation
and listener attachment into an abstract superclass or helper (referencing
monthlyRankingJob, aggregateStep, cleanupStep, JOB_NAME, STEP_AGGREGATE,
STEP_CLEANUP) and parameterize the differences (reader logic: monthlyReader vs
weeklyReader date-range calculation and entity type for monthlyWriter).
Implement shared methods for creating the chunk step and JpaItemWriter, and
provide small overrides or lambdas for the date-range and entity-specific parts
to eliminate the repeated code.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java (2)

54-71: static tableCreated 플래그는 병렬 테스트 실행 시 경쟁 조건 발생 가능

JUnit 5의 병렬 테스트 실행이 활성화되면 여러 테스트 클래스가 동시에 createTable을 호출할 수 있다. tableCreated 플래그가 동기화 없이 공유되어 race condition이 발생할 수 있다. CREATE TABLE IF NOT EXISTS가 멱등하므로 실제 문제는 적지만, tableCreated 체크 자체가 의미 없는 코드가 된다.

🔧 플래그 제거 또는 동기화 추가
-    private static boolean tableCreated = false;
-
     `@BeforeAll`
     static void createTable(`@Autowired` JdbcTemplate jdbcTemplate) {
-        if (!tableCreated) {
-            jdbcTemplate.execute("""
-                CREATE TABLE IF NOT EXISTS product_metrics_daily (
-                    ...
-                ) ENGINE=InnoDB
-                """);
-            tableCreated = true;
-        }
+        jdbcTemplate.execute("""
+            CREATE TABLE IF NOT EXISTS product_metrics_daily (
+                product_id BIGINT NOT NULL,
+                metric_date DATE NOT NULL,
+                view_count BIGINT NOT NULL DEFAULT 0,
+                like_count BIGINT NOT NULL DEFAULT 0,
+                order_count BIGINT NOT NULL DEFAULT 0,
+                PRIMARY KEY (product_id, metric_date)
+            ) ENGINE=InnoDB
+            """);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 54 - 71, The static tableCreated flag introduces a race when JUnit
runs tests in parallel—remove the flag and the conditional in the
createTable(`@Autowired` JdbcTemplate jdbcTemplate) method and always run the
idempotent jdbcTemplate.execute(...) call to create product_metrics_daily (or
alternatively replace the flag with a thread-safe AtomicBoolean or synchronize
createTable if you prefer synchronization); update references to tableCreated
accordingly so createTable unconditionally executes the CREATE TABLE IF NOT
EXISTS statement.

79-124: 경계값 및 실패 케이스 테스트 누락

현재 테스트는 정상 케이스와 멱등성만 검증한다. 운영 안정성을 위해 다음 케이스 추가를 권장한다:

  1. 빈 데이터: 해당 주간에 product_metrics_daily 데이터가 없을 때 Job이 정상 완료되고 MV가 비어 있는지
  2. LIMIT 경계: 101개 이상의 상품이 있을 때 정확히 100개만 적재되는지
  3. 동점 처리: total_score가 동일한 상품의 rank_position 결정 로직
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 79 - 124, Add three new tests in WeeklyRankingJobE2ETest: one
"emptyData" test that uses jobLauncherTestUtils.launchJob with a weekStart that
has no insertDailyMetrics calls and asserts job completes and
mvRepository.findAll() is empty; one "limitBoundary" test that inserts >100
distinct products via insertDailyMetrics (e.g., loop 1..101) for the same
weekStart, launches the job and asserts mvRepository.findAll().size() == 100 and
the top entries correspond to the highest-scoring 100 products; and one
"tieHandling" test that inserts multiple products with identical total scores
(use insertDailyMetrics to create equal view/like/order sums), runs the job and
asserts deterministic rank_position ordering (e.g., by productId or expected
tie-break rule) by examining mvRepository.findAll() and rank_position for those
products; use existing helpers (insertDailyMetrics, jobLauncherTestUtils,
mvRepository, MvProductRankWeekly) and the same JobParametersBuilder pattern to
run the job and verify assertions.
tests/k6/seed.sh (3)

36-43: 변수 인용(quoting) 누락으로 잠재적 오류 발생 가능

${TOP_N}, ${i} 등 변수가 따옴표 없이 사용되어 word splitting이나 globbing 문제가 발생할 수 있다. 특히 루프 범위 지정 시 $(seq 1 ${TOP_N})에서 TOP_N이 공백이나 특수문자를 포함할 경우 예기치 않은 동작이 발생한다.

🔧 수정 제안
-for i in $(seq 1 ${TOP_N}); do
+for i in $(seq 1 "${TOP_N}"); do
   BRAND_ID=$(( (i - 1) % 5 + 1 ))
   PRICE=$(( 10000 + (i * 2971) % 290000 ))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/k6/seed.sh` around lines 36 - 43, The shell loop and SQL build use
unquoted variables which can cause word-splitting/globbing bugs; update usages
of TOP_N and i in the loop header and any expansions (e.g., $(seq 1 ${TOP_N}),
${i}, ${PRICE}, ${BRAND_ID}, ${SQL}, ${MYSQL}) to use quoted expansions where
appropriate (e.g., "${TOP_N}", "${i}") and ensure SQL concatenation safely
handles values (SQL variable) to avoid accidental splitting; locate the for loop
and the SQL/MYSQL invocations in tests/k6/seed.sh (symbols: TOP_N, i, PRICE,
BRAND_ID, SQL, MYSQL, seq) and add proper quoting around variable expansions and
the final ${MYSQL} call.

43-43: MySQL 에러 출력 억제로 인한 디버깅 어려움

2>/dev/null로 stderr를 완전히 무시하면 INSERT 실패, 연결 오류, 권한 문제 등이 발생해도 알 수 없다. set -e가 있어 비정상 종료는 되지만, 원인 파악이 어렵다. 운영 환경에서 시드 실패 시 빠른 원인 분석이 불가능하다.

🔧 수정 제안: 에러 로그 파일로 리다이렉트
-${MYSQL} "${SQL}" 2>/dev/null
+${MYSQL} "${SQL}" 2>> /tmp/seed-mysql-errors.log || { cat /tmp/seed-mysql-errors.log; exit 1; }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/k6/seed.sh` at line 43, The current command `${MYSQL} "${SQL}"
2>/dev/null` silences all MySQL stderr which prevents diagnosing failures;
change it so stderr is preserved or persisted (e.g., remove the `2>/dev/null`
redirection or redirect stderr to a log file and/or tee it to the console) so
that when `set -e` triggers on failure you can see the underlying MySQL error;
update the invocation that uses `${MYSQL}` and `"${SQL}"` to write stderr to a
persistent log (and optionally to stdout) instead of discarding it.

75-76: Redis ZADD 명령어 변수 미인용

${ZADD_CMD} 변수가 공백으로 구분된 여러 인자를 포함하므로 의도적으로 word splitting이 필요하지만, ShellCheck 경고 억제를 명시하거나 배열을 사용하는 것이 안전하다.

🔧 배열 사용 제안
-ZADD_CMD="ZADD ${ZSET_KEY}"
+ZADD_ARGS=("ZADD" "${ZSET_KEY}")
 for i in $(seq 1 ${TOP_N}); do
   SCORE=$(( (TOP_N - i + 1) * 10 ))
-  ZADD_CMD+=" ${SCORE} ${i}"
+  ZADD_ARGS+=("${SCORE}" "${i}")
 done
-${REDIS} ${ZADD_CMD} > /dev/null
+${REDIS} "${ZADD_ARGS[@]}" > /dev/null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/k6/seed.sh` around lines 75 - 76, The ZADD_CMD variable is unquoted and
contains multiple space-separated arguments causing ShellCheck warnings and
potential word-splitting bugs; update the script to define ZADD_CMD as an array
(e.g., ZADD_CMD=(...)) and invoke it with "${ZADD_CMD[@]}" in the call that
currently uses ${ZADD_CMD}, or if keeping a string form, explicitly suppress
ShellCheck SC2086 with a comment and document why word-splitting is intentional;
ensure calls using REDIS and ZADD_CMD use proper quoting for ZSET_KEY (e.g.,
"${ZSET_KEY}") and use the array expansion form when referencing ZADD_CMD.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (2)

42-43: CHUNK_SIZE=500이지만 SQL LIMIT 100으로 실질적으로 단일 청크

청크 크기가 500이지만 쿼리가 최대 100건만 반환하므로 청크 처리의 이점이 없다. 현재 동작에 문제는 없으나, 향후 LIMIT 증가 시 청크 크기와의 관계를 재검토해야 한다. 문서화 주석 추가를 권장한다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java`
around lines 42 - 43, CHUNK_SIZE is set to 500 but the SQL used elsewhere
returns at most 100 rows, so chunking is effectively single-chunk; update the
code by adding a clear inline comment next to the CHUNK_SIZE constant (reference
CHUNK_SIZE in WeeklyRankingJobConfig) explaining that the query currently uses
LIMIT 100 and therefore only one chunk will be processed, and note that if the
query LIMIT is increased in future the CHUNK_SIZE should be reviewed or kept in
sync (or adjust the query to fetch by CHUNK_SIZE) to preserve chunking behavior.

88-92: weekStartDate 파싱 실패 시 예외 처리 부재

LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE)는 형식이 맞지 않으면 DateTimeParseException을 발생시킨다. 현재 예외가 그대로 전파되어 Job 실패로 이어지지만, 운영 로그에서 원인 파악이 어려울 수 있다. 명확한 메시지와 함께 예외를 래핑하거나, Job 파라미터 검증 단계를 분리하는 것이 운영 관점에서 유리하다.

🔧 명확한 예외 메시지 추가
     `@StepScope`
     `@Bean`
     public JdbcCursorItemReader<ProductScoreRow> weeklyReader(
         `@Value`("#{jobParameters['weekStartDate']}") String weekStartDate
     ) {
+        if (weekStartDate == null || weekStartDate.isBlank()) {
+            throw new IllegalArgumentException("weekStartDate 파라미터가 필요합니다");
+        }
+        LocalDate start;
+        try {
+            start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
+        } catch (DateTimeParseException e) {
+            throw new IllegalArgumentException(
+                "weekStartDate 형식 오류 (expected: yyyyMMdd, actual: %s)".formatted(weekStartDate), e);
+        }
-        LocalDate start = LocalDate.parse(weekStartDate, DateTimeFormatter.BASIC_ISO_DATE);
         LocalDate end = start.plusDays(6);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java`
around lines 88 - 92, 주간 파라미터 파싱에서 발생하는 DateTimeParseException을 그대로 흘려보내지 말고
weeklyReader 메서드의 LocalDate.parse(weekStartDate,
DateTimeFormatter.BASIC_ISO_DATE) 호출을 try-catch로 감싸서 입력값(weekStartDate)과 기대
포맷(BASIC_ISO_DATE)을 포함한 명확한 메시지로 예외를 래핑(예: IllegalArgumentException 또는
JobParametersInvalidException)해 던지거나, 파라미터 검증 로직을 분리해 유효성 검사 실패 시 동일한 명확한 예외를
발생시키도록 변경하세요; 이때 예외 처리 대상은 함수명 weeklyReader와 LocalDate.parse 호출부를 수정하면 됩니다.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java (1)

54-85: daily period 테스트 누락

API가 period=daily/weekly/monthly를 지원하는데, daily 케이스 테스트가 없다. Redis ZSET 기반 일간 랭킹 조회가 MV 테이블 조회와 다른 경로를 타므로 별도 검증이 필요하다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java`
around lines 54 - 85, Add a new E2E test for the daily period in
RankingV1ApiE2ETest: create a Nested class (e.g., DailyRanking) with a test
method returnsDailyRankings that mirrors returnsWeeklyRankings but uses the
Redis ZSET path instead of insertWeeklyMv — create brand/products, populate the
daily ranking Redis ZSET via a new helper (e.g., insertDailyZset(date,
productId, score) or use the existing Redis client), call exchange(ENDPOINT +
"?period=daily&date=20260415&size=10&page=1") and assert response status and
ordered data (rank, productName, score) like the weekly test; add the
insertDailyZset helper alongside insertWeeklyMv so the test setup is clear and
reusable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d60af69f-b9fc-4450-b853-d7404d9357a4

📥 Commits

Reviewing files that changed from the base of the PR and between 31ef519 and 256b46b.

📒 Files selected for processing (54)
  • .gitignore
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankEntry.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/product/BrandRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.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/test/java/com/loopers/application/like/LikeServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/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/ProductScoreRow.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/MonthlyRankingCleanupTasklet.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/WeeklyRankingCleanupTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyId.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyId.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingScoreCalculator.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/ranking/RankingScoreCalculatorTest.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/application/RankingCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyJpaRepository.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/resources/application.yml
  • docker/kafka-cluster-compose.yml
  • modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java
  • modules/kafka/src/main/java/com/loopers/kafka/event/CatalogEvent.java
  • modules/kafka/src/main/resources/kafka.yml
  • tests/k6/get-rankings.js
  • tests/k6/profiles.json
  • tests/k6/run-all.sh
  • tests/k6/seed.sh

Comment on lines +36 to +45
private static final boolean BYPASS_OUTBOX =
Boolean.parseBoolean(System.getenv().getOrDefault("BYPASS_OUTBOX", "false"));

private static final boolean CRASH_BEFORE_SEND =
Boolean.parseBoolean(System.getenv().getOrDefault("CRASH_BEFORE_SEND", "false"));

private final CouponTemplateRepository couponTemplateRepository;
private final IssuedCouponRepository issuedCouponRepository;
private final OutboxEventRepository outboxEventRepository;
private final KafkaTemplate<Object, Object> kafkaTemplate;
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 | 🟠 Major

Outbox 우회 경로를 운영 서비스 빈에 직접 두면 배포 단위로 전달 보장이 달라진다.

환경변수 한 번으로 동일한 CouponService가 Outbox 보장 경로와 비보장 경로를 오가면, 롤링 배포 중 일부 인스턴스만 다른 전달 의미를 갖게 된다. 운영에서는 동일 요청이 Pod마다 다르게 처리되어 유실 분석과 장애 재현이 어려워진다. 실험 경로는 @Profile 기반 별도 빈이나 테스트 전용 구성으로 분리하고, 기본 서비스는 항상 Outbox만 타도록 고정하는 편이 안전하다. 추가로 기본 프로필에서는 Outbox 저장만 수행되는 통합 테스트와, 실험 프로필에서만 direct publish 구현이 로딩되는 구성 테스트를 넣어 두는 것이 좋다.

Also applies to: 74-94

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java`
around lines 36 - 45, The CouponService currently toggles outbox bypassing via
static env flags (BYPASS_OUTBOX, CRASH_BEFORE_SEND) causing mixed delivery
semantics across rolling deployments; instead remove these environment-driven
conditionals from CouponService and provide two distinct beans: a default
production CouponService (or implementation) that always writes to Outbox, and a
separate experimental/test implementation activated via `@Profile`("experiment")
or a dedicated test profile that performs direct publish/crash behavior;
relocate CRASH_BEFORE_SEND and bypass logic into that experimental bean/config
and add integration tests ensuring the default profile only saves Outbox events
and the experiment profile loads the direct-publish behavior.

Comment on lines +85 to +90
try {
kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS, String.valueOf(couponTemplateId), objectMapper.readTree(payload));
log.info("[Bypass] direct Kafka send eventId={}", eventId);
} catch (Exception e) {
log.error("[Bypass] Kafka send failed eventId={}", eventId, e);
}
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine CouponService.java
find . -type f -name "CouponService.java" | head -5

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

Length of output: 168


🏁 Script executed:

#!/bin/bash
# Read the file to see context around lines 85-90
if [ -f "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" ]; then
  wc -l "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java"
fi

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

Length of output: 169


🏁 Script executed:

#!/bin/bash
# Check the context around the problematic code
cat -n "apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java" | sed -n '70,105p'

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

Length of output: 1966


🏁 Script executed:

#!/bin/bash
# Search for kafkaTemplate usage patterns in the codebase
rg "kafkaTemplate\.send" --type java -B 2 -A 2

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

Length of output: 1568


🏁 Script executed:

#!/bin/bash
# Check if Future callbacks are used anywhere in the codebase
rg "addCallback|ListenableFuture|SendResult" --type java

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check Spring Kafka configuration for this project
find . -type f \( -name "*Kafka*.java" -o -name "*kafka*.properties" -o -name "*kafka*.yml" \) | grep -E "config|properties|yml"

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

Length of output: 164


🏁 Script executed:

#!/bin/bash
# Check OutboxPublisher for similar patterns
cat -n "apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxPublisher.java" | head -50

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

Length of output: 2164


🏁 Script executed:

#!/bin/bash
# Check Kafka configuration for error handling or default error handler
cat "modules/kafka/src/main/resources/kafka.yml"

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

Length of output: 1940


🏁 Script executed:

#!/bin/bash
# Search for any KafkaTemplate configuration or bean setup
rg "KafkaTemplate|kafkaTemplate" --type java -B 3 -A 3 | head -80

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

Length of output: 6512


🏁 Script executed:

#!/bin/bash
# Check if there are any error handlers or listeners registered
rg "@KafkaListener|KafkaListenerErrorHandler|ErrorHandler" --type java

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

Length of output: 598


kafkaTemplate.send() 반환값(Future)을 확인하지 않아 비동기 브로커 실패를 감지하지 못한다.

kafkaTemplate.send(...)는 비동기 호출이므로 현재 try/catch는 직렬화 오류 같은 동기 예외만 잡는다. 브로커 미가용, 토픽 미존재, 할당량 초과 같은 원격 실패는 반환된 Future 콜백에서만 드러나는데, 이 코드는 Future를 무시하고 바로 성공 로그를 남긴다. 그 결과 실제 브로커 ACK 상태와 무관하게 로그와 모니터링 지표가 낙관적으로 왜곡되어 장애를 감지하기 어려워진다.

반환된 Future에 success/failure 콜백을 등록하거나, future.get(timeout, TimeUnit.MILLISECONDS)로 일정 시간 내 ACK 확인 후에만 성공으로 기록하도록 변경하자. 추가로 브로커 미가용, 잘못된 토픽 같은 조건에서 실제로 실패 로그가 남고 성공 로그가 기록되지 않는 통합 테스트를 작성하자. OutboxPublisher도 동일한 패턴을 가지고 있으므로 함께 개선하자.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java`
around lines 85 - 90, The current kafkaTemplate.send call in CouponService (and
the similar pattern in OutboxPublisher) ignores the returned ListenableFuture,
so remote broker failures are missed; update the send usage to capture the
returned ListenableFuture from
kafkaTemplate.send(KafkaTopics.COUPON_ISSUE_REQUESTS,
String.valueOf(couponTemplateId), objectMapper.readTree(payload)) and either
attach success/failure callbacks (addCallback) to log success only on onSuccess
and log errors on onFailure, or call future.get(timeout, TimeUnit.MILLISECONDS)
to synchronously confirm ACK before emitting the success log; ensure the catch
blocks only handle synchronous exceptions (e.g., JSON serialization) while
remote failures are handled by the future callback or get() exception handling,
and add integration tests asserting that broker-unavailable / missing-topic
scenarios produce failure logs and no success log for both CouponService and
OutboxPublisher.

Comment on lines +143 to 158
// 랭킹은 보조 지표이므로 실패해도 주문을 롤백하지 않는다 (fail-open)
try {
for (Order.OrderLineEntity ol : order.getOrderLines()) {
String eventId = UUID.randomUUID().toString();
CatalogEvent event = CatalogEvent.ordered(
eventId, ol.getProductId(), memberId, Instant.now().toEpochMilli(),
ol.getUnitPrice(), ol.getQuantity()
);
outboxEventRepository.save(OutboxEvent.create(
eventId, KafkaTopics.CATALOG_EVENTS, String.valueOf(ol.getProductId()),
objectMapper.writeValueAsString(event)
));
}
} catch (Exception e) {
log.warn("[Order] ORDERED 이벤트 Outbox 저장 실패 orderId={}", order.getId(), e);
}
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 | 🟠 Major

Outbox 패턴의 핵심 보장과 상충하는 fail-open 전략이다.

운영 관점 문제:

  1. Outbox 패턴의 본질은 "저장됐으면 반드시 발행된다"인데, try/catch로 저장 실패를 무시하면 이벤트가 영구 유실된다. OutboxPublisher는 저장된 이벤트만 재발행하므로, 저장 자체가 실패하면 복구 경로가 없다.
  2. LikeService, ProductFacade는 동일한 outbox save를 try/catch 없이 수행한다. 서비스 간 일관성이 깨지면 장애 분석 시 혼란을 야기한다.

수정안:

  • (A) fail-open 유지 시: 별도 DLQ 테이블이나 로컬 파일에 실패 이벤트를 기록하여 수동 복구 경로를 확보하라.
  • (B) 일관성 우선 시: try/catch를 제거하고 다른 서비스와 동일하게 처리하라. 랭킹 정합성이 중요하다면 주문 롤백이 더 안전하다.

추가 테스트:

  • outboxEventRepository.save() 예외 발생 시 주문이 정상 커밋되는지, 로그에 orderId가 남는지 검증하는 테스트 케이스가 필요하다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java`
around lines 143 - 158, The current fail-open try/catch around creating/saving
outbox events in OrderService (loop over Order.OrderLineEntity calling
outboxEventRepository.save and OutboxEvent.create) allows permanent event loss
which violates the outbox guarantee; either remove the try/catch to treat save
failures like other services (e.g., LikeService/ProductFacade) so the exception
bubbles and the order transaction rolls back, or keep fail-open but implement a
durable failure sink (DLQ table or local file) inside the catch that records the
event payload, eventId, topic (KafkaTopics.CATALOG_EVENTS) and orderId for
manual replay; also add a test that simulates outboxEventRepository.save
throwing and asserts the chosen behavior (rollback or DLQ entry and order
commit/logging).

Comment on lines +15 to +19
@Immutable
@Entity
@Table(name = "mv_product_rank_monthly")
@IdClass(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
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 | 🟡 Minor

JPA 엔티티에 기본 생성자가 누락되었다.

JPA 명세에 따르면 엔티티 클래스는 public 또는 protected 접근 수준의 기본 생성자가 필요하다. Hibernate가 일부 경우 동작할 수 있으나, 다른 JPA 구현체나 버전 업그레이드 시 InstantiationException이 발생할 수 있다.

🛡️ 기본 생성자 추가 제안
 `@Immutable`
 `@Entity`
 `@Table`(name = "mv_product_rank_monthly")
 `@IdClass`(MvProductRankMonthlyEntity.PK.class)
 public class MvProductRankMonthlyEntity {
+
+    protected MvProductRankMonthlyEntity() {}

     `@Id`
     `@Column`(name = "product_id")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Immutable
@Entity
@Table(name = "mv_product_rank_monthly")
@IdClass(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
`@Immutable`
`@Entity`
`@Table`(name = "mv_product_rank_monthly")
`@IdClass`(MvProductRankMonthlyEntity.PK.class)
public class MvProductRankMonthlyEntity {
protected MvProductRankMonthlyEntity() {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 15 - 19, Add a no-argument constructor with public or protected
visibility to the JPA entity class MvProductRankMonthlyEntity so Hibernate/JPA
can instantiate it; locate the class declaration for MvProductRankMonthlyEntity
(which uses `@Entity` and IdClass(MvProductRankMonthlyEntity.PK.class)) and add a
protected (or public) default constructor alongside existing constructors to
satisfy the JPA spec and avoid InstantiationException in other JPA
implementations.

Comment on lines +208 to +214
@Override
public List<Product> findAllByIds(List<Long> ids) {
return ids.stream()
.map(store::get)
.filter(java.util.Objects::nonNull)
.toList();
}
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 | 🟠 Major

FakeProductRepository의 batch 조회 계약이 실제 저장소와 다르다.

findAllByIds()store의 key로 조회하지만, 같은 클래스의 save()는 상품 ID가 아니라 nextId를 key로 넣고 있다. 상품 ID와 삽입 순서가 어긋나는 순간 잘못된 상품 목록이 반환되므로, 이번 PR의 batch 조회 경로가 깨져도 테스트가 놓치거나 거짓 실패를 낼 수 있다. 테스트 더블을 실제 계약에 맞추어 product.getId() 기준으로 저장/조회하도록 맞추는 편이 안전하다. 추가로 10L, 30L처럼 비연속 ID 상품을 저장한 뒤 findAllByIds(List.of(30L, 10L))가 둘 다 정확히 반환하는 케이스를 넣어 회귀를 막아야 한다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java`
around lines 208 - 214, FakeProductRepository's findAllByIds(List<Long>) uses
the map key (store::get) but its save(...) stores products under nextId rather
than product.getId(), causing mismatches; update the fake so save(...) uses
product.getId() as the key and make findAllByIds remain based on those IDs, and
add a test that saves products with non-sequential IDs (e.g., 10L and 30L) then
asserts findAllByIds(List.of(30L, 10L)) returns both products exactly to prevent
regression; reference FakeProductRepository, save, findAllByIds, store, nextId,
and product.getId() when making the changes.

Comment on lines +99 to 104
// [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현
if (SLOW_ACK) {
log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!");
try { Thread.sleep(10_000); } catch (InterruptedException ignored) {}
}
ack.acknowledge();
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 인터럽트 처리 누락 및 ack 호출 위치를 확인한다.
rg -n -C2 'InterruptedException|ack\.acknowledge\(\)' apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java

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

Length of output: 350


🏁 Script executed:

# SLOW_ACK 플래그가 어디에서 정의되고 사용되는지 확인
rg -n "SLOW_ACK" apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java

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

Length of output: 392


🏁 Script executed:

# CouponIssueConsumer의 전체 구조와 메서드 확인
head -110 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java | tail -40

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

Length of output: 2146


🏁 Script executed:

# 파일 전체 라인 수 확인
wc -l apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java

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

Length of output: 182


🏁 Script executed:

# 관련된 테스트 파일 검색
fd -e java -path "*test*" -o -path "*Test*" | grep -i "coupon" | head -20

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

Length of output: 253


🏁 Script executed:

# CouponIssueConsumer 관련 테스트 파일 검색
find . -name "*Test.java" -o -name "*Tests.java" | xargs grep -l "CouponIssueConsumer" 2>/dev/null | head -10

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

Length of output: 67


🏁 Script executed:

# 테스트 디렉토리 구조 확인
find . -path "*/test/java/*" -name "*Coupon*.java" | head -20

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

Length of output: 335


🏁 Script executed:

# CouponIssueConsumer 클래스 전체 라인 수와 주요 메서드 확인
head -20 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java

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

Length of output: 963


InterruptedException 무시로 인한 ack() 호출로 재전달 테스트 신뢰도 저하

SLOW_ACK 실험의 목적은 shutdown 시점에 kill을 유도하여 offset 미커밋 상태에서 재시작 후 메시지 재전달을 검증하는 것이다. 그러나 InterruptedException을 무시하고 계속 실행하면 kill 신호로 인한 인터럽트가 발생해도 ack.acknowledge()가 호출되어 offset이 커밋되므로, 재전달 실험이 정상 동작하지 않는다.

인터럽트 발생 시 다음과 같이 처리해야 한다:

  • 인터럽트 상태 복구 (Thread.currentThread().interrupt())
  • ack 호출 없이 반환하여 offset 미커밋 보장
  • 적절한 로깅으로 재전달 시나리오 추적 가능

추가로 모의 Acknowledgment를 사용한 단위 테스트를 작성하여 인터럽트 시 ack가 호출되지 않음을 검증해야 한다.

수정안
         if (SLOW_ACK) {
             log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!");
-            try { Thread.sleep(10_000); } catch (InterruptedException ignored) {}
+            try {
+                Thread.sleep(10_000);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                log.warn("[CouponIssue][SlowAck] interrupted before ack. skip ack for redelivery.");
+                return;
+            }
         }
         ack.acknowledge();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현
if (SLOW_ACK) {
log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!");
try { Thread.sleep(10_000); } catch (InterruptedException ignored) {}
}
ack.acknowledge();
// [실험 2] SLOW_ACK: ack 전 10초 대기 → 이 사이에 kill → 재시작 후 같은 메시지 재전달 재현
if (SLOW_ACK) {
log.info("[CouponIssue][SlowAck] sleeping 10s before ack — kill me now!");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[CouponIssue][SlowAck] interrupted before ack. skip ack for redelivery.");
return;
}
}
ack.acknowledge();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java`
around lines 99 - 104, In CouponIssueConsumer's SLOW_ACK block, don't ignore
InterruptedException; instead catch it, call Thread.currentThread().interrupt()
to restore the interrupt status, log that an interrupt occurred during the
sleep, and return from the method without calling ack.acknowledge() so the
offset remains uncommitted; update the catch around Thread.sleep(...)
accordingly (referencing SLOW_ACK, Thread.sleep, InterruptedException, and
ack.acknowledge()). Also add a unit test using a mocked Acknowledgment to
simulate an interrupt during the sleep and assert that ack.acknowledge() is not
invoked and the interrupt status is restored.

ports:
- "19092:19092"
environment:
- KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Bitnami Kafka 3.5.1 KRaft 환경에서 KAFKA_KRAFT_CLUSTER_ID가 기존 /bitnami/kafka 볼륨의 meta.properties cluster.id와 다를 때 브로커 기동이 실패하는지 공식 문서/이슈로 확인해 달라

💡 Result:

네, Bitnami Kafka 3.5.1 KRaft 환경에서 KAFKA_KRAFT_CLUSTER_ID가 기존 /bitnami/kafka 볼륨의 meta.properties cluster.id와 다르면 브로커 기동이 실패합니다. 이는 Apache Kafka KRaft의 표준 동작으로, Bitnami 이미지도 이를 따릅니다.

Citations:


🏁 Script executed:

# Verify the file exists and contains the referenced lines
git ls-files | grep -E 'docker/kafka-cluster-compose.yml'

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

Length of output: 117


하드코딩된 KRaft Cluster ID는 기존 볼륨 재사용 시 브로커 기동을 방해한다

9번 라인의 cluster ID가 기존 /bitnami/kafka 볼륨의 meta.properties에 기록된 ID와 다르면, 브로커가 기동 실패한다. 이는 Apache Kafka KRaft의 표준 동작으로, 35번, 61번 라인의 다른 kafka 서비스들도 동일한 문제를 갖고 있다. 이로 인해 개발/운영 환경에서 볼륨을 재사용할 때 불필요한 복구 시간이 발생한다.

수정안으로 cluster ID를 환경 변수로 분리하고, 최초 적용 시 볼륨 초기화 절차를 명시해야 한다.

수정 예시
 services:
   kafka-1:
     environment:
-      - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+      - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}
@@
   kafka-2:
     environment:
-      - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+      - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}
@@
   kafka-3:
     environment:
-      - KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
+      - KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}

수정 후 다음 3가지 시나리오를 검증해야 한다.

  1. 새 볼륨에서 up 후 3개 브로커의 healthcheck 통과 여부 확인
  2. 동일한 KAFKA_KRAFT_CLUSTER_ID로 재기동 시 정상적으로 재합류하는지 확인
  3. 기존 볼륨에 다른 cluster ID가 기록되어 있는 경우 기동 실패를 재현한 후, 문서화된 복구 절차(down -v 또는 볼륨 교체)로 정상 복구되는지 확인
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- KAFKA_KRAFT_CLUSTER_ID=abcdefghijklmnopqrstuv
- KAFKA_KRAFT_CLUSTER_ID=${KAFKA_KRAFT_CLUSTER_ID:?set_in_.env}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/kafka-cluster-compose.yml` at line 9, The hardcoded KRaft cluster ID
(KAFKA_KRAFT_CLUSTER_ID) in the compose file should be replaced with a
configurable environment variable and initialization guidance: remove the
literal "abcdefghijklmnopqrstuv" and use an env substitution (e.g.,
${KAFKA_KRAFT_CLUSTER_ID}) for each kafka service entry (the occurrences around
the current KAFKA_KRAFT_CLUSTER_ID and the other kafka services mentioned), add
a comment or note in the compose file describing that the cluster ID must match
any existing /bitnami/kafka/meta.properties or a fresh volume must be
initialized, and update the repo docs or compose comments to include the three
verification steps (initial bring-up healthchecks for 3 brokers, restart with
same KAFKA_KRAFT_CLUSTER_ID to ensure rejoin, and reproduce failure/describe
recovery via down -v or replacing volume). Ensure all kafka service definitions
that set KAFKA_KRAFT_CLUSTER_ID are changed consistently.

Comment on lines +53 to +60
spring.config.activate.on-profile: acks-one

spring:
kafka:
producer:
acks: 1
properties:
enable.idempotence: false
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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Spring Boot에서 spring.config.activate.on-profileacks-one & (local | test) 같은 profile expression을 공식적으로 지원하는지 확인해 달라.

💡 Result:

결론: Spring Boot의 spring.config.activate.on-profile는 profile expression(복합 조건) 문법을 “공식적으로” 지원하며, 예시로 든 acks-one & (local | test) 같은 AND/OR(및 필요 시 NOT) 표현을 사용할 수 있습니다. 근거 요약: 1) Spring Boot 문서에서 on-profile을 “profile expression(프로필 표현식)”이며 문서 활성 조건으로 사용한다고 명시합니다. 2) 해당 profile expression에서 지원하는 연산자(&, |, !)와 괄호 규칙(예: &와 |를 괄호 없이 혼합할 수 없음을 포함)은 Spring Framework의 profile expression 정의(javadoc)가 명확히 설명합니다. 따라서 spring.config.activate.on-profile: "acks-one & (local | test)" 형태는, 괄호를 사용해 &와 |의 우선순위/혼합 규칙을 만족하므로 문서 활성 조건으로 평가될 수 있습니다. 추가로 참고할 점: - spring.profiles.active/spring.profiles.include는 spring.config.activate.on-profile로 활성화되는 프로필-특정 문서 안에서는 사용할 수 없다는 제약이 문서에 따로 존재합니다(다만 이번 질문의 핵심인 on-profile expression 지원 여부와는 별개입니다).

Citations:


acks-one 프로필을 비운영 환경으로 제한하고, 금지 조합을 검증하는 테스트를 추가해야 한다.

현재 설정에서 acks-one 프로필이 제약 없이 활성화되면, cluster/qa/prd 프로필과 함께 동작할 경우 의도치 않게 Kafka 프로듀서의 내구성 설정이 acks: 1로 약화될 수 있다. 이는 메시지 손실 위험을 증가시켜 운영 환경의 데이터 신뢰성을 저하시킨다.

spring.config.activate.on-profile에 profile expression 문법(&, | 연산자)을 사용해 다음과 같이 수정한다:

수정안
-spring.config.activate.on-profile: acks-one
+spring.config.activate.on-profile: acks-one & (local | test)

이와 함께 CI 단계에서 금지된 조합(acks-one + cluster/qa/prd)을 검증하는 설정 테스트를 필수로 추가해야 한다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
spring.config.activate.on-profile: acks-one
spring:
kafka:
producer:
acks: 1
properties:
enable.idempotence: false
spring.config.activate.on-profile: acks-one & (local | test)
spring:
kafka:
producer:
acks: 1
properties:
enable.idempotence: false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/kafka/src/main/resources/kafka.yml` around lines 53 - 60, The current
kafka profile activation allows `acks-one` to run alongside production profiles,
weakening producer durability; update the `spring.config.activate.on-profile`
expression for the `acks-one` block so it only activates in non-production
contexts (use profile expression logic to exclude `cluster`, `qa`, and `prd`
when `acks-one` is applied) and add a CI configuration test that
programmatically loads `kafka.yml` (or uses Spring's Environment/Profile
resolution) to assert the forbidden combination (`acks-one` + any of `cluster`,
`qa`, `prd`) is not allowed, failing the build if detected; references: the
`spring.config.activate.on-profile` key and the `acks-one` profile name in
kafka.yml.

Comment thread tests/k6/run-all.sh
Comment on lines +47 to +57
WARMUP_END=$(( $(date +%s) + WARMUP_SEC ))
WARMUP_COUNT=0
while [ $(date +%s) -lt ${WARMUP_END} ]; do
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}"
WARMUP_COUNT=$(( WARMUP_COUNT + 4 ))
done
wait
echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)"
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 | 🟡 Minor

$(date +%s) 커맨드 치환에 따옴표가 필요하다.

Line 49의 $(date +%s)는 word splitting 방지를 위해 따옴표로 감싸야 한다. 또한 warmup 단계에서 background curl(&)이 무제한으로 생성되어 서버에 과부하를 줄 수 있다. rate limiting 추가를 권장한다.

🔧 수정안
-    while [ $(date +%s) -lt ${WARMUP_END} ]; do
+    while [ "$(date +%s)" -lt "${WARMUP_END}" ]; do
       curl -s -o /dev/null "${WARMUP_URL}" &
       curl -s -o /dev/null "${WARMUP_URL}" &
       curl -s -o /dev/null "${WARMUP_URL}" &
       curl -s -o /dev/null "${WARMUP_URL}"
       WARMUP_COUNT=$(( WARMUP_COUNT + 4 ))
+      sleep 0.1  # rate limiting
     done
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
WARMUP_END=$(( $(date +%s) + WARMUP_SEC ))
WARMUP_COUNT=0
while [ $(date +%s) -lt ${WARMUP_END} ]; do
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}"
WARMUP_COUNT=$(( WARMUP_COUNT + 4 ))
done
wait
echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)"
WARMUP_END=$(( $(date +%s) + WARMUP_SEC ))
WARMUP_COUNT=0
while [ "$(date +%s)" -lt "${WARMUP_END}" ]; do
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}" &
curl -s -o /dev/null "${WARMUP_URL}"
WARMUP_COUNT=$(( WARMUP_COUNT + 4 ))
sleep 0.1 # rate limiting
done
wait
echo ">> warmup 완료 (총 ${WARMUP_COUNT} 요청)"
🧰 Tools
🪛 Shellcheck (0.11.0)

[warning] 49-49: Quote this to prevent word splitting.

(SC2046)

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

In `@tests/k6/run-all.sh` around lines 47 - 57, The while-loop using unquoted
command substitution should wrap date calls in quotes (use "$(date +%s)") to
prevent word-splitting when comparing against WARMUP_END; update occurrences in
the condition and when computing WARMUP_END if any. Also prevent unbounded
background curls spawned from the loop: throttle concurrency by limiting the
number of background jobs (e.g., track a job counter and call wait when it
reaches a threshold) or insert a short sleep between iterations so WARMUP_COUNT
and WARMUP_URL usage doesn’t spawn infinite background processes; modify the
loop that references WARMUP_END, WARMUP_COUNT, and WARMUP_URL accordingly.

Comment thread tests/k6/seed.sh
Comment on lines +47 to +54
SQL="USE loopers; INSERT INTO mv_product_rank_weekly (week_start_date, product_id, rank_position, total_score, aggregated_at) VALUES "
for i in $(seq 1 ${TOP_N}); do
SCORE=$(( (TOP_N - i + 1) * 10 ))
SQL+="('${WEEK_START}', ${i}, ${i}, ${SCORE}.0, NOW())"
[ ${i} -lt ${TOP_N} ] && SQL+=", "
done
SQL+=";"
${MYSQL} "${SQL}" 2>/dev/null
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
# MV 테이블의 실제 DDL을 확인하여 NOT NULL 컬럼과 DEFAULT 값 존재 여부 점검
ast-grep --pattern 'CREATE TABLE IF NOT EXISTS mv_product_rank_weekly'
rg -n "view_count|like_count|order_count" --glob "*MvProductRank*.java" -A2 -B2

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

Length of output: 2617


🏁 Script executed:

#!/bin/bash
# DDL 및 마이그레이션 파일 검색
find . -type f \( -name "*.sql" -o -name "*schema*" -o -name "*migration*" \) | head -20

# mv_product_rank_weekly 테이블 생성 구문 검색
rg -n "mv_product_rank_weekly" -i -A 10 -B 2

# 스키마 디렉토리 확인
fd -t f -e sql | xargs grep -l "mv_product_rank_weekly" 2>/dev/null

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

Length of output: 7495


🏁 Script executed:

#!/bin/bash
# DDL/마이그레이션 파일 찾기
find . -type d -name "db" -o -name "migration" -o -name "migrations" -o -name "schema"

# SQL 파일 모두 찾기
find . -name "*.sql" -type f

# commerce-api, commerce-batch의 resources 디렉토리 확인
find apps -type d -name "resources"

# schema.sql, ddl.sql 같은 파일 확인
find . -name "*schema*.sql" -o -name "*ddl*.sql" -o -name "V*.sql"

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

Length of output: 202


🏁 Script executed:

#!/bin/bash
# resources 디렉토리 내 SQL 파일 확인
ls -la apps/commerce-api/src/main/resources/
ls -la apps/commerce-batch/src/main/resources/

# 프로퍼티 파일 확인
find apps -name "application*.yml" -o -name "application*.properties" | xargs grep -l "jpa\|hibernate\|ddl"

# 모든 SQL 관련 파일 찾기
find . -path "./.*" -prune -o -type f -name "*.sql" -print

# commerce-batch에서 엔티티 정의 전체 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java

# commerce-api에서 엔티티 정의 전체 확인  
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java

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

Length of output: 4435


🏁 Script executed:

#!/bin/bash
# application.yml 파일들 확인
cat apps/commerce-api/src/main/resources/application.yml
echo "---"
cat apps/commerce-batch/src/main/resources/application.yml

# 테이블이 실제로 어떻게 생성되는지 확인하기 위해 CREATE TABLE 구문 검색
rg -n "CREATE TABLE" --type sql -A 20

# H2 또는 다른 임베디드 DB 스키마 파일 확인
find . -name "*.sql" -o -name "schema*" -o -name "data*" 2>/dev/null | head -20

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

Length of output: 3499


🏁 Script executed:

#!/bin/bash
# jpa.yml 파일 확인
find . -name "jpa.yml" -type f | xargs cat

# 테스트에서 실제 사용하는 INSERT 구문 다시 확인
rg -n "INSERT INTO mv_product_rank_weekly" -A 3 -B 3

# 배치에서 엔티티 저장하는 로직 확인
rg -n "MvProductRankWeekly" --type java -A 5 -B 5 | grep -A 5 -B 5 "save\|insert"

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

Length of output: 3776


🏁 Script executed:

#!/bin/bash
# commerce-batch에서 ranking job 코드 검색
find apps/commerce-batch -name "*.java" -type f | xargs grep -l "MvProductRankWeekly"

# ranking 배치 job 파일들 확인
find apps/commerce-batch -path "*ranking*" -type f -name "*.java"

# MvProductRankWeekly.save() 또는 저장 로직 확인
rg -n "MvProductRankWeekly" apps/commerce-batch --type java -A 10 | grep -A 10 "save\|insert\|persist"

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

Length of output: 3896


🏁 Script executed:

#!/bin/bash
# WeeklyRankingProcessor에서 MvProductRankWeekly 객체 생성 방식 확인
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingProcessor.java

# MvProductRankWeeklyJpaRepository 확인
cat apps/commerce-batch/src/main/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java

# 배치 테스트에서 실제 데이터 검증 방식 확인
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java

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

Length of output: 8457


🏁 Script executed:

#!/bin/bash
# MvProductRankWeeklyJpaRepository 위치 찾기
find apps/commerce-batch -name "*MvProductRankWeekly*" -type f

# 실제 경로 확인
find apps/commerce-batch -path "*/ranking/*" -name "*.java" | grep -i repository

# 테이블이 실제로 어떤 필드를 가지고 생성되는지 확인
# Hibernate 로그나 generated DDL 확인
rg -n "DEFAULT" apps/commerce-batch --type java -B 5

# 혹은 MySQL에서 show create table 결과를 보자
# MvProductRankMonthly도 같은 구조인지 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java

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

Length of output: 5040


🏁 Script executed:

#!/bin/bash
# 실제 프로덕션에서 사용할 DDL이 있는지 확인
find . -path "*/flyway*" -o -path "*/db/migration*" -o -path "*/liquibase*" 2>/dev/null

# seed.sh 실행 결과나 에러 로그가 있는지 확인
find . -name "*.log" -o -name "*seed*" | grep -E "(log|result)" 2>/dev/null

# 최종 확인: 두 엔티티가 같은 테이블을 매핑하는지 명확히
rg "@Table\(name = \"mv_product_rank_weekly\"\)" -A 1

# 배치 job이 보내는 INSERT 시점에 테이블이 어떤 구조인지 추적
rg -n "INSERT INTO mv_product_rank" apps --type java -B 5 -A 5

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

Length of output: 3435


두 엔티티의 스키마 정의 불일치로 인한 테이블 생성 실패 위험

같은 테이블 mv_product_rank_weekly에 대해 상충하는 엔티티 정의가 존재한다:

  • commerce-batch/MvProductRankWeekly: 8개 필드 (view_count, like_count, order_count nullable=false)
  • commerce-api/MvProductRankWeeklyEntity: 5개 필드만 정의, view_count/like_count/order_count 제외 (@Immutable)

Hibernate의 ddl-auto=create (local/test 프로필)에서 어느 엔티티를 먼저 스캔하는지에 따라:

  • API 엔티티 우선 → 5개 컬럼만 생성 → 배치 Job의 INSERT 실패 (컬럼 누락)
  • 배치 엔티티 우선 → 8개 컬럼 생성 (DEFAULT값 없음) → seed.sh의 INSERT 실패 (필수 컬럼 미포함)

스키마 정의를 통일하고, 필수 컬럼에 대한 DEFAULT값을 명시하거나 INSERT 구문을 완성해야 한다. 또한 프로덕션(ddl-auto=none)에서 사용할 명시적 마이그레이션 스크립트가 두 엔티티 정의와 일치하는지 검증이 필수다.

🧰 Tools
🪛 Shellcheck (0.11.0)

[info] 48-48: Double quote to prevent globbing and word splitting.

(SC2086)


[info] 51-51: Double quote to prevent globbing and word splitting.

(SC2086)


[info] 51-51: Double quote to prevent globbing and word splitting.

(SC2086)

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

In `@tests/k6/seed.sh` around lines 47 - 54, The test seed INSERT in seed.sh risks
failing because two conflicting JPA entities define mv_product_rank_weekly
differently (commerce-batch/MvProductRankWeekly includes view_count, like_count,
order_count as non-nullable; commerce-api/MvProductRankWeeklyEntity defines only
five columns and is `@Immutable`), so fix by reconciling schemas and/or making the
seed INSERT match the stricter entity: either update
commerce-api/MvProductRankWeeklyEntity to include the missing non-nullable
fields (view_count, like_count, order_count) with appropriate defaults or change
commerce-batch/MvProductRankWeekly to allow NULL/defaults, and then update
seed.sh’s SQL string construction to insert values for view_count, like_count,
order_count (or ensure DB columns have DEFAULTs) so the INSERT into
mv_product_rank_weekly always supplies all required columns; also ensure
migration scripts used in prod reflect the unified schema.

@simoncho91
Copy link
Copy Markdown
Author

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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