⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

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

Open
HongChangMo wants to merge 5 commits intoLoopers-dev-lab:HongChangMofrom
HongChangMo:round-10
Open

[volume-10] Collect, Stack, Zip#236
HongChangMo wants to merge 5 commits intoLoopers-dev-lab:HongChangMofrom
HongChangMo:round-10

Conversation

@HongChangMo
Copy link
Collaborator

@HongChangMo HongChangMo commented Jan 2, 2026

📌 Summary

  • Spring Batch + MV 기반 월간, 주간 상품 집계 데이터 수집
  • Chunk Oriented Processing 기반 배치 처리 구현
  • 일간, 주간, 월간 랭킹 제공 API 구현

💬 Review Points

커스텀 ItemReader가 아닌 RepositoryItemReader를 사용

처음 구현할때, 커스텀 ItemReader를 통해서 만약 좋아요 데이터가 100만 건이라면,
100만건을 모두 읽어와 처리를 한다면 데이터 양이 더 많아지면 OOM 이슈가 발생할 수 있을 것 같아,
Spring Batch 에서 지원하는 RepositoryItemReader를 사용해서 chunk size와 동일한
페이징 데이터를 처리하도록 구현하였습니다.
궁금한 점은 RepositoryItemReader 를 사용해서 처리하는 경우도 실무에서 많은지 궁금하고,
정답은 없다고 하셨지만, 구현한 방식이 올바른 방향성이 맞는지 궁금합니다.


MV를 활용한 주간 월간 랭킹 가중치 적용 질문

Redis의 ZSET으로 일간 랭킹을 구현할때에는 ZSET score 기반 top 랭킹을 뽑는 방법으로 구현했었는데, 이번 과제는 Spring Batch + MV 를 활용해 주간, 월간 집계 데이터를 수집하고, commerce-api에서 top 100 랭킹을 조회하는 기능을 구현해야 하는데,

제가 궁금한 점은 MV에 저장되는 데이터는 집계에 대한 내용뿐이라, top N 을 뽑아야하는 경우 별도의 score 를 어떻게 구현하는게 좋을지 궁금합니다.

저는 이번 과제에서 api를 조회하는 commerce-api 에 읽기 전용 엔티티를 만들고 MV에서 데이터를 db에서 조회할 때 score를 계산해서 조회하도록 처리하였습니다.

제가 생각한 다른 방법으로는 내부에 score를 계산하는 도메인 로직을 통해 종합 점수를 계산하도록 하는 방법도 있을 것 같아보이는데, commerce-collector 집계 처리하는 부분에서 미리 score를 계산하는 것이 더 좋은 방법인지, 아니면 제가 구현한 부분도 괜찮은 방법인지 궁금합니다.

스코어 계산을 repository에서 처리

@Query("""
        SELECT w FROM ProductMetricsWeekly w
        WHERE w.year = :year AND w.week = :week
        ORDER BY (w.totalLikeCount * 0.2 + w.totalViewCount * 0.1 + w.totalOrderCount * 0.6) DESC
        """)
    List<ProductMetricsWeekly> findByYearAndWeekOrderByCompositeScoreDesc(
            @Param("year") int year,
            @Param("week") int week,
            Pageable pageable
    );

도메인 로직에서 처리하는 경우

/**
     * 종합 점수 계산 (가중치 적용)
     * Score = (like * 0.2) + (view * 0.1) + (order * 0.6)
     */
    public double calculateCompositeScore() {
        return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6);
    }

✅ Checklist

🧱 Spring Batch

  • Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.
  • 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다.

🧩 Ranking API

  • API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 기간별 상품 랭킹 조회 추가 (일간, 주간, 월간)
    • 여러 순위 기준 지원: 좋아요, 조회수, 주문 기반 순위 및 복합 점수
    • 주간 및 월간 상품 지표 추적 기능 구현
    • 페이징 지원으로 대규모 랭킹 데이터 조회 개선
  • 테스트

    • 주간 및 월간 지표 집계 배치 작업 통합 테스트 추가

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

- Chunk Oriented Processing 기반 주간 랭킹 배치 처리 구현
- 상품 집계 결과 MV 활용
- Chunk Oriented Processing 기반 월간 랭킹 배치 처리 구현
- 상품 집계 결과 MV 활용
- 오래된 일자별 데이터 정리 10일 기준 -> 30일 수정
- PeriodType별 일간, 주간, 월간 랭킹 제공
- 일간은 Redis ZSET 으로 제공
- 주간, 월간 MV를 활용한 랭킹 제공
@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

워크스루

주간 및 월간 상품 순위 조회 기능을 추가하기 위해 PeriodType 열거형과 PeriodRankingService를 도입했습니다. 주간/월간 제품 지표를 위한 새로운 JPA 엔티티, 저장소 및 배치 프로세서를 구현하고, RankingFacade 및 컨트롤러를 업데이트하여 기간 유형 파라미터를 전달하며, 매월 지표 정리 기간을 10일에서 30일로 연장했습니다.

변경 사항

