Skip to content

[volume-10] Collect, Stack, Zip #233

Merged
minor7295 merged 2 commits intoLoopers-dev-lab:minor7295from
minor7295:base-pr-round10
Jan 4, 2026
Merged

[volume-10] Collect, Stack, Zip #233
minor7295 merged 2 commits intoLoopers-dev-lab:minor7295from
minor7295:base-pr-round10

Conversation

@minor7295
Copy link
Collaborator

@minor7295 minor7295 commented Jan 1, 2026

📌 Summary

Spring Batch를 활용하여 product_metrics 테이블 기반으로 주간/월간 랭킹 시스템을 구현했습니다. 대량 데이터 집계의 정확성과 안정성을 위해 2-Step 구조로 집계와 랭킹을 분리하고, Materialized View에 TOP 100 랭킹을 저장하여 조회 성능을 최적화했습니다.

주요 구현 내용:

  • Spring Batch Job 구현: product_metrics 테이블을 읽어 Chunk-Oriented Processing으로 대량 데이터 집계
  • 2-Step 구조: Step 1에서 점수 집계 → Step 2에서 TOP 100 선정 및 랭킹 부여
  • Materialized View 설계: 하나의 테이블(mv_product_rank)에 period_type으로 주간/월간 구분하여 TOP 100 저장
  • Ranking API 확장: 기존 API에 period 파라미터 추가하여 일간(Redis), 주간/월간(Materialized View) 랭킹 제공
  • commerce-batch 모듈 분리: 실행 주기, 트랜잭션 성격, 장애 대응 방식의 차이를 고려하여 API와 배치를 독립적인 애플리케이션으로 분리 (지속 실행 vs 단발성 실행, 짧은 트랜잭션 vs 긴 트랜잭션, 즉시 응답 vs 재시작 가능)
  • 비즈니스 로직 중심 테스트: 배치 전체 실행 대신 Reader/Processor/Writer의 핵심 로직만 단위 테스트로 검증

구현된 기능:

  • GET /api/v1/rankings?date=yyyyMMdd&period=WEEKLY&size=20&page=1: 주간/월간 랭킹 조회
  • Spring Batch Job 파라미터 기반 실행: periodType=WEEKLY targetDate=20241215

💬 Review Points

1. 2-Step 구조로 집계와 랭킹 분리: 전체 데이터 기반 정확한 TOP 100 선정

배경 및 설계 의도:
대량 데이터를 Chunk 단위로 처리할 때, 각 Chunk마다 TOP 100을 계산하면 전체 데이터를 기반으로 한 정확한 TOP 100을 선정할 수 없습니다. 예를 들어, 첫 번째 Chunk에서 점수가 높은 상품 100개를 선정했지만, 이후 Chunk에서 더 높은 점수를 가진 상품이 나타날 수 있어 결과가 부정확해집니다.

이 문제를 해결하기 위해 Step을 실패 격리와 재시작 단위로 사용하여 집계 계산과 랭킹 적재를 분리했습니다. 이렇게 분리하면:

  • 전체 데이터 기반 정확한 TOP 100 선정: Step 1에서 모든 데이터를 집계한 후, Step 2에서 전체 집계 데이터를 기반으로 랭킹 계산
  • 트랜잭션 경계 명확화: 각 Step이 독립적인 트랜잭션 경계를 가지므로, 집계 계산과 랭킹 적재의 트랜잭션 성격 차이를 명확히 구분
  • 재시작 가능성: Step 1이 완료되면 Step 2는 독립적으로 재시작 가능하여, 집계 계산은 성공했지만 랭킹 적재만 실패한 경우 Step 2만 재실행 가능
  • 의존성 분리: Step 1의 집계 결과를 임시 테이블에 저장하여 Step 2와의 의존성을 명확히 분리

구조:

Step 1: scoreAggregationStep
  ├─ Reader: product_metrics 테이블 페이징 조회 (Chunk 단위)
  ├─ Processor: Pass-through
  └─ Writer: product_id별 메트릭 집계 → tmp_product_rank_score 저장
      ↓ (임시 테이블을 통한 데이터 전달)
Step 2: rankingCalculationStep
  ├─ Reader: tmp_product_rank_score 전체 조회 (점수 내림차순)
  ├─ Processor: TOP 100 선정 및 랭킹 번호 부여
  └─ Writer: mv_product_rank 저장 (delete + insert)

관련 코드:

@Bean
public Job productRankAggregationJob(
    Step scoreAggregationStep,
    Step rankingCalculationStep
) {
    return new JobBuilder("productRankAggregationJob", jobRepository)
        .start(scoreAggregationStep)        // Step 1 먼저 실행
        .next(rankingCalculationStep)        // Step 1 완료 후 Step 2 실행
        .build();
}

고민한 점 및 의사결정:

  1. Step 분리 vs StepListener 사용

    • 고민: StepListener를 사용하여 하나의 Step 내에서 집계와 저장을 분리하는 방안도 고려했습니다.
    • 선택: Step을 분리하여 트랜잭션 경계를 명확히 하고, 재시작 가능성을 확보하는 방식을 선택했습니다.
    • 이유: 집계 계산(Step 1)과 랭킹 적재(Step 2)는 트랜잭션 성격과 자원 사용 특성이 다르다고 판단했습니다. 집계 계산은 재시작 가능성을 우선 고려하고, 랭킹 적재는 데이터 정합성과 원자성을 우선 고려합니다.
  2. 임시 테이블 도입

    • 고민: Step 간 데이터 전달을 위해 임시 테이블(tmp_product_rank_score)을 도입했습니다.
    • 선택: 임시 테이블을 사용하여 Step 1과 Step 2를 완전히 분리했습니다.
    • 이유:
      • Step 1과 Step 2가 독립적인 트랜잭션으로 처리되어 실패 격리가 명확함
      • Step 1이 완료되면 Step 2는 독립적으로 재시작 가능
      • 다음 배치 실행 시 자동으로 덮어쓰기되므로 별도 정리 로직 불필요
    • 트레이드오프: 중간 저장소 관리 오버헤드가 있지만, 재시작 가능성과 실패 격리 측면에서 이점이 더 큼
  3. 주간/월간 처리 방식

    • 고민: 주간 랭킹과 월간 랭킹을 별도 Step으로 분리하는 방안을 고려했습니다.
    • 선택: Job 파라미터(periodType)로 분기하여 별도 실행하는 방식을 선택했습니다.
    • 이유:
      • 주간 랭킹과 월간 랭킹은 서로 독립적인 결과 스냅샷이므로 별도 실행이 자연스러움
      • 실행 주기가 다르므로(주간은 매주, 월간은 매월) 별도 실행이 더 적합
      • 하나의 Job에서 주간과 월간을 모두 처리하면 불필요한 의존성과 복잡도가 증가
    • 트레이드오프: Step 단위 재시작은 불가능하지만, Job 단위 재시작으로 충분하며 구조가 단순함
  4. Chunk 단위 처리와 전체 데이터 집계

    • 고민: Step 1에서 Chunk 단위로 처리하면서도 전체 데이터를 기반으로 집계해야 합니다.
    • 선택: Chunk 단위로 집계하되, 같은 product_id가 여러 Chunk에 걸쳐 있을 경우 임시 테이블(tmp_product_rank_score)에 UPSERT 방식으로 누적했습니다.
    • 이유:
      • 메모리 효율성을 위해 Chunk 단위로 처리
      • 전체 데이터를 읽기 전에 집계를 완료할 수 없으므로, 임시 테이블에 누적 저장
      • Step 2에서 전체 집계 데이터를 읽어 TOP 100 선정
    • 구현:
      • Step 1의 Writer에서 Chunk 내 product_id별로 집계한 후, 기존 데이터를 일괄 조회(findAllByProductIdIn)하여 누적합니다.
      • 누적된 데이터를 productRankScoreRepository.saveAll()로 저장하며, Repository 구현체에서 entityManager.merge()를 사용하여 UPSERT 방식으로 저장합니다.
  5. Materialized View 저장 방식: delete+insert

    • 고민: Step 2에서 Materialized View에 저장할 때 upsert, delete+insert, staging 기반 교체 방식을 고려했습니다.
    • 선택: delete+insert 방식을 선택했습니다.
    • 이유:
      • 단순하고 의도가 명확함 (기존 데이터를 완전히 교체)
      • 랭킹을 기간 종료 시점의 스냅샷으로 다루는 설계 원칙과 일치
      • 과제 범위와 운영 복잡도를 고려했을 때 가장 적절
    • 구현:
      • Step 2 Writer에서 모든 Chunk를 메모리에 수집한 후, 각 Chunk마다 전체 데이터를 저장합니다.
      • saveRanks() 메서드에서 deleteByPeriod() 호출 후 entityManager.persist()로 저장합니다.
      • 각 Chunk마다 전체를 저장하지만, saveRanks()가 delete+insert를 수행하므로 중복 저장 문제가 없습니다.

