⚠ 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#245

Open
sky980221 wants to merge 7 commits intoLoopers-dev-lab:sky980221from
sky980221:round-10-quests
Open

[volume-10] Collect, Stack, Zip#245
sky980221 wants to merge 7 commits intoLoopers-dev-lab:sky980221from
sky980221:round-10-quests

Conversation

@sky980221
Copy link
Collaborator

@sky980221 sky980221 commented Jan 2, 2026

📌 Summary

주/월간 랭킹 배치 구현: Rolling 7/30일 윈도우로 집계 후 MV(mv_product_rank_weekly/monthly) 적재하였습니다.

💬 Review Points

  1. API에서 파라미터 추가 없이 서버에서 날짜 범위를 계산해 한 번에 내려줘야 할지, 아니면 period(Daily, Weekly, Monthly) 같은 추가 파라미터까지는 넣어도 될지 고민이 되었습니다.

    1. 무신사 홈페이지를 기준으로 보았을 때, 유저가 특정 주간의 랭킹을 직접 조회가 가능하지는 않고 오늘 날짜를 기준으로 실시간, 1일, 1주, 1개월 랭킹만 조회가 가능한데 이러한 이유가 있을까요? 유저가 5월 둘째주 랭킹을 알고싶을 수도 있는데 성능이나 데이터 적재량 때문에 제공을 하지 않는걸까요 ?
  2. 딥하게 생각하는 팁이 궁금합니다. 멘토님들의 조언을 통해 “나는 이런 고민했는데 왜 이런거예요?” 같은 생각의 꼬리의 꼬리를 무는 습관을 들여야 한다는 것을 배웠습니다. 근데 저는 어느 순간부터 스스로 “아 여기까지만 알면 되겠다”, “돌아가면 됐지~” 라는 잘못된 판단을 내리는 습관이 생긴 것 같습니다. 추가적으로 멘토님께서는 블랙박스로 써도 되는 영역의 경계를 어떻게 나누시는지도 궁금합니다.

✅ Checklist

🧱 Spring Batch

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

🧩 Ranking API

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

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 상품 순위 조회에 주간 및 월간 순위 뷰 추가
    • 배치 작업 시스템으로 주기별 순위 자동 계산 지원
    • 비동기 메서드 실행 활성화
    • 순위 API에 기간 유형 선택 옵션 추가
  • 개선 사항

    • 이벤트 처리 및 결제 처리 트랜잭션 격리 강화
    • 순위 데이터에 점수 및 재고 정보 추가
  • 테스트

    • 동시성 처리 및 이벤트 발행 검증 개선

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

@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

주간 및 월간 상품 순위 배치 처리 기능을 추가하고, 비동기 이벤트 처리를 활성화하며, 순위 API에 기간 선택 파라미터를 도입합니다. 새로운 배치 모듈과 JPA 엔티티를 추가하고 데이터베이스 마이그레이션을 포함합니다.

Changes

Cohort / File(s) 변경 요약
CommerceApi 비동기 및 이벤트 처리
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java, apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java
@EnableAsync 추가, 이벤트 핸들러에 @Transactional(REQUIRES_NEW) 적용으로 트랜잭션 격리 강화
순위 API 및 Facade 확장
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java, apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java
주간/월간 순위 조회 메서드 추가, periodType 파라미터 도입, DTO에 rank/score/stock 필드 추가
순위 도메인 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java, apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java
주간/월간 순위 JPA 엔티티 및 저장소 인터페이스 추가
테스트 코드 수정
apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java, apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
비동기 대기 유틸리티 추가, 이벤트 발행 검증으로 테스트 변경
배치 애플리케이션 구성
apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java, apps/commerce-batch/src/main/resources/application.yml
새 배치 모듈 추가, 시간대 설정, 배치 스키마 초기화 구성
배치 작업 및 단계 설정
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java
데모 작업 및 주간/월간 순위 배치 작업 구성 추가
배치 단계 컴포넌트
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java
Reader/Processor/Writer로 순위 집계, 처리, 저장 로직 구현
배치 스케줄러 및 리스너
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
주간/월간 순위 작업 일정 예약, 배치 실행 모니터링 리스너 추가
배치 도메인 및 저장소
apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java, apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java
순위 JPA 엔티티, 집계 데이터 클래스, 배치 전용 저장소 추가
배치 테스트
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java, apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
배치 애플리케이션 및 데모 작업 통합 테스트 추가
이벤트 컨슈머 개선
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
배치 리스너 팩토리 적용, 이벤트 역직렬화 헬퍼 메서드 추가
설정 및 마이그레이션
apps/commerce-streamer/src/main/resources/application.yml, apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql, modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java, modules/jpa/src/main/resources/jpa.yml, supports/monitoring/src/main/resources/monitoring.yml, settings.gradle.kts, apps/commerce-api/src/main/resources/application.yml
순위 관련 테이블 생성, 저장소 스캔 범위 확대, 포트 구성 매개변수화, 배치 모듈 포함

Sequence Diagram(s)

sequenceDiagram
    actor Scheduler as ProductRankingJobScheduler
    participant JobLauncher as JobLauncher
    participant Reader as RankingScoreReader
    participant Processor as RankingScoreProcessor
    participant Writer as RankingWriter
    participant DB as Database

    Scheduler->>JobLauncher: runWeeklyRanking()/runMonthlyRanking()
    JobLauncher->>Reader: read()
    Reader->>DB: SELECT SUM(like/view/order/sales) GROUP BY product_id
    DB-->>Reader: ProductRankingAggregation list
    Reader-->>Processor: aggregate(ProductRankingAggregation)
    
    loop Process each aggregation
        Processor->>Processor: calculateScore() + assign rank
        Processor-->>Writer: WeeklyProductRank/MonthlyProductRank
    end
    
    Writer->>DB: DELETE existing period data
    Writer->>DB: INSERT new rankings
    DB-->>Writer: Success
    Writer-->>JobLauncher: Completed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #225: 이벤트 기반 아웃박스 패턴 구현으로, 주요 PR에서 추가된 @EnableAsync가 비동기 @TransactionalEventListener 핸들러 실행을 가능하게 함
  • PR #210: 상품 순위 기능을 구현하며, RankingFacade/RankingProductInfo/순위 저장소 등 동일한 순위 영역 코드 수정
  • PR #124: CommerceApiApplication.java에서 Spring 기능 애노테이션(@EnableAsync vs @EnableScheduling) 추가로 동일 클래스 수정

Suggested labels

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 3.45% 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'은 모호하고 변경사항의 구체적인 내용을 명확히 전달하지 않습니다. 제목을 '[volume-10] 주간/월간 랭킹 배치 및 API 구현' 또는 '[volume-10] Weekly/Monthly Ranking Batch Job Implementation' 같이 변경사항을 명확히 반영하는 것을 권장합니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 주요 구성요소(Summary, Review Points, Checklist, References 섹션)를 충분히 포함하고 있으며, 구현 내용과 검토 포인트가 명확히 제시되어 있습니다.

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

♻️ Duplicate comments (1)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java (1)

72-80: 중복 코드 제거 필요

readEvent 메서드는 CatalogEventConsumer의 동일 메서드와 완전히 중복됩니다. CatalogEventConsumer의 리뷰 코멘트를 참고하여 공통 유틸리티로 추출해주세요.

🧹 Nitpick comments (30)
settings.gradle.kts (1)

3-14: LGTM!

새로운 commerce-batch 모듈이 정상적으로 추가되었습니다. 다만, 모듈 순서가 appsmodulessupports 패턴과 약간 다르게 :modules:pg-simulator 뒤에 배치되어 있습니다. 기능에는 영향이 없지만, 가독성을 위해 다른 apps 모듈들과 함께 그룹화하는 것을 고려해 보세요.

apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java (1)

123-136: awaitTrue가 타임아웃 시 실패하지 않으면 테스트 신뢰도가 저하될 수 있습니다.