코호트 / 파일 변경 요약
도메인 모델 - 순위
apps/commerce-api/src/main/java/com/loopers/domain/ranking/*
PeriodType 열거형 추가 (DAILY, WEEKLY, MONTHLY); Ranking 엔티티에 totalLikeCount, totalViewCount, totalOrderCoun 필드 및 정적 팩토리 메서드 of() 추가
주간/월간 지표 엔티티 및 저장소 (commerce-api)
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java, ProductMetricsMonthly.java
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetrics*Repository.java
읽기 전용 JPA 엔티티 ProductMetricsWeekly, ProductMetricsMonthly 추가; 복합 점수 계산 메서드 포함; 각각에 대한 도메인 저장소 인터페이스 및 JPA 저장소 추가
주간/월간 순위 서비스
apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java
새로운 서비스 추가: getTopWeeklyRanking(), getTopMonthlyRanking(), getWeeklyRankingWithPaging(), getMonthlyRankingWithPaging() 메서드 구현
RankingFacade 및 컨트롤러
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
RankingFacadePeriodRankingService 주입; getTopRanking(), getRankingWithPaging() 메서드에 PeriodType 파라미터 추가; DTO에 periodType, product 필드 추가
배치 의존성
apps/commerce-collector/build.gradle.kts
Spring Batch 관련 의존성 추가: spring-boot-starter-batch, spring-batch-test
배치 리스너
apps/commerce-collector/src/main/java/com/loopers/batch/listener/*
세 개의 배치 리스너 추가: JobListener (작업 시작/종료 로깅), StepMonitorListener (스텝 실패 감지), ChunkListener (청크 단위 통계 로깅)
주간/월간 지표 엔티티 및 저장소 (commerce-collector)
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java, ProductMetricsMonthly.java
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetrics*Repository.java
읽기/쓰기 JPA 엔티티 추가; 정적 팩토리 create(), updateMetrics(), reset() 메서드 포함; 저장, 삭제 작업을 위한 저장소 인터페이스 추가
집계 DTO
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java, MonthlyAggregationDto.java
주간/월간 집계 데이터를 위한 DTO 클래스 추가
배치 프로세서 및 라이터
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java, MonthlyMetricsProcessor.java
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java, MonthlyMetricsWriter.java
주간/월간 지표 변환 및 저장을 위한 배치 프로세서 및 라이터 추가
배치 작업 설정
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java, ProductMetricsMonthlyJobConfig.java
리더-프로세서-라이터 파이프라인, 청크 기반 처리 (크기 100), 리스너 연결을 포함한 배치 작업 설정
저장소 구현
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetrics*RepositoryImpl.java
JPA 저장소에 위임하고 JDBC 기반 벌크 UPSERT (MySQL ON DUPLICATE KEY UPDATE) 구현
JPA 저장소 확장
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java
주간/월간 집계 조회 메서드 findWeeklyAggregation(), findMonthlyAggregation() 추가
배치 테스트
apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java, ProductMetricsMonthlyJobTest.java
성공 경로, 데이터 없음 시나리오, UPSERT 동작, 타겟팅된 스텝 실행에 대한 통합 테스트 추가
스케줄러 유지보수
apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java
일일 메트릭 정리 윈도우를 10일에서 30일로 확장

시퀀스 다이어그램

sequenceDiagram
    participant Client
    participant RankingController as RankingV1<br/>Controller
    participant RankingFacade as RankingFacade
    participant PeriodService as PeriodRanking<br/>Service
    participant WeeklyRepo as Weekly<br/>Repository
    participant MonthlyRepo as Monthly<br/>Repository
    participant Enrichment as Enrichment<br/>Logic

    Client->>RankingController: GET /ranking<br/>(rankingType, periodType,<br/>date, limit)
    RankingController->>RankingFacade: getTopRanking(type,<br/>periodType, date, limit)
    
    alt periodType == WEEKLY
        RankingFacade->>PeriodService: getTopWeeklyRanking(type,<br/>date, limit)
        PeriodService->>WeeklyRepo: findByYearAndWeek<br/>OrderBy[Type]Desc(year,<br/>week, limit)
        WeeklyRepo-->>PeriodService: List<ProductMetricsWeekly>
        PeriodService->>PeriodService: convertWeeklyToRanking()<br/>calculateScore()
        PeriodService-->>RankingFacade: List<Ranking>
    else periodType == MONTHLY
        RankingFacade->>PeriodService: getTopMonthlyRanking(type,<br/>date, limit)
        PeriodService->>MonthlyRepo: findByYearAndMonth<br/>OrderBy[Type]Desc(year,<br/>month, limit)
        MonthlyRepo-->>PeriodService: List<ProductMetricsMonthly>
        PeriodService->>PeriodService: convertMonthlyToRanking()<br/>calculateScore()
        PeriodService-->>RankingFacade: List<Ranking>
    else periodType == DAILY (default)
        RankingFacade->>RankingFacade: getTopRanking(type,<br/>date, limit)<br/>기존 로직
    end
    
    RankingFacade->>Enrichment: enrichWithProductInfo(rankings)
    Enrichment-->>RankingFacade: List<RankingInfo>
    RankingFacade-->>RankingController: List<RankingInfo>
    RankingController-->>Client: JSON Response
Loading

예상 코드 리뷰 노력

🎯 4 (복잡함) | ⏱️ ~60분

관련 가능성 있는 PR

  • [volume-9] Product Ranking with Redis #216: 동일한 RankingFacade, DTO, 순위 도메인 타입 파일을 수정하며, 순위 기능에 대한 코드 수준의 연관성이 있습니다 (주요 PR은 기간 기반 순위 및 PeriodRankingService 추가, 검색된 PR은 Redis 기반 순위/캐시 및 RankingFacade 연결 구현).

제안된 레이블

enhancement

시 (Poem)

🐰 주간, 월간 순위가 쏙쏙,
배치로 집계해 척척 처리!
기간 타입으로 유연하게,
상품 지표가 반짝반짝,
리더부터 라이터까지,
순위 시스템, 한 단계 업!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.60% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive 제목 '[volume-10] Collect, Stack, Zip'은 모호하고 구체적인 정보가 부족합니다. 변경 사항의 핵심을 명확하게 전달하지 못합니다. 제목을 더 구체적으로 변경하세요. 예: '[batch] Implement Spring Batch weekly/monthly ranking aggregation with materialized views' 또는 '[ranking] Add period-based ranking support for daily/weekly/monthly'
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명은 제시된 템플릿의 주요 섹션(Summary, Review Points, Checklist)을 포함하고 있으며, 구현 내용과 검토 포인트가 명확히 기술되어 있습니다.
✨ 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.

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: 9

Caution

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

⚠️ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)

37-37: getTotalRankingCount에 periodType 전달 누락

getRankingWithPaging에는 periodType을 전달하지만, getTotalRankingCount에는 전달하지 않습니다. 주간/월간 랭킹의 경우 데이터 소스가 다르므로(일간: Redis, 주간/월간: MV), 총 개수 계산도 periodType을 고려해야 합니다.

이로 인해 페이지네이션의 총 페이지 수가 잘못 계산될 수 있습니다.

🔎 제안하는 수정 사항
-        long totalCount = rankingFacade.getTotalRankingCount(request.type(), date);
+        long totalCount = rankingFacade.getTotalRankingCount(request.type(), request.periodType(), date);

63-63: 동일 이슈: getTopRanking에서도 periodType 누락

getTopRanking 엔드포인트에서도 getTotalRankingCount 호출 시 periodType을 전달해야 합니다.

🔎 제안하는 수정 사항
-        long totalCount = rankingFacade.getTotalRankingCount(request.type(), date);
+        long totalCount = rankingFacade.getTotalRankingCount(request.type(), request.periodType(), date);
♻️ Duplicate comments (1)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)

38-65: 동일한 null 처리 검증이 필요합니다.

GetTopRankingRequest와 동일하게 periodType 필드의 null 처리를 확인하세요.

🧹 Nitpick comments (16)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java (1)

32-47: 팩토리 메서드 개선 제안

  • 파라미터명 irank로 변경하여 가독성을 높이세요.
  • Line 40의 new Ranking 사이 이중 공백을 수정하세요.
🔎 제안하는 수정 사항
     public static Ranking of(
-            int i,
+            int rank,
             Long productId,
             double score,
             Long totalLikeCount,
             Long totalViewCount,
             Long totalOrderCount
     ) {
-        return new  Ranking(
-                i,
+        return new Ranking(
+                rank,
                 productId,
                 score,
                 totalLikeCount,
                 totalViewCount,
                 totalOrderCount
         );
     }
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java (1)

164-188: FQN 대신 import 문 사용 권장

org.springframework.data.domain.Page, org.springframework.data.domain.Pageable, 그리고 DTO 클래스들에 대해 완전한 패키지명(FQN)을 사용하고 있습니다. 가독성을 위해 파일 상단에 import 문을 추가하는 것이 좋습니다.

🔎 제안하는 수정 사항

파일 상단에 import 추가:

import com.loopers.domain.metrics.dto.MonthlyAggregationDto;
import com.loopers.domain.metrics.dto.WeeklyAggregationDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

그 후 메서드 시그니처 간소화:

     @Override
-    public org.springframework.data.domain.Page<com.loopers.domain.metrics.dto.WeeklyAggregationDto> findWeeklyAggregation(
+    public Page<WeeklyAggregationDto> findWeeklyAggregation(
             Integer year,
             Integer week,
             LocalDate startDate,
             LocalDate endDate,
-            org.springframework.data.domain.Pageable pageable
+            Pageable pageable
     ) {
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (1)

63-64: 복합 점수 계산 로직이 올바르게 구현되었습니다.

가중치 계산 공식(좋아요 0.2 + 조회수 0.1 + 주문수 0.6)이 ProductMetricsWeekly와 일관되게 적용되었습니다.

🔎 방어적 null 처리 추가 고려

스키마에서 nullable = false로 설정되어 있어 안전하지만, 방어적 코딩을 위해 null 체크를 추가할 수 있습니다:

 public double calculateCompositeScore() {
-    return (totalLikeCount * 0.2) + (totalViewCount * 0.1) + (totalOrderCount * 0.6);
+    long likeCount = totalLikeCount != null ? totalLikeCount : 0L;
+    long viewCount = totalViewCount != null ? totalViewCount : 0L;
+    long orderCount = totalOrderCount != null ? totalOrderCount : 0L;
+    return (likeCount * 0.2) + (viewCount * 0.1) + (orderCount * 0.6);
 }
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java (1)

22-26: 타입 안전성을 개선할 수 있습니다.

Line 26에서 List<? extends ProductMetricsMonthly>List<ProductMetricsMonthly>로 unchecked cast하고 있습니다. Spring Batch 컨텍스트에서는 일반적으로 안전하지만, 컴파일러 경고가 발생하며 이론적으로는 타입 안전성 문제가 있을 수 있습니다.

🔎 타입 안전한 대안
 @Override
 public void write(Chunk<? extends ProductMetricsMonthly> chunk) {
-    List<? extends ProductMetricsMonthly> items = chunk.getItems();
-
-    // Bulk Insert/Update (UPSERT)
-    monthlyRepository.saveAll((List<ProductMetricsMonthly>) items);
+    List<ProductMetricsMonthly> items = new ArrayList<>();
+    for (ProductMetricsMonthly item : chunk.getItems()) {
+        items.add(item);
+    }
+    
+    // Bulk Insert/Update (UPSERT)
+    monthlyRepository.saveAll(items);

     log.info("월간 집계 저장 완료: {} 건", items.size());
 }

또는 Java 10+ 스트림 사용:

 @Override
 public void write(Chunk<? extends ProductMetricsMonthly> chunk) {
-    List<? extends ProductMetricsMonthly> items = chunk.getItems();
-
-    // Bulk Insert/Update (UPSERT)
-    monthlyRepository.saveAll((List<ProductMetricsMonthly>) items);
+    List<ProductMetricsMonthly> items = chunk.getItems().stream()
+        .map(ProductMetricsMonthly.class::cast)
+        .toList();
+    
+    monthlyRepository.saveAll(items);

     log.info("월간 집계 저장 완료: {} 건", items.size());
 }
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java (1)

12-30: 엔티티 생성 패턴 검토 제안.

현재 ProductMetricsMonthly.create()로 엔티티를 생성한 후 즉시 updateMetrics()를 호출하는 패턴을 사용하고 있습니다. 이 패턴은 동작하지만, 두 단계로 나뉘어 있어 다소 비직관적일 수 있습니다.

다음 중 하나를 고려해보세요:

  1. create() 메서드가 모든 매개변수(metrics 포함)를 받도록 확장
  2. 빌더 패턴 사용
  3. 현재 패턴이 의도적인 설계라면 그대로 유지

현재 구현도 정상 동작하므로 팀의 설계 철학에 따라 선택하시면 됩니다.

apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java (1)

8-10: 페이지네이션 지원 및 saveAll 반환 타입 개선 권장

현재 구현에서 두 가지 개선 사항을 고려해보세요:

  1. findAll() 메서드는 페이지네이션 없이 모든 데이터를 메모리로 로드하므로 데이터가 많을 경우 성능 문제가 발생할 수 있습니다.
  2. saveAll()이 void를 반환하는데, 일반적인 Spring Data 패턴에서는 저장된 엔티티 리스트를 반환하여 생성된 ID 등을 확인할 수 있도록 합니다.
🔎 개선된 메서드 시그니처 제안
 public interface ProductMetricsWeeklyRepository {
 
     ProductMetricsWeekly save(ProductMetricsWeekly metrics);
-    void saveAll(List<ProductMetricsWeekly> metricsList);
+    List<ProductMetricsWeekly> saveAll(List<ProductMetricsWeekly> metricsList);
     int deleteByYearAndWeekBefore(Integer year, Integer week);
-    List<ProductMetricsWeekly> findAll();
+    List<ProductMetricsWeekly> findAll(Pageable pageable);
 }
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java (1)

12-30: DTO 입력값에 대한 null 검증 추가 권장

현재 프로세서는 DTO의 필드값에 대한 null 체크 없이 바로 사용하고 있습니다. Spring Batch에서 DTO 생성 시 null 값이 포함될 수 있으므로, 방어적 프로그래밍을 위해 입력값 검증을 추가하는 것을 고려해보세요.

🔎 null 검증 추가 제안
 public class WeeklyMetricsProcessor implements ItemProcessor<WeeklyAggregationDto, ProductMetricsWeekly> {
     @Override
     public ProductMetricsWeekly process(WeeklyAggregationDto dto) {
+        // 필수 필드 검증
+        if (dto.getProductId() == null || dto.getYear() == null || dto.getWeek() == null) {
+            throw new IllegalArgumentException("Required fields cannot be null");
+        }
+        
         // DTO를 도메인 엔티티로 변환
         ProductMetricsWeekly metrics = ProductMetricsWeekly.create(
                 dto.getProductId(),
                 dto.getYear(),
                 dto.getWeek(),
                 dto.getPeriodStartDate(),
                 dto.getPeriodEndDate()
         );
 
         // 집계 메트릭 업데이트
         metrics.updateMetrics(
-                dto.getTotalLikeCount(),
-                dto.getTotalViewCount(),
-                dto.getTotalOrderCount()
+                dto.getTotalLikeCount() != null ? dto.getTotalLikeCount() : 0L,
+                dto.getTotalViewCount() != null ? dto.getTotalViewCount() : 0L,
+                dto.getTotalOrderCount() != null ? dto.getTotalOrderCount() : 0L
         );
 
         return metrics;
     }
 }
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java (1)

7-9: 페이지네이션 지원 및 saveAll 반환 타입 개선 권장

ProductMetricsWeeklyRepository와 동일한 개선 사항을 고려해보세요:

  1. findAll() 메서드에 페이지네이션 지원 추가로 대용량 데이터 처리 시 메모리 문제 방지
  2. saveAll()의 반환 타입을 List<ProductMetricsMonthly>로 변경하여 저장된 엔티티에 접근 가능하도록 개선
🔎 개선된 메서드 시그니처 제안
 public interface ProductMetricsMonthlyRepository {
     ProductMetricsMonthly save(ProductMetricsMonthly metrics);
-    void saveAll(List<ProductMetricsMonthly> metricsList);
+    List<ProductMetricsMonthly> saveAll(List<ProductMetricsMonthly> metricsList);
     int deleteByYearAndMonthBefore(Integer year, Integer month);
-    List<ProductMetricsMonthly> findAll();
+    List<ProductMetricsMonthly> findAll(Pageable pageable);
 }
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (1)

91-100: 테스트 용이성을 위해 Clock 주입 고려

Line 99에서 ZonedDateTime.now()를 직접 호출하면 단위 테스트 시 시간을 제어하기 어렵습니다. 프로덕션 환경에서는 문제없지만, 테스트 시 특정 시간을 검증해야 할 경우 어려움이 있을 수 있습니다.

🔎 Clock 주입을 통한 개선 제안

필드에 Clock 추가:

+import java.time.Clock;
+
 @Entity
 @Table(...)
 @Getter
 @NoArgsConstructor
 public class ProductMetricsMonthly extends BaseEntity {
+    
+    private Clock clock = Clock.systemDefaultZone();
+    
+    // For testing
+    public void setClock(Clock clock) {
+        this.clock = clock;
+    }

updateMetrics 메서드 수정:

     public void updateMetrics(
             Long likeCount,
             Long viewCount,
             Long orderCount
     ) {
         this.totalLikeCount = likeCount;
         this.totalViewCount = viewCount;
         this.totalOrderCount = orderCount;
-        this.aggregatedAt = ZonedDateTime.now();
+        this.aggregatedAt = ZonedDateTime.now(clock);
     }
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (1)

91-100: 테스트 용이성을 위해 Clock 주입 고려

Line 99의 ZonedDateTime.now() 직접 호출은 단위 테스트에서 시간 제어를 어렵게 만듭니다. ProductMetricsMonthly와 동일하게 Clock 주입을 고려해보세요.

🔎 Clock 주입을 통한 개선 제안
+import java.time.Clock;
+
 @Entity
 @Table(...)
 @Getter
 @NoArgsConstructor
 public class ProductMetricsWeekly extends BaseEntity {
+    
+    private Clock clock = Clock.systemDefaultZone();
+    
+    // For testing
+    public void setClock(Clock clock) {
+        this.clock = clock;
+    }
     
     // ... existing fields ...
     
     public void updateMetrics(
             Long likeCount,
             Long viewCount,
             Long orderCount
     ) {
         this.totalLikeCount = likeCount;
         this.totalViewCount = viewCount;
         this.totalOrderCount = orderCount;
-        this.aggregatedAt = ZonedDateTime.now();
+        this.aggregatedAt = ZonedDateTime.now(clock);
     }
 }
apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java (2)

61-78: 인메모리 페이징은 대용량 데이터에서 비효율적입니다.

현재 구현은 (page + 1) * size 만큼의 데이터를 조회한 후 인메모리에서 skip/limit을 적용합니다. 페이지 번호가 커질수록 불필요한 데이터를 많이 조회하게 됩니다.

Repository에서 직접 OFFSET/LIMIT을 사용하는 페이징 쿼리 메서드를 추가하는 것을 고려해주세요.

🔎 개선 제안
// Repository에 페이징 메서드 추가
List<ProductMetricsWeekly> findByYearAndWeekOrderByLikeCountDesc(
    int year, int week, int offset, int limit);

// Service에서 직접 사용
public List<Ranking> getWeeklyRankingWithPaging(RankingType type, LocalDate date, int page, int size) {
    LocalDate targetDate = date != null ? date : LocalDate.now();
    int year = targetDate.getYear();
    int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
    
    List<ProductMetricsWeekly> weeklyMetrics = switch (type) {
        case LIKE -> weeklyRepository.findByYearAndWeekOrderByLikeCountDescWithPaging(
            year, week, page * size, size);
        // ... other cases
    };
    
    return convertWeeklyToRanking(weeklyMetrics, type, page * size);
}

81-130: 중복 코드 추출을 고려해볼 수 있습니다.

convertWeeklyToRankingconvertMonthlyToRanking 메서드가 거의 동일한 로직을 가지고 있습니다. 공통 인터페이스나 함수형 접근법으로 추출하면 유지보수성이 향상될 수 있습니다.

🔎 리팩토링 예시
// 공통 변환 메서드 추출
private <T> List<Ranking> convertToRanking(
        List<T> metrics,
        RankingType type,
        Function<T, Long> productIdGetter,
        Function<T, Long> likeCountGetter,
        Function<T, Long> viewCountGetter,
        Function<T, Long> orderCountGetter
) {
    int rank = 1;
    List<Ranking> rankings = new ArrayList<>();
    
    for (T metric : metrics) {
        double score = calculateScore(type,
                likeCountGetter.apply(metric),
                viewCountGetter.apply(metric),
                orderCountGetter.apply(metric));
        
        rankings.add(Ranking.of(
                rank++,
                productIdGetter.apply(metric),
                score,
                likeCountGetter.apply(metric),
                viewCountGetter.apply(metric),
                orderCountGetter.apply(metric)
        ));
    }
    return rankings;
}
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java (2)

73-91: ISO 주차 날짜 계산 시 연도 경계 엣지 케이스 주의

ISO 8601 주차 기준으로, 1월 1일이 항상 해당 연도의 첫 주에 속하지 않을 수 있습니다. 예를 들어, 2026년 1월 1일은 ISO 기준 2025년의 53번째 주에 해당합니다.

현재 구현에서 LocalDate.of(year, 1, 1)을 시작점으로 사용하면, 특정 연도/주차 조합에서 예상치 못한 날짜가 계산될 수 있습니다.

🔎 더 안정적인 ISO 주차 날짜 계산 방식 제안
     @Bean
     @StepScope
     public RepositoryItemReader<WeeklyAggregationDto> weeklyMetricsReader(
             @Value("#{jobParameters['year']}") Integer year,
             @Value("#{jobParameters['week']}") Integer week
     ) {
         // 주간 시작일/종료일 계산
-        LocalDate startDate = LocalDate.of(year, 1, 1)
-                .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week)
-                .with(DayOfWeek.MONDAY);
+        LocalDate startDate = LocalDate.ofYearDay(year, 1)
+                .with(IsoFields.WEEK_BASED_YEAR, year)
+                .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week)
+                .with(DayOfWeek.MONDAY);
         LocalDate endDate = startDate.plusDays(6);

93-103: Processor와 Writer의 불필요한 @StepScope 제거 고려

weeklyMetricsProcessor()weeklyMetricsWriter()는 job parameter를 사용하지 않으므로 @StepScope가 필요하지 않습니다. @StepScope는 매 Step 실행마다 새 인스턴스를 생성하여 약간의 오버헤드가 발생합니다.

🔎 @StepScope 제거 제안
     @Bean
-    @StepScope
     public ItemProcessor<WeeklyAggregationDto, ProductMetricsWeekly> weeklyMetricsProcessor() {
         return new WeeklyMetricsProcessor();
     }

     @Bean
-    @StepScope
     public ItemWriter<ProductMetricsWeekly> weeklyMetricsWriter() {
         return new WeeklyMetricsWriter(weeklyRepository);
     }
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java (1)

89-99: Processor와 Writer의 불필요한 @StepScope 제거 고려

주간 설정과 동일하게, monthlyMetricsProcessor()monthlyMetricsWriter()는 job parameter를 사용하지 않으므로 @StepScope가 불필요합니다.

🔎 @StepScope 제거 제안
     @Bean
-    @StepScope
     public ItemProcessor<MonthlyAggregationDto, ProductMetricsMonthly> monthlyMetricsProcessor() {
         return new MonthlyMetricsProcessor();
     }

     @Bean
-    @StepScope
     public ItemWriter<ProductMetricsMonthly> monthlyMetricsWriter() {
         return new MonthlyMetricsWriter(monthlyRepository);
     }
apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (1)

16-18: @component 대신 @repository 사용 고려

이 클래스는 데이터 접근 계층을 담당하므로, @Component 대신 @Repository를 사용하면 의미적으로 더 명확하고, Spring의 예외 변환(exception translation) 기능도 활용할 수 있습니다.

🔎 @repository 사용 제안
-import org.springframework.stereotype.Component;
+import org.springframework.stereotype.Repository;

 /**
  * 주간 상품 집계 Repository 구현 (읽기 전용)
  * commerce-collector에서 생성한 집계 데이터 조회
  */