2. Materialized View 설계: 하나의 테이블에 period_type으로 구분

배경 및 문제 상황:
요구사항에서는 mv_product_rank_weeklymv_product_rank_monthly를 별도 테이블로 설계하라고 했습니다. 하지만 실제 구현에서는 하나의 테이블(mv_product_rank)에 period_type 컬럼으로 주간/월간을 구분하는 방식으로 구현했습니다.

해결 방안:
논리적으로는 별도 테이블처럼 동작하지만, 물리적으로는 하나의 테이블에 period_type으로 구분하는 방식을 선택했습니다:

  • 테이블 구조: mv_product_rank 테이블에 period_type (WEEKLY/MONTHLY) 컬럼으로 구분
  • 인덱스 전략: (period_type, period_start_date, rank) 복합 인덱스로 기간별 랭킹 조회 최적화
  • 조회 로직: period_typeperiod_start_date로 필터링하여 조회

이 방식의 장점:

  • 테이블 관리가 단순함 (하나의 테이블만 관리)
  • 인덱스 전략이 명확함
  • 향후 일간 랭킹 추가 시에도 동일한 테이블 구조 활용 가능

관련 코드:

// apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
@Entity
@Table(
    name = "mv_product_rank",
    indexes = {
        @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
        @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
    }
)
public class ProductRank {
    @Enumerated(EnumType.STRING)
    @Column(name = "period_type", nullable = false, length = 20)
    private PeriodType periodType; // WEEKLY 또는 MONTHLY
    
    @Column(name = "period_start_date", nullable = false)
    private LocalDate periodStartDate;
    // ...
}

고민한 점:

  • 요구사항에서는 별도 테이블을 요구했지만, 하나의 테이블에 period_type으로 구분하는 방식이 더 유연하고 관리하기 쉽다고 판단했습니다. 논리적으로는 별도 테이블처럼 동작하므로 요구사항의 의도는 충족한다고 봅니다.
  • 향후 일간 랭킹을 추가할 때도 동일한 테이블 구조를 활용할 수 있어 확장성이 좋습니다.

3. 배치 모듈 분리: API와 배치를 독립적인 애플리케이션으로 분리

배경 및 문제 상황:
API 요청 처리와 배치 집계는 실행 주기, 트랜잭션 성격, 장애 대응 방식이 다릅니다. API는 실시간 요청 처리에 최적화되어 있고, 배치는 대량 데이터 처리에 최적화되어 있습니다. 하나의 모듈에 두 가지를 모두 포함하면 설정, Job/Step 구성, 테스트 전략이 섞여 관리 복잡도가 증가합니다.

분리의 핵심 이유:

  1. 실행 주기의 차이

    • API: 지속 실행 (Long-running) - HTTP 요청 대기 상태로 계속 실행
    • 배치: 단발성 실행 (Short-lived) - Job 실행 후 자동 종료
    # API: 서버 시작 후 계속 실행
    java -jar commerce-api.jar
    
    # 배치: Job 완료 후 자동 종료
    java -jar commerce-batch.jar \
      --spring.batch.job.names=productRankAggregationJob \
      periodType=WEEKLY targetDate=20241215
  2. 트랜잭션 성격의 차이

    • API: 짧은 트랜잭션 (수백 ms ~ 수초), 다중 요청 동시 처리
    • 배치: 긴 트랜잭션 (수분 ~ 수시간), Chunk 단위 순차 처리, 재시작 가능
  3. 장애 대응 방식의 차이

    • API: 즉시 응답 (Circuit Breaker, Retry, Fallback)
    • 배치: 재시작 가능 (Chunk 단위 재시작, Spring Batch 메타데이터로 재시작 지점 추적)
  4. 독립적 실행, 재실행, 관측

    • 독립 실행: 배치 실행 시 API 서버 불필요
    • 독립 재실행: 실패 시 마지막 완료된 Chunk부터 재시작, 멱등성 보장
    • 관측 가능: Spring Batch 메타데이터로 Job/Step/Chunk 단위 추적

해결 방안:
commerce-batch 모듈을 별도로 분리하여 독립적인 애플리케이션으로 구성했습니다:

  • 독립 실행: BatchApplication을 통해 배치만 독립적으로 실행 가능
  • 설정 분리: application.yml에서 배치 전용 설정 관리 (웹 서버 비활성화, Job 자동 실행 비활성화)
  • 의존성 최소화: Kafka, Feign Client, Resilience4j 등 불필요한 의존성 제거
  • 도메인 공유: com.loopers.domain 패키지의 도메인은 공유하되, Repository 구현은 모듈별로 분리
  • 테스트 전략 분리: 배치 테스트는 비즈니스 로직 중심의 단위 테스트로 구성

구조:

commerce-api/
  └─ API 요청 처리, 실시간 랭킹 조회 (Redis)
      ├─ 웹 서버 활성화 (Servlet)
      ├─ Feign Client, Resilience4j
      └─ HTTP 요청 기반 테스트
  
commerce-batch/
  └─ 배치 집계, Materialized View 적재
      ├─ BatchApplication (독립 실행, Job 완료 후 자동 종료)
      ├─ ProductRankJobConfig (Job/Step 구성)
      ├─ 웹 서버 비활성화 (web-application-type: none)
      ├─ Spring Batch 전용 의존성
      └─ 비즈니스 로직 중심 단위 테스트

관련 코드:

// apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java
@SpringBootApplication(scanBasePackages = "com.loopers")
@EnableJpaRepositories(basePackages = "com.loopers.infrastructure")
@EntityScan(basePackages = "com.loopers.domain")
public class BatchApplication {
    public static void main(String[] args) {
        // Job 완료 후 자동 종료
        System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args)));
    }
}

// apps/commerce-batch/src/main/resources/application.yml
spring:
  main:
    web-application-type: none # 배치 전용이므로  서버 불필요
  batch:
    jdbc:
      initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성
    job:
      enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화

분리의 효과:

  • 관리 복잡도 감소: 설정, Job/Step 구성, 테스트 전략 분리
  • 의존성 최소화: 배치 모듈에 불필요한 의존성 제거 (Kafka, Feign Client, Resilience4j)
  • 배포 전략 분리: API는 수평 확장, 배치는 수직 확장
  • 모니터링 분리: API는 Actuator, 배치는 Spring Batch 메타데이터
  • 장애 격리: 배치 작업 실패가 API 서비스에 영향 없음

고민한 점:

  • 모듈을 분리하면 코드 중복이 발생할 수 있지만, 각 모듈의 목적이 다르므로 분리하는 것이 더 명확하다고 판단했습니다.
  • 도메인은 공유하되, Repository 구현은 모듈별로 분리하여 각 모듈의 필요에 맞게 최적화했습니다. 예를 들어, 배치 모듈에서는 대량 조회에 최적화된 Repository를 구현했습니다.