현재 구현은 3초 내에 조건이 충족되지 않아도 조용히 반환됩니다. 이후 assertThat에서 실패가 감지되긴 하지만, 이 경우 awaitTrue가 단순 지연 역할만 하게 됩니다. 타임아웃 시 명확한 실패를 던지는 것이 테스트 의도를 더 잘 표현합니다.

🔎 타임아웃 시 AssertionError를 던지도록 개선
 private static void awaitTrue(BooleanSupplier condition) {
     long deadline = System.currentTimeMillis() + 3000; // wait up to 3s
     while (System.currentTimeMillis() < deadline) {
         if (condition.getAsBoolean()) {
             return;
         }
         try {
             Thread.sleep(20);
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            return;
+            throw new AssertionError("Interrupted while waiting for condition");
         }
     }
+    throw new AssertionError("Condition not met within 3 seconds");
 }

또는 Awaitility 라이브러리 사용을 고려해 보세요:

await().atMost(3, SECONDS).until(condition::getAsBoolean);
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)

14-17: @RequiredArgsConstructor가 불필요합니다.

이 클래스에는 final 필드가 없어서 @RequiredArgsConstructor가 빈 생성자만 생성합니다. 제거해도 기능에 영향이 없습니다.

🔎 불필요한 어노테이션 제거
 @Slf4j
-@RequiredArgsConstructor
 @Component
 public class JobListener {
apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java (1)

38-39: save() 호출은 생략할 수 있습니다.

@Transactional 컨텍스트 내에서 엔티티 상태 변경은 JPA dirty checking에 의해 자동으로 flush됩니다. 기존 learnings에 따르면, 이 프로젝트에서는 명시적 save() 호출보다 dirty checking을 활용하는 패턴을 선호합니다.

🔎 제안된 변경
            order.markAsConfirmed();
-           orderRepository.save(order);

Line 64의 orderRepository.save(order)도 동일하게 적용됩니다.

apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java (1)

30-31: save() 호출 생략 가능

PaymentEventHandler와 동일하게, @Transactional 내에서 dirty checking이 적용되므로 명시적 save() 호출은 선택 사항입니다. Learnings에 따른 프로젝트 패턴과 일관성을 위해 생략을 고려해 볼 수 있습니다.

🔎 제안된 변경
                    .ifPresent(product -> {
                        product.increaseLikeCount();
-                       productRepository.save(product);
                    });

Line 50의 decreaseLikeCount()save() 호출도 동일하게 적용됩니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)

17-19: periodType을 enum 타입으로 변경 권장

현재 String 타입으로 구현되어 있으며, 잘못된 값이 입력되면 조용히 "DAILY" 기본값으로 처리됩니다. enum 타입으로 변경하면 Spring에서 자동으로 문자열을 변환하고, 잘못된 값에 대해 400 Bad Request를 반환하여 더 명확한 에러 처리가 가능합니다.

enum 타입 사용 제안
public enum PeriodType {
    DAILY, WEEKLY, MONTHLY
}
-@Parameter(description = "Period Type (DAILY, WEEKLY, MONTHLY)", example = "DAILY")
-@RequestParam(required = false) String periodType,
+@Parameter(description = "Period Type", example = "DAILY")
+@RequestParam(required = false) PeriodType periodType,

컨트롤러의 switch 문도 String 비교 대신 enum 상수 비교로 변경하면 타입 안정성이 향상됩니다.

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

23-43: 에러 처리 로직이 적절하며, 향후 개선 계획이 명시되어 있습니다.

예외 메시지 수집 및 로깅 로직이 올바르게 구현되었습니다. Line 39의 Slack 전송 계획에 대해 Issue로 트래킹하거나 TODO 주석을 추가하는 것을 권장합니다.

Line 39의 모니터링 알림 기능 구현을 위한 별도 Issue를 생성하시겠습니까?

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

37-38: totalScoreBigDecimal 사용을 고려해 보세요.

salesAmountBigDecimal을 사용하고 있는데, totalScore가 금액 기반 계산을 포함한다면 Double의 부동소수점 정밀도 문제가 발생할 수 있습니다. 점수 계산 로직에 따라 BigDecimal 사용을 검토해 보세요.

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

45-59: periodType에 enum 사용과 명시적 에러 처리를 고려해 보세요.

현재 잘못된 periodType 값이 들어오면 조용히 DAILY로 폴백됩니다. 이는 클라이언트가 오타를 내도 에러를 인지하지 못하는 문제가 있습니다.

또한, switch 케이스의 문법이 일관되지 않습니다 (DAILY는 중괄호 없이, WEEKLY/MONTHLY는 중괄호 사용).

🔎 enum 사용 및 일관된 switch 문법 제안
-        String period = (periodType == null || periodType.isBlank())
-                ? "DAILY"
-                : periodType.trim().toUpperCase();
-
-        List<RankingProductInfo> rankings;
-        switch (period) {
-            case "DAILY" -> rankings = rankingFacade.getDailyRanking(targetDate, page, size);
-            case "WEEKLY" -> {
-                rankings = rankingFacade.getWeeklyRanking(targetDate, page, size);
-            }
-            case "MONTHLY" -> {
-                rankings = rankingFacade.getMonthlyRanking(targetDate, page, size);
-            }
-            default -> rankings = rankingFacade.getDailyRanking(targetDate, page, size);
-        }
+        String period = (periodType == null || periodType.isBlank())
+                ? "DAILY"
+                : periodType.trim().toUpperCase();
+
+        List<RankingProductInfo> rankings = switch (period) {
+            case "DAILY" -> rankingFacade.getDailyRanking(targetDate, page, size);
+            case "WEEKLY" -> rankingFacade.getWeeklyRanking(targetDate, page, size);
+            case "MONTHLY" -> rankingFacade.getMonthlyRanking(targetDate, page, size);
+            default -> rankingFacade.getDailyRanking(targetDate, page, size);
+        };
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java (1)

8-25: Java record 사용을 고려해 보세요.

불변 데이터 전송 객체로 사용된다면 record가 더 간결하고 의도를 명확히 표현합니다. 또한 필드 사이의 불필요한 빈 줄을 제거하면 가독성이 향상됩니다.

🔎 record 사용 제안
package com.loopers.domain.rank;

import java.math.BigDecimal;

public record ProductRankingAggregation(
    Long productId,
    Integer likeCount,
    Integer viewCount,
    Integer orderCount,
    BigDecimal salesAmount,
    Integer rankPosition
) {}
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java (1)

11-11: 사용하지 않는 import를 제거하세요.

java.util.List가 import되었지만 사용되지 않습니다.

🔎 수정 제안
-import java.util.List;
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java (3)

4-4: 사용하지 않는 import를 제거하세요.

java.util.Map이 import되었지만 사용되지 않습니다.

🔎 수정 제안
-import java.util.Map;

42-44: 예외 발생 시 모니터링 및 알림 전략을 확인하세요.

현재 예외가 로깅만 되고 다시 throw되지 않아, 모니터링 시스템에서 Job 실패를 감지하지 못할 수 있습니다. 다음을 고려해 보세요:

  • 예외를 다시 throw하거나
  • 메트릭/알림 시스템에 실패를 보고하거나
  • 최소한 Job의 ExitStatus를 확인하여 실패 여부를 로깅

24-31: LocalDate.now()의 타임존을 명시하는 것을 고려하세요.

LocalDate.now()는 시스템 기본 타임존을 사용합니다. 서버 환경에 따라 다른 날짜가 계산될 수 있으므로, 명시적인 타임존 지정을 권장합니다.