-@Component
+@Repository
 @RequiredArgsConstructor
 public class ProductMetricsWeeklyRepositoryImpl implements ProductMetricsWeeklyRepository {
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0129d10 and d5068dd.

📒 Files selected for processing (40)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
  • apps/commerce-collector/build.gradle.kts
  • apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsProcessor.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java
  • apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java
  • apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2026-01-01T14:12:04.385Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/ChunkListener.kt:4-5
Timestamp: 2026-01-01T14:12:04.385Z
Learning: In Spring Batch 6.x, ChunkListener API changed from using ChunkContext to Chunk<T> parameters. The correct method signatures are: beforeChunk(Chunk<T> chunk), afterChunk(Chunk<T> chunk), and onChunkError(Exception exception, Chunk<T> chunk). The import path is org.springframework.batch.core.listener.ChunkListener.

Applied to files:

  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
📚 Learning: 2026-01-01T14:12:07.943Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/JobListener.kt:4-5
Timestamp: 2026-01-01T14:12:07.943Z
Learning: Spring Batch 6.x에서 JobExecution은 org.springframework.batch.core.job 패키지에, JobExecutionListener는 org.springframework.batch.core.listener 패키지에 위치합니다. Spring Batch 5.x와 달리 하위 패키지를 사용합니다.

Applied to files:

  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.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-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java
  • apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.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-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java
📚 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-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java
  • apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java
🧬 Code graph analysis (10)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java (2)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java (1)
  • Slf4j (14-53)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
  • Slf4j (14-45)
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (2)
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java (1)
  • Slf4j (12-25)
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java (1)
  • Slf4j (20-189)
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/MonthlyMetricsWriter.java (1)
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java (1)
  • Slf4j (17-84)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java (3)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)
  • Component (16-154)
apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java (1)
  • Component (16-67)
apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (1)
  • Component (16-67)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (6)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
  • Slf4j (9-21)
apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java (1)
  • Slf4j (14-53)
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java (1)
  • Slf4j (12-25)
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java (1)
  • Slf4j (20-189)
apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (1)
  • Slf4j (17-84)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
  • log (22-119)
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (1)
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java (1)
  • Getter (13-26)
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (3)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java (1)
  • Getter (6-49)
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (1)
  • Entity (15-66)
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
  • name (14-87)
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/Ranking.java (1)
  • Getter (6-49)
apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (1)
  • Entity (15-66)
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobConfig.java (1)
apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsProcessor.java (1)
  • WeeklyMetricsProcessor (10-31)
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (1)
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java (1)
  • Getter (13-26)
🔇 Additional comments (46)
apps/commerce-collector/build.gradle.kts (2)

36-36: Spring Batch 테스트 종속성이 올바르게 추가되었습니다.

spring-batch-testtestImplementation 스코프로 적절하게 추가되어 배치 작업 테스트(JobLauncherTestUtils, StepScopeTestExecutionListener 등)를 지원할 수 있습니다.


14-14: Spring Batch 종속성이 적절히 추가되었습니다.

spring-boot-starter-batchspring-batch-test 종속성이 올바르게 추가되었습니다. 종속성은 적절히 스코프 처리되었으며(implementation vs testImplementation), Spring Boot 3.4.4의 의존성 관리를 통해 버전이 관리됩니다.

