Skip to content

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

Open
yeonjiyeon wants to merge 172 commits intoLoopers-dev-lab:yeonjiyeonfrom
yeonjiyeon:feature/week10
Open

[volume-10] Collect, Stack, Zip#237
yeonjiyeon wants to merge 172 commits intoLoopers-dev-lab:yeonjiyeonfrom
yeonjiyeon:feature/week10

Conversation

@yeonjiyeon
Copy link
Collaborator

@yeonjiyeon yeonjiyeon commented Jan 2, 2026

📌 Summary

Spring Batch를 활용하여 주간/월간 상품 랭킹 시스템을 구축했습니다. 실시간 집계 정보(ProductMetrics)를 기반으로 랭킹 점수를 산출하고, Materialized View(MV) 구조를 설계 및 적용했습니다.

  • Spring Batch Job 구현: Chunk-Oriented 방식을 통한 대량 메트릭 데이터 집계 로직 구축
  • Materialized View 적용: 조회 전용 테이블(mv_product_rank_weekly/monthly) 설계 및 데이터 적재 프로세스 구현
  • Ranking API 확장: 일간(Redis), 주간/월간(DB MV) 데이터를 통합 제공하도록 RankingFacade 개선
  • Event Enrichment: 모듈 간 결합도를 낮추기 위해 이벤트 기반의 상품 정보 스냅샷 전파 로직 적용

💬 Review Points

(1) 도메인 격리를 고려한 데이터 흐름 설계

commerce-batch 모듈이 commerce-api에만 존재하는 Product 도메인(DB, Service)을 직접 의존하지 않도록 설계하는 데 많은 고민을 했습니다. 이를 위해 주문 발생 시 발행되는 ProductStockEvent에 상품명과 가격 정보를 포함하는 Event Enrichment 패턴을 적용했습니다.

[관련 질문]

해당 설계 과정에서 Event Enrichment를 통해 배치의 독립성을 확보했으나, 상품명이나 가격이 빈번하게 변경되는 도메인일 경우 이벤트 메시지의 크기가 커지거나 데이터 불일치 기간이 길어질 수 있을 것 같은데 실무에서는 어떤 방식으로 이를 해결을 하는지 궁금합니다.

⚠️ 리뷰 범위 안내
리베이스 과정에서 이전 주차들의 커밋들이 포함되었습니다. 이번 과제(주간/월간 랭킹)의 실질적인 리뷰는 아래 커밋부터 확인해 주시면 감사하겠습니다.

시작 커밋: ee16413 ([refactor: 동적 랭킹 키 생성을 위한 RankingKeyGenerator 분리...])

주요 구현 범위: 이후 진행된 주간/월간 배치 Job 구성, MV 설계, API 확장 관련 커밋들입니다.

✅ Checklist

🧱 Spring Batch

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

🧩 Ranking API

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

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 랭킹 조회 기능 확대: 일일, 주간, 월간 3가지 타입의 랭킹을 조회할 수 있습니다.
    • 자동 배치 처리: 주간 및 월간 랭킹 데이터를 자동으로 생성하고 관리합니다.
    • 상품 메트릭스 추적: 조회, 좋아요, 판매 데이터를 통합 관리합니다.
  • 개선 사항

    • 상품 정보 표시: 재고 상태를 품절/판매중으로 더 명확히 표시합니다.
    • API 응답 확장: 상품 이름 정보가 추가되었습니다.
  • 문서

    • 프로젝트 구조 업데이트

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

JVHE and others added 30 commits October 28, 2025 17:01
회원 가입시 User 저장이 수행된다. ( spy 검증 )
이미 가입된 ID 로 회원가입 시도 시, 실패한다.
회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.
회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다.
해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.
해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.
내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.
존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.
해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.
해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.