🔎 타임존 명시 예시
+import java.time.ZoneId;
+
+// 클래스 내부에 상수 정의
+private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul");
+
 @Scheduled(cron = "0 10 1 * * *")
 public void runWeeklyRanking() {
-    runWithAnchor(weeklyRankingJob, LocalDate.now().minusDays(1));
+    runWithAnchor(weeklyRankingJob, LocalDate.now(ZONE_ID).minusDays(1));
 }

 @Scheduled(cron = "0 20 1 * * *")
 public void runMonthlyRanking() {
-    runWithAnchor(monthlyRankingJob, LocalDate.now().minusDays(1));
+    runWithAnchor(monthlyRankingJob, LocalDate.now(ZONE_ID).minusDays(1));
 }
apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (3)

4-4: 사용되지 않는 @RequiredArgsConstructor 어노테이션

이 테스트 클래스에는 final 필드가 없어 @RequiredArgsConstructor가 불필요합니다. 제거를 권장합니다.


37-40: @BeforeEach 메서드 제거 권장

내용이 없는 beforeEach() 메서드입니다. 향후 사용 계획이 없다면 제거하는 것이 깔끔합니다.


42-56: 테스트 메서드명이 실제 테스트 내용과 불일치

shouldNotSaveCategories_whenApiError는 API 오류 상황을 테스트하는 것처럼 보이지만, 실제로는 requestDate 파라미터 누락 시 실패를 검증합니다. 테스트 의도를 명확히 반영하도록 메서드명 수정을 권장합니다.

🔎 메서드명 수정 제안
-    void shouldNotSaveCategories_whenApiError() throws Exception {
+    void shouldFailJob_whenRequestDateIsMissing() throws Exception {
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)

16-17: 사용되지 않는 @RequiredArgsConstructor 어노테이션

final 필드가 없어 Lombok @RequiredArgsConstructor가 생성하는 생성자가 없습니다. 제거하거나, requestDate 필드를 생성자 주입 방식으로 변경하세요.


27-29: System.out.println 대신 SLF4J 로거 사용 권장

데모 코드라 하더라도 System.out.println보다는 SLF4J 로거를 사용하는 것이 일관된 로깅 패턴을 유지하고, 로그 레벨 제어 및 운영 환경에서의 가시성 확보에 유리합니다.

🔎 로거 사용 예시
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @StepScope
 @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME)
-@RequiredArgsConstructor
 @Component
 public class DemoTasklet implements Tasklet {
     @Value("#{jobParameters['requestDate']}")
     private String requestDate;

     @Override
     public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
         if (requestDate == null) {
             throw new RuntimeException("requestDate is null");
         }
-        System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")");
+        log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate);
         Thread.sleep(1000);
-        System.out.println("Demo Tasklet 작업 완료");
+        log.info("Demo Tasklet 작업 완료");
         return RepeatStatus.FINISHED;
     }
 }
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)

40-47: Step 메서드명과 실제 용도 불일치

메서드명 categorySyncStep()이 실제 데모 작업과 맞지 않습니다. STEP_DEMO_SIMPLE_TASK_NAME과 일관되게 demoSimpleTaskStep() 등으로 변경하면 가독성이 향상됩니다.

🔎 메서드명 수정 제안
     @JobScope
     @Bean(STEP_DEMO_SIMPLE_TASK_NAME)
-    public Step categorySyncStep() {
+    public Step demoSimpleTaskStep() {
         return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository)
                 .tasklet(demoTasklet, new ResourcelessTransactionManager())
                 .listener(stepMonitorListener)
                 .build();
     }

demoJob() 메서드 내에서 호출부도 함께 수정하세요:

     return new JobBuilder(JOB_NAME, jobRepository)
             .incrementer(new RunIdIncrementer())
-            .start(categorySyncStep())
+            .start(demoSimpleTaskStep())
             .listener(jobListener)
             .build();
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java (1)

1-2: 패키지 선언 후 빈 줄 누락

코드 스타일 일관성을 위해 패키지 선언과 import 문 사이에 빈 줄을 추가하세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java (3)

12-13: Object 반환 타입은 타입 안전성을 저하시킴

ItemProcessor<ProductRankingAggregation, Object> 반환 타입이 Object이므로 호출부에서 캐스팅이 필요합니다. 제네릭을 활용하거나 WeeklyRankingProcessor, MonthlyRankingProcessor로 분리하면 타입 안전성과 가독성이 향상됩니다.

현재 구조가 의도된 것이라면, 최소한 메서드에 반환 타입에 대한 문서화(@return)를 추가하세요.


24-26: LocalDate.parse() 예외 처리 부재

anchorDate가 잘못된 형식일 경우 DateTimeParseException이 발생합니다. Job Parameter 검증 단계에서 형식이 보장된다면 괜찮지만, 그렇지 않다면 명시적 검증 또는 예외 처리를 추가하세요.