2025~2026년 현재까지 Spring Batch 관련 보안 취약점(CVE)이 발견되지 않았으므로 보안상 문제가 없습니다.

apps/commerce-collector/src/main/java/com/loopers/application/ranking/RankingScheduler.java (1)

135-141: LGTM! 보관 기간 변경이 적절합니다.

주간/월간 집계 배치 작업을 지원하기 위해 10일에서 30일로 보관 기간을 확장한 것은 합리적입니다. 월간 집계에는 최소 30일의 데이터가 필요합니다.

apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java (2)

31-54: GROUP BY와 Page 조합 시 count 쿼리 검증 필요

Spring Data JPA에서 GROUP BY가 포함된 쿼리에 Page를 사용할 경우, 자동 생성되는 count 쿼리가 올바르게 동작하지 않을 수 있습니다. GROUP BY 결과의 행 수가 아닌 전체 레코드 수를 반환할 수 있습니다.

count 쿼리를 명시적으로 지정하는 것을 권장합니다:

🔎 제안하는 수정 사항
     @Query(value = """
         SELECT new com.loopers.domain.metrics.dto.WeeklyAggregationDto(
             p.productId,
             :year,
             :week,
             :startDate,
             :endDate,
             SUM(p.likeDelta),
             SUM(p.viewDelta),
             SUM(p.orderDelta),
             0L
         )
         FROM ProductMetricsDaily p
         WHERE p.metricDate BETWEEN :startDate AND :endDate
         GROUP BY p.productId
         ORDER BY p.productId
-    """)
+    """,
+    countQuery = """
+        SELECT COUNT(DISTINCT p.productId)
+        FROM ProductMetricsDaily p
+        WHERE p.metricDate BETWEEN :startDate AND :endDate
+    """)
     Page<WeeklyAggregationDto> findWeeklyAggregation(

56-82: 월간 집계 쿼리도 동일하게 count 쿼리 명시 필요

주간 집계와 동일한 이유로, 월간 집계 쿼리에도 명시적인 count 쿼리를 추가하세요.

🔎 제안하는 수정 사항
     @Query(value = """
         SELECT new com.loopers.domain.metrics.dto.MonthlyAggregationDto(
             p.productId,
             :year,
             :month,
             :startDate,
             :endDate,
             SUM(p.likeDelta),
             SUM(p.viewDelta),
             SUM(p.orderDelta),
             0L
         )
         FROM ProductMetricsDaily p
         WHERE p.metricDate BETWEEN :startDate AND :endDate
         GROUP BY p.productId
         ORDER BY p.productId