4. 배치 테스트 전략: 비즈니스 로직 중심의 단위 테스트

배경 및 설계 의도:
멘토링 세션에서 배치 전체를 exec해서 잘 실행되는지를 확인하는 것보다 그 안에 있는 processor같은 의미있는 비즈니스 로직에 대한 테스트로 처리하는 게 낫다는 조언을 받았습니다. 따라서 배치 전체 실행 테스트 대신, 비즈니스 로직이 있는 컴포넌트에 대한 단위 테스트에 초점을 두었습니다:

  • Reader/Processor/Writer 단위 테스트: 각 컴포넌트의 비즈니스 로직(집계, 점수 계산, 랭킹 부여 등)을 Mock을 사용하여 격리된 환경에서 검증
  • 배치 전체 실행 테스트는 제외: 배치 전체를 실행하는 통합 테스트는 작성하지 않음
  • 핵심 로직 검증: 메트릭 집계, 점수 계산, TOP 100 필터링 등 핵심 로직만 독립적으로 검증

테스트 예시:

// apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java
@ExtendWith(MockitoExtension.class)
class ProductRankScoreAggregationWriterTest {
    @Mock
    private ProductRankScoreRepository productRankScoreRepository;
    
    @InjectMocks
    private ProductRankScoreAggregationWriter writer;
    
    @Test
    void aggregatesMetricsByProductId() throws Exception {
        // Chunk 내에서 같은 product_id를 가진 메트릭을 집계하는 로직 검증
        // ...
    }
    
    @Test
    void calculatesScoreWithCorrectWeights() throws Exception {
        // 점수 계산 로직 검증 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
        // ...
    }
}

고민한 점:

  • 각 컴포넌트의 핵심 로직을 독립적으로 검증하면, 변경 시 영향 범위를 명확히 파악할 수 있습니다.
  • 비즈니스 로직 중심의 단위 테스트로 구성하면 테스트가 단순해지고 실행 시간도 짧아집니다. 또한 각 컴포넌트의 책임이 명확해져 유지보수가 쉬워집니다.

✅ Checklist

Spring Batch

  • Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다

    • Job 파라미터: periodType(WEEKLY/MONTHLY), targetDate(yyyyMMdd)
    • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
  • Chunk Oriented Processing (Reader/Processor/Writer) 기반의 배치 처리를 구현했다

    • Chunk 크기: 100
    • Step 1: Reader(페이징 조회) → Processor(Pass-through) → Writer(집계 및 저장)
    • Step 2: Reader(전체 조회) → Processor(TOP 100 선정) → Writer(저장)
    • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
  • 집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다

    • 테이블: mv_product_rank (period_type으로 주간/월간 구분)
    • 저장 방식: delete + insert (TOP 100만 저장)
    • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java

Ranking API

  • API가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다
    • 일간: Redis ZSET에서 조회
    • 주간/월간: Materialized View에서 조회
    • GET /api/v1/rankings?date=yyyyMMdd&period=WEEKLY&size=20&page=1
    • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
    • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java

📎 References

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 상품 순위 조회에 기간 선택 옵션 추가 (일간/주간/월간)
    • 일간 순위는 Redis에서, 주간/월간 순위는 최적화된 데이터 저장소에서 제공
    • 페이지네이션을 지원하는 상품 순위 조회 기능 추가
  • 개선

    • 순위 조회 실패 시 자동으로 이전 데이터로 안정적 제공
    • 배치 처리 기반 상품 메트릭 수집 및 순위 계산 시스템 구축

✏️ Tip: You can customize this high-level summary in your review settings.

* feat: batch 처리 모듇 분리

* feat: batch 모듈에 ProductMetrics 도메인 추가

* feat: ProudctMetrics의 Repository 추가

* test: Product Metrics 배치 작업에 대한 테스트 코드 추가

* feat: ProductMetrics 배치 작업 구현

* test: Product Rank에 대한 테스트 코드 추가

* feat: Product Rank 도메인 구현

* feat: Product Rank Repository 추가

* test: Product Rank 배치에 대한 테스트 코드 추가

* feat: Product Rank 배치 작업 추가

* feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가

* refractor: 랭킹 집계 로직을 여러 step으로 분리함

* chore: db 초기화 로직에서 발생하는 오류 수정

* test: 랭킹 집계의 각 step에 대한 테스트 코드 추가
@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Walkthrough

새로운 배치 처리 모듈(commerce-batch)을 추가하고 상품 순위를 일일(Redis), 주간/월간(구체화된 뷰)으로 처리하도록 확장했습니다. RankingService에 다중 기간 쿼리 지원, ProductMetrics 및 ProductRank 엔티티, 배치 작업 및 관련 저장소를 도입했습니다.

Changes

코호트 / 파일 변경 요약
기존 의존성 제거
apps/commerce-api/build.gradle.kts, apps/commerce-api/src/main/resources/application.yml
Spring Boot Batch 의존성 및 설정 제거; 배치 메타데이터 초기화 비활성화
API 순위 기능 확장
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
PeriodType(DAILY/WEEKLY/MONTHLY) 열거형 추가; getRankings에 다중 기간 라우팅 로직 추가; 구체화된 뷰 지원 메서드 및 점수 계산 추가; 선택적 period 매개변수로 컨트롤러 업데이트
상품 순위 도메인 엔티티(API)
apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java, apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
구체화된 뷰(mv_product_rank)에 매핑된 ProductRank 엔티티 추가; 기간별 조회를 위한 인덱스 및 ProductRankRepository 인터페이스 추가
상품 순위 저장소 구현(API)
apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
JPA EntityManager를 사용하는 ProductRankRepository 구현 추가
배치 애플리케이션 진입점
apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java, apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/resources/application.yml
새로운 배치 모듈 생성; 의존성 추가(JPA, Redis, Jackson, Batch); 배치 설정 파일 추가
메트릭 집계 도메인
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
배치 집계용 ProductMetrics 엔티티 추가; 좋아요/판매/조회 수 증감 메서드 포함; 버전 관리 지원
메트릭 저장소 구현
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
JPA 저장소 및 구현 추가; 기간 범위 쿼리(findByUpdatedAtBetween) 포함
메트릭 배치 작업
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java
청크 기반 메트릭 읽기/처리/쓰기 컴포넌트 추가; targetDate 매개변수 기반 범위 쿼리 구성
순위 점수 계산 도메인
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
임시 점수 집계용 ProductRankScore 엔티티 추가(tmp_product_rank_score 테이블); 가중치 기반 점수 계산 지원
순위 계산 도메인
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
배치용 ProductRank 엔티티 추가; updateRank 메서드 포함; UPSERT 의미론의 저장소 인터페이스 추가
순위 집계 배치 작업
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
주간/월간 기간 관리 프로세서 추가; 범위별 메트릭 읽기 추가; 집계된 점수 저장소에 쓰기 추가
순위 계산 배치 작업
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
ProductRankScore를 ProductRank로 변환; TOP 100 선택; 구체화된 뷰에 순위 저장
저장소 구현(순위)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
ProductRank 및 ProductRankScore 저장소의 JPA 기반 구현 추가
테스트 커버리지
apps/commerce-batch/src/test/java/com/loopers/domain/metrics/*, apps/commerce-batch/src/test/java/com/loopers/domain/rank/*, apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/*/*
ProductMetrics 엔티티, ProductRank 엔티티, 배치 컴포넌트(Reader/Processor/Writer) 및 집계 로직에 대한 포괄적 단위 테스트 추가
인프라 유틸
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
TRUNCATE TABLE 전에 테이블 존재 여부 확인 추가; 테스트 안정성 개선
그래들 설정
settings.gradle.kts
모듈 목록에 :apps:commerce-batch 추가