포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.
`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.
0 이하의 정수로 포인트를 충전 시 실패한다.
존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.
존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.
존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.
- PaymentRepository 및 관련 메서드에서 LocalDateTime 대신 ZonedDateTime 사용하도록 수정
- 이벤트 리스너의 트랜잭션 전파 방식을 Propagation.REQUIRES_NEW로 개선
- 사용되지 않는 데이터소스 설정 파일(jpa.yml, redis.yml) 삭제
- KafkaConfig 패키지명 오타 수정 (confg → config)
- build.gradle.kts에 kafka 모듈 의존성 추가 및 application.yml 설정 반영
- `OutboxEvent` 엔티티 및 JPA 매핑 추가로 Outbox 기반 이벤트 관리 구현
- `OutboxService`를 통해 이벤트 저장 및 직렬화 로직 처리
- `OutboxRepository`로 비동기 이벤트 조회 및 관리 가능
- `OutboxEvent` 구조 확장: 고유 `eventId` 필드 추가 및 저장 로직 개선.
- `LikeCreatedEvent`의 생성 메서드(`of`) 도입으로 eventId 자동 생성 및 타임스탬프 추가.
- Kafka 연동을 위한 `LikeEventOutboxHandler` 컴포넌트 추가.
- `OutboxService`에 이벤트 발행 완료 상태 관리 로직(`markPublished`) 추가.
- `LikeFacade` 및 `LikeCountAggregateListener`에서 이벤트 처리 로직 확장 및 관련 클래스 패키지 구조 정리.
- `LikeEventConsumer` 추가로 Kafka 이벤트 수신 및 처리 로직 구현.
- `ProductMetricsService`를 통해 좋아요 수 업데이트 처리.
- `ProductMetrics` 및 `EventHandled` 엔티티 추가로 이벤트 중복 처리 방지 및 메트릭 저장.
- 관련 JPA 리포지토리 인터페이스 (`ProductMetricsRepository`, `EventHandledRepository`) 추가.
- 이에 따른 컨슈머 및 퍼블리셔 구현체 수정
- OrderCreatedEvent, LikeEventOutboxHandler, LikeCountAggregateListener 패키지 구조 정리
- 상품 조회 액션을 기록하기 위한 ProductViewEvent 도입
- ProductViewEvent를 처리하고 Kafka와 연동하는 ProductEventOutboxHandler 구현
- 상품 정보 조회 시 ProductFacade에서 ProductViewEvent 발행하도록 수정
- 이벤트 저장, 중복 제거, Kafka 발행 로직 포함
- `OrderEventOutboxHandler` 추가로 주문 생성 이벤트 처리 및 Kafka 발행 로직 구현.
- `OrderSalesAggregateListener`를 통해 판매 집계 Kafka 이벤트 발행 및 비동기 처리 지원.
- `OrderCreatedEvent` 확장: 고유 `eventId` 추가 및 주문 품목 전달 정보 구조 변경.
- 신규 `SalesCountEvent` 추가로 Kafka 기반 판매 메트릭 관리 가능.
- `OrderFacade`, `PointPaymentEventListener`, `PgPaymentEventListener` 등 관련 클래스 수정 및 패키지 정리.
- `LikeEventConsumer`를 `MetricsEventConsumer`로 변경하고 상품 지표(좋아요, 조회수, 판매량) 처리 지원.
- `ProductMetricsService` 확장: 조회수(`ProductViewEvent`), 판매량(`SalesCountEvent`) 메트릭 처리 로직 추가.
- `ProductMetrics` 엔티티에 조회수와 판매량 속성 및 관련 업데이트 메서드 추가.
- `OutboxEvent`, `ProductMetrics`, `EventHandled` 엔티티에 @table 애너테이션 추가.
- `IdempotencyIntegrationTest` 구현으로 중복 이벤트 처리 검증.
… 로직 개선

- SalesCountEvent를 제거하고 관련 로직을 ProductStockEvent로 전면 대체
- MetricsEventConsumer에서 ProductStockEvent를 처리하도록 수정
- ProductMetricsService를 확장하여 재고 기반 지표 관리 및 재고 소진 시 Redis 캐시 삭제 로직 추가
- ProductRepository에 findStockById 메서드, ProductService에 getStock 메서드 추가
- Redis 캐시에서 페이징 응답을 감싸기 위한 PageWrapper 도입
- 패턴 기반 캐시 삭제를 지원하도록 RedisCacheHandler 이동 및 기능 확장
- `OutboxEvent`에 `OutboxStatus` 및 `retryCount` 필드 추가로 상태 관리 지원.
- 실패 이벤트를 처리하기 위한 `markFailed` 메서드 구현.
- `OutboxRelay` 컴포넌트 추가로 실패 이벤트 재발행 스케줄링 및 처리 로직 구현.
- 관련 서비스 및 핸들러에 실패 상태 업데이트 로직 통합 (`markFailed` 호출 추가).
- Kafka 설정에 enable.idempotence=true 및 acks=all 프로듀서 옵션 추가
- OutboxService에서 사용하지 않는 import 제거
- 미사용 클래스(DemoKafkaConsumer) 삭제로 코드 정리.
- 관련 Kafka 리스너 및 의존성 제거.
- 이벤트 리스너의 외부 서비스 의존성 제거 (Decoupling)
- 이벤트 객체가 발생 시점의 상태(재고)를 온전히 표현하도록 수정
- 비동기 처리 시 발생할 수 있는 데이터 불일치 가능성 방지
- ProductStockEvent에 가격(price) 필드 추가로 이벤트 정보 확장
- OrderCreatedEvent에서 상품과 가격 데이터를 함께 매핑하도록 수정
- OrderSalesAggregateListener에서 이벤트 발행 시 가격 정보를 관리하도록 개선
- 이벤트 타임스탬프 필드를 `LocalDateTime`으로 변경하고 이벤트 생성 로직 업데이트
- `RankingService` 추가로 상품 점수 관리 로직 구현 (조회수, 좋아요, 판매량 가중치 적용)
- MetricsEventConsumer 확장: 랭킹 점수 계산 및 Redis 업데이트 지원
- Kafka 리스너 및 배치 처리 로직 개선, 이벤트별 점수 업데이트 처리 추가
- OutboxHandler 수정: 이벤트 타입 변경 및 카탈로그 이벤트 발행 정리
- `RankingFacade` 및 `RankingService` 추가로 상품 랭킹 조회 로직 구현
- Redis 기반 랭킹 데이터 처리 및 상품 상세 정보 매핑 지원
- `RankingV1Controller`와 `RankingV1ApiSpec` 추가로 REST API 제공
- `RankingV1Dto`와 `RankingInfo` 구현으로 응답 데이터 구조화
- `RankingService` 메서드 `getProductRank` 구현으로 Redis 기반 상품 랭킹 조회 지원
- 상품 상세 조회 시 현재 랭킹 정보를 포함하도록 `ProductInfo` 및 `ProductFacade` 수정
- 기존 `RankingService` 패키지 위치를 `infrastructure`로 변경하여 관리 개선
- `carryOverRanking` 메서드를 `RankingService`에 추가하여 랭킹 데이터 이월 지원
- 이월 작업 자동화를 위한 `RankingScheduler` 구현 및 Cron 스케줄 설정
- Redis 연산을 활용한 이월 데이터 처리 및 만료 시간 설정 로직 추가
…스케줄러 사용을 위한 RankingService 개선

- 동적이고 재사용 가능한 랭킹 키 생성을 위해 RankingKeyGenerator 인터페이스와 RedisRankingKeyGenerator 구현체 도입
- 유지보수성 향상을 위해 RankingService에서 RankingKeyGenerator를 사용하도록 리팩토링
- commerce-api에 있던 중복 carryOverRanking 로직 제거 후 commerce-streamer 모듈로 통합
- 리팩토링된 RankingService를 사용하도록 RankingScheduler 수정
- `ProductStockEvent`와 `OrderCreatedEvent`의 상품명(`productName`) 필드 추가로 이벤트 정보를 확장
- 랭킹 엔티티 매핑을 위한 `RankingInfo` 생성 메서드 확장 (주간 및 월간 랭킹 지원)
- `productName`, `price`, `isSoldOut` 필드 추가로 상품 데이터 확장
- `updateProductSnapshot` 메서드 구현으로 상품 상태 동기화 지원
- 패키지 구조 개선: `ProductMetrics`를 `metrics`에서 최상위 `domain`으로 이동
- `ProductMetricsService`에서 상품 정보 업데이트 로직 통합 및 Redis 캐시 처리 강화
- RankingService 인터페이스를 구현한 RankingServiceImpl 추가
- 주간·월간 랭킹 조회를 위한 getWeeklyRankings, getMonthlyRankings 메서드 구현
- 입력값에 따라 랭킹 타입을 동적으로 분기하도록 RankingFacade 개선
- DAILY, WEEKLY, MONTHLY 랭킹 타입을 지원하도록 RankingV1ApiSpec 및 RankingV1Controller 확장
- 주간 랭킹 처리를 위한 도메인 엔티티(WeeklyRankingMV, WeeklyRankingWork) 추가
- WeeklyRankingJobConfig 및 RankingChunkConfig로 배치 잡 구성
- WeeklyRankingProcessor 및 Tasklet을 통해 점수 계산, 데이터 준비 및 스왑 처리 구현
- 스케줄링을 통해 배치 잡 자동 실행 (매주 월요일 2시 설정)
- MonthlyRankingJob 스켈레톤 추가, 향후 확장 고려
- 월간 랭킹 처리를 위한 도메인 엔티티(MonthlyRankingMV) 및 레포지토리 추가
- MonthlyRankingJobConfig와 Tasklet으로 배치 잡 구성
- 월간 점수 계산 및 랭킹 데이터 준비를 위한 Processing 로직 구현
- Redis를 활용한 스냅샷 데이터 처리 및 랭킹 데이터 스왑 로직 추가
@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 2026

Walkthrough

GitHub Actions 워크플로우를 추가하고 순위 시스템을 재구조화하며 새로운 commerce-batch 모듈을 도입합니다. 주간/월간 순위 배치 작업, 주기적 순위 스케줄러, 관련 JPA 엔티티 및 저장소를 포함합니다. 또한 OrderCreatedEvent와 ProductStockEvent에 productName 필드를 추가하고 API 응답에서 stock을 isSoldOut 불린 값으로 변경합니다.

Changes

코호트 / 파일 변경 요약
GitHub Actions 및 빌드
.github/workflows/main.yml, settings.gradle.kts
PR Agent 워크플로우 추가 및 commerce-batch 모듈을 멀티모듈 프로젝트에 포함
의존성 업데이트
apps/commerce-api/build.gradle.kts, apps/pg-simulator/src/main/resources/application.yml, apps/commerce-api/src/main/resources/application.yml
resilience4j, Spring Cloud OpenFeign 의존성 추가; Feign 클라이언트 타임아웃 설정 추가; 일부 재시도 설정 제거
순위 API 및 서비스 인터페이스 재구조화
apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java, apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java
RankingService를 인터페이스로 전환; 주간/월간 순위 조회 메서드 추가; RankingInfo의 stock을 isSoldOut 불린값으로 변경; 팩토리 메서드 추가
순위 API 엔드포인트
apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java
getRankings 메서드에 type 파라미터 추가; RankingResponse의 stock 필드를 isSoldOut으로 변경
이벤트 업데이트
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java, apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java, modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java
OrderCreatedEvent.OrderItemInfo에 productName 필드 추가; ProductStockEvent에 productName 필드 추가
배치 모듈 설정 및 진입점
apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java, apps/commerce-batch/src/main/resources/application.yml
새로운 commerce-batch 모듈 생성; 배치 프레임워크 의존성 추가; 애플리케이션 설정 추가
배치 작업 설정 - 주간 순위
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java
주간 순위 배치 작업 구성; 가중치 점수 계산 프로세서; 청크 기반 읽기/쓰기 설정
배치 작업 설정 - 월간 순위
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.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/ranking/step/RankingPrepareTasklet.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/...
순위 준비/테이블 스왑 태스클렛; 작업/단계/청크 리스너 추가
배치 스케줄러
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java
주간/월간 순위 배치 작업 스케줄링 (월요일 02:00, 월 1일 03:00)
배치 테스트
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java, apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
컨텍스트 로드 테스트 및 데모 배치 작업 E2E 테스트
스트리머 순위 통합
apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java, apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java, apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java, apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java
순위 캐리오버 스케줄러 추가; 순위 키 생성 인터페이스화; 일일 순위 합산 로직 추가
제품 메트릭 및 순위 엔티티
modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java, modules/jpa/src/main/java/com/loopers/domain/rank/weekly/..., modules/jpa/src/main/java/com/loopers/domain/rank/monthly/...
ProductMetrics 엔티티 추가; WeeklyRankingMV, WeeklyRankingWork, MonthlyRankingMV 엔티티 추가; 관련 저장소 추가
기타
README.md, apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
순위 관련 변경사항에 따른 마이너 업데이트

Sequence Diagram(s)

sequenceDiagram
    participant Scheduler as RankingScheduler
    participant JobLauncher as JobLauncher
    participant PrepareStep as Prepare Step<br/>(RankingPrepareTasklet)
    participant CalcStep as Calculation Step<br/>(Chunk Processing)
    participant SwapStep as Table Swap Step<br/>(RankingTableSwapTasklet)
    participant Repo as Repository<br/>(WeeklyRankingWork/MV)
    participant Redis as Redis
    participant DB as Database<br/>(ProductMetrics)

    rect rgba(100, 150, 200, 0.2)
    Note over Scheduler,DB: Weekly Ranking Job Flow
    Scheduler->>JobLauncher: runWeeklyRankingJob<br/>(with requestDate param)
    JobLauncher->>PrepareStep: Execute Prepare Step
    PrepareStep->>Repo: deleteAllInBatch()<br/>(WeeklyRankingWork)
    Repo-->>PrepareStep: Cleared
    
    PrepareStep->>CalcStep: Step Completed
    CalcStep->>DB: Read ProductMetrics<br/>(past 7 days)
    DB-->>CalcStep: Metrics List
    CalcStep->>CalcStep: Process: Calculate<br/>weighted score
    CalcStep->>Repo: Write WeeklyRankingWork<br/>(chunks of 100)
    Repo-->>CalcStep: Saved
    
    CalcStep->>SwapStep: Step Completed
    SwapStep->>Repo: findAll()<br/>(WeeklyRankingWork)
    Repo-->>SwapStep: Work Records
    SwapStep->>Repo: saveAll()<br/>(WeeklyRankingMV)
    Repo-->>SwapStep: MV Created
    SwapStep-->>JobLauncher: Job Complete
    end

    rect rgba(150, 100, 200, 0.2)
    Note over Scheduler,DB: Daily Ranking Carry-Over
    Scheduler->>RankingScheduler: scheduleRankingCarryOver<br/>(23:50 daily)
    RankingScheduler->>Redis: ZUNIONSTORE<br/>(today → tomorrow<br/>with weight 0.1)
    Redis-->>RankingScheduler: Merged Ranking
    RankingScheduler->>Redis: EXPIRE<br/>(tomorrow key)
    Redis-->>RankingScheduler: TTL Set
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

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 2.86% 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'은 주간/월간 랭킹 시스템 구축이라는 주요 변경사항을 구체적으로 설명하지 못하고 있습니다. 모호한 표현으로 동료가 변경사항의 핵심을 쉽게 파악하기 어렵습니다. 제목을 'Add weekly/monthly ranking batch jobs with materialized views' 같은 구체적인 표현으로 변경하여 변경사항을 명확하게 전달하세요.
✅ 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: 16

🧹 Nitpick comments (20)
apps/commerce-api/build.gradle.kts (1)

54-54: 새 의존성은 유효하지만 배치를 조정하세요.

spring-cloud-starter-circuitbreaker-resilience4j 의존성 추가는 적절합니다. Resilience4j와의 Spring Cloud CircuitBreaker 통합을 지원합니다. 중복 의존성을 제거한 후, 이 선언을 26번 라인 근처의 다른 Spring Cloud 의존성들과 함께 배치하는 것을 권장합니다.

🔎 권장 배치

중복 제거 후 다음과 같이 배치:

 //Spring Cloud OpenFeign
 implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
+
+//Spring Cloud CircuitBreaker
+implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java (1)

3-5: 사용되지 않는 import 제거 필요

org.springframework.stereotype.Component import가 선언되어 있지만 인터페이스에서 사용되지 않습니다. 구현체(RedisRankingKeyGenerator)에 @Component가 적용되어 있으므로 이 import는 제거해도 됩니다.

🔎 수정 제안
 package com.loopers.domain.rank;
 
-import org.springframework.stereotype.Component;
-
 public interface RankingKeyGenerator {
 
   String generateDailyKey(String date);
 }
apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java (1)

18-34: 가중치 매직 넘버를 상수로 추출하는 것을 고려해 주세요

0.1 가중치 값이 하드코딩되어 있습니다. 이 값을 상수로 추출하면 의미가 명확해지고 추후 수정이 용이합니다. 또한, DateTimeFormatter는 스레드 세이프하므로 정적 상수로 선언하여 매 호출마다 생성하지 않도록 할 수 있습니다.

🔎 수정 제안
 @Slf4j
 @Component
 @RequiredArgsConstructor
 public class RankingScheduler {

   private final RankingService rankingService;
+  private static final double CARRY_OVER_WEIGHT = 0.1;
+  private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

   @Scheduled(cron = "0 50 23 * * *")
   public void scheduleRankingCarryOver() {
     LocalDateTime now = LocalDateTime.now();
-    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");

-    String today = now.format(formatter);
-    String tomorrow = now.plusDays(1).format(formatter);
+    String today = now.format(DATE_FORMATTER);
+    String tomorrow = now.plusDays(1).format(DATE_FORMATTER);

     log.info("Starting Ranking Carry-Over: {} -> {}", today, tomorrow);

     try {
-      rankingService.carryOverRanking(today, tomorrow, 0.1);
+      rankingService.carryOverRanking(today, tomorrow, CARRY_OVER_WEIGHT);
       log.info("Ranking Carry-Over completed successfully.");
     } catch (Exception e) {
       log.error("Ranking Carry-Over failed", e);
     }
   }
 }
.github/workflows/main.yml (1)

10-10: 액션 버전을 고정하는 것을 권장합니다.

@main 브랜치 대신 특정 릴리스 태그나 커밋 SHA를 사용하면 워크플로우의 안정성과 재현성이 향상됩니다.

🔎 권장 수정
-      - name: PR Agent action step
-        uses: Codium-ai/pr-agent@main
+      - name: PR Agent action step
+        uses: Codium-ai/pr-agent@v0.x.x  # 최신 안정 버전으로 교체
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)

27-29: 로깅 프레임워크 사용을 권장합니다

System.out.println 대신 Slf4j 로거를 사용하는 것이 좋습니다. 클래스에 이미 Lombok의 @requiredargsconstructor가 있으므로 @slf4j를 추가하고 log.info()를 사용하세요.

🔎 제안하는 수정안

클래스 레벨에 @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 {

execute 메서드 내 로깅 변경:

-        System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")");
+        log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate);
         Thread.sleep(1000);
-        System.out.println("Demo Tasklet 작업 완료");
+        log.info("Demo Tasklet 작업 완료");

28-28: Thread.sleep 사용에 대한 검토

데모 목적의 Tasklet이지만 Thread.sleep(1000)은 배치 처리 스레드를 블로킹합니다. 실제 작업을 시뮬레이션하려는 의도라면 괜찮지만, 프로덕션 코드에서는 제거하거나 실제 비즈니스 로직으로 대체해야 합니다.

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

43-49: type 파라미터에 방어적 null 처리를 추가하세요.

type.toUpperCase()는 type이 null일 경우 NullPointerException을 발생시킵니다. 현재 컨트롤러에서 기본값을 제공하지만, RankingFacade는 public 컴포넌트이므로 다른 곳에서도 호출될 수 있습니다.

🔎 방어적 null 처리 제안
 public List<RankingInfo> getRankings(String type, String date, int page, int size) {
-  return switch (type.toUpperCase()) {
+  String normalizedType = (type != null) ? type.toUpperCase() : "DAILY";
+  return switch (normalizedType) {
     case "WEEKLY" -> rankingService.getWeeklyRankings(date, page, size);
     case "MONTHLY" -> rankingService.getMonthlyRankings(date, page, size);
     default -> getTopRankings(date, page, size);
   };
 }

또는 Java Objects 유틸리티 사용:

+import java.util.Objects;
+
 public List<RankingInfo> getRankings(String type, String date, int page, int size) {
-  return switch (type.toUpperCase()) {
+  return switch (Objects.requireNonNullElse(type, "DAILY").toUpperCase()) {
     case "WEEKLY" -> rankingService.getWeeklyRankings(date, page, size);
     case "MONTHLY" -> rankingService.getMonthlyRankings(date, page, size);
     default -> getTopRankings(date, page, size);
   };
 }
apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java (1)

23-29: 선택 사항: 타입 안정성을 위해 enum 사용을 고려하세요.

현재 type을 String으로 처리하고 있어 유연하지만, enum을 사용하면 컴파일 타임 안정성과 명확한 API 문서화를 제공할 수 있습니다.

🔎 Enum 기반 리팩토링 제안

RankingType enum 생성:

public enum RankingType {
    DAILY, WEEKLY, MONTHLY
}

컨트롤러 수정:

 public ApiResponse<List<RankingResponse>> getRankings(
-    @RequestParam(value = "type", defaultValue = "DAILY") String type,
+    @RequestParam(value = "type", defaultValue = "DAILY") RankingType type,
     @RequestParam(value = "date") String date,
     @RequestParam(value = "page", defaultValue = "1") int page,
     @RequestParam(value = "size", defaultValue = "20") int size
 ) {
-  List<RankingInfo> infos = rankingFacade.getRankings(type, date, page, size);
+  List<RankingInfo> infos = rankingFacade.getRankings(type.name(), date, page, size);

이렇게 하면:

  • 잘못된 type 값에 대해 Spring이 자동으로 400 에러 반환
  • API 문서에서 허용된 값이 명확하게 표시됨
  • IDE 자동완성 지원
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java (2)

37-39: 향후 모니터링 강화 고려

예외 발생 시 로그만 남기고 있습니다. 주석에 언급된 것처럼 Slack 등 외부 알림 채널로 에러를 전송하는 기능을 추가하면 운영 중 문제 감지가 더 빨라질 것입니다.

Slack 알림 기능 구현을 도와드릴까요? Webhook 기반 알림 코드를 생성해 드릴 수 있습니다.

Also applies to: 55-57


27-40: 배치 잡 중복 실행 방지 확인 권장

스케줄러에 동시 실행 방지 로직이 보이지 않습니다. 만약 배치 잡이 예상보다 오래 실행되어 다음 스케줄 시간과 겹치면 문제가 발생할 수 있습니다.

Spring Batch의 기본 JobLauncher는 동일한 파라미터로 완료된 잡의 재실행을 방지하지만, 실행 중인 잡과의 동시 실행은 막지 않을 수 있습니다. 다음 중 하나를 고려하세요:

  1. Spring의 @ScheduledfixedDelay 사용 (이전 실행 완료 후 대기)
  2. ShedLock 같은 분산 락 라이브러리 사용
  3. 배치 잡 내부에서 이미 실행 중인 인스턴스 확인

현재 주간/월간 배치라서 중복 실행 가능성은 낮지만, 운영 환경에서의 안정성을 위해 검토를 권장합니다.

Also applies to: 42-58

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

29-37: 대용량 데이터 처리 시 메모리 문제 가능성

workRepository.findAll()mvRepository.saveAll(newData)는 모든 데이터를 메모리에 로드합니다. 상위 100개 제품만 처리하는 현재 설계에서는 문제없지만, 데이터가 증가할 경우 배치 청크 처리나 페이징을 고려하세요.

또한 deleteAllInBatch()saveAll() 사이에 장애 발생 시 데이터 손실 가능성이 있습니다. 중요한 경우 트랜잭션 범위 또는 swap 전략(임시 테이블 활용)을 검토하세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (2)

37-45: WeeklyRankingJobConfig와 달리 JobListener가 누락되었습니다.

WeeklyRankingJobConfig에서는 .listener(jobListener)를 사용하여 Job 실행을 모니터링하지만, MonthlyRankingJobConfig에는 누락되어 있습니다. 일관성을 위해 추가를 권장합니다.

🔎 수정 제안
+  private final JobListener jobListener;
+
   @Bean(JOB_NAME)
   public Job monthlyRankingJob() {
     return new JobBuilder(JOB_NAME, jobRepository)
         .incrementer(new RunIdIncrementer())
         .start(monthlyPrepareStep())
         .next(monthlyCalculationStep())
         .next(monthlyTableSwapStep())
+        .listener(jobListener)
         .build();
   }

30-35: 스케줄러 타이밍으로 현재 동시 실행 방지되어 있으나, 코드 수준의 보호 없이 설계 위험 존재

현재 스케줄러 설정(weeklyRankingJob은 매주 월요일 02:00, monthlyRankingJob은 매월 1일 03:00)으로 인해 두 작업의 동시 실행이 실제로는 방지되고 있습니다.

그러나 RankingPrepareTaskletMonthlyRankingTableSwapTasklet이 동일한 WeeklyRankingWorkRepository(weekly_ranking_work 테이블)를 공유하는 구조는 여전히 설계적 위험을 안고 있습니다. 스케줄러 설정 변경이나 수동 잡 실행 시 데이터 충돌 가능성이 있으므로, 별도의 Monthly용 작업 테이블 도입을 권장합니다.

modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java (1)

31-43: WeeklyRankingMV와 MonthlyRankingMV의 팩토리 메서드 접근 방식 차이 검토 필요

MonthlyRankingMV.createFromWork가 productName, price, isSoldOut을 파라미터로 받는 것은 올바른 설계입니다.

다만 WeeklyRankingMV.createFromWork는 현재 "상품명 임시", 0L, false로 하드코딩된 플레이스홀더를 사용하고 있습니다 (modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java:48-50, 코드 주석 "실제 구현 시 Product 정보 결합 필요").

이 불일치를 개선하려면 RankingTableSwapTasklet에서 Product 정보를 함께 조회하여 전달해야 합니다. 현재는 WeeklyRankingWork 데이터만 사용 가능하므로, 원본 Design이 의도한 것인지 확인하고 필요시 productId 기반 Product 조회 로직을 추가하는 것을 검토해주세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)

40-47: Step 이름과 메서드명 불일치 확인 필요

Step 빈 이름은 demoSimpleTask이지만 메서드명은 categorySyncStep입니다. 데모 목적의 코드라면 큰 문제는 아니지만, 메서드명을 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();
     }
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java (1)

27-37: 주간 랭킹도 Job 파라미터로 날짜를 받는 것을 고려해 보세요

monthlyRankingReaderstartDate Job 파라미터를 사용하는 반면, rankingReaderLocalDateTime.now().minusDays(7)를 하드코딩하고 있습니다. 재처리나 테스트를 위해 날짜를 외부에서 주입받으면 더 유연해집니다.

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

56-64: DateTimeFormatter 인스턴스를 static final로 추출하세요

DateTimeFormatter.ofPattern("yyyyMMdd")가 매 호출마다 생성됩니다. 클래스 레벨 상수로 추출하면 불필요한 객체 생성을 피할 수 있습니다.

🔎 제안된 수정
+  private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
+
   @Override
   public Integer getProductRank(Long productId) {
-    String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+    String today = LocalDateTime.now().format(DATE_FORMATTER);
     String key = "ranking:all:" + today;

23-38: Redis 키 생성 로직 하드코딩 및 NumberFormatException 위험성

getTopRankingIds 메서드(25행)와 getProductRank 메서드(59행)에서 "ranking:all:" 키 패턴이 하드코딩되어 있습니다. 동일한 패턴이 다른 파일(MetricsEventConsumer 66행 등)에서도 반복되고 있으며, 별도 모듈의 RankingKeyGenerator 인터페이스(generateDailyKey 메서드 제공)와 불일치합니다. 중앙화된 키 생성 로직으로 통합하십시오.

또한 36행의 Long::valueOf는 에러 처리가 없어 Redis에 유효하지 않은 데이터가 저장된 경우 NumberFormatException을 발생시킬 수 있습니다. 변환 전 데이터 검증 또는 예외 처리를 추가하십시오.

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

14-19: type 파라미터에 Enum 사용을 고려해 보세요

type 파라미터가 String으로 정의되어 있어 런타임에 잘못된 값이 전달될 수 있습니다. RankingType enum을 정의하면 컴파일 타임 안전성과 Swagger 문서 자동화 이점을 얻을 수 있습니다.

🔎 Enum 정의 예시
public enum RankingType {
    DAILY, WEEKLY, MONTHLY
}
   ApiResponse<List<RankingResponse>> getRankings(
-      @Parameter(description = "랭킹 타입 (DAILY, WEEKLY, MONTHLY)", example = "WEEKLY") String type,
+      @Parameter(description = "랭킹 타입", example = "WEEKLY") RankingType type,
       @Parameter(description = "조회 날짜 (yyyyMMdd)", example = "20251225") String date,
       @Parameter(description = "페이지 번호") int page,
       @Parameter(description = "페이지 크기") int size
   );
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java (1)

32-34: Redis 키 패턴을 상수로 추출 권장

"product:snapshot:" 키 패턴이 하드코딩되어 있습니다. 다른 곳에서도 동일한 패턴을 사용한다면 상수나 공통 유틸리티로 추출하여 일관성을 유지하는 것이 좋습니다.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 360a1ac and 84b46b4.

📒 Files selected for processing (49)
  • .github/workflows/main.yml
  • README.md
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java
  • apps/commerce-api/src/main/resources/application.yml
  • 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/ranking/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.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/resources/application.yml
  • 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/application/rank/RankingScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
  • apps/pg-simulator/src/main/resources/application.yml
  • modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java
  • modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java
  • modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java
  • settings.gradle.kts
💤 Files with no reviewable changes (1)
  • apps/commerce-api/src/main/resources/application.yml
🧰 Additional context used
🧠 Learnings (8)
📚 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:

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

Applied to files:

  • modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.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/domain/metrics/ProductMetricsService.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java
  • modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.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/domain/metrics/ProductMetricsService.java
  • modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.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-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.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
🧬 Code graph analysis (14)
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java (1)
  • Component (14-50)
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java (2)
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java (1)
  • Component (14-50)
apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java (1)
  • Component (5-14)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
  • Slf4j (14-53)
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/ranking/RankingChunkConfig.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
  • StepScope (14-32)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
  • StepScope (14-32)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java (1)
  • Component (12-24)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java (1)
  • Component (9-30)
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)
modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java (2)
modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java (1)
  • Entity (12-30)
modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java (1)
  • Getter (8-19)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java (1)
  • Component (17-40)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java (1)
  • Component (9-30)
modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java (1)
modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java (1)
  • Entity (12-53)
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (2)
apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java (1)
  • ConfigurationPropertiesScan (10-22)
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)
  • main (22-24)
modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt (1)
  • name (14-87)
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/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java (1)
  • SpringBootTest (6-10)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.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/JobListener.java (1)
  • Slf4j (14-53)
🔇 Additional comments (44)
README.md (1)

30-30: 문서 업데이트가 올바르게 반영되었습니다.

새로운 commerce-batch 모듈 추가를 문서에 정확하게 반영했습니다. settings.gradle.kts의 실제 모듈 포함과 일관성 있게 작성되었습니다.

settings.gradle.kts (1)

7-7: 모듈 추가가 올바르게 구성되었습니다.

commerce-batch 모듈이 Gradle 멀티모듈 프로젝트에 올바르게 포함되었습니다. 모듈 경로 형식과 위치가 기존 구조와 일관성 있게 작성되었습니다.

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

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

배치 모듈의 의존성 구성이 PR 목표와 잘 정렬되어 있습니다:

  • Spring Batch 프레임워크 (starter 및 test 지원)
  • 내부 모듈 의존성 (JPA, Redis, Jackson 등)이 주간/월간 랭킹 배치 작업의 요구사항을 충족
  • QueryDSL 설정이 Jakarta 네임스페이스를 올바르게 사용 (::jakarta classifier)
  • 테스트 픽스처 재사용으로 일관성 확보
apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java (2)

48-48: 일관된 null 처리 구현

productName 추출 로직이 다른 필드들(price, stock)과 동일한 패턴으로 null-safe하게 구현되어 일관성이 유지되고 있습니다.

참고: "Unknown" 문자열이 하드코딩되어 있으나, 학습된 내용에 따르면 이 이벤트는 내부 전용이므로 현재 구현이 적절합니다.


24-24: 이 코멘트는 검토 불필요합니다.

OrderCreatedEvent 내의 OrderItemInfoOrderInfo.javaOrderItemInfo와 별개의 중첩 레코드입니다. 각각 다른 목적과 필드를 가지고 있으며, 같은 시그니처를 공유하지 않습니다. OrderCreatedEvent 내의 모든 OrderItemInfo 생성자 호출(46-52줄)은 새로운 5개 필드 시그니처와 정확히 일치합니다. 따라서 레코드 시그니처 변경으로 인한 호환성 문제는 없습니다.

Likely an incorrect or invalid review comment.

apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java (1)

5-13: LGTM!

RankingKeyGenerator 인터페이스의 깔끔한 구현입니다. 키 prefix가 상수로 분리되어 있어 유지보수성이 좋습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java (2)

19-25: LGTM!

RankingKeyGenerator를 통한 키 생성 로직 추상화가 잘 적용되었습니다. 하드코딩된 prefix 대신 인터페이스를 사용함으로써 테스트 용이성과 유연성이 향상되었습니다.


41-49: unionAndStore 동작 확인 필요

unionAndStoretargetKey에 이미 데이터가 존재할 경우 해당 데이터를 덮어씁니다. 만약 당일 자정 전에 이미 익일(targetKey) 랭킹 데이터가 축적되어 있다면, 이 데이터가 손실될 수 있습니다.

현재 스케줄러가 23:50에 실행되므로, 익일 키에 데이터가 있을 가능성은 낮지만, 의도된 동작인지 확인해 주세요. 만약 기존 데이터와 병합해야 한다면 targetKey도 union 대상에 포함해야 합니다.

🔎 기존 데이터 보존이 필요한 경우 수정 제안
   public void carryOverRanking(String sourceDate, String targetDate, double weight) {
     String sourceKey = rankingKeyGenerator.generateDailyKey(sourceDate);
     String targetKey = rankingKeyGenerator.generateDailyKey(targetDate);

-    redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey,
-        Aggregate.SUM, Weights.of(weight));
+    // targetKey 기존 데이터와 병합 (source: weight 적용, target: 1.0 유지)
+    redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(targetKey), targetKey,
+        Aggregate.SUM, Weights.of(weight, 1.0));

     redisTemplate.expire(targetKey, 2, TimeUnit.DAYS);
   }
apps/pg-simulator/src/main/resources/application.yml (1)

26-32: Feign 클라이언트 타임아웃 설정이 지나치게 짧고 일관되지 않습니다.

connectTimeout과 readTimeout이 각각 1초로 설정되어 있어 정상적인 네트워크 지연 상황에서도 요청이 실패할 수 있습니다. 현재 Tomcat의 connection-timeout이 60초(Line 7)로 설정된 것과 비교하면 심각한 불일치가 있습니다.

추가 문제: 불완전한 회복력 메커니즘

일부 Feign 클라이언트 메서드에만 @Retry@CircuitBreaker가 적용되어 있습니다. 예를 들어 PgClientrequestPayment() 메서드는 보호되지만, getTransactionsByOrder() 메서드는 아무런 보호도 없습니다. 모든 외부 API 호출에 대해 일관된 회복력 전략이 필요합니다.

권장 조치:

  1. Feign 타임아웃을 적절한 수준으로 상향 조정 (connectTimeout: 35초, readTimeout: 1030초)
  2. 모든 Feign 클라이언트 메서드에 @Retry@CircuitBreaker 적용
  3. Tomcat과 Feign 타임아웃 설정의 일관성 검토
⛔ Skipped due to learnings
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 156
File: apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt:51-59
Timestamp: 2025-12-05T04:37:02.942Z
Learning: User HongChangMo prefers to skip detailed code reviews for the pg-simulator module (apps/pg-simulator/**) in the loopers-spring-java-template repository.
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)

16-20: 타임존 설정이 올바르게 구현되었습니다.

@PostConstruct를 사용하여 애플리케이션 시작 시 타임존을 Asia/Seoul로 설정하는 것은 배치 작업의 시간 관련 데이터 일관성을 보장하는 좋은 방법입니다. CommerceStreamerApplication과 동일한 패턴을 따르고 있어 일관성도 유지됩니다.

.github/workflows/main.yml (1)

1-4: 워크플로우 트리거 설정이 적절합니다.

PR이 열리거나 동기화될 때 자동으로 실행되는 설정이 올바르게 구성되어 있습니다.

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

6-10: 기본 컨텍스트 로딩 테스트 확인됨

애플리케이션 컨텍스트가 정상적으로 로드되는지 확인하는 표준 스모크 테스트입니다. DemoJobE2ETest와 함께 기본적인 통합 테스트 커버리지를 제공합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)

14-17: 컴포넌트 설정 확인됨

@StepScope와 @ConditionalOnProperty를 사용한 조건부 빈 등록이 적절합니다. DemoJobConfig.JOB_NAME과 연동되어 특정 잡 실행 시에만 활성화됩니다.


19-26: Job 파라미터 검증 로직 확인됨

SpEL을 통한 job parameter 주입과 null 체크가 적절합니다. DemoJobE2ETest의 테스트 케이스와 일치하는 검증 로직입니다.

apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (1)

7-7: Import 재정렬 확인

RankingService import의 위치만 변경된 것으로, 기능적 변경은 없습니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java (1)

3-3: 패키지 재구성 확인됨

ProductMetrics 엔티티의 패키지 경로가 com.loopers.domain.metrics에서 com.loopers.domain으로 변경되었습니다. 레포지토리 인터페이스의 기능적 변경은 없으며, PR의 전반적인 도메인 구조 재구성과 일치합니다.

modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java (1)

1-9: LGTM!

표준 Spring Data JPA 리포지토리 인터페이스로 적절하게 구현되었습니다. 배치 작업에서 WeeklyRankingWork 엔티티를 관리하는 데 필요한 CRUD 기능을 제공합니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java (1)

7-24: API 호환성을 고려한 변경 전략을 검토하세요.

stock (int) 필드를 isSoldOut (boolean)으로 변경하는 것은 기존 API 소비자에게 breaking change입니다.

이 변경사항이 V1 API (/api/v1/rankings)에 적용되므로, 다음을 고려해야 합니다:

  • 기존 클라이언트가 stock 필드를 예상하고 있다면 에러가 발생합니다
  • API 버전 업그레이드(V2) 또는 deprecation 기간을 고려하세요
  • 또는 두 필드를 모두 제공하는 점진적 마이그레이션 전략을 검토하세요

의도적인 breaking change라면 API 변경 사항을 문서화하고 클라이언트에게 공지해야 합니다.

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

24-24: LGTM! 하위 호환성이 잘 유지되었습니다.

type 파라미터에 defaultValue = "DAILY"를 지정하여 기존 클라이언트가 파라미터를 생략해도 기본 동작(일간 랭킹)이 유지됩니다. 이는 하위 호환성을 고려한 좋은 설계입니다.

apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java (1)

25-31: LGTM! 이벤트 강화(Event Enrichment) 패턴이 올바르게 적용되었습니다.

ProductStockEvent가 이제 productName을 포함하도록 확장되어, commerce-batch 모듈이 commerce-api 도메인에서 분리될 수 있습니다. 이는 PR 목표에 명시된 이벤트 강화 전략과 일치합니다.

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

25-52: afterJob 구현이 정확합니다.

시간 계산 및 형식화 로직이 올바르게 구현되었습니다. Duration API를 사용한 시간 계산과 포맷팅이 적절합니다.

modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java (1)

6-25: LGTM! 이벤트 강화를 위한 productName 필드 추가가 올바릅니다.

ProductStockEventproductName 필드를 추가하여 이벤트가 제품 스냅샷 데이터(이름, 가격)를 전달하도록 확장했습니다. 이는 PR 목표에 명시된 대로 commerce-batch 모듈이 commerce-api 도메인에서 분리될 수 있도록 합니다. 팩토리 메서드 시그니처와 레코드 구조가 일관성 있게 업데이트되었습니다.

modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java (1)

8-12: LGTM! Spring Data JPA 리포지토리가 올바르게 구현되었습니다.

WeeklyRankingMVRepository는 Spring Data JPA 규칙을 따르며, 파생 쿼리 메서드 findByBaseDateOrderByCurrentRankAsc가 적절하게 정의되었습니다. 학습된 내용에 따라 이 코드베이스에서는 도메인 리포지토리에 Pageable을 사용하는 것이 허용된 아키텍처 결정입니다.

apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)

58-75: LGTM! 성공 케이스 테스트가 올바르게 구현되었습니다.

JobParametersBuilder를 사용하여 LocalDate 파라미터를 전달하고, 배치 작업의 성공적인 완료를 검증하는 테스트 구조가 적절합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java (2)

18-22: 가중치 공식과 100개 제한 검증 권장

점수 계산 가중치(조회수 0.1, 좋아요 0.2, 판매수 0.6)와 상위 100개 제한이 비즈니스 요구사항과 일치하는지 확인이 필요합니다. 특히 100개 제한이 하드코딩되어 있어 향후 변경 시 코드 수정이 필요합니다.

요구사항 문서나 관련 이슈에서 다음을 확인하세요:

  • 가중치 비율이 올바른지
  • 상위 랭킹 개수가 100개로 고정되어야 하는지, 아니면 설정 가능해야 하는지

13-20: 멀티스레드 TaskExecutor 설정이 없어 동시성 문제 우려는 근거가 없음

WeeklyRankingJobConfig의 calculationStep() 메서드를 확인한 결과, .taskExecutor() 호출이 없습니다. Spring Batch는 taskExecutor가 명시적으로 구성되지 않으면 기본적으로 단일 스레드로 처리합니다. 따라서 rankCounter 필드의 현재 구현은 동시성 문제가 없습니다.

@StepScope 어노테이션은 각 Step 실행마다 새로운 processor 인스턴스를 생성하므로, rankCounter는 단일 스레드에서만 접근됩니다.

modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java (1)

1-19: LGTM!

간단하고 명확한 값 객체입니다. Serializable 구현은 Redis 캐싱에 적합하며, Lombok 사용으로 보일러플레이트가 최소화되었습니다.

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

18-21: LGTM!

스텝 시작 로깅이 명확하고 적절합니다.

apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java (2)

11-11: API 개선 - stock에서 isSoldOut으로 변경

stock 정수 값을 isSoldOut 불린으로 변경한 것은 좋은 캡슐화입니다. API 소비자는 재고 수량 대신 품절 여부만 알면 되므로 정보 노출이 최소화됩니다.

Also applies to: 20-20


25-43: LGTM!

WeeklyRankingMVMonthlyRankingMV로부터 RankingInfo를 생성하는 팩토리 메서드가 명확하고 올바르게 구현되었습니다. MV 기반 랭킹 조회 파이프라인과 잘 통합됩니다.

modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java (1)

8-12: LGTM!

Spring Data JPA 규칙을 잘 따르고 있습니다. findByBaseDateOrderByCurrentRankAsc 메서드가 순위를 올바르게 오름차순으로 정렬합니다.

참고: 현재 List 반환 타입은 페이지네이션 메타데이터(총 개수 등)가 필요 없는 경우 적합합니다. 나중에 메타데이터가 필요하면 Page<MonthlyRankingMV>로 변경을 고려하세요.

modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java (1)

17-29: 엔티티 구조는 적절합니다.

MV 엔티티로서 필요한 필드들이 잘 정의되어 있습니다. 성능 최적화가 필요한 경우, baseDatecurrentRank 컬럼에 복합 인덱스 추가를 고려해보세요 (쿼리 패턴: findByBaseDateOrderByCurrentRankAsc).

modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java (1)

12-29: LGTM!

배치 작업을 위한 작업 테이블 엔티티가 잘 구현되었습니다. RankingPrepareTasklet에서 deleteAllInBatch()로 정리한 후 데이터를 적재하는 흐름이므로 중복 방지 로직이 적절히 처리됩니다.

참고: productId에 unique 제약조건은 없지만, 배치 시작 시 테이블을 비우는 현재 설계에서는 문제없습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (2)

41-50: LGTM!

Job 구성이 잘 설계되었습니다. RunIdIncrementer로 재실행을 지원하고, 3단계(prepare → calculation → tableSwap) 흐름이 명확합니다. JobListener로 모니터링도 가능합니다.


59-67: 청크 처리 구성이 적절합니다.

청크 크기 100은 합리적인 선택입니다. WeeklyRankingProcessor에서 상위 100개만 처리하는 로직과 잘 맞습니다.

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

12-23: LGTM!

deleteAllInBatch()를 사용한 효율적인 테이블 정리 구현입니다. Weekly와 Monthly 작업 모두에서 재사용되는 설계가 좋습니다.

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

6-15: LGTM! 인터페이스 추출이 잘 되었습니다.

구체 클래스에서 인터페이스로 전환하여 관심사 분리가 명확해졌습니다. 일간(Redis), 주간/월간(MV) 랭킹에 대한 메서드 시그니처가 적절하게 정의되어 있습니다.

modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java (1)

29-31: 초기화 시 updatedAt이 null로 남습니다

생성자에서 updatedAt을 초기화하지 않아, 첫 번째 updateLikeCount 호출 전까지 updatedAtnull입니다. 이는 의도된 동작일 수 있지만, 배치 작업에서 updatedAt 기반 쿼리 시 새로 생성된 메트릭이 누락될 수 있습니다.

🔎 제안된 수정
   public ProductMetrics(Long productId) {
     this.productId = productId;
+    this.updatedAt = LocalDateTime.now();
   }
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java (1)

28-30: 트랜잭션 보호로 인해 delete-then-insert 패턴의 데이터 손실 위험은 없음

이 Tasklet은 MonthlyRankingJobConfig에서 .tasklet(tableSwapTasklet, transactionManager)로 설정되어 있어, execute() 메서드 전체가 Spring Batch의 트랜잭션으로 보호됩니다(67번 라인). 따라서 deleteAllInBatch() 실행 후 saveAll() 전에 예외가 발생하면 delete도 함께 롤백되므로 데이터 손실 위험은 없습니다.

다만, Redis 조회 실패(36번 라인) 또는 데이터 변환 과정(42-58번 라인)에서의 예외는 전체 Step을 실패시키므로, 실패 처리 전략과 에러 로깅을 확인하는 것이 좋습니다.

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

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

web-application-type: none 설정과 매개변수화된 job.name은 Spring Batch 애플리케이션에 적합하며, initialize-schema: never는 프로덕션 환경에서의 자동 스키마 초기화를 방지하는 올바른 기본값입니다.


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

로컬 및 테스트 프로파일에서 initialize-schema: always로 설정하여 개발 환경에서 배치 메타데이터 테이블을 자동 생성하는 것은 적절한 구성입니다.


34-50: 환경별 프로파일 선언이 올바릅니다.

dev, qa, prd 프로파일이 명시적 오버라이드 없이 선언되어 있으며, 이는 기본 설정을 상속하고 환경별 설정은 임포트된 파일(jpa.yml, redis.yml 등)에서 관리하는 패턴으로 적절합니다.


52-54: 배치 애플리케이션에서 SpringDoc 비활성화가 적절합니다.

web-application-type: none과 일관되게 API 문서를 비활성화하여 불필요한 오버헤드를 제거한 것은 배치 애플리케이션에 적합한 구성입니다.


20-23: 헬스 엔드포인트 비활성화로 이해된 부분을 재검토해주세요.

management.health.defaults.enabled: false는 기본 헬스 지표(데이터베이스, 디스크 공간 등)를 비활성화하는 것이며, 헬스 엔드포인트 자체를 비활성화하지 않습니다.

imported된 monitoring.yml에서 다음이 명시적으로 설정되어 있습니다:

  • management.endpoints.web.exposure.include: [health, prometheus] - 헬스 엔드포인트 노출
  • management.endpoint.health.probes.enabled: true - 헬스 프로브 활성화
  • management.health.livenessState.enabled: true, readinessState.enabled: true - 쿠버네티스 프로브 활성화

배치 애플리케이션(web-application-type: none)의 경우 기본 지표를 비활성화하는 것이 적절합니다. 배치 작업의 간헐적인 데이터베이스 연결로 인한 불필요한 헬스 체크 실패를 방지하면서도, /actuator/health 엔드포인트(포트 8081)는 쿠버네티스 readiness/liveness probe를 위해 정상 작동합니다.

Likely an incorrect or invalid review comment.

Comment on lines +11 to +13
env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
GITHUB_TOKEN: ${{ secrets.G_TOKEN }}
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:

cd .github/workflows && find . -name "main.yml" -exec cat -n {} \;

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

Length of output: 487


🏁 Script executed:

# Check if there are any other references to G_TOKEN or pr-agent in the repo
rg -i "g_token|pr-agent|openai" --type yaml --type md -A 2 -B 2 2>/dev/null || echo "Search completed"

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

Length of output: 104


🌐 Web query:

Codium-ai pr-agent GitHub action trustworthiness security

💡 Result:

Short answer: PR-Agent (Codium‑AI / now Qodo) has had real, documented security issues; two vulnerabilities (CVE‑2024‑51355 and CVE‑2024‑51356) were reported and responsible disclosures and fixes were published — so treat the GitHub Action as potentially risky unless you follow mitigation steps and use patched/pinned images or self‑host. Sources: independent security research and the project's security notes. [1][2][3]

Key points and what to do

  • History: Multiple serious vulnerabilities were published by Kudelski Security in Aug–Nov 2024 (PoC showed secret exfiltration and repo write risks); CVEs were assigned. The researcher showed many repos using the action could be affected. [1]
  • Project response: The PR‑Agent repo/project (now Qodo) has published security guidance, release notes and recommends using fixed releases or pinned Docker digests; they also provide a security contact. Confirm you run a version that includes the fixes. [2][3]
  • Immediate mitigations (minimum best practice):
    1. Do not use unpinned uses: avoid using uses: codiumai/pr-agent@main. Pin to a specific, patched release or to the Docker image digest. [2]
    2. Self‑host the agent or run the Docker image in your own environment where you control secrets and network egress. [2]
    3. Limit GitHub Actions permissions and tokens: restrict GITHUB_TOKEN permissions (no write unless absolutely needed) and avoid passing high‑privilege secrets into the action. Use least privilege for workflow tokens. (This is standard Actions hardening and directly mitigates the PoC.) [1][2]
    4. Upgrade to a version >= the patched release (check project release notes / changelog) and monitor the repo for new advisories. [2][3]
    5. Scan your repo and workflows for other uses of PR‑Agent and rotated any secrets that may have been exposed if you used vulnerable versions. [1]
  • When to avoid it: do not install the action on sensitive/private repos or orgs that contain production secrets unless you self‑host or have confirmed the version is patched and permissions are locked down. [1][2]

Want me to:

  • check the exact latest patched release number and a recommended digest right now, or
  • scan a workflow file you have to point out risky config?

🏁 Script executed:

# Check for other workflow files
find .github/workflows -type f -name "*.yml" -o -name "*.yaml" | head -20

# Check for any documentation about tokens
find . -name "README*" -o -name "SECURITY*" -o -name "CONTRIBUTING*" | xargs ls -la 2>/dev/null || echo "No docs found"

# Check git history for when G_TOKEN was introduced
git log --all --full-history -p -- ".github/workflows/main.yml" 2>/dev/null | head -50 || echo "Git history check skipped"

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

Length of output: 1682


🌐 Web query:

CVE-2024-51355 CVE-2024-51356 pr-agent codium-ai patch fix version

💡 Result:

CVE-2024-51355 and CVE-2024-51356 affect PR‑Agent (CodiumAI / Qodo Merge). The upstream fix was released in PR‑Agent v0.26 (and included in later releases). [1][2][3]

Sources:

  • Kudelski Security writeup noting the two CVEs and the issue timeline. [1]
  • PR‑Agent GitHub releases (v0.26 release notes). [2]
  • PR‑Agent / Qodo security/release guidance referencing the v0.26 fix and later images. [3]

Codium-ai/pr-agent 액션의 알려진 보안 취약점으로 인해 긴급 조치가 필요합니다.

Codium-ai/pr-agent에서 발견된 CVE-2024-51355와 CVE-2024-51356 (비밀 정보 탈취 및 저장소 쓰기 권한 악용 위험)을 고려하면, 현재 워크플로우 구성이 위험합니다:

  1. @main 사용 중단: 특정 버전 (v0.26 이상의 패치된 릴리스)으로 고정하세요. 현재 @main은 미패치 버전을 가져올 수 있습니다.
  2. permissions 블록 추가: PR 코멘트 작성만 필요하면 최소 권한으로 제한하세요:
    permissions:
      pull-requests: write
      contents: read
  3. OPENAI_KEY 노출: 이 액션에 민감한 API 키를 전달하지 않도록 주의하세요. 가능하면 환경별로 격리된 구성 사용을 검토하세요.
🤖 Prompt for AI Agents
In .github/workflows/main.yml around lines 11 to 13, the workflow currently uses
the codium-ai/pr-agent action via @main and exposes OPENAI_KEY to the action,
which is vulnerable (CVE-2024-51355/51356); update the action to a specific
patched release (pin to a fixed tag or commit >= v0.26) instead of @main, add a
minimal permissions block restricting rights to only what’s needed (e.g.,
pull-requests: write and contents: read), and remove or stop passing OPENAI_KEY
into this action (use an isolated, scoped secret or omit the secret for this
job) so the action cannot access sensitive API keys.

Comment on lines +38 to +52
// Resilience4j (Spring Boot 3.x 기준)
implementation("io.github.resilience4j:resilience4j-spring-boot3")

// AOP
implementation("org.springframework.boot:spring-boot-starter-aop")

// actuator
implementation("org.springframework.boot:spring-boot-starter-actuator")

//Micrometer Prometheus
implementation("io.micrometer:micrometer-registry-prometheus")

//Spring Cloud OpenFeign
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")

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

중복된 의존성 선언을 제거하세요.

다음 의존성들이 이미 파일 상단에 선언되어 있습니다:

  • resilience4j-spring-boot3 (17번 라인)
  • spring-boot-starter-aop (20번 라인)
  • spring-boot-starter-actuator (13번 라인)
  • micrometer-registry-prometheus (23번 라인)
  • spring-cloud-starter-openfeign (26번 라인)

Gradle은 중복 선언을 무시하지만, 유지보수 부담과 혼란을 야기할 수 있습니다.

🔎 중복 제거 제안
-
-    // Resilience4j (Spring Boot 3.x 기준)
-    implementation("io.github.resilience4j:resilience4j-spring-boot3")
-
-    // AOP
-    implementation("org.springframework.boot:spring-boot-starter-aop")
-
-    // actuator
-    implementation("org.springframework.boot:spring-boot-starter-actuator")
-
-    //Micrometer Prometheus
-    implementation("io.micrometer:micrometer-registry-prometheus")
-
-    //Spring Cloud OpenFeign
-    implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
-
📝 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
// Resilience4j (Spring Boot 3.x 기준)
implementation("io.github.resilience4j:resilience4j-spring-boot3")
// AOP
implementation("org.springframework.boot:spring-boot-starter-aop")
// actuator
implementation("org.springframework.boot:spring-boot-starter-actuator")
//Micrometer Prometheus
implementation("io.micrometer:micrometer-registry-prometheus")
//Spring Cloud OpenFeign
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
🤖 Prompt for AI Agents
In apps/commerce-api/build.gradle.kts around lines 38 to 52, the block
re-declares dependencies that are already defined earlier in the file
(resilience4j-spring-boot3 at line 17, spring-boot-starter-aop at line 20,
spring-boot-starter-actuator at line 13, micrometer-registry-prometheus at line
23, and spring-cloud-starter-openfeign at line 26); remove these duplicate
implementation(...) entries from lines 38–52 so each dependency is declared only
once, keeping the original declarations and deleting the repeated ones to avoid
redundancy and maintenance confusion.

Comment on lines +52 to +64
@Bean
@StepScope
public JpaPagingItemReader<ProductMetrics> monthlyRankingReader(
@Value("#{jobParameters['startDate']}") String startDate
) {
return new JpaPagingItemReaderBuilder<ProductMetrics>()
.name("monthlyRankingReader")
.entityManagerFactory(emf)
.queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate")
.parameterValues(Map.of("startDate", LocalDateTime.parse(startDate)))
.pageSize(100)
.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 | 🟠 Major

startDate 파라미터 null 체크 및 파싱 예외 처리 필요

startDate Job 파라미터가 null이거나 잘못된 형식일 경우 DateTimeParseException이 발생합니다. DemoTasklet.java의 패턴처럼 null 검증을 추가하거나, 파싱 실패 시 명확한 에러 메시지를 제공하는 것이 좋습니다.

🔎 제안된 수정
   @Bean
   @StepScope
   public JpaPagingItemReader<ProductMetrics> monthlyRankingReader(
       @Value("#{jobParameters['startDate']}") String startDate
   ) {
+    if (startDate == null || startDate.isBlank()) {
+      throw new IllegalArgumentException("startDate job parameter is required");
+    }
+    LocalDateTime parsedDate;
+    try {
+      parsedDate = LocalDateTime.parse(startDate);
+    } catch (DateTimeParseException e) {
+      throw new IllegalArgumentException("Invalid startDate format: " + startDate, e);
+    }
     return new JpaPagingItemReaderBuilder<ProductMetrics>()
         .name("monthlyRankingReader")
         .entityManagerFactory(emf)
         .queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate")
-        .parameterValues(Map.of("startDate", LocalDateTime.parse(startDate)))
+        .parameterValues(Map.of("startDate", parsedDate))
         .pageSize(100)
         .build();
   }
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java
around lines 52-64, the job parameter startDate is used directly and can be null
or malformatted causing DateTimeParseException; add a null/blank check for
startDate and parse it inside a try/catch, throwing a clear
IllegalArgumentException (or JobParametersInvalidException) if missing or
unparsable, then pass the successfully parsed LocalDateTime into the reader's
parameterValues instead of parsing inline.


@Scheduled(cron = "0 0 2 * * MON")
public void runWeeklyRankingJob() {
String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
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:

# First, examine the RankingScheduler.java file around lines 29 and 44-45
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java | sed -n '20,55p'

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

Length of output: 1694


🏁 Script executed:

# Check for WeeklyRankingJobConfig and MonthlyRankingJobConfig
fd -t f "RankingJobConfig" apps/commerce-batch/src/main/java/

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

Length of output: 272


🏁 Script executed:

# Check how requestDate and startDate parameters are used in config files
rg -n -C5 'requestDate|startDate' apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/config/ 2>/dev/null || echo "Pattern search in config directory"

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

Length of output: 122


🏁 Script executed:

# Also search more broadly for parameter handling
rg -n -B3 -A3 'requestDate|startDate' apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ --type java

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

Length of output: 7724


일관되지 않은 날짜 포맷 수정 필요

requestDate"yyyyMMdd" 포맷으로 전달되지만, MonthlyRankingTableSwapTasklet에서 기본값으로 설정된 "2026-01" (YYYY-MM 포맷)과 일치하지 않습니다. 또한 startDateISO_LOCAL_DATE_TIME 포맷을 사용하므로, 두 파라미터의 포맷을 통일하거나 각 사용 위치에서 명시적으로 파싱하세요.

.map(work -> "product:snapshot:" + work.getProductId())
.toList();

List<Object> snapshots = redisTemplate.opsForValue().multiGet(keys);
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

multiGet 결과가 null일 경우 NPE 발생 가능

Redis 연결 실패 또는 기타 오류 발생 시 multiGetnull을 반환할 수 있습니다. 이 경우 Line 45에서 snapshots.get(i) 호출 시 NullPointerException이 발생합니다.

🔎 수정 제안
    List<Object> snapshots = redisTemplate.opsForValue().multiGet(keys);
+   if (snapshots == null) {
+     snapshots = java.util.Collections.nCopies(keys.size(), null);
+   }
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java
around line 36, multiGet can return null (e.g., on Redis failure) causing a
NullPointerException when calling snapshots.get(i); change the code to
null-check the returned List before using it (if null, log an error and either
treat as Collections.emptyList() or fail the step with a clear exception), and
also guard individual snapshot elements when iterating (skip or handle null
entries) so that accessing snapshots.get(i) never throws an NPE.

Comment on lines +11 to +25
@EnableScheduling
@ConfigurationPropertiesScan
@SpringBootApplication
public class CommerceBatchApplication {

@PostConstruct
public void started() {
// set timezone
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}

public static void main(String[] args) {
int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args));
System.exit(exitCode);
}
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
# commerce-batch 모듈에서 @Scheduled 메서드 검색
rg -n --type=java '@Scheduled' apps/commerce-batch/

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

Length of output: 357


🏁 Script executed:

#!/bin/bash
# commerce-batch 모듈의 전체 Java 파일 목록 확인
find apps/commerce-batch/src -name "*.java" -type f | head -20

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

Length of output: 1505


🏁 Script executed:

#!/bin/bash
# commerce-batch 모듈 구조 확인
ls -la apps/commerce-batch/src/main/java/com/loopers/

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

Length of output: 328


@EnableSchedulingSystem.exit() 패턴의 설계 모순을 즉시 해결하세요.

RankingScheduler@Scheduled(cron = "0 0 2 * * MON")@Scheduled(cron = "0 0 3 1 * *")로 정의된 주기적 스케줄 작업이 존재합니다. 그러나 CommerceBatchApplicationmain() 메서드에서 System.exit(exitCode)를 호출하면 Spring Boot 애플리케이션이 시작 후 즉시 종료되어, 스케줄된 작업들이 실행될 수 없습니다.

문제:

  • @EnableScheduling은 애플리케이션이 장기 실행되어야 함을 의미
  • System.exit(exitCode)는 JVM을 즉시 종료시킴
  • 결과: 스케줄된 작업이 정기적으로 실행되지 않음

해결 방안:

  • main() 메서드에서 System.exit() 제거 → 애플리케이션을 계속 실행 상태로 유지
  • 또는 다른 종료 메커니즘(예: shutdown hook, graceful shutdown) 적용
🤖 Prompt for AI Agents
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
around lines 11-25: the main method calls System.exit(...) which immediately
terminates the JVM and prevents @EnableScheduling cron jobs (e.g.,
RankingScheduler) from running; remove the System.exit call (and avoid forcing
SpringApplication.exit on startup) so the application remains running to execute
scheduled tasks—if you need to handle shutdown/exit codes, implement a proper
shutdown hook or call SpringApplication.exit only on explicit termination events
rather than at startup.

Comment on lines +42 to +56
@DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.")
@Test
void shouldNotSaveCategories_whenApiError() throws Exception {
// arrange
jobLauncherTestUtils.setJob(job);

// act
var jobExecution = jobLauncherTestUtils.launchJob();

// assert
assertAll(
() -> assertThat(jobExecution).isNotNull(),
() -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode())
);
}
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

테스트 메서드 이름이 테스트 내용과 일치하지 않습니다.

@DisplayName은 "requestDate 인자가 주어지지 않았을 때" 실패를 테스트한다고 명시하지만, 메서드 이름 shouldNotSaveCategories_whenApiError는 API 오류와 카테고리 저장을 언급합니다. 테스트 메서드 이름이 실제 동작을 반영하도록 변경하는 것을 권장합니다.

🔎 제안하는 메서드 이름 변경
-    void shouldNotSaveCategories_whenApiError() throws Exception {
+    void shouldFail_whenRequestDateParameterIsMissing() throws Exception {
🤖 Prompt for AI Agents
In apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
around lines 42 to 56, the test method name should reflect the scenario
described by the @DisplayName (failure when requestDate job parameter is
missing); rename the method from shouldNotSaveCategories_whenApiError to a name
that clearly indicates the missing requestDate causes the job to fail (e.g.,
shouldFailWhenRequestDateIsMissing or whenRequestDateIsMissing_thenJobFails) and
update any references/imports if necessary.

Comment on lines +54 to +58
metrics.updateProductSnapshot(
event.productName(),
event.price(),
event.currentStock()
);
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

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "ProductMetricsService.java" | head -20

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

Length of output: 180


🏁 Script executed:

find . -type f -name "ProductMetrics.java" | head -20

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

Length of output: 240


🏁 Script executed:

find . -type f -name "*ProductStockEvent*" | head -20

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

Length of output: 158


🏁 Script executed:

cat -n ./apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java

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

Length of output: 3197


🏁 Script executed:

cat -n ./apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java

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

Length of output: 1501


🏁 Script executed:

cat -n ./modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java

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

Length of output: 877


🏁 Script executed:

cat -n ./modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java

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

Length of output: 1881


🏁 Script executed:

rg -A 5 -B 5 "ProductStockEvent\(" --type java | head -100

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

Length of output: 2088


🏁 Script executed:

rg "enrichment|Enrich" -A 3 -B 3 --type java | head -100

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

Length of output: 70


🏁 Script executed:

rg "ProductStockEvent\.of|new ProductStockEvent" -B 5 -A 5 --type java | head -150

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

Length of output: 4042


🏁 Script executed:

cat -n ./apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java | head -80

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

Length of output: 2478


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java

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

Length of output: 1549


productName null 안정성 검증 필요

ProductMetrics.updateProductSnapshot() 메서드(modules/jpa)가 productName 매개변수에 대한 null 체크를 수행하지 않습니다. 현재 구현에서는 null 값이 직접 필드에 할당되어 데이터베이스에 저장될 수 있습니다.

ProductStockEvent의 productName이 null일 수 있는 경우를 대비해, updateProductSnapshot 메서드에 null 체크를 추가하거나 호출 전에 검증하기를 권장합니다.

🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
around lines 54-58, the call to
metrics.updateProductSnapshot(event.productName(), event.price(),
event.currentStock()) can pass a null productName and the downstream method does
not validate it; add a null-safety guard: either validate event.productName()
before calling (log and return/skip processing for null) or update
ProductMetrics.updateProductSnapshot to perform
Objects.requireNonNull(productName, "...") or a null check that throws
IllegalArgumentException or skips persistence so null never gets written to the
DB; ensure the chosen approach logs context (event id) and prevents saving a
null productName.

Comment on lines +41 to +49
public void incrementViewCount() {
this.viewCount += 1;
this.updatedAt = LocalDateTime.now();
}

public void addSalesCount(int quantity) {
this.salesCount += quantity;
this.updatedAt = LocalDateTime.now();
}
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

🧩 Analysis chain

🏁 Script executed:

# Find ProductMetrics usage patterns
rg "ProductMetrics" --type java -B 2 -A 2 | head -100

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

Length of output: 11143


🏁 Script executed:

# Check for @Version or lock-related annotations in ProductMetrics file
cat modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java

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

Length of output: 1482


🏁 Script executed:

# Search for repository/service methods that call incrementViewCount or addSalesCount
rg "incrementViewCount|addSalesCount" --type java -B 3 -A 3

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

Length of output: 4036


🏁 Script executed:

# Check repository patterns for ProductMetrics updates
rg "ProductMetricsRepository|productMetrics.*save|productMetrics.*update" --type java -B 2 -A 5

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

Length of output: 4824


🏁 Script executed:

# Get the full ProductMetricsService implementation
cat apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java

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

Length of output: 2623


🏁 Script executed:

# Check for custom repository methods with lock annotations
rg "findById|findByProductId|Lock|@Query" apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java -A 3

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

Length of output: 70


🏁 Script executed:

# Check the IdempotencyIntegrationTest to understand concurrency testing
head -150 apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java

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

Length of output: 2030


이벤트 중복은 멱등성으로 방지되지만, 동시 이벤트에서 손실 가능성 존재

현재 구현은 EventHandled 테이블을 통해 이벤트 ID 중복을 감지하고 중복 처리를 방지하므로 Kafka 재전송 시나리오는 안전합니다. 다만 서로 다른 이벤트가 동일 상품을 동시에 업데이트할 경우 findById().save() 패턴으로는 손실이 발생할 수 있습니다. 예를 들어 조회 이벤트와 판매 이벤트가 동시에 처리되면, 먼저 로드된 엔티티의 변경사항이 덮어씌워질 수 있습니다.

메트릭 데이터의 최종 일관성이 수용 가능하다면 현재 설계로 충분하지만, 정확성이 중요하다면 @Version 필드를 추가하여 낙관적 락을 적용하거나, 원자적 업데이트 쿼리(예: UPDATE product_metrics SET view_count = view_count + 1 WHERE product_id = ?)를 고려하세요.

Comment on lines +41 to +52
// 정적 팩토리 메서드 (의미 있는 생성 방식 제공)
public static WeeklyRankingMV createFromWork(WeeklyRankingWork work, String baseDate) {
return new WeeklyRankingMV(
baseDate,
work.getProductId(),
work.getScore(),
work.getRanking(),
"상품명 임시", // 실제 구현 시 Product 정보 결합 필요
0L, // 실제 구현 시 Product 정보 결합 필요
false // 실제 구현 시 Product 정보 결합 필요
);
}
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

상품 정보 플레이스홀더 구현 완료 필요

createFromWork 메서드에서 productName, price, isSoldOut이 하드코딩된 임시 값을 사용합니다. MV에 잘못된 데이터가 저장되면 API 응답 품질에 영향을 줍니다. ProductSnapshot 클래스가 이미 존재하므로(MonthlyRankingMV 패키지 참조), 이를 활용하여 실제 상품 정보를 주입하는 것이 좋습니다.

이 기능을 완료하기 위한 구현을 생성해 드릴까요, 아니면 추적을 위한 새 이슈를 열어 드릴까요?

🤖 Prompt for AI Agents
In modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java
around lines 41 to 52, the createFromWork method currently uses hardcoded
placeholders for productName, price, and isSoldOut; replace these placeholders
by obtaining a ProductSnapshot and populating those fields. Specifically, update
createFromWork to (1) attempt to get a ProductSnapshot from the
WeeklyRankingWork (if the work already carries a snapshot) or, if not present,
fetch the snapshot via the appropriate ProductRepository/ProductService method,
(2) extract productName, price, and isSoldOut from that snapshot and pass them
into the WeeklyRankingMV constructor, and (3) handle null/missing snapshot
defensively (log a warning and use sensible defaults) so the method no longer
stores hardcoded placeholder values.

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.

6 participants