-    """)
+    """,
+    countQuery = """
+        SELECT COUNT(DISTINCT p.productId)
+        FROM ProductMetricsDaily p
+        WHERE p.metricDate BETWEEN :startDate AND :endDate
+    """)
     Page<MonthlyAggregationDto> findMonthlyAggregation(
apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java (1)

27-66: LGTM! 인터페이스 설계가 잘 되어 있습니다.

주간/월간 집계 메서드에 대한 Javadoc이 명확하고, 페이징 지원이 Spring Batch Job과의 통합에 적합합니다. 기존 코드베이스의 Page/Pageable 사용 패턴과 일관성이 있습니다.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java (1)

1-15: LGTM! 깔끔한 enum 설계입니다.

PeriodType enum이 명확하게 정의되어 있으며, 한국어 레이블이 사용자 친화적입니다. Lombok을 활용하여 보일러플레이트를 최소화한 점도 좋습니다.

apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (2)

15-57: 읽기 전용 엔티티 설계가 적절합니다.

Materialized View에 매핑되는 읽기 전용 엔티티로 @Immutable 어노테이션이 적절하게 사용되었습니다. JPA를 통해 조회만 가능하며, 실제 데이터는 commerce-collector의 배치 작업에서 생성됩니다.


15-65: AI 요약과 실제 코드 간의 불일치가 있습니다.

AI 요약에서 언급된 다음 메서드들이 실제 코드에는 존재하지 않습니다:

  • static factory create(productId, year, month, periodStartDate, periodEndDate)
  • updateMetrics(likeCount, viewCount, orderCount)
  • reset()

읽기 전용 엔티티이므로 이러한 메서드가 없는 것이 올바른 설계이며, 실제 코드가 정확합니다.

apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/WeeklyAggregationDto.java (1)

13-26: DTO 설계가 깔끔하고 적절합니다.

Spring Batch ItemReader에서 사용될 주간 집계 데이터 DTO가 올바르게 정의되었습니다. Lombok 어노테이션을 사용하여 보일러플레이트 코드를 효과적으로 제거했습니다.

apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeeklyRepository.java (1)

6-36: 저장소 인터페이스가 잘 설계되었습니다.

주간 메트릭스 조회를 위한 다양한 정렬 기준(좋아요, 조회수, 주문수, 복합 점수)을 제공하며, 메서드 명명이 명확하고 Javadoc이 적절하게 작성되었습니다.

apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthlyRepository.java (1)

6-36: 저장소 인터페이스가 잘 설계되었습니다.

월간 메트릭스 조회를 위한 다양한 정렬 기준을 제공하며, ProductMetricsWeeklyRepository와 일관된 설계 패턴을 따르고 있습니다. 메서드 명명과 문서화가 적절합니다.

apps/commerce-collector/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)

14-15: 코드는 현재 프로젝트의 Spring Batch 버전과 일치합니다.

프로젝트는 Spring Boot 3.4.4를 사용하며, 이는 Spring Batch 5.2.2를 포함합니다. Spring Batch 5.2.2에서 ChunkListener의 올바른 메서드 시그니처는 ChunkContext 파라미터를 사용하는 것입니다:

@AfterChunk
void afterChunk(ChunkContext chunkContext)

현재 코드는 Spring Batch 5.2.2 API 사양을 정확히 따르고 있으므로 문제가 없습니다. Chunk<T> 파라미터로의 변경은 Spring Batch 6.x 이상으로 업그레이드할 때만 필요합니다.

apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java (1)

11-20: 삭제 쿼리 로직 확인 완료.

년도와 주차를 기준으로 이전 데이터를 삭제하는 로직이 올바르게 구현되었습니다. (m.year < :year) OR (m.year = :year AND m.week < :week) 조건은 정확히 의도한 대로 동작합니다.

apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (2)

15-57: 읽기 전용 엔티티 구조가 잘 설계되었습니다.

@Immutable 어노테이션과 함께 읽기 전용 엔티티로 명확히 정의되어 있어, Materialized View의 특성을 코드 레벨에서 잘 표현하고 있습니다. 주간 집계 데이터를 위한 필드 구성도 적절합니다.


59-65: 점수 계산 로직의 일관성이 확보되었습니다.

calculateCompositeScore() 메서드가 ProductMetricsMonthly(apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java Line 57-61)와 동일한 가중치(좋아요 0.2, 조회 0.1, 주문 0.6)를 사용하여 일관성을 유지하고 있습니다.

apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java (1)

11-20: 삭제 쿼리 로직 확인 완료.

년도와 월을 기준으로 이전 데이터를 삭제하는 로직이 올바르게 구현되었습니다. (m.year < :year) OR (m.year = :year AND m.month < :month) 조건은 정확히 의도한 대로 동작합니다.

apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobTest.java (5)

26-61: 테스트 설정이 적절합니다.

Spring Batch 테스트 설정과 생명주기 관리가 올바르게 구성되어 있습니다. DatabaseCleanUp을 사용한 테이블 정리 방식도 적절합니다.