Sequence Diagrams

sequenceDiagram
    participant Client
    participant RankingV1Controller
    participant RankingService
    participant Redis as Redis<br/>(DAILY)
    participant MV as Materialized View<br/>(WEEKLY/MONTHLY)
    participant ProductDB as Product<br/>Repository

    Client->>RankingV1Controller: getRankings(date, period, page, size)
    RankingV1Controller->>RankingV1Controller: parsePeriodType(period)
    RankingV1Controller->>RankingService: getRankings(date, PeriodType, page, size)

    alt period == DAILY
        RankingService->>Redis: Redis ZSET 조회
        Redis-->>RankingService: 순위 데이터
    else period == WEEKLY or MONTHLY
        RankingService->>MV: periodStartDate, PeriodType로 조회
        MV-->>RankingService: TOP 100 상품 순위
    end

    RankingService->>ProductDB: 상품/브랜드 정보 조회
    ProductDB-->>RankingService: 상품 데이터
    RankingService->>RankingService: 순위 항목 구성
    RankingService-->>RankingV1Controller: RankingsResponse (페이지네이션)
    RankingV1Controller-->>Client: ApiResponse<RankingsResponse>
Loading
sequenceDiagram
    participant Scheduler as Job Scheduler
    participant BatchApp as Batch<br/>Application
    participant MetricsJob as ProductMetrics<br/>Aggregation Job
    participant RankJob as ProductRank<br/>Aggregation Job
    participant MetricsDB as ProductMetrics<br/>Table
    participant ScoreTable as ProductRankScore<br/>(Temp)
    participant RankMV as ProductRank MV<br/>(mv_product_rank)

    Scheduler->>BatchApp: productMetricsAggregationJob(targetDate)
    BatchApp->>MetricsJob: Step 1: 메트릭 읽기/처리/쓰기
    MetricsJob->>MetricsDB: updatedAt 범위로 조회
    MetricsDB-->>MetricsJob: ProductMetrics 청크
    MetricsJob->>MetricsJob: pass-through (처리)
    MetricsJob->>MetricsJob: 로깅

    Scheduler->>BatchApp: productRankAggregationJob(periodType, targetDate)
    BatchApp->>RankJob: Step 1: 메트릭 -> 점수 집계
    RankJob->>MetricsDB: 범위별 메트릭 읽기
    MetricsDB-->>RankJob: ProductMetrics
    RankJob->>RankJob: 제품별 그룹화<br/>가중치 점수 계산<br/>(좋아요 0.3, 판매 0.5, 조회 0.2)
    RankJob->>ScoreTable: ProductRankScore 저장 (UPSERT)
    ScoreTable-->>RankJob: 저장 완료

    BatchApp->>RankJob: Step 2: 점수 -> 순위 변환
    RankJob->>ScoreTable: 점수 내림차순 조회
    ScoreTable-->>RankJob: ProductRankScore (모든 항목)
    RankJob->>RankJob: TOP 100 선택<br/>1-100 순위 할당<br/>(ThreadLocal 카운터)
    RankJob->>RankMV: ProductRank 저장<br/>(기간별 UPSERT)
    RankMV-->>RankJob: 저장 완료

    RankJob->>ScoreTable: 임시 테이블 정리
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • [volume - 9] What is Popularity? #210: 초기 Redis 기반 순위 지정 구현 위에 구체화된 뷰 및 배치 지원을 추가한 것으로, 동일 RankingService 및 관련 순위 도메인 클래스를 수정합니다.
  • [volume-9] Product Ranking with Redis  #217: 동일 순위 서브시스템을 수정하며, ProductRank 구체화된 뷰/엔티티/저장소 및 메트릭/순위 집계 배치 작업을 추가/확장합니다.
  • [volume-9] Product Ranking with Redis #216: commerce-api 순위 기능을 수정하며, RankingV1Controller 및 순위 서비스/집계/Redis 기반 검색 코드 경로를 건드립니다.

Suggested labels

enhancement

Poem

🐰 배치 모듈을 갖춰진 토끼,
주간과 월간 순위를 묶어,
구체화된 뷰에 점수 적재,
TOP 100이 반짝이며,
상품들이 춤춘다네! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.36% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive PR 제목 '[volume-10] Collect, Stack, Zip'은 추상적이고 구체적인 변경 사항을 명확히 전달하지 못합니다. 실제 변경은 Spring Batch 기반 주간/월간 랭킹 시스템 구현이지만, 제목에서는 이를 알 수 없습니다. 제목을 더 구체적으로 변경하세요. 예: '[Feature] Spring Batch를 활용한 주간/월간 랭킹 시스템 구현' 또는 '[Feature] Materialized View 기반 주간/월간 랭킹 집계'
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed 풀 리퀘스트 설명이 매우 상세하고 구조화되어 있으며, 요구된 섹션을 모두 포함하고 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings

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.

@minor7295
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 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.

@minor7295 minor7295 self-assigned this Jan 1, 2026
@minor7295 minor7295 changed the title Feature/batch (#40) [volume-10] Collect, Stack, Zip Jan 1, 2026
Copy link

@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: 13

🧹 Nitpick comments (24)
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java (1)

43-57: 모든 예외를 무시하는 것은 디버깅을 어렵게 만들 수 있습니다.

테이블 존재 여부를 먼저 확인하는 로직은 좋지만, 모든 예외를 로깅 없이 삼키는 것은 실제 문제(DB 연결 오류, 권한 문제 등)를 숨길 수 있습니다. 테스트 실패 원인 파악이 어려워질 수 있으니 최소한의 디버그 로깅을 고려해주세요.

또한, 테이블 존재 여부를 확인한 후에도 try-catch가 필요한 이유가 불분명합니다. 존재 확인이 성공하면 TRUNCATE도 성공할 가능성이 높으므로, 예외 발생 시 원인을 파악할 수 있도록 로깅이 더욱 중요합니다.

🔎 최소 로깅 추가 제안
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 @Component
 public class DatabaseCleanUp implements InitializingBean {
+    private static final Logger log = LoggerFactory.getLogger(DatabaseCleanUp.class);
 
     @PersistenceContext
     private EntityManager entityManager;
             } catch (Exception e) {
-                // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행
-                // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음)
+                // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행
+                log.debug("테이블 {} 정리 중 예외 발생 (무시됨): {}", table, e.getMessage());
             }
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (2)

64-71: 테스트 가능성을 위해 시간 주입 고려

생성자에서 LocalDateTime.now()를 직접 호출하면 테스트 작성이 어렵고 시스템 시간에 결합됩니다.

테스트 가능성과 도메인 순수성을 개선하려면 다음을 고려하세요:

  • 옵션 1: updatedAt을 생성자 파라미터로 받기
  • 옵션 2: Clock 객체를 주입받아 사용
🔎 시간 주입 예시
-public ProductMetrics(Long productId) {
+public ProductMetrics(Long productId, LocalDateTime now) {
     this.productId = productId;
     this.likeCount = 0L;
     this.salesCount = 0L;
     this.viewCount = 0L;
     this.version = 0L;
-    this.updatedAt = LocalDateTime.now();
+    this.updatedAt = now;
 }

76-113: 메서드 내 시간 생성 일관성

모든 변경 메서드에서 LocalDateTime.now()를 직접 호출하는 패턴이 반복됩니다. 생성자에서 언급한 것과 동일한 테스트 가능성 문제가 있습니다.

긍정적인 점:

  • decrementLikeCount()의 음수 방지 가드 로직이 적절합니다
  • incrementSalesCount()의 수량 검증이 올바릅니다
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java (1)

38-50: 중복 테스트 케이스

processesNonNullItem 테스트는 processesItem_andReturnsSameItem 테스트와 중복됩니다. 첫 번째 테스트가 이미 동일한 검증(동일 객체 반환, non-null)을 더 포괄적으로 수행하고 있습니다.

테스트 스위트를 간결하게 유지하기 위해 이 중복 테스트를 제거하는 것을 고려하세요.

apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (2)

13-15: 사용되지 않는 import 문이 있습니다.

LocalDateTimeLocalTime이 import되었지만 실제로 사용되지 않습니다.

🔎 수정 제안
 import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;

94-114: 테스트 검증이 불완전합니다.

parsesDateCorrectly_andSetsDateTimeRange 테스트에서 expectedStartexpectedEnd 변수를 선언했지만 실제 검증에 사용되지 않습니다. 주석에서 "간접적으로 검증"이라고 언급하고 있으나, Reader 내부 상태를 직접 검증하거나 Repository 호출 시 전달된 인자를 검증하는 것이 더 명확합니다.

현재 구현에서는 Repository 메서드 호출 시 ArgumentCaptor를 사용하거나, Reader의 내부 상태에 접근할 수 있다면 해당 값을 검증하는 것을 권장합니다. 만약 현재 구조에서 검증이 어렵다면, 사용되지 않는 변수는 제거하고 테스트 이름과 주석을 실제 검증 내용에 맞게 수정하는 것이 좋습니다.

apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java (1)

54-82: 시간 기반 테스트에서 Thread.sleep 사용

Thread.sleep(1)을 사용하여 타임스탬프 차이를 보장하고 있습니다. 현재 접근 방식은 동작하지만, 테스트가 간헐적으로 실패할 가능성이 있습니다.

더 결정적인 테스트를 위해 java.time.Clock을 주입하여 시간을 제어하는 방식을 고려해볼 수 있습니다. 다만, 현재 1ms sleep은 실용적으로 충분히 안정적입니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)

3-15: 사용되지 않는 import 문이 있습니다.

ProductMetrics, ItemProcessor, Comparator, List, Map, Collectors, IntStream 등의 import가 사용되지 않습니다. 리팩토링 후 남은 것으로 보입니다.

🔎 정리 제안
 package com.loopers.infrastructure.batch.rank;
 
-import com.loopers.domain.metrics.ProductMetrics;
 import com.loopers.domain.rank.ProductRank;
 import lombok.extern.slf4j.Slf4j;
-import org.springframework.batch.item.ItemProcessor;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDate;
 import java.time.temporal.TemporalAdjusters;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)