🔎 방어적 코드 예시
     @Override
     public Object process(ProductRankingAggregation item) {
         double score = calculateScore(item);
-        LocalDate periodStart = LocalDate.parse(anchorDate);
+        LocalDate periodStart;
+        try {
+            periodStart = LocalDate.parse(anchorDate);
+        } catch (DateTimeParseException e) {
+            throw new IllegalArgumentException("Invalid anchorDate format: " + anchorDate, e);
+        }

57-66: 점수 계산 가중치 하드코딩

VIEW(0.1), LIKE(0.2), ORDER(0.6) 가중치가 코드에 직접 하드코딩되어 있습니다. 향후 가중치 조정이 필요할 경우 설정 파일이나 상수로 분리하면 유지보수성이 향상됩니다. 현재 데모/MVP 단계라면 이대로 진행해도 무방합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java (1)

50-51: FQN 대신 import 사용 권장

@org.springframework.beans.factory.annotation.Value가 세 곳에서 FQN으로 사용되고 있습니다. 가독성을 위해 import 문으로 정리하세요.

🔎 import 추가 및 수정
+import org.springframework.beans.factory.annotation.Value;
 
 ...
 
 	public ItemReader<ProductRankingAggregation> monthlyRankingReader(
-			@org.springframework.beans.factory.annotation.Value("#{jobParameters['anchorDate']}")
+			@Value("#{jobParameters['anchorDate']}")
 			String anchorDate
     ) {

Also applies to: 63-64, 75-76

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java (1)

42-91: 전체 데이터를 메모리에 로드하는 방식은 대용량 데이터에서 문제가 될 수 있습니다.

fetch() 메서드가 모든 집계 결과를 한 번에 메모리로 로드합니다. 상품 수가 많아지면 OOM(Out of Memory) 위험이 있습니다. Spring Batch의 cursor/paging 기반 reader 사용을 고려해 보세요.

또한, Line 72의 calcScore() 로직이 RankingScoreProcessor.calculateScore()와 동일하게 중복되어 있습니다. 점수 계산 로직을 한 곳에서 관리하면 유지보수성이 향상됩니다.

🔎 점수 계산 로직 통합 제안

점수 계산 유틸리티 클래스를 만들어 중복을 제거할 수 있습니다:

public final class RankingScoreCalculator {
    private static final double VIEW_WEIGHT = 0.1d;
    private static final double LIKE_WEIGHT = 0.2d;
    private static final double ORDER_WEIGHT = 0.6d;

    public static double calculate(int viewCount, int likeCount, int orderCount, BigDecimal salesAmount) {
        BigDecimal amount = salesAmount == null ? BigDecimal.ZERO : salesAmount;
        double orderBase = amount.signum() > 0 ? amount.doubleValue() : (double) orderCount;
        return (VIEW_WEIGHT * viewCount) + (LIKE_WEIGHT * likeCount) + (ORDER_WEIGHT * orderBase);
    }
}
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java (2)

6-6: 사용하지 않는 import가 있습니다.

MonthlyProductRank가 import되어 있지만 이 클래스에서 사용되지 않습니다. WeeklyProductRank만 사용됩니다.

🔎 제안된 수정
-import com.loopers.domain.rank.MonthlyProductRank;

65-72: 타입 캐스팅 방식보다는 제네릭 기반 구조 검토를 권장합니다.

RankingScoreProcessor.process()Object를 반환하므로 호출 측에서 (WeeklyProductRank) 캐스팅이 필요합니다. 현재 코드에서는 "WEEKLY"를 하드코딩하여 안전하지만, 더 나은 방식으로는 RankingScoreProcessor를 제네릭으로 설계하면 캐스팅 없이 타입 안전성을 확보할 수 있습니다. 참고로 유효하지 않은 periodType이 전달되면 ClassCastException 대신 IllegalArgumentException이 발생하도록 이미 검증 로직이 구현되어 있습니다.

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

62-73: Daily 랭킹에서 AtomicInteger 사용이 불필요합니다.

단일 스레드에서 순차적으로 처리되므로 AtomicInteger 대신 일반 int 변수로 충분합니다. AtomicInteger는 동시성이 필요한 경우에 사용됩니다.

🔎 제안된 수정
     int baseRank = (int) start + 1;
-    AtomicInteger rankCounter = new AtomicInteger(baseRank);
+    int rank = baseRank;
     for (ZSetOperations.TypedTuple<String> t : tuples) {
         String member = t.getValue();
         if (member == null || member.isBlank()) continue;
         Long productId = Long.valueOf(member);
         Product product = productMap.get(productId);
         if (product == null) continue;
-        int rank = rankCounter.getAndIncrement();
         Double score = t.getScore();
-        result.add(toInfo(productId, product, rank, score));
+        result.add(toInfo(productId, product, rank++, score));
     }

21-24: AtomicInteger import가 제거 가능합니다.

위 리팩토링을 적용하면 AtomicInteger import도 불필요해집니다.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fcc0b10 and 2aa4b68.

📒 Files selected for processing (47)
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
  • apps/commerce-batch/build.gradle.kts
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/resources/application.yml
  • modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java
  • modules/jpa/src/main/resources/jpa.yml
  • settings.gradle.kts
  • supports/monitoring/src/main/resources/monitoring.yml
💤 Files with no reviewable changes (1)
  • apps/commerce-api/src/main/resources/application.yml
🧰 Additional context used
🧠 Learnings (12)
📚 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-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.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:

  • modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.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/batch/job/productRankingJob/ProductRankingJobConfig.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
📚 Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
📚 Learning: 2025-12-18T13:24:51.650Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 190
File: apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductViewKafkaConsumer.java:25-35
Timestamp: 2025-12-18T13:24:51.650Z
Learning: Adopt centralized error handling for Kafka consumers by using the EventInboxAspect to intercept methods annotated with InboxEvent. Ensure that service methods annotated with InboxEvent save failed EventInbox entries and log errors, avoiding duplicating error handling logic in individual consumers. Apply this pattern broadly to similar consumer/service layers within the commerce-streamer module.

Applied to files:

  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
📚 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-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.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/rank/WeeklyRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyRankRepository.java
📚 Learning: 2025-12-07T12:45:45.556Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 52
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/payment/Payment.kt:160-181
Timestamp: 2025-12-07T12:45:45.556Z
Learning: In the Payment.confirmPayment method in com.loopers.domain.payment.Payment, the when-branch order is intentionally designed with business logic checks before timeout: (1) check SUCCESS/FAILED status first, (2) check for null match (business failure), (3) check timeout only as last resort for PENDING transactions. If matched transaction is PENDING and not timed out, no branch matches and payment stays IN_PROGRESS for scheduler retry. This design prioritizes business outcome determination over time-based fallback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java
📚 Learning: 2025-11-30T15:44:28.336Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 48
File: apps/commerce-api/src/main/kotlin/com/loopers/application/order/OrderFacade.kt:80-83
Timestamp: 2025-11-30T15:44:28.336Z
Learning: User ghojeong prefers explicit state management over transaction rollback for handling payment failures in order creation flow. When payment fails after order creation, the order should be explicitly marked as cancelled and persisted, rather than relying on transaction rollback.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.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-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
📚 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-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
📚 Learning: 2025-12-07T15:47:18.277Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 52
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/payment/PgFeignConfig.kt:0-0
Timestamp: 2025-12-07T15:47:18.277Z
Learning: In Spring Cloud OpenFeign, configuration classes specified in FeignClient(configuration = ...) do NOT need the Configuration annotation. Spring Cloud OpenFeign creates a separate ApplicationContext for each named client and directly instantiates Bean methods from the configuration class. Adding Configuration would cause the class to be registered globally via ComponentScan, making the configuration apply to ALL Feign clients instead of just the specific client, which is usually undesirable.

Applied to files:

  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
🧬 Code graph analysis (9)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java (1)
  • RequiredArgsConstructor (14-42)
apps/commerce-api/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java (1)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankingAggregation.java (1)
  • Getter (8-25)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java (1)
  • RequiredArgsConstructor (12-40)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
  • Slf4j (9-21)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
  • Slf4j (13-44)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
  • ConditionalOnProperty (19-48)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java (2)
apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyProductRank.java (1)
  • Entity (17-57)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MonthlyProductRank.java (1)
  • Entity (25-106)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
  • Slf4j (9-21)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
  • Slf4j (14-53)
modules/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
  • log (22-119)
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java (1)
apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)
  • SpringBootTest (23-76)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java (4)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java (1)
  • RankingScoreProcessor (12-70)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java (1)
  • RankingScoreReader (15-138)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java (1)
  • RequiredArgsConstructor (14-42)
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java (1)
  • Configuration (23-80)
🔇 Additional comments (34)
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java (1)

30-31: 배치 리스너 설정 확인 필요

KafkaConfig.BATCH_LISTENER 상수가 올바르게 정의되어 있는지 확인해주세요. CatalogEventConsumer의 동일 설정과 함께 검증이 필요합니다.

apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java (1)

30-31: KafkaConfig.BATCH_LISTENER 상수가 올바르게 정의되어 있습니다.

KafkaConfig 클래스에서 BATCH_LISTENER 상수가 public static final String BATCH_LISTENER = "BATCH_LISTENER_DEFAULT";로 올바르게 정의되어 있으며, 현재 코드에서의 사용도 정확합니다.

modules/jpa/src/main/resources/jpa.yml (1)

40-40: migration 파일이 사용되지 않는 orphaned 상태입니다.

프로젝트에는 Flyway나 Liquibase 같은 마이그레이션 도구가 구성되어 있지 않습니다. generate-ddl: false는 단순히 Hibernate 자동 DDL 생성을 비활성화하는 설정일 뿐입니다.

따라서 local 환경에서 ddl-auto: update로 설정하는 것은 적절합니다. 데이터 손실 없이 로컬 개발 중 스키마 변경을 반영할 수 있으며, 새로운 JPA 엔티티(WeeklyProductRank, MonthlyProductRank)도 올바르게 정의되어 있습니다.

다만 다음을 확인해 주세요:

  1. V20260102__ranking_mv_ddl.sql 마이그레이션 파일의 용도가 명확하지 않습니다. 어떤 도구로도 처리되지 않는 orphaned 상태입니다. 이 파일의 목적을 문서화하거나, 향후 마이그레이션 도구(Flyway/Liquibase) 도입 시 연계 계획을 검토하시기 바랍니다.

  2. 엔티티들이 @Immutable로 표시되어 있으므로 materialized view 패턴의 사용이 의도된 것으로 보입니다. 이 설정은 적절합니다.

Likely an incorrect or invalid review comment.

apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java (1)

6-10: LGTM!

Spring 애플리케이션 컨텍스트 로딩을 검증하는 표준 스모크 테스트입니다. DemoJobE2ETest와 같은 다른 배치 테스트들과 일관된 패턴을 따르고 있습니다.

apps/commerce-streamer/src/main/resources/application.yml (1)

44-48: LGTM!

local/test 프로필에서 포트 설정이 적절합니다. server.port: 8082로 다른 모듈과의 포트 충돌을 방지하고, management.server.port: 0으로 테스트 환경에서 임의의 포트를 사용하여 바인딩 문제를 예방합니다.

apps/commerce-api/src/test/java/com/loopers/domain/like/LikeIntegrationTest.java (1)

76-78: 비동기 이벤트 처리를 위한 폴링 도입이 적절합니다.

@EnableAsync 활성화로 인해 이벤트 처리가 비동기로 동작하므로, 조건 폴링 방식이 필요합니다. 테스트 의도가 명확하게 드러납니다.

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

25-52: LGTM!

배치 작업 실행 시간 계산 및 로깅 로직이 잘 구현되어 있습니다. Duration API를 활용한 시간 포맷팅과 text block을 사용한 가독성 높은 로그 메시지가 적절합니다.

apps/commerce-batch/build.gradle.kts (1)

1-21: LGTM!

배치 모듈에 필요한 의존성이 적절하게 구성되어 있습니다. Spring Batch, QueryDSL (Jakarta 분류자), 그리고 테스트 픽스처 설정이 Spring Boot 3.x 기반 프로젝트에 맞게 구성되었습니다.

apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java (1)

29-29: REQUIRES_NEW 트랜잭션 전파 설정이 적절합니다.

@TransactionalEventListener(phase = AFTER_COMMIT)@Async를 함께 사용할 때, REQUIRES_NEW를 통해 새로운 트랜잭션 컨텍스트에서 비동기 처리가 이루어지도록 한 것은 올바른 접근입니다. 원본 트랜잭션이 이미 커밋된 후에 실행되므로, 새로운 트랜잭션이 필요합니다.

Also applies to: 54-54

apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (2)

16-20: 타임존 설정 방식은 동작하지만, 대안을 고려해 볼 수 있습니다.

TimeZone.setDefault()는 JVM 전역에 영향을 미칩니다. Spring Boot에서는 spring.jackson.time-zone이나 application.yml의 설정을 통해 더 선언적으로 관리할 수도 있습니다. 현재 방식도 동작에는 문제없습니다.


22-25: 배치 애플리케이션 종료 패턴이 적절합니다.

SpringApplication.exit()을 사용하여 종료 코드를 반환하는 패턴은 배치 애플리케이션의 표준 관행입니다. 이를 통해 스케줄러나 오케스트레이션 도구가 작업 성공/실패를 올바르게 판단할 수 있습니다.

apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java (2)

33-34: 이벤트 기반 테스트로의 전환이 적절합니다.

ApplicationEventPublisher 모킹을 통해 이벤트 발행을 검증하는 방식으로 변경된 것은 event-driven 아키텍처 전환에 부합합니다. 직접적인 productRepository.save() 검증 대신 이벤트 발행 검증으로 관심사가 올바르게 분리되었습니다.


137-138: 이벤트 발행 순서 검증이 명확합니다.

LikeCreatedEventLikeDeletedEvent가 각각 한 번씩 발행되는지 검증하고 있습니다. 순서까지 검증이 필요하다면 InOrder를 사용할 수 있지만, 현재 테스트 의도에는 충분합니다.

apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java (1)

25-26: REQUIRES_NEW 트랜잭션 전파가 올바르게 적용되었습니다.

@Async@TransactionalEventListener(phase = AFTER_COMMIT) 조합에서 REQUIRES_NEW를 사용하여 새 트랜잭션에서 이벤트를 처리하는 것은 적절합니다.

Also applies to: 44-45

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

75-94: 엔티티 설계가 적절합니다.

@Builder 패턴, protected 기본 생성자, 그리고 @PrePersist/@PreUpdate 라이프사이클 훅을 통한 타임스탬프 관리가 JPA 모범 사례를 따르고 있습니다. (product_id, period_start) 유니크 제약 조건으로 중복 방지도 잘 되어 있습니다.

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

14-15: 현재 코드는 Spring Batch 5.x에 적합하므로 변경 불필요

프로젝트에서 사용 중인 Spring Boot 버전은 3.4.4이며, 이는 Spring Batch 5.1.x를 포함합니다. 현재 코드의 ChunkContext 파라미터는 Spring Batch 5.x의 올바른 API이므로 수정이 필요하지 않습니다. Spring Batch 6.x의 API 변경(Chunk 사용)은 Spring Boot 4.x 이상을 사용할 때만 적용됩니다.

Likely an incorrect or invalid review comment.

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

12-22: LGTM!

레포지토리 인터페이스가 Spring Data JPA 패턴을 잘 따르고 있습니다. @Modifying 어노테이션의 clearAutomaticallyflushAutomatically 설정이 적절하며, 삭제된 행 수를 반환하는 것도 검증에 유용합니다. 학습 기록에 따르면, 도메인 레포지토리에서 Pageable 타입 사용은 허용된 아키텍처 결정입니다.

apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql (1)

1-50: 스키마 설계가 잘 되어 있습니다.

인덱스 전략이 랭킹 조회 패턴에 적합합니다:

  • (period_start, rank_position): 기간별 상위 N개 조회에 최적화
  • (period_start, total_score): 점수 기반 정렬 쿼리 지원
  • 유니크 제약조건으로 중복 데이터 방지

product_id에 대한 외래키 제약조건이 없는데, 이는 materialized view 패턴에서 성능과 디커플링을 위해 의도적으로 생략된 것으로 보입니다. 만약 데이터 정합성이 중요하다면 추후 FK 추가를 고려해 볼 수 있습니다.

supports/monitoring/src/main/resources/monitoring.yml (1)

33-34: 기본 포트 값 0 사용 검증 필요

환경 변수로 포트를 설정할 수 있게 변경한 것은 좋지만, 기본값 0(랜덤 포트)이 운영 환경에서 의도한 것인지 확인이 필요합니다.

랜덤 포트 사용 시 고려사항:

  • 로드밸런서 헬스체크 설정
  • Prometheus 스크래핑 타겟 설정
  • Kubernetes 서비스 디스커버리

로컬 개발 환경에서만 사용하거나 서비스 디스커버리를 활용하는 경우라면 문제없습니다. 그렇지 않다면 예측 가능한 기본값을 설정하는 것이 좋습니다:

port: ${MANAGEMENT_PORT:8081}
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductInfo.java (1)

5-13: LGTM!

레코드 구조가 랭킹 정보를 담기에 적합합니다. rank가 primitive int로 정의되어 있어 null이 불가능하지만, 랭킹 컨텍스트에서는 항상 값이 존재해야 하므로 적절한 선택입니다.

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

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

web-application-type: none 설정과 job.name: ${job.name:NONE} 기본값은 배치 애플리케이션에 적합하며, 의도하지 않은 Job 실행을 방지합니다.


25-32: 로컬/테스트 환경의 스키마 자동 초기화 설정이 적절합니다.

initialize-schema: always 설정은 개발 및 테스트 환경에서 배치 메타데이터 테이블을 자동으로 생성하여 편의성을 높입니다.

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

18-21: Step 시작 로깅이 적절합니다.

간단하고 명확한 로깅 구현입니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java (1)

10-13: LGTM!

Spring Data JPA 명명 규칙을 잘 따르고 있습니다. Pageable을 사용하면서 List를 반환하는 것은 페이지네이션된 결과만 필요하고 전체 개수는 필요 없을 때 적절한 선택입니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/MonthlyRankJpaRepository.java (1)

10-13: LGTM!

WeeklyRankJpaRepository와 일관된 구조로 잘 구현되었습니다.

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

52-56: createdAt/updatedAt 타임스탬프 자동 생성 확인이 필요합니다.

@CreationTimestamp@UpdateTimestamp 어노테이션이 없습니다. Materialized view이고 배치에서 직접 값을 설정한다면 문제없지만, 그렇지 않다면 이 필드들이 항상 null이 될 수 있습니다. 배치 Writer에서 이 값들을 어떻게 설정하는지 확인해 주세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java (1)

21-27: Job 실패 시 데이터 무결성 확인이 필요합니다.

open() 시점에 기존 데이터를 삭제하고 write()에서 새 데이터를 저장하는 패턴입니다. Job이 삭제 후 쓰기 완료 전에 실패하면 해당 기간의 데이터가 손실될 수 있습니다.

MonthlyRankingWriter도 동일한 패턴을 사용하므로 의도된 설계일 수 있지만, 프로덕션 환경에서는 다음을 고려해 보세요:

  • Job 실패 시 재실행으로 복구 가능한지 확인
  • 또는 임시 테이블에 쓴 후 swap하는 방식 검토
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java (1)

19-25: open() 단계에서의 삭제 작업 - 장애 시 데이터 정합성 확인 필요

open()에서 기존 데이터를 삭제하고 write()에서 새 데이터를 저장하는 패턴입니다. 배치 작업이 중간에 실패하면 해당 periodStart의 데이터가 유실될 수 있습니다.

이 패턴이 의도된 것이라면 괜찮지만, 운영 환경에서는 재처리 전략(예: 실패 시 재시작, 모니터링 알림)이 갖춰져 있는지 확인하세요. WeeklyRankingWriter와 동일한 구조로 일관성 있게 구현되어 있습니다.

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

25-39: 엔티티 구조 적절함 - 읽기/쓰기 분리 패턴 확인

commerce-batch 모듈의 WeeklyProductRank는 쓰기용으로, commerce-api 모듈의 동일 테이블 엔티티는 @Immutable로 읽기 전용입니다. 모듈 간 책임 분리가 잘 되어 있습니다.

유니크 제약조건과 인덱스 정의도 MonthlyProductRank와 일관되게 구성되어 있습니다.


96-105: LocalDateTime.now() 사용 시 시간대(Timezone) 고려

@PrePersist@PreUpdate에서 LocalDateTime.now()를 사용합니다. 서버 시간대에 따라 값이 달라질 수 있으므로, 운영 환경에서 시간대 일관성이 보장되는지 확인하세요. UTC 기준이 필요하다면 LocalDateTime.now(ZoneOffset.UTC) 사용을 고려할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/MonthlyRankingStepConfig.java (1)

28-28: 사용되지 않는 TOP_N 상수

TOP_N = 100이 정의되어 있지만 코드에서 사용되지 않습니다. 의도된 기능이 누락된 것인지, 아니면 불필요한 상수인지 확인하세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java (1)

43-45: 윈도우 계산 로직에 대한 확인이 필요합니다.

window = 6 (WEEKLY) 또는 29 (MONTHLY)로 설정하여 startDate = endDate.minusDays(window)를 계산합니다. 이는 anchor date 포함 시 각각 7일, 30일이 됩니다.

SQL의 BETWEEN :start AND :end는 양 끝을 포함하므로:

  • WEEKLY: anchorDate - 6일 ~ anchorDate = 7일간
  • MONTHLY: anchorDate - 29일 ~ anchorDate = 30일간

이것이 의도한 동작인지 확인해 주세요. 일반적으로 "주간"은 7일, "월간"은 30일을 의미하므로 맞아 보이지만, 비즈니스 요구사항과 일치하는지 검증이 필요합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java (1)

36-51: LGTM - Step 구성이 올바르게 되어 있습니다.

@JobScope@StepScope 어노테이션이 적절하게 사용되었고, chunk 기반 처리 흐름이 MonthlyRankingStepConfig와 일관된 패턴을 따르고 있습니다. null 파라미터 패턴은 Spring Batch에서 SpEL을 통한 job parameter 주입을 위한 표준 방식입니다.

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

123-154: LGTM - 제네릭 헬퍼 메서드가 잘 설계되었습니다.

buildRanking 메서드가 함수형 인터페이스를 활용하여 weekly/monthly 랭킹 로직의 중복을 효과적으로 제거했습니다. 상품 ID 일괄 조회로 N+1 문제를 방지한 점도 좋습니다.

Comment on lines +77 to +92
@Transactional(readOnly = true)
public List<RankingProductInfo> getWeeklyRanking(String weekStartYyyymmdd, int page, int size) {
int p = Math.max(1, page);
int s = Math.max(1, size);
LocalDate periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER);

var rows = weeklyRankJpaRepository.findByPeriodStartOrderByRankPositionAsc(
periodStart, PageRequest.of(p - 1, s)
);
return buildRanking(
rows,
r -> r.getProductId(),
r -> r.getRankPosition() != null ? r.getRankPosition() : 0,
r -> r.getTotalScore()
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

날짜 파싱 시 예외 처리가 필요합니다.

LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER)는 잘못된 형식의 입력에 대해 DateTimeParseException을 발생시킵니다. API 레이어에서 적절한 예외 처리 또는 입력 검증이 필요합니다.

🔎 예외 처리 추가 제안

Facade 레이어에서 처리하거나, Controller 레이어에서 @DateTimeFormat 등을 활용할 수 있습니다:

 public List<RankingProductInfo> getWeeklyRanking(String weekStartYyyymmdd, int page, int size) {
     int p = Math.max(1, page);
     int s = Math.max(1, size);
-    LocalDate periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER);
+    LocalDate periodStart;
+    try {
+        periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER);
+    } catch (DateTimeParseException e) {
+        throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd", e);
+    }
     ...
 }