63-123: 월간 집계 성공 케이스가 잘 검증되었습니다.

3개 상품에 대한 31일간 집계 데이터 생성 및 검증 로직이 정확합니다. Job/Step 상태와 집계 결과(310 likes, 3100 views, 155 orders)가 올바르게 검증되고 있습니다.


125-151: 데이터 부재 시나리오가 적절히 검증되었습니다.

Daily 데이터가 없을 때 Job이 성공하지만 처리 건수가 0이고 Monthly 데이터가 생성되지 않는 것을 올바르게 검증하고 있습니다.


153-206: UPSERT 동작이 정확히 검증되었습니다.

동일 월에 대해 재실행 시 새 레코드가 생성되지 않고 기존 레코드가 업데이트되는 것(10 → 30)을 올바르게 테스트하고 있습니다.


208-272: Step 실행과 월별 일수 처리가 잘 검증되었습니다.

특정 Step만 실행하는 테스트와 2월(28일)과 12월(31일)의 일수 차이를 정확히 검증하는 테스트가 모두 적절합니다. 2025년이 평년임을 고려한 28일 검증도 정확합니다.

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

67-88: 상품 정보 필드가 올바르게 매핑되었습니다.

RankingItemproduct 필드가 추가되었고 from() 메서드에서 info.product()를 사용하여 정확히 매핑하고 있습니다.


16-36: 이 검토 의견은 근거가 없습니다. periodType이 null일 때 NPE가 발생할 수 있다는 우려는 타당하지 않습니다.

RankingFacade 클래스의 getTopRanking()getRankingWithPaging() 메서드에서 다음과 같이 null 처리가 명시적으로 구현되어 있습니다:

PeriodType period = periodType != null ? periodType : PeriodType.DAILY;

따라서 null로 전달되더라도 기본값(DAILY)으로 자동 설정되므로 서비스 레이어에서 NPE 위험은 없습니다. DTO에 @NotNull 추가나 기본값 설정은 불필요합니다.

Likely an incorrect or invalid review comment.

apps/commerce-collector/src/main/java/com/loopers/batch/metrics/WeeklyMetricsWriter.java (2)

18-19: unchecked cast는 Spring Batch 패턴에서 불가피합니다.

Line 19의 unchecked cast는 Spring Batch의 제네릭 제약으로 인해 불가피하며, 런타임에 안전합니다.


12-25: @component 어노테이션이 필요하지 않습니다.

WeeklyMetricsWriterProductMetricsWeeklyJobConfig에서 @Bean 메서드로 명시적으로 인스턴스화되고 있습니다. 이는 Spring Batch의 표준 패턴으로, 자동 스캔이 아닌 설정 클래스에서 직접 빈을 생성합니다. @Component 어노테이션 없이 현재 구현이 정상 작동하므로 추가 조치가 필요하지 않습니다.

Likely an incorrect or invalid review comment.

apps/commerce-collector/src/main/java/com/loopers/batch/listener/JobListener.java (1)

25-52: Job 완료 시 duration 계산 및 로깅이 적절합니다.

실행 시간 계산과 한국어 시간 단위(시간/분/초) 포매팅이 정확하며, 로깅 로직에 문제가 없습니다.

apps/commerce-collector/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)

19-22: Step 시작 로깅이 적절합니다.

Step 시작 시 Step 이름을 올바른 SLF4J 구문으로 로깅하고 있습니다.

apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsMonthly.java (1)

16-28: 테이블 설계가 잘 되어 있습니다

Unique constraint와 인덱스 설정이 적절합니다:

  • uk_product_year_month는 중복 데이터 방지
  • idx_year_month는 기간별 조회 최적화
  • idx_product_id는 상품별 조회 최적화

이는 배치 upsert 및 랭킹 조회 패턴에 잘 맞습니다.

apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java (2)

16-28: 테이블 설계가 적절합니다

Unique constraint와 인덱스 구성이 주간 랭킹 조회 패턴에 잘 맞습니다:

  • uk_product_year_week로 중복 방지
  • idx_year_week로 기간별 조회 최적화
  • idx_product_id로 상품별 조회 최적화

1-111: AI 요약의 모호함: calculateCompositeScore() 메서드 위치 명확화 필요

검토 대상인 apps/commerce-collector/src/main/java/com/loopers/domain/metrics/ProductMetricsWeekly.java 파일에는 실제로 calculateCompositeScore() 메서드가 없습니다. 다만, 이는 코드 결함이 아니라 의도적인 아키텍처 설계입니다.

코드베이스에는 동일한 이름의 ProductMetricsWeekly 엔티티가 두 모듈에 존재합니다:

  • commerce-collector: 배치 집계용 쓰기 엔티티 (BaseEntity 상속) - 점수 계산 메서드 없음
  • commerce-api: 읽기 전용 엔티티 (@immutable) - calculateCompositeScore() 메서드 구현됨 (가중치: like×0.2 + view×0.1 + order×0.6)

commerce-collector 모듈에서는 배치 처리 중 메트릭 집계만 담당하며, 점수 계산은 읽기 시점에 commerce-api의 엔티티 메서드나 PeriodRankingService 등에서 수행됩니다. 따라서 현재 코드는 정상이며, AI 요약이 컨텍스트 없이 메서드 추가를 언급한 것으로 보입니다.

apps/commerce-collector/src/main/java/com/loopers/domain/metrics/dto/MonthlyAggregationDto.java (1)

1-26: LGTM!

DTO 구조가 월간 집계 용도에 적합하며, JPA 프로젝션 매핑을 위한 @NoArgsConstructor와 편의성을 위한 @AllArgsConstructor가 올바르게 적용되어 있습니다.

apps/commerce-collector/src/test/java/com/loopers/batch/metrics/job/ProductMetricsWeeklyJobTest.java (2)

125-232: LGTM!

나머지 테스트 케이스들이 잘 구성되어 있습니다:

  • 빈 데이터 시나리오 처리
  • UPSERT 멱등성 검증 (10 + 20 = 30)
  • Step 단위 실행 검증

테스트 커버리지가 배치 Job의 주요 동작을 포괄적으로 다루고 있습니다.


63-123: 테스트 케이스가 잘 구성되어 있습니다.

주간 집계 배치 Job의 성공 시나리오를 적절히 검증하고 있으며, 7일간의 Daily 데이터가 Weekly로 올바르게 집계됩니다. ISO 주차 계산도 정확합니다: 2025-12-01은 월요일이며 ISO 주차 49의 첫 날입니다(49주 = 2025-12-01 월 ~ 2025-12-07 일).

apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodRankingService.java (1)

132-139: 복합 점수 가중치 합계가 1.0이 아닙니다.