100-150: 날짜 범위 계산 로직 검증이 누락되었습니다.

weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeekmonthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth 테스트는 "모두 같은 주/월의 시작일부터 시작해야 함"이라고 주석으로 명시하지만, 이를 검증하는 assertion이 없습니다.

날짜 범위 계산 로직을 별도 헬퍼 메서드로 추출하면 단위 테스트가 용이해집니다:

// ProductRankAggregationReader에 추가
public LocalDate calculateWeekStart(LocalDate targetDate) {
    return targetDate.with(DayOfWeek.MONDAY);
}

public LocalDate calculateMonthStart(LocalDate targetDate) {
    return targetDate.with(TemporalAdjusters.firstDayOfMonth());
}

그런 다음 이 메서드들을 직접 테스트할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)

69-72: 불변 Map 사용을 고려해주세요.

단일 항목만 포함하는 정렬 기준에 HashMap을 사용하고 있습니다. Java 9+의 Map.of()를 사용하면 더 간결하고 불변성을 보장합니다.

🔎 수정 제안
-        // 정렬 기준 설정 (product_id 기준 오름차순)
-        Map<String, Sort.Direction> sorts = new HashMap<>();
-        sorts.put("productId", Sort.Direction.ASC);
+        // 정렬 기준 설정 (product_id 기준 오름차순)
+        Map<String, Sort.Direction> sorts = Map.of("productId", Sort.Direction.ASC);

이 경우 java.util.HashMap import도 제거할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java (2)

34-41: 인덱스 정의 방식에 대한 참고사항입니다.

@Indexunique = true@Columnunique = true가 모두 적용되어 있습니다 (Line 38과 Line 54). 두 설정 모두 유니크 제약조건을 생성하므로 중복 정의일 수 있습니다.

일관성을 위해 둘 중 하나만 유지하는 것을 고려해주세요. 일반적으로 @Column(unique = true)는 단일 컬럼 유니크 제약에, @Index(unique = true)는 복합 인덱스의 유니크 제약에 사용됩니다.


95-101: setMetrics() 메서드가 public으로 노출되어 있습니다.

Javadoc에서 "Repository에서만 사용하는 내부 메서드"라고 명시했지만 접근 제어자가 public입니다. 의도치 않은 외부 접근을 방지하려면 package-private 또는 protected로 변경하는 것을 고려해주세요.

🔎 수정 제안
-    public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) {
+    void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) {
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)

76-79: 매 Chunk마다 전체 데이터 삭제+삽입은 비효율적

현재 구현은 매 Chunk마다 delete + insert를 수행합니다. Step 완료 시점에 한 번만 저장하는 방식이 더 효율적입니다. StepExecutionListener.afterStep()에서 최종 저장을 수행하는 것을 고려해 보세요.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)

146-168: Java record로 간소화 가능

AggregatedMetrics 내부 클래스를 Java record로 대체하면 보일러플레이트 코드를 줄일 수 있습니다.

🔎 record 사용 제안
-    private static class AggregatedMetrics {
-        private final Long likeCount;
-        private final Long salesCount;
-        private final Long viewCount;
-
-        public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {
-            this.likeCount = likeCount;
-            this.salesCount = salesCount;
-            this.viewCount = viewCount;
-        }
-
-        public Long getLikeCount() {
-            return likeCount;
-        }
-
-        public Long getSalesCount() {
-            return salesCount;
-        }
-
-        public Long getViewCount() {
-            return viewCount;
-        }
-    }
+    private record AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {}
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)

35-42: 관리 상태 엔티티에 merge() 호출 불필요

existingScorefindByProductId에서 조회된 관리(managed) 상태 엔티티입니다. @Transactional 컨텍스트 내에서 setMetrics()로 값을 변경하면 JPA dirty checking에 의해 자동으로 flush됩니다. merge() 호출은 불필요합니다.

🔎 수정 제안
             ProductRankScore existingScore = existing.get();
             existingScore.setMetrics(
                 score.getLikeCount(),
                 score.getSalesCount(),
                 score.getViewCount(),
                 score.getScore()
             );
-            entityManager.merge(existingScore);
             log.debug("ProductRankScore 업데이트: productId={}", score.getProductId());

Based on learnings, 이 코드베이스에서는 트랜잭션 컨텍스트 내에서 JPA dirty checking을 통한 자동 영속화를 선호합니다.

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)

102-109: JPA 라이프사이클 콜백으로 타임스탬프 관리 권장

createdAtupdatedAt을 수동으로 설정하는 대신 @PrePersist@PreUpdate를 사용하면 일관성 있는 타임스탬프 관리가 가능합니다.

🔎 JPA 콜백 사용 제안
+    @PrePersist
+    protected void onCreate() {
+        this.createdAt = LocalDateTime.now();
+        this.updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        this.updatedAt = LocalDateTime.now();
+    }
+
     public ProductRank(
         PeriodType periodType,
         LocalDate periodStartDate,
         Long productId,
         Integer rank,
         Long likeCount,
         Long salesCount,
         Long viewCount
     ) {
         this.periodType = periodType;
         this.periodStartDate = periodStartDate;
         this.productId = productId;
         this.rank = rank;
         this.likeCount = likeCount;
         this.salesCount = salesCount;
         this.viewCount = viewCount;
-        this.createdAt = LocalDateTime.now();
-        this.updatedAt = LocalDateTime.now();
     }

     public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) {
         this.rank = rank;
         this.likeCount = likeCount;
         this.salesCount = salesCount;
         this.viewCount = viewCount;
-        this.updatedAt = LocalDateTime.now();
     }