📝 Committable suggestion

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

Suggested change
@Transactional(readOnly = true)
public List<RankingProductInfo> getWeeklyRanking(String weekStartYyyymmdd, int page, int size) {
int p = Math.max(1, page);
int s = Math.max(1, size);
LocalDate periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER);
var rows = weeklyRankJpaRepository.findByPeriodStartOrderByRankPositionAsc(
periodStart, PageRequest.of(p - 1, s)
);
return buildRanking(
rows,
r -> r.getProductId(),
r -> r.getRankPosition() != null ? r.getRankPosition() : 0,
r -> r.getTotalScore()
);
}
@Transactional(readOnly = true)
public List<RankingProductInfo> getWeeklyRanking(String weekStartYyyymmdd, int page, int size) {
int p = Math.max(1, page);
int s = Math.max(1, size);
LocalDate periodStart;
try {
periodStart = LocalDate.parse(weekStartYyyymmdd, DATE_FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd", e);
}
var rows = weeklyRankJpaRepository.findByPeriodStartOrderByRankPositionAsc(
periodStart, PageRequest.of(p - 1, s)
);
return buildRanking(
rows,
r -> r.getProductId(),
r -> r.getRankPosition() != null ? r.getRankPosition() : 0,
r -> r.getTotalScore()
);
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 77-92, LocalDate.parse(...) can throw DateTimeParseException for
invalid input; wrap the parse call in a try-catch that catches
DateTimeParseException, validate the input format if desired, and rethrow a
clear runtime exception (e.g., IllegalArgumentException or a custom
BadRequest/InvalidRequestException) with a descriptive message that includes the
offending weekStartYyyymmdd value so the controller can map it to a 400
response.

Comment on lines +17 to 18
@EnableAsync
public class CommerceApiApplication {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for custom async executor configuration
rg -n --type=java -C3 'AsyncConfigurer|TaskExecutor|@Bean.*Executor' --max-count=50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 6652


🏁 Script executed:

#!/bin/bash
# Search for @Async method usage in the codebase
rg -n --type=java '@Async' --max-count=50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1692


🏁 Script executed:

#!/bin/bash
# Search for configurations or config classes
fd -e java | xargs rg -l 'Configuration|Config' | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1816


🏁 Script executed:

#!/bin/bash
# Check if executor bean is defined anywhere
rg -n --type=java 'getAsyncExecutor|setAsyncExecutor|@Bean.*executor' -i --max-count=50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Search for eventTaskExecutor bean definition
rg -n --type=java 'eventTaskExecutor' --max-count=50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 985


🏁 Script executed:

#!/bin/bash
# Search for configuration classes that might define the executor
rg -n --type=java 'AsyncConfigurer|@EnableAsync|TaskExecutor' -B5 -A10 --max-count=100

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 12161


🏁 Script executed:

#!/bin/bash
# Look in config directories for AsyncConfiguration
fd -e java -path '*/config/*' | xargs rg -l 'AsyncConfigurer\|TaskExecutor' 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 258


🏁 Script executed:

#!/bin/bash
# Search the entire apps/commerce-api for eventTaskExecutor definition
find apps/commerce-api -name "*.java" -type f | xargs grep -l "eventTaskExecutor" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 364


🏁 Script executed:

#!/bin/bash
# Search for @Bean definitions with executor
rg -n --type=java '@Bean' -A5 | grep -i 'executor\|task' | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 524


🏁 Script executed:

#!/bin/bash
# List Java files in commerce-api config directories
find apps/commerce-api -type d -name config -o -name config -type d 2>/dev/null | xargs find -name "*.java" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 17299


🏁 Script executed:

#!/bin/bash
# Search for AsyncConfigurer implementation
rg -n --type=java 'implements AsyncConfigurer' -B3 -A20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Search entire repository for eventTaskExecutor definition
rg -n 'eventTaskExecutor' --type=java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 985


🏁 Script executed:

#!/bin/bash
# Search for @Bean with getAsyncExecutor or custom executor methods
rg -n '@Bean' -A3 --type=java | grep -B3 -i 'executor\|async' | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Check if there's a configuration class defining TaskExecutor bean
fd -name "*.java" -type f | xargs rg -l 'TaskExecutor|ThreadPoolTaskExecutor' | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 612


🏁 Script executed:

#!/bin/bash
# Search properties and yml files for executor configuration
find apps/commerce-api -name "*.yml" -o -name "*.yaml" -o -name "*.properties" | xargs grep -i 'executor\|async' 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 72


🏁 Script executed:

#!/bin/bash
# Broader search for any configuration with @Bean that might define eventTaskExecutor
rg -n 'eventTaskExecutor|@Bean.*Executor|ThreadPoolTaskExecutor' --type=java --max-count=20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 985


🏁 Script executed:

#!/antml:bin/bash
# Check if there are any config files in the entire project for async
find . -name "*Async*Config*.java" -o -name "*Executor*Config*.java" 2>/dev/null | head -10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


eventTaskExecutor 빈이 정의되지 않았습니다. 런타임 오류가 발생합니다.

@Async("eventTaskExecutor")를 사용하는 7개 메서드(ProductEventHandler, PaymentEventHandler, DataPlatformEventHandler)에서 참조하는 eventTaskExecutor 빈이 정의되어 있지 않습니다. 이로 인해 런타임에 NoSuchBeanDefinitionException이 발생합니다.

다음 중 하나를 수행하세요:

  • eventTaskExecutor 빈을 정의하는 설정 클래스를 작성하여 ThreadPoolTaskExecutor로 구성
  • 또는 @Async 주석에서 명시적 executor 이름을 제거하고 기본 executor 설정 추가
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
around lines 17-18, several methods use @Async("eventTaskExecutor") but no bean
named eventTaskExecutor is defined, causing NoSuchBeanDefinitionException at
runtime; fix by either (A) adding a configuration class that defines a
ThreadPoolTaskExecutor bean named "eventTaskExecutor" (configure corePoolSize,
maxPoolSize, queueCapacity, setThreadNamePrefix, call initialize(), and return
the executor) or (B) remove the explicit executor name from @Async annotations
and add a default async executor bean (e.g., a ThreadPoolTaskExecutor bean named
"taskExecutor") or enable the default by providing a @Bean of type
AsyncConfigurer/TaskExecutor so @Async uses it.

Comment on lines 14 to 22
@Bean
public Job monthlyRankingJob(
JobRepository jobRepository,
@Qualifier("monthlyRankingStep") Step monthlyRankingStep
) {
return new JobBuilder("monthlyRankingJob", jobRepository)
.start(monthlyRankingStep)
.build();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

중복된 Bean 정의로 인한 애플리케이션 시작 실패.

monthlyRankingJob Bean이 이 클래스와 ProductRankingJobConfig.java (Lines 27-31)에 모두 정의되어 있습니다. Spring 컨텍스트 초기화 시 Bean 이름 충돌이 발생합니다.

WeeklyRankingJobConfig와 함께 이 클래스를 제거하고, ProductRankingJobConfig에서 통합 관리하는 것을 권장합니다.

🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java
around lines 14–22, there is a duplicate monthlyRankingJob Bean also defined in
ProductRankingJobConfig (lines 27–31) causing a Spring bean name collision;
remove the duplicate definition by deleting the monthlyRankingJob @Bean method
(or remove the entire MonthlyRankingJobConfig class if it contains no other
unique beans), and ensure monthlyRankingStep is wired/qualified from
ProductRankingJobConfig instead so the single monthlyRankingJob Bean remains
defined only in ProductRankingJobConfig; also clean up any now-unused imports
and references.

Comment on lines +19 to +21
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM WeeklyProductRank w WHERE w.periodStart = :periodStart")
int deleteByPeriodStart(@Param("periodStart") LocalDate periodStart);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# deleteByPeriodStart 호출부 검색
rg -n "deleteByPeriodStart" --type=java -B5 -A2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4091


🏁 Script executed:

#!/bin/bash
# WeeklyRankingWriter와 MonthlyRankingWriter 파일 전체 조회
echo "=== WeeklyRankingWriter.java ===" 
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java

echo -e "\n=== MonthlyRankingWriter.java ===" 
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/MonthlyRankingWriter.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2975


호출 지점에 @Transactional 명시적 적용 필요

deleteByPeriodStart 메서드가 호출되는 WeeklyRankingWriterMonthlyRankingWriter 클래스에 @Transactional 어노테이션이 없습니다. @Modifying으로 선언된 벌크 삭제 작업은 반드시 트랜잭션 컨텍스트 내에서 실행되어야 영속성 컨텍스트가 제대로 관리됩니다. 두 writer 클래스의 open() 메서드 또는 클래스 레벨에 @Transactional을 추가하세요.

🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java
around lines 19-21 the method deleteByPeriodStart is annotated with @Modifying
and therefore must be invoked within a transactional context; update the caller
classes WeeklyRankingWriter and MonthlyRankingWriter by adding @Transactional
(either at the class level or on their open() methods) so the bulk delete runs
inside a transaction, import
org.springframework.transaction.annotation.Transactional, ensure the transaction
is not readOnly, and confirm the open() methods remain public so Spring can
proxy and apply the transaction.

Comment on lines +60 to +68
@SuppressWarnings("unchecked")
private Map<String, Object> readEvent(String raw) throws Exception {
if (raw == null) return null;
String s = raw.trim();
if (s.startsWith("\"") && s.endsWith("\"")) {
s = objectMapper.readValue(s, String.class);
}
return objectMapper.readValue(s, Map.class);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

중복 코드 제거를 위한 공통 유틸리티 추출 필요

readEvent 메서드가 OrderEventConsumer에도 동일하게 구현되어 있습니다. DRY 원칙에 따라 공통 유틸리티 클래스로 추출하는 것을 권장합니다.

🔎 리팩토링 제안

공통 유틸리티 클래스를 생성하세요 (예: EventParsingUtils.java):

package com.loopers.infrastructure.kafka;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;

public class EventParsingUtils {
    
    @SuppressWarnings("unchecked")
    public static Map<String, Object> parseEvent(ObjectMapper objectMapper, String raw) throws Exception {
        if (raw == null) return null;
        String s = raw.trim();
        if (s.startsWith("\"") && s.endsWith("\"")) {
            s = objectMapper.readValue(s, String.class);
        }
        return objectMapper.readValue(s, Map.class);
    }
}

그 다음 두 Consumer에서 사용:

-	@SuppressWarnings("unchecked")
-	private Map<String, Object> readEvent(String raw) throws Exception {
-		if (raw == null) return null;
-		String s = raw.trim();
-		if (s.startsWith("\"") && s.endsWith("\"")) {
-			s = objectMapper.readValue(s, String.class);
-		}
-		return objectMapper.readValue(s, Map.class);
-	}
+	private Map<String, Object> readEvent(String raw) throws Exception {
+		return EventParsingUtils.parseEvent(objectMapper, raw);
+	}

Committable suggestion skipped: line range outside the PR's diff.

@EnableTransactionManagement
@EntityScan({"com.loopers"})
@EnableJpaRepositories({"com.loopers.infrastructure"})
@EnableJpaRepositories({"com.loopers"})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all JpaRepository implementations and their locations

echo "=== 모든 JpaRepository 확장 인터페이스 ==="
find . -name "*.java" -type f | xargs grep -l "extends.*JpaRepository" | head -20

echo ""
echo "=== 패키지별 JpaRepository 위치 분석 ==="
find . -name "*.java" -type f | xargs grep -B5 "extends.*JpaRepository" | grep -E "^./|package|interface|extends.*JpaRepository" | head -100

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 13167


🏁 Script executed:

#!/bin/bash
# Count repositories by package location

echo "=== infrastructure 내부의 레포지토리 ==="
find . -path "*infrastructure*" -name "*.java" -type f | xargs grep -l "extends.*JpaRepository" 2>/dev/null | wc -l

echo ""
echo "=== infrastructure 외부의 레포지토리 ==="
find . -name "*.java" -type f ! -path "*infrastructure*" | xargs grep -l "extends.*JpaRepository" 2>/dev/null

echo ""
echo "=== domain 패키지 내 레포지토리 ==="
find . -path "*domain*" -name "*.java" -type f | xargs grep -l "extends.*JpaRepository" 2>/dev/null

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 723


🏁 Script executed:

#!/bin/bash
# Check current JpaConfig.java and related configurations

echo "=== JpaConfig.java 확인 ==="
cat -n modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java 2>/dev/null || find . -name "JpaConfig.java" -type f

echo ""
echo "=== EntityScan 설정 확인 ==="
find . -name "*.java" -type f | xargs grep -n "EntityScan\|EnableJpaRepositories" 2>/dev/null | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1171


@EnableJpaRepositories 스캔 범위가 과도하게 넓어짐

com.loopers.infrastructure에서 com.loopers로 변경하면 모든 모듈의 JpaRepository가 스캔됩니다. 현재 다음 도메인 레포지토리들이 의도치 않게 포함됩니다:

  • commerce-batch: MonthlyRankRepository, WeeklyRankRepository (com.loopers.domain.rank)
  • commerce-streamer: ProductMetricsRepository (com.loopers.domain.metrics)

이로 인해:

  • 모듈 간 의존성이 증가
  • 도메인 레포지토리가 infrastructure 설정에 의해 자동 관리됨
  • 아키텍처 경계가 모호해짐

패키지를 명시적으로 지정하거나 새 레포지토리를 infrastructure 패키지로 이동하세요:

명시적 패키지 지정 예시
-@EnableJpaRepositories({"com.loopers"})
+@EnableJpaRepositories({
+    "com.loopers.infrastructure",
+    "com.loopers.domain.rank"
+})
🤖 Prompt for AI Agents
In modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java around line
11, @EnableJpaRepositories is set to scan "com.loopers" which is too broad and
pulls in domain repositories from other modules; change the base package(s) to
only the infrastructure packages (e.g. "com.loopers.infrastructure" or an
explicit list of infrastructure packages) or relocate repository classes into an
infrastructure package so only intended repositories are scanned; update the
annotation to use basePackages with the correct package(s) and run integration
tests to verify no unintended repositories are picked up.

@looper88888
Copy link
Contributor

Spring Batch

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

🧩 Ranking API

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

@coderabbitai 체크리스트를 확인해줘

@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

Tip

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

🧩 Analysis chain

@looper88888, 체크리스트를 확인해드리겠습니다. 코드를 상세히 검토하여 각 항목의 구현 상태를 확인하겠습니다.


🏁 Script executed:

#!/bin/bash

echo "=== 1. Checking Spring Batch Job configurations with parameters ==="
echo "--- Job Configurations ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/WeeklyRankingJobConfig.java | head -20
echo ""
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/MonthlyRankingJobConfig.java | head -20
echo ""

echo "--- Scheduler with Job Parameters ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/ProductRankingJobScheduler.java | grep -A 10 "runWithAnchor"
echo ""

echo "=== 2. Checking Chunk Oriented Processing ==="
echo "--- Step Configuration (Weekly) ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/WeeklyRankingStepConfig.java | grep -E "CHUNK_SIZE|ItemReader|ItemProcessor|ItemWriter|chunk"
echo ""

echo "--- Reader ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/reader/RankingScoreReader.java | head -15
echo ""

echo "--- Processor ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/processor/RankingScoreProcessor.java | head -20
echo ""

echo "--- Writer ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/productRankingJob/step/writer/WeeklyRankingWriter.java | head -20
echo ""

echo "--- Tasklet Example (Demo) ---"
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java | head -20
echo ""

echo "=== 3. Checking Materialized View Structure ==="
echo "--- DDL Migration ---"
cat apps/commerce-batch/src/main/resources/db/migration/V20260102__ranking_mv_ddl.sql
echo ""

echo "=== 4. Checking Ranking API Support ==="
echo "--- API Controller ---"
cat apps/commerce-batch/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java 2>/dev/null || cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java | grep -A 30 "getRankingPage"
echo ""

echo "--- Facade Methods ---"
cat apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | grep -E "getDailyRanking|getWeeklyRanking|getMonthlyRanking" -A 5

Length of output: 9341


@looper88888, 체크리스트를 모두 확인했습니다! 각 항목이 잘 구현되어 있습니다. 🎉

✅ Spring Batch

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

  • ProductRankingJobScheduler에서 anchorDatets 파라미터를 사용하여 Job을 실행합니다
  • @Value("#{jobParameters['anchorDate']}")를 통해 Step 레벨에서 파라미터를 주입받아 사용합니다
JobParameters params = new JobParametersBuilder()
    .addString("anchorDate", anchor.toString())
    .addLong("ts", System.currentTimeMillis())
    .toJobParameters();

✓ Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.

  • Chunk 방식: WeeklyRankingStepConfigMonthlyRankingStepConfig에서 CHUNK_SIZE=100으로 설정
    • RankingScoreReader: 일간 데이터를 집계하여 읽기
    • RankingScoreProcessor: 가중치 점수 계산 및 엔티티 변환
    • WeeklyRankingWriter/MonthlyRankingWriter: MV 테이블에 적재
  • Tasklet 방식: DemoTasklet으로 단순 작업 처리 예시 구현

✓ 집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다.

  • mv_product_rank_weekly, mv_product_rank_monthly 테이블 설계:
    • 복합 인덱스: (period_start, rank_position), (period_start, total_score)
    • 유니크 제약: (product_id, period_start)
    • 집계 필드: like_count, view_count, order_count, sales_amount, total_score
  • Writer의 open() 메서드에서 기존 데이터를 삭제 후 신규 데이터 적재

✅ 🧩 Ranking API

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

  • RankingV1Controller에서 periodType 파라미터로 DAILY/WEEKLY/MONTHLY 구분
  • RankingFacade의 각 메서드가 적절한 데이터 소스 사용:
    • getDailyRanking(): Redis 기반 실시간 집계
    • getWeeklyRanking(): mv_product_rank_weekly MV 조회
    • getMonthlyRanking(): mv_product_rank_monthly MV 조회

모든 요구사항이 충족되었습니다! 특히 파라미터 기반 Job 실행, Chunk/Tasklet 혼용, MV 설계와 롤링 윈도우 집계가 잘 구현되어 있습니다. 👏

    (\_(\ 
    (='.'=)  Great job on the batch implementation!
    (")_(")

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.

3 participants