ALL 타입의 가중치 합계가 0.9입니다 (0.2 + 0.1 + 0.6). 의도된 것인지 확인이 필요합니다. 만약 정규화된 점수가 필요하다면 가중치 조정을 고려해주세요.

apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (1)

25-28: LGTM!

단일 저장 및 조회/삭제 메서드가 JPA Repository에 적절히 위임되어 있습니다.

Also applies to: 75-83

apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java (1)

1-67: LGTM!

읽기 전용 Repository 구현이 JPA Repository에 적절히 위임되어 있으며, commerce-api 모듈의 ProductMetricsWeeklyRepositoryImpl과 일관된 패턴을 따르고 있습니다.

apps/commerce-collector/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyRepositoryImpl.java (1)

25-28: LGTM!

단일 저장 및 조회/삭제 메서드가 JPA Repository에 적절히 위임되어 있습니다.

Also applies to: 75-83

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

37-41: DAILY 타입에서 date 파라미터가 무시됩니다.

DAILY 케이스에서 전달된 date 파라미터 대신 LocalDate.now()를 사용하고 있습니다. 이는 의도된 동작일 수 있지만, API 사용자가 특정 날짜의 일일 랭킹을 조회하려 할 때 혼란을 줄 수 있습니다.

의도된 동작이라면 Javadoc에 이 동작을 명시하거나, 파라미터명을 weeklyOrMonthlyDate로 변경하는 것을 고려해주세요.

Also applies to: 64-68


21-21: LGTM!

PeriodRankingService 통합이 잘 구현되었습니다. switch 표현식을 사용한 기간별 라우팅 로직이 명확하고, null 처리를 통한 DAILY 기본값 적용이 적절합니다.

Also applies to: 28-48

apps/commerce-collector/src/main/java/com/loopers/batch/metrics/job/ProductMetricsMonthlyJobConfig.java (1)

46-64: LGTM!

월간 배치 Job 구성이 주간 구성과 일관된 패턴으로 잘 구현되었습니다. Job/Step/Chunk 리스너 연결과 chunk 크기(100)가 pageSize와 일치하여 효율적인 처리가 가능합니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsMonthlyJpaRepository.java (2)

33-42: 복합 점수 가중치 합계 확인 필요

현재 복합 점수 계산식의 가중치 합이 0.9입니다 (0.2 + 0.1 + 0.6 = 0.9). 이것이 의도된 설계인지, 아니면 가중치가 1.0이 되어야 하는지 확인이 필요합니다.

또한, 이 쿼리가 자주 호출된다면 (year, month) 복합 인덱스가 성능에 도움이 될 수 있습니다.


12-53: LGTM!

Repository 인터페이스가 Spring Data JPA 명명 규칙을 잘 따르고 있으며, 다양한 정렬 기준(좋아요, 조회수, 주문수, 복합 점수)에 대한 쿼리 메서드가 잘 구성되어 있습니다. Based on learnings, Pageable 사용은 이 코드베이스의 허용된 아키텍처 결정입니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyRepositoryImpl.java (1)

22-66: LGTM!

JPA Repository로의 위임 패턴이 깔끔하게 구현되었습니다. PageRequest.of(0, limit)를 사용한 페이지네이션 처리도 적절합니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsWeeklyJpaRepository.java (2)

33-42: 복합 점수 가중치 합계 - Monthly Repository와 동일한 확인 필요

Monthly Repository와 동일하게 가중치 합이 0.9입니다. 두 repository 간 일관성은 유지되어 있으나, 의도된 설계인지 확인이 필요합니다.


12-53: LGTM!

주간 Repository가 월간 Repository와 일관된 패턴으로 잘 구현되었습니다.

참고: AI 요약에서 deleteByYearAndWeekBefore 메서드가 언급되었으나 실제 코드에는 존재하지 않습니다. 해당 메서드가 다른 파일에 있거나 요약이 부정확할 수 있습니다.

- 오타 수정
- NPE 방지 코드 적용
@HongChangMo HongChangMo changed the title Round 10 [volume-10] Collect, Stack, Zip Jan 2, 2026
@HongChangMo
Copy link
Collaborator Author

@coderabbitai 아래의 체크리스트를 만족하는지 확인해줘

🧱 Spring Batch

  • Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.
  • 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다.

🧩 Ranking API

  • API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.

@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

Tip

For best results, initiate chat on the files or code changes.

@HongChangMo 체크리스트 항목들을 확인한 결과, 모든 요구사항이 충족되었습니다! ✅

🧱 Spring Batch

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

    • ProductMetricsWeeklyJobConfigProductMetricsMonthlyJobConfig가 구현되어 있습니다.
    • @StepScope를 사용하여 Job Parameter를 주입받습니다:
      • 주간 집계: year, week 파라미터
      • 월간 집계: year, month 파라미터
    • 테스트 코드에서 JobParameters를 통해 파라미터 기반 실행을 검증하고 있습니다.
  • Chunk Oriented Processing (Reader/Processor/Writer) 기반의 배치 처리를 구현했다.

    • 주간 집계:
      • Reader: RepositoryItemReader<WeeklyAggregationDto>
      • Processor: WeeklyMetricsProcessor
      • Writer: WeeklyMetricsWriter
    • 월간 집계:
      • Reader: RepositoryItemReader<MonthlyAggregationDto>
      • Processor: MonthlyMetricsProcessor
      • Writer: MonthlyMetricsWriter
    • 두 Job 모두 chunk size 100으로 설정되어 있습니다.
  • 집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다.

    • 주간 MV: ProductMetricsWeekly (테이블: mv_product_metrics_weekly)
      • productId, year, week, period dates, 집계 카운트(like/view/order)
      • unique constraint: (product_id, year, week)
    • 월간 MV: ProductMetricsMonthly (테이블: mv_product_metrics_monthly)
      • productId, year, month, period dates, 집계 카운트(like/view/order)
      • unique constraint: (product_id, year, month)
    • JDBC의 bulk UPSERT (ON DUPLICATE KEY UPDATE)를 사용하여 효율적으로 적재합니다.

🧩 Ranking API

  • API가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.
    • PeriodType enum (DAILY, WEEKLY, MONTHLY)이 추가되었습니다.
    • RankingFacade가 PeriodType에 따라 적절한 데이터 소스로 라우팅합니다:
      • DAILY: Redis ZSET (기존 로직)
      • WEEKLY: ProductMetricsWeekly MV (PeriodRankingService)
      • MONTHLY: ProductMetricsMonthly MV (PeriodRankingService)
    • API 엔드포인트에서 periodType 파라미터를 받아 처리합니다.

모든 요구사항이 잘 구현되어 있으며, 테스트 코드로도 검증되어 있습니다! 🎉

@HongChangMo HongChangMo self-assigned this Jan 2, 2026
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