Also applies to: 138-139, 155-155

apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java (2)

36-38: 대량 데이터 저장 시 성능 개선 고려

개별 persist() 호출 대신 일정 간격으로 flush()clear()를 수행하면 대량 데이터 처리 시 메모리 효율이 향상됩니다. TOP 100으로 제한되어 현재는 문제없지만, 향후 확장 시 고려해 주세요.

🔎 배치 플러시 패턴 제안
     public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List<ProductRank> ranks) {
         // 기존 데이터 삭제
         deleteByPeriod(periodType, periodStartDate);

         // 새 데이터 저장
-        for (ProductRank rank : ranks) {
-            entityManager.persist(rank);
-        }
+        for (int i = 0; i < ranks.size(); i++) {
+            entityManager.persist(ranks.get(i));
+            if (i > 0 && i % 50 == 0) {
+                entityManager.flush();
+                entityManager.clear();
+            }
+        }
+        entityManager.flush();

         log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}",
             periodType, periodStartDate, ranks.size());
     }

68-77: getResultList()를 사용한 간결한 구현 고려

getSingleResult()와 예외 처리 대신 getResultList()를 사용하면 코드가 더 간결해집니다.

🔎 간결한 구현 제안
-        try {
-            ProductRank rank = entityManager.createQuery(jpql, ProductRank.class)
-                .setParameter("periodType", periodType)
-                .setParameter("periodStartDate", periodStartDate)
-                .setParameter("productId", productId)
-                .getSingleResult();
-            return Optional.of(rank);
-        } catch (jakarta.persistence.NoResultException e) {
-            return Optional.empty();
-        }
+        return entityManager.createQuery(jpql, ProductRank.class)
+            .setParameter("periodType", periodType)
+            .setParameter("periodStartDate", periodStartDate)
+            .setParameter("productId", productId)
+            .getResultList()
+            .stream()
+            .findFirst();
apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)

46-52: Thread.sleep() 대신 더 안정적인 시간 검증 방법 고려

Thread.sleep(1)은 간헐적으로 테스트 실패를 유발할 수 있습니다. 시스템 부하에 따라 1ms 내에 두 작업이 동일한 시각에 완료될 수 있습니다.

Clock 주입 패턴이나 isAfterOrEqualTo() 같은 완화된 검증을 고려해 주세요.

🔎 대안 제안
-        Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연
         metrics.incrementLikeCount();

         // assert
         assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1);
         assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1);
-        assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt);
+        assertThat(metrics.getUpdatedAt()).isAfterOrEqualTo(initialUpdatedAt);
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)

104-106: Map.of() 사용으로 간결화 가능

단일 엔트리의 경우 Map.of()를 사용하면 더 간결하고 불변성을 보장합니다.

🔎 제안
-        // 정렬 기준 설정 (product_id 기준 오름차순)
-        Map<String, Sort.Direction> sorts = new HashMap<>();
-        sorts.put("productId", Sort.Direction.ASC);
+        // 정렬 기준 설정 (product_id 기준 오름차순)
+        Map<String, Sort.Direction> sorts = Map.of("productId", Sort.Direction.ASC);
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)

187-188: Reader Bean에서 Processor 상태 설정은 결합도가 높음

Reader bean 생성 시 productRankAggregationProcessor.setPeriod()를 호출하는 것은 암시적 의존성을 만듭니다. Step 리스너나 별도의 초기화 빈을 사용하는 것이 더 명시적입니다.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (3)

49-49: 완전한 클래스명(FQCN) 대신 import 문 사용을 권장합니다.

com.loopers.domain.rank.ProductRankRepositorycom.loopers.domain.rank.ProductRank 클래스가 여러 곳(라인 375-378, 380, 396, 399-400, 422)에서 완전한 클래스명으로 사용되고 있습니다. 가독성을 위해 상단에 import 문을 추가하는 것이 좋습니다.

🔎 제안하는 수정 사항

파일 상단 import 영역에 추가:

import com.loopers.domain.rank.ProductRank;
import com.loopers.domain.rank.ProductRankRepository;

그 후 해당 클래스명을 간략하게 수정:

-    private final com.loopers.domain.rank.ProductRankRepository productRankRepository;
+    private final ProductRankRepository productRankRepository;

480-487: PeriodType enum 중복 정의 검토.

RankingService.PeriodTypeProductRank.PeriodType 두 개의 enum이 존재하며, 라인 375-378에서 수동으로 변환하고 있습니다. 이러한 구조는 향후 새로운 기간 타입 추가 시 동기화 누락 위험이 있습니다.

다음 방안을 고려해 볼 수 있습니다:

  • 도메인 enum 재사용: ProductRank.PeriodType을 API 계층에서도 사용
  • 공통 enum 추출: 별도 공통 모듈에 enum 정의 후 양쪽에서 참조

현재 기능 동작에는 문제 없으므로 향후 리팩토링으로 고려하셔도 됩니다.


380-396: 페이지네이션을 DB 쿼리로 위임하는 것을 고려할 수 있습니다.

현재 100건 전체를 조회한 후 메모리에서 subList로 페이징하고 있습니다. TOP 100 제한이 있어 성능상 큰 문제는 없지만, ProductRankRepository.findByPeriod에 offset/limit 파라미터를 추가하면 불필요한 데이터 전송을 줄일 수 있습니다.

현재 구현도 동작에 문제없으므로 필요시 최적화로 검토하시면 됩니다.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f8db897 and d4ceccc.

📒 Files selected for processing (42)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-batch/build.gradle.kts
  • apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java
  • modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
  • settings.gradle.kts
💤 Files with no reviewable changes (2)
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/build.gradle.kts
🧰 Additional context used
🧠 Learnings (8)
📓 Common learnings
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
  • apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
  • apps/commerce-batch/src/main/resources/application.yml
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
📚 Learning: 2025-12-19T03:23:51.674Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:51.674Z
Learning: Maintain the use of static initialization blocks in test fixtures for testcontainers configurations (e.g., MySQL, Redis, Kafka) to ensure consistency and predictable initialization across the loopers-spring-java-template project. Apply this pattern to all test-fixture Java files under src/testFixtures/java to preserve uniform lifecycle behavior, unless there's a compelling, well-justified reason to diverge.

Applied to files:

  • modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
🧬 Code graph analysis (10)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java (2)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java (1)
  • Slf4j (25-44)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
  • Component (30-72)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (3)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java (1)
  • Slf4j (30-86)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)
  • Slf4j (33-81)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)
  • Slf4j (65-256)
apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
  • Entity (35-165)
apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)
  • main (22-24)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (1)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)
  • ExtendWith (21-151)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
  • result (7-19)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (5)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)
  • Slf4j (27-73)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)
  • Slf4j (36-122)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java (1)
  • Slf4j (30-86)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)
  • Slf4j (33-81)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)
  • Slf4j (36-169)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
  • Component (30-72)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)
  • Slf4j (20-99)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java (4)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (1)
  • ExtendWith (23-133)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)
  • ExtendWith (21-151)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java (1)
  • ExtendWith (27-250)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
  • result (7-19)
🔇 Additional comments (40)
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java (1)

45-49: 테이블 존재 확인 쿼리는 적절합니다.

information_schema.tables 조회 시 파라미터화된 쿼리를 사용하여 SQL 인젝션을 방지하고 있으며, DATABASE() 함수를 사용하여 현재 스키마 내에서만 검색하는 것이 올바릅니다. 배치 작업으로 인한 추가 테이블(mv_product_rank 등)이 일부 테스트 컨텍스트에서만 존재할 수 있는 상황에서 이 방어적 접근은 합리적입니다.

settings.gradle.kts (1)

5-5: 모듈 등록이 올바르게 수행되었습니다.

새로운 commerce-batch 모듈이 프로젝트 구조에 정확하게 추가되었습니다.

apps/commerce-batch/src/main/resources/application.yml (2)

1-13: 배치 애플리케이션 기본 설정이 적절합니다.

웹 서버 비활성화(web-application-type: none)와 필요한 설정 파일 임포트가 올바르게 구성되어 있습니다.


14-18: 프로덕션 환경에서 스키마 자동 초기화 설정을 검토하세요.

initialize-schema: always 설정이 모든 환경에 적용됩니다. 프로덕션 환경에서는 이 설정이 예기치 않은 스키마 재생성을 유발하거나 기존 배치 메타데이터를 손상시킬 수 있습니다.

프로필별로 설정을 분리하여 local/test 환경에서만 always를 사용하고, dev/qa/prd 환경에서는 never를 사용하도록 수정하는 것을 권장합니다.

🔎 프로필별 설정 분리 예시
-  batch:
-    jdbc:
-      initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성
-    job:
-      enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화
+  batch:
+    job:
+      enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화

 ---
 spring:
   config:
     activate:
       on-profile: local, test
+  batch:
+    jdbc:
+      initialize-schema: always

 ---
 spring:
   config:
     activate:
       on-profile: dev
+  batch:
+    jdbc:
+      initialize-schema: never

 ---
 spring:
   config:
     activate:
       on-profile: qa
+  batch:
+    jdbc:
+      initialize-schema: never

 ---
 spring:
   config:
     activate:
       on-profile: prd
+  batch:
+    jdbc:
+      initialize-schema: never
⛔ Skipped due to learnings
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
apps/commerce-batch/build.gradle.kts (1)

1-21: 의존성 구성이 적절합니다.

배치 모듈에 필요한 Spring Batch, QueryDSL, 테스트 픽스처 등의 의존성이 올바르게 설정되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

33-67: 도메인 저장소 메서드가 적절하게 설계되었습니다.

save, findByProductId, findByUpdatedAtBetween 메서드가 배치 처리 요구사항을 잘 반영하고 있으며, Spring Data의 Page/Pageable 사용도 코드베이스의 아키텍처 결정에 부합합니다.

Based on learnings, Spring Data의 Page/Pageable 타입 사용은 이 코드베이스에서 허용된 아키텍처 결정입니다.

apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java (1)

13-38: 인터페이스는 올바르게 설계되었습니다 - 모듈별 책임 분리가 적절합니다.

검증 결과, 이 인터페이스(apps/commerce-api)는 의도적으로 조회 메서드만 포함하도록 설계되었습니다. 저장 및 삭제 메서드(saveRanks, deleteByPeriod)는 배치 모듈(apps/commerce-batch)의 별도 인터페이스에만 정의되어 있으며, 실제로 배치 작업에서만 사용됩니다. 이는 DIP 위반이 아니라 모듈별 책임 분리의 올바른 예입니다:

  • API 모듈: 읽기 전용 (findByPeriod, findByPeriodAndProductId)
  • 배치 모듈: 읽기 + 쓰기 (모든 4개 메서드 포함)

AI 요약이 두 모듈의 인터페이스를 혼동했으나, 코드 설계 자체는 문제가 없습니다.

apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

125-132: 버전 기반 업데이트 체크 로직 확인

이벤트 버전 비교 로직이 올바르게 구현되어 있습니다. null 처리를 통한 하위 호환성도 좋습니다.

참고: version 필드가 JPA의 @Version(낙관적 락)과 혼동될 수 있습니다. 이 필드는 이벤트 버전 관리용이므로 명확히 구분됩니다.

apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java (1)

14-86: Pass-through 프로세서 테스트 커버리지

Pass-through 로직을 위한 테스트 커버리지가 충분합니다. PR 목표에 명시된 대로 전체 Job 통합 테스트가 아닌 핵심 로직의 단위 테스트에 집중하는 전략과 일치합니다.

테스트가 명확하고 프로세서의 예상 동작을 검증합니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)

56-64: 기간 파라미터 기본값 처리

@RequestParamdefaultValue = "DAILY"parsePeriodType의 DAILY 기본값이 중복되지만 방어적이고 안전합니다.

현재 구현은 잘못된 period 값을 조용히 DAILY로 폴백합니다. 이는 사용자 친화적이지만, API 사용자가 오타를 발견하기 어려울 수 있습니다. 프로젝트의 에러 처리 방침에 따라 다음을 고려하세요:

  • 현재 방식 유지: 관대한 처리로 사용자 경험 개선
  • 또는 400 에러 반환: 명시적 피드백으로 API 계약 강화

현재 API 에러 처리 방침(관대한 기본값 vs 명시적 에러)을 확인하세요.


112-123: 기간 타입 파싱 로직 구현

parsePeriodType 메서드가 잘 구현되었습니다:

  • null/blank 처리
  • 대소문자 구분 없는 파싱 (toUpperCase())
  • 안전한 예외 처리

코드가 명확하고 견고합니다.

apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java (1)

25-32: 배치 애플리케이션 진입점 구현

배치 애플리케이션이 올바르게 구성되었습니다:

  • @SpringBootApplicationscanBasePackages가 적절히 설정됨
  • @EnableJpaRepositories@EntityScan이 인프라 및 도메인 패키지를 올바르게 지정
  • SpringApplication.exit 패턴이 배치 작업 완료 후 적절한 종료 코드를 반환하도록 보장

Javadoc의 실행 예시도 명확하고 유용합니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java (2)

28-38: 기간별 랭킹 조회 쿼리 구현

JPQL 쿼리가 올바르게 구현되었습니다:

  • 적절한 파라미터 바인딩
  • ORDER BY pr.rank ASC로 순위 정렬
  • setMaxResults로 결과 제한

PR 목표에 명시된 복합 인덱스(period_type, period_start_date, rank)가 있으면 이 쿼리의 성능이 최적화됩니다.


40-61: 특정 상품 랭킹 조회 구현

개별 상품 랭킹 조회가 잘 구현되었습니다:

  • NoResultException 예외를 적절히 처리
  • Optional 패턴을 올바르게 사용
  • 명확한 쿼리 로직
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java (1)

1-120: LGTM! 테스트 구성이 잘 되어 있습니다.

ProductRankAggregationProcessor의 기간 설정 로직에 대한 테스트가 체계적으로 작성되었습니다. 주간/월간 기간 계산, 다양한 날짜 입력에 대한 경계 케이스, 그리고 여러 번 설정 시 상태 업데이트가 잘 검증되고 있습니다.

apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java (1)

100-116: LGTM! 테스트 헬퍼 메서드가 잘 구현되었습니다.

createProductMetricsList 헬퍼 메서드가 테스트 데이터 생성을 효과적으로 지원합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java (1)

25-44: LGTM! 확장 포인트로서의 역할이 명확합니다.

현재 pass-through 구현이지만, Javadoc에서 향후 집계/변환/필터링 로직 추가를 위한 확장 포인트임을 잘 설명하고 있습니다.

@Slf4j 어노테이션이 선언되어 있지만 현재 사용되지 않습니다. 향후 로직 추가 시 사용될 것으로 보이므로 유지해도 무방합니다.

apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java (1)

182-233: LGTM! PeriodType enum과 랭킹 범위 테스트가 잘 작성되었습니다.

PeriodType enum 검증과 TOP 100/1위 랭킹 경계 테스트가 적절하게 구현되었습니다.

apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java (2)

92-129: ThreadLocal 정리 동작 테스트에 대한 참고 사항

이 테스트는 ThreadLocal 정리 동작을 검증하기 위해 내부 구현에 의존하고 있습니다. 주석에서 설명하듯이 101번째 처리가 실제 배치 동작과 다르지만, ThreadLocal 정리가 올바르게 수행되는지 확인하는 목적입니다.

실제 배치 실행 시에는 100개 이후 항목이 처리되지 않으므로, 이 테스트가 구현 변경 시 깨질 수 있음을 인지하시기 바랍니다. 주석이 이미 이 점을 잘 설명하고 있습니다.


255-261: LGTM! 테스트 헬퍼 메서드가 올바르게 구현되었습니다.

createProductRankScore 헬퍼가 점수 계산 공식(가중치 0.3, 0.5, 0.2)을 ProductRankScoreAggregationWriterTest의 공식과 일치하게 구현하고 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java (1)

41-56: 현재 상태는 의도적인 설계입니다.

ProductMetricsItemWriter가 실제 데이터를 저장하지 않는 것은 확인되었습니다. 그러나 ProductMetricsJobConfig의 Javadoc에서 "Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)"이라고 명시된 대로 이는 의도적인 설계입니다.

데이터 유실 우려는 없습니다. ProductRankScoreAggregationWriter는 별개의 Step에서 ProductMetrics를 읽어 ProductRankScore를 계산하여 productRankScoreRepository.saveAll()로 실제 DB에 저장하므로, 역할 구분이 명확합니다:

  • ProductMetricsItemWriter: ProductMetrics 로깅 (현재 상태), 향후 Materialized View 저장 예정
  • ProductRankScoreAggregationWriter: ProductMetrics를 ProductRankScore로 변환하여 실제 DB 저장 (이미 구현)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)

27-53: 동시 Job 실행 시 스레드 안전성 검토가 필요합니다.

이 클래스는 싱글톤 @Component이지만 가변 인스턴스 필드(periodType, periodStartDate)를 가지고 있습니다. 동일한 Job이 다른 파라미터로 동시에 실행될 경우 경쟁 상태가 발생할 수 있습니다.

현재 구조에서는 productRankReader@StepScope이지만 이 Processor는 싱글톤이므로, 동시 실행 시 한 Job의 setPeriod() 호출이 다른 Job의 기간 정보를 덮어쓸 수 있습니다.

동시 배치 Job 실행이 예상되지 않는다면 현재 구현으로 충분하지만, 동시 실행이 필요하다면 @StepScope로 변경하거나 ThreadLocal을 사용하는 것을 고려해주세요.

apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java (1)

35-118: LGTM!

엔티티 구조가 적절합니다. API 모듈에서는 조회 전용으로 사용되므로 생성자 없이 @NoArgsConstructor(access = AccessLevel.PROTECTED)만 있는 것이 의도된 설계입니다. 인덱스 전략도 기간별 랭킹 조회와 특정 상품 조회에 최적화되어 있습니다.

apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java (3)

36-93: LGTM!

집계 로직 테스트가 잘 작성되었습니다. 같은 product_id를 가진 메트릭들의 집계, 다른 product_id 처리, 그리고 결과 검증이 명확합니다.


95-159: LGTM!

점수 가중치 계산 테스트와 기존 데이터 누적 테스트가 잘 작성되었습니다. 가중치 공식(like * 0.3 + sales * 0.5 + view * 0.2)이 명확하게 검증됩니다.


161-249: LGTM!

빈 Chunk 처리, 다중 product_id 처리, 새 데이터 생성 테스트가 edge case를 잘 커버합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)

97-109: 날짜 파싱 실패 시 오늘 날짜로 대체하는 동작을 재고해주세요.

잘못된 날짜 파라미터가 전달되면 경고만 로깅하고 오늘 날짜로 진행합니다. 스케줄링된 배치 작업에서 이는 잘못된 데이터 처리로 이어질 수 있습니다.

의도적인 설계라면 현재 상태로 유지해도 되지만, 엄격한 파라미터 검증이 필요한 경우 예외를 던지는 것을 고려해주세요:

private LocalDate parseDate(String dateStr) {
    if (dateStr == null || dateStr.isEmpty()) {
        throw new IllegalArgumentException("날짜 파라미터가 필수입니다.");
    }
    try {
        return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
    } catch (DateTimeParseException e) {
        throw new IllegalArgumentException("잘못된 날짜 형식: " + dateStr, e);
    }
}
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java (1)

124-138: LGTM!

생성자가 모든 필수 필드를 초기화하고 타임스탬프를 설정합니다. 배치 처리의 임시 테이블용 엔티티로 적절한 구조입니다.

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java (1)

7-58: LGTM!

Repository 인터페이스가 잘 설계되었습니다. 기간별 랭킹 조회, 특정 상품 조회, 저장/삭제 메서드가 Materialized View 패턴에 적합하게 정의되어 있습니다. Javadoc도 명확합니다.

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java (1)

12-67: LGTM!

인터페이스 설계가 명확하고, UPSERT 동작 및 용도가 Javadoc에 잘 문서화되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)

59-71: NoResultException 처리 방식 적절

예외 기반 Optional 처리가 정확하게 구현되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)

35-42: 인덱스 설계 적절

기간별 랭킹 조회 및 특정 상품 랭킹 조회 쿼리 패턴에 맞는 복합 인덱스가 잘 설계되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)

30-72: LGTM!

JPA 레포지토리에 대한 위임 패턴이 깔끔하게 구현되어 있습니다. getJpaRepository()는 Spring Batch의 RepositoryItemReader API 요구사항을 충족하기 위한 적절한 접근 방식입니다.

apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)

162-215: shouldUpdate 로직 테스트 커버리지 우수

이벤트 버전과 메트릭 버전 비교 로직에 대한 테스트가 잘 구성되어 있습니다. null 하위 호환성, 초기 버전 처리 등 다양한 엣지 케이스를 다루고 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java (2)

74-109: Job 및 Step 구성이 적절합니다

Chunk 크기 100, StepScope Reader 활용, 명확한 문서화 등 Spring Batch 모범 사례를 따르고 있습니다.


120-126: 문제 없음 - null 파라미터 처리가 이미 구현되어 있습니다

ProductMetricsItemReader.createReader() 메서드 내부의 parseDate() 메서드(라인 97-109)에서 이미 null 및 빈 문자열에 대한 처리가 구현되어 있습니다. null인 경우 오늘 날짜를 반환하며, 파싱 실패 시에도 동일하게 처리됩니다. 이는 ProductRankJobConfig에서 사용되는 패턴과 동일합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)

52-65: 주간 날짜 범위 계산 정확

월요일부터 다음 주 월요일 00:00:00까지의 범위가 정확하게 계산되어 있습니다. exclusive end date 패턴을 올바르게 사용하고 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)

93-102: 2-Step Job 구조 적절

Step 1에서 점수 집계 후 Step 2에서 랭킹 계산하는 구조가 명확합니다. start().next() 체인으로 순차 실행이 보장됩니다.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (2)

79-87: LGTM!

기간 타입에 따른 분기 처리가 명확하고, 기존 Redis 기반 일간 랭킹과 새로운 Materialized View 기반 주간/월간 랭킹을 깔끔하게 분리했습니다.


474-478: 배치 작업과의 점수 계산 로직은 이미 일관성을 유지하고 있습니다.

API의 calculateScore 메서드는 배치 작업의 ProductRankScoreAggregationWriter에서 사용하는 것과 동일한 가중치(좋아요 0.3, 판매량 0.5, 조회수 0.2)를 적용하고 있습니다. 유일한 차이는 API에서 null 값을 처리하기 위해 삼항 연산자를 사용하는 것인데, 이는 Materialized View 조회 결과에 null 값이 올 수 있기 때문에 적절합니다.

Likely an incorrect or invalid review comment.

@minor7295 minor7295 marked this pull request as ready for review January 2, 2026 03:53
* 트랜젝션 어노테이션 추가

* 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리

* @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성

* 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지

* 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정

* n+1 쿼리 개선
@minor7295 minor7295 merged commit 438c77d into Loopers-dev-lab:minor7295 Jan 4, 2026
2 checks passed
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