Skip to content

[10주차] 주간,월간 랭킹 (Spring Batch) - 김우철 - #78

Open
jikimee64 wants to merge 21 commits into
Loopers-dev-lab:jikimee64from
jikimee64:round-10
Open

[10주차] 주간,월간 랭킹 (Spring Batch) - 김우철#78
jikimee64 wants to merge 21 commits into
Loopers-dev-lab:jikimee64from
jikimee64:round-10

Conversation

@jikimee64

@jikimee64 jikimee64 commented Dec 30, 2025

Copy link
Copy Markdown
Collaborator

📌 Summary

Spring Batch를 활용한 주간/월간 상품 랭킹 시스템을 구축했습니다. 기존 product_metrics 일간 집계 데이터를 기반으로 주간/월간 랭킹을 배치로 집계하고, Materialized View에 저장하여 조회 성능을 최적화했습니다.

주요 변경사항:

  • commerce-batch 앱 신규 추가 (Spring Batch 전용 애플리케이션)
  • 주간/월간 상품 랭킹 배치 Job 구현 (Chunk-Oriented Processing)
  • Materialized View 설계 (mv_product_rank_weekly, mv_product_rank_monthly)
  • Ranking API 확장 (일간/주간/월간 랭킹 통합 조회)
  • 배치 실행 REST API 제공 (/api/v1/batch/weekly-ranking, /api/v1/batch/monthly-ranking)

💬 Review Points

  1. 주간 랭킹 / 월간 랭킹 batch job 로직이 적절한지 리뷰 받고 싶습니다.
  • 아쉬운 점은 없는지, 대용량 환경에서 문제점은 없는지..
  • Writer에서 데이터를 누적하면 OOM이 발생할 수 있어 임시 테이블에 적재 후 TOP 100 랭킹을 구하는 방법을 채택하였습니다.

ProductWeeklyRankingJob (주간 랭킹)

  Reader: product_metrics 테이블에서 주간 데이터를 날짜별로 분리하여 집계
  - GROUP BY product_id, days_from_end (days_from_end: 주 종료일로부터 며칠 전인지 0~6)
  - 같은 상품이 7일치 데이터가 있으면 7개의 레코드로 반환

  Processor: 날짜별 감쇠 가중치 적용
  - 기본 점수 = 조회수×1 + 주문수×10 + 좋아요×5
  - 날짜별 가중치: D+0(최근) 1.0 → D+1: 0.9 → D+2: 0.8 → ... → D+6(오래됨) 0.1
  - 최종 점수 = 기본 점수 × 감쇠 가중치

  Writer: 임시 테이블(temp_weekly_ranking)에서 점수 합산 후 Top 100 저장
  - 청크 단위로 임시 테이블에 INSERT (중복 시 점수 합산)
  - AfterStep에서 점수순 정렬 후 Top 100 추출
  - 동일 주차 데이터 삭제 후 재적재 (멱등성 확보)

ProductMonthlyRankingJob (월간 랭킹)

  Reader: product_metrics 테이블에서 월간 데이터를 전체 합산하여 집계
  - GROUP BY product_id (날짜 구분 없이 월 전체를 하나로)

  Processor: 단순 가중치 적용
  - 최종 점수 = 총 조회수×1 + 총 주문수×10 + 총 좋아요×5
  - 날짜별 감쇠 가중치 없음

  Writer: 임시 테이블(temp_monthly_ranking)에서 Top 100 저장
  - 주간 랭킹과 동일한 방식
  - 동일 월 데이터 삭제 후 재적재 (멱등성 확보)
  1. chunk size와 페이징 size 선정 기준
  • Chunk Size를 어떤 기준으로 설정하시는지 궁금합니다.
  • Reader에서 Paging으로 처리할 경우 pageSize와 chunk size를 동일하게 구성하는지 궁금합니다
  • 배치 job을 실행할 서버(혹은 로컬)의 메모리 사이즈보다 적은 값을 찾아서 실행 하면 될까요?
  1. 월간 랭킹 집계 방법
  • product_metric를 1일 ~ 말일 조건으로 풀스캔하여 월간 랭킹 집계를 만들었습니다.
  • 문득, 집계한 주간 랭킹 TOP 100으로 월간 랭킹 집계를 만들어도 되지 않을까 생각해봤습니다.
  • 이러한 고민은 개발자가 아닌 PO 선택 사항 인걸까요? 리뷰어님은 어떤 방법을 선호하시는지 궁금합니다.
  • 주간 랭킹 집계로 만들 경우 아래와 같은 케이스가 발생할 수 있을 것 같습니다.
  - ❌ 데이터 손실 위험: 주간 101위 이하 상품은 월간 집계에서 제외됨
  예시:
  - 상품 A: 1주차 101위, 2주차 101위, 3주차 101위, 4주차 101위
    → 매주 꾸준히 상위권이었지만 주간 Top 100 밖이라 월간 랭킹에서 누락
  - 상품 B: 1주차 5위, 나머지 주는 활동 없음
    → 월간으로는 A가 더 높아야 하지만 B만 포함됨

✅ Checklist

🧱 Spring Batch

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

🧩 Ranking API

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

📎 References

Summary by CodeRabbit

  • New Features

    • 랭킹 조회에 period 파라미터 추가: daily/weekly/monthly 선택 가능
    • 배치 앱 추가 및 배치 API 제공: 주간/월간 랭킹 수동 실행 가능
  • Chores

    • 배치 파이프라인 도입: 주/월 단위 집계, 점수 산출, Top100 저장 워크플로 추가
    • 데이터 모델 및 변환기 보강: 주/월 기간 처리 지원
  • Tests

    • 통합·배치 테스트 보강: 주간/월간 시나리오 및 Top100 검증 추가

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

jikimee64 and others added 18 commits December 27, 2025 23:43
새로운 commerce-batch 모듈을 초기화하고 기본 구조를 설정합니다.
배치 애플리케이션의 필수 구성 요소를 포함합니다.

- `build.gradle.kts`에 Spring Batch, JPA, 액츄에이터 등 의존성 추가
- `application.yml`에 배치 설정, 프로필 및 모듈 임포트 구성
- `CommerceBatchApplication.kt` 생성 및 기본 타임존 "Asia/Seoul" 설정
- `settings.gradle.kts`에 `commerce-batch` 모듈을 멀티 프로젝트에 추가
기존 상품 메트릭이 단일 누적 방식으로 관리되어
일자별 상세 분석이 어려웠습니다.
이를 개선하기 위해 ProductMetrics 엔티티의
기본 키를 상품 ID와 메트릭 일자를 포함하는 복합 키로 변경하고,
모든 관련 서비스, 리포지토리, 테스트 코드를 수정하여
메트릭을 일자별로 집계하도록 기능을 확장했습니다.
이를 통해 더욱 세분화된 데이터 분석이 가능해집니다.
배치 통합 테스트를 위한 기본 환경을 구축했습니다.
SpringBatchTest 어노테이션을 활용하여 배치 테스트를 지원하며,
각 테스트 실행 후 데이터베이스를 초기화하여 테스트 간 독립성을
보장합니다.

application.yml에 배치 작업 저장소 스키마 자동 초기화 설정과
랭킹 계산에 사용될 조회수, 좋아요, 판매수 가중치 설정을 추가했습니다.

build.gradle.kts에 spring-batch-test 의존성을 추가하여
테스트 환경을 완성했습니다.
일자별 상품 메트릭(좋아요, 조회, 판매) 집계를 위한
ProductMetrics 엔티티와 복합키 ProductMetricsId를 추가합니다.
JPA 기반 데이터 접근을 위한 ProductMetricsJpaRepository
인터페이스를 정의합니다.
주간 상품 랭킹을 집계하고 저장하는 배치 기능을 추가합니다.
product_metrics 데이터를 기반으로 상품별 주간 랭킹을 계산하며,
조회수, 좋아요, 판매량에 일자별 감쇠 가중치를 적용하여 점수를
산정합니다. JdbcPagingItemReader를 사용하여 대용량 데이터를
효율적으로 처리하고, 계산된 랭킹 중 상위 100개 상품만
데이터베이스에 저장합니다.

관련 도메인 엔티티, JPA 레포지토리, 서비스 구현체, 배치 설정
및 통합 테스트 코드를 포함합니다.
BatchJobController를 추가하여 배치 Job 실행을 위한 REST API를 제공합니다.
현재는 ProductWeeklyRankingJob을 실행하는 엔드포인트를 포함합니다.
weekStart 및 weekEnd 파라미터를 통해 주간 상품 랭킹 배치를 수동으로
실행할 수 있도록 합니다.
배치 작업의 운영 유연성을 향상시킵니다.
ProductWeeklyRankingWriter의 기존 주간 랭킹 데이터 점수 합산 로직을
제거하고, 현재 주차 점수만으로 Top 100을 선정하도록 변경했습니다.
동일 주차 랭킹 데이터 처리 시, 기존 데이터를 삭제 후 재적재하여
배치 작업의 멱등성을 확보했습니다.

ProductWeeklyRankingJobConfig에서 JdbcPagingItemReader 빈 주입 시
@qualifier를 명시하여 의존성 주입의 명확성을 높였습니다.
ProductWeeklyRankingReader에 job 파라미터 사용 및 SQL 바인딩 관련
주석을 추가하여 코드 이해도를 높였습니다.
- 월간 상품 랭킹 집계 및 저장을 위한 배치 Job을 구현합니다.
  - `PRODUCT_MONTHLY_RANKING_JOB` 및 `PRODUCT_MONTHLY_RANKING_STEP` 정의.
  - 주간 랭킹(mv_product_rank_weekly) 데이터를 월별로 합산하여
    상품별 총점을 계산하고 상위 100개 상품을 선정합니다.
  - `ProductMonthlyRanking` 엔티티와 이를 관리하는 Repository
    인터페이스 및 JPA 구현체를 추가했습니다.
  - `JdbcPagingItemReader`를 사용하여 데이터를 효율적으로 읽고,
    `ItemWriter`에서 Top 100을 필터링하여 저장합니다.
  - 특정 월의 랭킹 데이터는 배치 재실행 시 기존 데이터를 삭제하고
    새롭게 재적재하여 멱등성을 보장합니다.
  - `java.time.YearMonth` 타입을 JPA 엔티티에서 사용할 수 있도록
    `YearMonthAttributeConverter`를 구현했습니다.
- 배치 Job의 정상 동작 및 핵심 로직 검증을 위한 통합 테스트를
  작성하여 신뢰성을 확보합니다.
ProductMonthlyRankingJob 실행을 위한 REST API를 추가합니다.
BatchJobController에 다음 변경사항을 적용했습니다:
- ProductMonthlyRankingJobConfig 임포트
- productMonthlyRankingJob 의존성 주입
- /api/v1/batch/monthly-ranking 엔드포인트 추가
- yearMonth 파라미터로 특정 월의 랭킹 배치 실행 가능
랭킹 조회 API에 기간(period) 파라미터를 추가하여 일간, 주간, 월간 랭킹을
조회할 수 있도록 기능을 확장했습니다.

세부 변경 사항:
- RankingService에 주간/월간 랭킹 조회 로직을 추가하고 RankingPeriod에
  따라 동적으로 랭킹을 조회하도록 개선했습니다.
- RankingFacade는 RankingService의 새로운 getRankingScores 메서드를
  활용하도록 변경하여 랭킹 조회 로직을 단순화하고 기간별 랭킹 조회를
  지원합니다.
- RankingV1Controller에 랭킹 기간(period) 파라미터를 추가하고
  RankingPeriod enum을 신규 도입하여 기간별 랭킹 조회를 제공합니다.
- ProductWeeklyRanking, ProductMonthlyRanking 엔티티 및 관련 JPA/인프라
  레포지토리를 추가하여 주간/월간 랭킹 데이터를 관리합니다.
- YearMonthAttributeConverter를 도입하여 YearMonth 타입의 JPA 매핑을
  지원합니다.
- RankingRedisRepositoryOrderingTest에서 Redis ZSET 멤버를 0으로 채워
  정렬 순서 문제를 방지하도록 수정했습니다.
배치 작업의 견고성과 유연성을 향상시키기 위해 다음과 같은
변경 사항을 적용했습니다.

- 주간 랭킹 리더: 주간 상품 랭킹 점수 계산 시 `CURDATE()` 대신
  `:weekEnd` 파라미터를 사용하도록 변경하여, 특정 주차 종료일을
  기준으로 랭킹을 집계할 수 있도록 유연성을 확보했습니다.
- 랭킹 라이터 (주간/월간): 배치 청크(chunk) 처리 시 Top 100
  순위가 올바르게 집계되도록 `initialized` 플래그와 `rankCounter`를
  도입했습니다. 이는 청크별로 독립적으로 처리되던 순위 계산을
  전체 작업 기준으로 통합하여 정확한 Top 100 목록을 생성합니다.
  또한, 동일 주차/월 데이터 삭제 로직을 첫 청크에서만 실행하도록
  수정하여 멱등성을 유지하면서 불필요한 반복 작업을 방지합니다.
- 배치 컨트롤러: 주간/월간 상품 랭킹 배치 실행 시점에 대한
  설명을 추가하여 배치 운영 가이드라인을 명확히 했습니다.
주간 및 월간 상품 랭킹 조회 API의 동작을 검증하기 위해
새로운 테스트 케이스를 추가합니다.

- Weekly/Monthly 랭킹 데이터 생성 및 조회 로직을 확인합니다.
- 관련 JPA Repository 의존성 및 시간 관련 유틸리티 import를
  추가했습니다.
ProductMonthlyRankingReader는 mv_product_rank_weekly 대신
product_metrics 테이블을 직접 조회하도록 변경되었습니다.
랭킹 점수 계산 시 조회수, 좋아요, 판매수에 대한
가중치를 적용하도록 수정되었습니다. (설정값 주입)
이에 따라 테스트 코드도 ProductMetrics 데이터를 사용하도록
업데이트되었습니다.

주간 랭킹 MV에 의존하는 대신 원본 메트릭 데이터를 직접
집계하여 랭킹 계산의 유연성을 높이고, 가중치 기반의
점수 계산을 도입하기 위함입니다.
기존 주간 집계 방식은 유연한 가중치 적용에 한계가 있었습니다.
`web-application-type`을 `none`에서 `servlet`으로 변경하여 배치
애플리케이션의 웹 기능을 활성화합니다.
배치 작업의 성능 최적화 및 코드 구조 개선을 진행했습니다.

- 월간/주간 상품 랭킹 배치 `CHUNK_SIZE` 및 `pageSize`를 1000에서
  100으로 조정하여 배치 처리 효율성 및 메모리 사용량을 최적화했습니다.
- `ProductMonthlyRankingReader`, `ProductWeeklyRankingReader`에서
  `GROUP BY product_id` 절을 `whereClause`에서 분리하여
  `QueryGenerator.Builder.setGroupClause`를 통해 명시적으로 설정하도록
  리팩토링했습니다. 이를 통해 쿼리 구성의 가독성을 높였습니다.
- 불필요한 JPA Repository 및 Repository 구현체의 KDoc 주석을 제거하여
  코드의 간결성을 유지했습니다.
ProductMetrics 엔티티에서 비즈니스 로직과 낙관적 락을 위한
버전 필드를 제거합니다.

이는 Spring Batch에선 엔티티가 순수한 Read 데이터 모델 역할에 집중하기 위함입니다.
기존 상품 주간 랭킹 배치 작업의 책임 분리 및 대량 데이터 처리
효율성을 개선합니다.

- ProductWeeklyRankingReader:
  - product_metrics에서 주간 범위의 날짜별 메트릭 데이터를 집계하여
    반환하도록 변경. 최종 점수 계산 로직은 제거.
  - Reader는 이제 product_id와 days_from_end로 그룹화하여
    날짜별 원본 메트릭 데이터를 읽습니다.

- ProductWeeklyRankingProcessor (신규):
  - ProductWeeklyMetricsAggregate를 받아 날짜별 감쇠 가중치를 적용하여
    최종 랭킹 점수를 계산.
  - 배치 작업의 유연성 및 테스트 용이성 향상.

- ProductWeeklyRankingWriter:
  - 임시 테이블(temp_weekly_ranking)을 활용하는 새로운 전략 채택.
  - 청크 단위로 임시 테이블에 점수를 누적하고, AfterStep에서 Top 100
    랭킹을 집계 및 저장.
  - 대량 데이터 처리 시 메모리 사용량을 최소화하고 확장성을 확보.

- ProductWeeklyMetricsAggregate (신규 DTO):
  - Reader와 Processor 간의 데이터 전달을 위한 DTO 추가.

- 테스트: 변경된 배치 로직에 맞춰 테스트 코드를 업데이트하고, 날짜별 감쇠
  가중치 적용 및 Top 100 검증 로직을 강화했습니다.
- Reader, Processor, Writer 역할 분리 및 명확화.
- Reader는 월간 상품 메트릭 원본 데이터 집계 역할만 수행.
- ProductMonthlyRankingProcessor를 도입하여 가중치 기반 점수 계산 로직 분리.
  - 배치 파라미터로 가중치 설정 가능.
- Writer는 임시 테이블을 사용하여 대용량 데이터 처리 효율성 개선.
  - AfterStep에서 임시 테이블의 전체 데이터를 정렬하여 Top 100 저장.
  - 메모리 사용량 최소화 및 멱등성 보장.
- ProductMonthlyMetricsAggregate DTO 추가.
- 관련 테스트 코드 수정.
@jikimee64 jikimee64 self-assigned this Dec 30, 2025
@coderabbitai

coderabbitai Bot commented Dec 30, 2025

Copy link
Copy Markdown

Walkthrough

기간(일/주/월) 기반 상품 랭킹을 도입했습니다. API 파라미터와 Facade/Service 시그니처가 변경되었고, 주간·월간 JPA 엔터티·저장소, YearMonth 변환기, 배치 모듈(리더·프로세서·라이터), 관련 테스트와 빌드 설정이 추가되었습니다.

Changes

Cohort / File(s) 요약
API / Controller
apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiSpec.kt, apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1Controller.kt
period 파라미터 추가(daily/weekly/monthly), getRankings 시그니처 및 문서/파라미터 처리 변경
Facade / Service
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt, apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt
getRankings 시그니처 → (period, date, pageable) 변경, getRankingScores(period,date,pageable) 도입 및 기간별 페이징 소스 사용으로 로직 변경
기간 타입
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.kt
RankingPeriod enum(DAILY/WEEKLY/MONTHLY) 및 문자열 파싱(기본 DAILY, 유효성 검사) 추가
도메인 엔터티 — 주간/월간
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt, .../ProductMonthlyRanking.kt, apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/*
주간/월간 랭킹 JPA 엔터티 추가(인덱스·유니크 제약·필드 매핑 포함), batch 쪽에도 동명 엔터티 추가
저장소 인터페이스(도메인)
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/*, apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/*
주간/월간 조회·저장·삭제·업서트 등 메서드 계약 추가
인프라 JPA 구현
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/*, apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/*
Spring Data JPA 리포지토리 인터페이스 및 Impl 추가(페이징·정렬 적용 등)
YearMonth 변환기
apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.kt, apps/commerce-batch/.../YearMonthAttributeConverter.kt
JPA용 YearMonth <-> String 변환기(@Converter(autoApply=true)) 추가
배치 모듈 추가 / 설정
settings.gradle.kts, apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/kotlin/com/loopers/CommerceBatchApplication.kt, apps/commerce-batch/src/main/resources/application.yml
commerce-batch 모듈 추가, 빌드·의존성 설정, 배치 앱 진입점, 타임존 및 배치 구성(가중치 등)
배치: Job/Step 구성
apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingJobConfig.kt, ProductMonthlyRankingJobConfig.kt
청크 기반 Job/Step(청크 100) 정의, reader/processor/writer 연결
배치: Reader
ProductWeeklyRankingReader.kt, ProductMonthlyRankingReader.kt
JdbcPagingItemReader 구성, 기간 필터 SQL·집계·파라미터 바인딩 구현
배치: Processor
ProductWeeklyRankingProcessor.kt, ProductMonthlyRankingProcessor.kt
가중치 기반 점수 계산(주간은 감쇠 적용), @StepScope 컴포넌트 추가
배치: Writer
ProductWeeklyRankingWriter.kt, ProductMonthlyRankingWriter.kt
임시 테이블에 점수 누적, AfterStep에서 Top-100 선정 후 트랜잭션으로 기존 주/월 랭킹 교체 저장
배치 컨트롤러
apps/commerce-batch/src/main/kotlin/com/loopers/batch/BatchJobController.kt
수동으로 주간/월간 배치 실행하는 REST 엔드포인트 추가
도메인 메트릭(배치/스트리머)
apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt, ProductMetricsId.kt, apps/commerce-streamer/.../ProductMetrics.kt, ProductMetricsId.kt
일별 메트릭을 metricDate 포함 복합키로 리팩터링, 관련 저장소/쿼리·서비스 로직 날짜 기반으로 변경
메트릭 저장소/구현
apps/commerce-batch/.../ProductMetricsJpaRepository.kt, apps/commerce-streamer/.../ProductMetricsJpaRepository.kt, 구현체들
복합키 기반 JpaRepository 및 날짜 범위 조회·락 메서드 추가/수정
배치 DTO / 내부 모델
apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/*
ProductWeekly/MonthlyMetricsAggregate, RankedProduct 등 DTO 추가
테스트 업데이트 / 추가
apps/commerce-api/src/test/.../RankingV1ApiTest.kt, RankingServiceTest.kt, apps/commerce-batch/src/test/.../*, apps/commerce-streamer/src/test/.../*
API 테스트에 주간/월간 시나리오 추가, 배치 통합 테스트(Top-100/감쇠/다중 실행 등), 서비스 테스트 및 테스트 픽스처 수정

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant API as RankingV1Controller
  participant Facade as RankingFacade
  participant Service as RankingService
  participant Repo as Repository

  rect rgba(120,180,200,0.12)
  Client->>API: GET /api/v1/rankings?period={p}&date={d}&page...
  API->>Facade: getRankings(RankingPeriod.from(p), d, pageable)
  Facade->>Service: getRankingScores(period, d, pageable)
  end

  alt period == DAILY
    Service->>Repo: rankingRepository.getPagedScores(date, pageable)
  else period == WEEKLY
    Service->>Repo: productWeeklyRankingRepository.findByWeekRange(weekStart, weekEnd, pageable)
  else period == MONTHLY
    Service->>Repo: productMonthlyRankingRepository.findByMonthPeriod(yearMonth, pageable)
  end

  Repo-->>Service: Page<RankingScore>
  Service->>Facade: Page<RankingScore>
  Facade->>API: Page<RankingResult.RankedInfo>
  API-->>Client: 200 OK (paged results)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60분

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 새벽 들판에 점수 모아,
일·주·월마다 춤을 추네.
배치가 달빛에 조용히 뛰어,
토끼는 당근으로 축하하네.
후다닥, 랭킹이 피어났네 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.21% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 주간, 월간 랭킹 및 Spring Batch 구현이라는 주요 변경 사항을 명확하게 나타내고 있으며, 담당자 이름도 포함되어 있습니다.
Description check ✅ Passed PR 설명이 템플릿의 모든 필수 섹션을 충실히 포함하고 있으며, 변경사항과 검토 포인트가 상세하게 작성되어 있습니다.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (25)
apps/commerce-batch/src/main/resources/application.yml (1)

28-51: 선택사항: 비어있는 프로필 블록을 제거하거나 주석을 추가하세요.

대부분의 프로필 블록(local, test, dev, qa)이 activation 외에는 비어있습니다. 향후 환경별 설정을 추가할 계획이라면 주석으로 의도를 명시하거나, 당장 필요하지 않다면 제거하여 설정 파일을 간결하게 유지할 수 있습니다.

🔎 제안: 비어있는 블록 제거 또는 주석 추가

옵션 1: 비어있는 블록 제거

 ---
 spring:
   config:
     activate:
       on-profile: local, test

----
-spring:
-  config:
-    activate:
-      on-profile: dev
-
----
-spring:
-  config:
-    activate:
-      on-profile: qa
-
 ---
 spring:
   config:
     activate:
       on-profile: prd

옵션 2: 향후 설정을 위한 플레이스홀더 주석 추가

 ---
 spring:
   config:
     activate:
       on-profile: dev
+  # TODO: dev 환경별 설정 추가 예정

 ---
 spring:
   config:
     activate:
       on-profile: qa
+  # TODO: qa 환경별 설정 추가 예정
apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt (1)

14-21: LGTM!

JPA 복합키로 적절하게 구현되었습니다. data class를 사용하여 equals()hashCode()가 자동으로 제공되며, Serializable 인터페이스도 올바르게 구현되었습니다.

선택적으로, Serializable 구현 시 serialVersionUID를 명시하면 역직렬화 호환성을 더 잘 관리할 수 있습니다:

🔎 선택적 개선안
 @Embeddable
 data class ProductMetricsId(
     @Column(name = "product_id", nullable = false)
     val productId: Long,

     @Column(name = "metric_date", nullable = false)
     val metricDate: LocalDate,
-) : Serializable
+) : Serializable {
+    companion object {
+        private const val serialVersionUID: Long = 1L
+    }
+}
apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt (1)

19-25: 인덱스 이름 충돌 가능성 확인 필요

idx_score라는 인덱스 이름이 ProductWeeklyRanking 엔티티에서도 동일하게 사용됩니다. 동일 데이터베이스 스키마에서 인덱스 이름은 고유해야 하므로, idx_monthly_score와 같이 명확하게 구분하는 것이 좋습니다.

🔎 수정 제안
     indexes = [
-        Index(name = "idx_month_period", columnList = "month_period"),
-        Index(name = "idx_score", columnList = "score"),
+        Index(name = "idx_monthly_month_period", columnList = "month_period"),
+        Index(name = "idx_monthly_score", columnList = "score"),
     ],
apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt (1)

22-25: 인덱스 이름을 테이블별로 고유하게 지정하세요.

idx_scoreProductMonthlyRanking과 중복됩니다. 데이터베이스 레벨에서 인덱스 이름 충돌을 방지하려면 테이블 prefix를 추가하세요.

🔎 수정 제안
     indexes = [
-        Index(name = "idx_week", columnList = "week_start, week_end"),
-        Index(name = "idx_score", columnList = "score"),
+        Index(name = "idx_weekly_week", columnList = "week_start, week_end"),
+        Index(name = "idx_weekly_score", columnList = "score"),
     ],
apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt (1)

53-58: updatedAt 갱신 로직 개선 고려

likeCount가 0일 때 decreaseLikeCount()를 호출하면 실제 값 변경 없이 updatedAt만 갱신됩니다. @Version 필드가 있으므로 불필요한 버전 증가가 발생할 수 있습니다.

🔎 수정 제안
     fun decreaseLikeCount() {
         if (this.likeCount > 0) {
             this.likeCount--
+            this.updatedAt = ZonedDateTime.now()
         }
-        this.updatedAt = ZonedDateTime.now()
     }

decreaseSoldCount()에도 동일한 패턴을 적용하세요.

apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt (2)

30-37: 도메인 필드의 기본값 제거 권장

ranking, productId, score 필드에 기본값(0, 0, 0.0)이 설정되어 있습니다. 이러한 필드는 도메인 로직에서 필수 값이므로, 기본값 대신 생성자 파라미터로 명시적으로 요구하는 것이 더 안전합니다.

🔎 제안하는 수정
-    @Column(name = "ranking", nullable = false)
-    val ranking: Int = 0,
+    @Column(name = "ranking", nullable = false)
+    val ranking: Int,

-    @Column(name = "product_id", nullable = false)
-    val productId: Long = 0,
+    @Column(name = "product_id", nullable = false)
+    val productId: Long,

-    @Column(name = "score", nullable = false)
-    val score: Double = 0.0,
+    @Column(name = "score", nullable = false)
+    val score: Double,

45-46: createdAt 기본값 타이밍 문제

createdAt의 기본값이 ZonedDateTime.now()로 설정되어 있어 객체 생성 시점에 평가됩니다. 이는 DB 삽입 시점과 다를 수 있으며, 특히 배치 처리나 테스트 환경에서 타임스탬프 불일치를 유발할 수 있습니다.

@PrePersist를 사용하거나 DB 컬럼 기본값(예: CURRENT_TIMESTAMP)을 설정하여 삽입 시점에 타임스탬프가 결정되도록 하는 것이 더 정확합니다.

🔎 제안하는 수정 (Option 1: @PrePersist)
+import jakarta.persistence.PrePersist
+
 @Entity
 @Table(...)
 class ProductWeeklyRanking(
     ...
     @Column(name = "created_at", nullable = false)
-    var createdAt: ZonedDateTime = ZonedDateTime.now(),
-)
+    var createdAt: ZonedDateTime? = null,
+) {
+    @PrePersist
+    fun prePersist() {
+        if (createdAt == null) {
+            createdAt = ZonedDateTime.now()
+        }
+    }
+}
apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.kt (1)

1-17: YearMonth 컨버터 구현 확인

JPA AttributeConverter 구현이 올바르며 autoApply = true 설정으로 모든 YearMonth 필드에 자동 적용됩니다. Null 처리도 적절합니다.

Line 15의 YearMonth.parse()는 잘못된 형식의 문자열에 대해 DateTimeParseException을 발생시킵니다. DB에 잘못된 데이터가 있을 경우 애플리케이션이 예기치 않게 실패할 수 있습니다. 데이터 무결성이 보장되는 경우 현재 구현이 적절하지만, 방어적 처리가 필요한 경우 try-catch 추가를 고려하세요.

🔎 선택적 개선: 파싱 오류 처리
 override fun convertToEntityAttribute(dbData: String?): YearMonth? {
-    return dbData?.let { YearMonth.parse(it) }
+    return dbData?.let {
+        try {
+            YearMonth.parse(it)
+        } catch (e: DateTimeParseException) {
+            // 로깅 또는 기본값 반환
+            throw IllegalStateException("Invalid YearMonth format in database: $it", e)
+        }
+    }
 }
apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.kt (1)

3-9: 모듈 간 도메인 리포지토리 계약이 다릅니다.

commerce-batch 모듈의 ProductMonthlyRankingRepository가 배치 작업에 특화된 메서드를 제공하는 것은 적절하지만, commerce-api 모듈과 동일한 엔티티(ProductMonthlyRanking)에 대해 서로 다른 도메인 리포지토리 계약을 사용하면 일관성 문제가 발생할 수 있습니다.

현재 상황:

  • 배치: findByMonthPeriod(YearMonth): List (전체 조회)
  • API: findByMonthPeriod(YearMonth, Pageable): Page (페이지네이션)

권장 사항:
배치 작업에 필요한 메서드(대량 삭제, 비페이지네이션 조회)를 명확히 구분하기 위해 리포지토리 이름을 더 명시적으로 변경하는 것을 고려하세요 (예: ProductMonthlyRankingBatchOperations 또는 ProductMonthlyRankingBatchRepository).

apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt (1)

22-22: 파라미터 이름 불일치

findByWeekRange 메서드의 파라미터 이름이 monthStart, monthEnd로 되어 있지만, 주간(weekly) 데이터를 조회하는 메서드입니다. 일관성을 위해 weekStart, weekEnd 또는 startDate, endDate로 변경하는 것이 좋습니다.

🔎 수정 제안
-    fun findByWeekRange(monthStart: LocalDate, monthEnd: LocalDate): List<ProductWeeklyRanking>
+    fun findByWeekRange(startDate: LocalDate, endDate: LocalDate): List<ProductWeeklyRanking>
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt (1)

33-40: getProductRank는 일별 랭킹만 지원

현재 getProductRank 메서드는 todayDateKey()를 사용하여 일별 랭킹만 조회합니다. 향후 주간/월간 개별 상품 순위 조회가 필요한 경우 이 메서드도 RankingPeriod 파라미터 지원이 필요할 수 있습니다. 현재 요구사항에서 필요하지 않다면 그대로 두어도 됩니다.

apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt (1)

17-24: 호출자의 정렬 옵션을 무시하는 것이 의도된 동작인지 확인 필요

pageable 파라미터에 포함된 정렬 정보를 무시하고 항상 ranking 오름차순으로 강제 정렬하고 있습니다. 이것이 의도된 동작이라면 메서드 시그니처나 문서에 명시하는 것이 좋습니다. 혹은 Pageable 대신 페이지 정보만 받도록 변경하는 것도 고려해 보세요.

🔎 대안: 페이지 정보만 받도록 시그니처 변경
 override fun findByWeekRange(
     weekStart: LocalDate,
     weekEnd: LocalDate,
-    pageable: Pageable,
+    page: Int,
+    size: Int,
 ): Page<ProductWeeklyRanking> {
-    val sortedPageable = PageRequest.of(pageable.pageNumber, pageable.pageSize, Sort.by("ranking").ascending())
+    val sortedPageable = PageRequest.of(page, size, Sort.by("ranking").ascending())
     return productWeeklyRankingJpaRepository.findByWeekStartAndWeekEnd(weekStart, weekEnd, sortedPageable)
 }
apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingReader.kt (2)

73-78: 날짜 파싱 실패 시 예외 처리 부재

LocalDate.parse(weekStart)LocalDate.parse(weekEnd)는 잘못된 형식의 문자열이 전달되면 DateTimeParseException을 발생시킵니다. Job 파라미터 검증 단계에서 형식을 미리 확인하거나, 여기서 명시적으로 예외를 처리하여 더 명확한 에러 메시지를 제공하는 것이 좋습니다.


48-71: GROUP BY에서 base column 대신 alias 사용 제거 권장

days_from_end는 SELECT 절의 계산된 alias이며, 현재 코드는 GROUP BY와 ORDER BY에서 이 alias를 사용하고 있습니다. 테스트(ProductWeeklyRankingJobTest)에서 pageSize=100으로 150개 상품을 처리하는 케이스를 통해 페이징이 정상적으로 동작함이 확인되었습니다. 다만, MySQL과 표준 SQL 호환성을 위해 GROUP BY 절에서는 base column인 metric_date 대신 alias를 사용하는 것보다, 실제 컬럼명을 명시적으로 사용하는 것이 더 명확합니다:

groupClause = "product_id, metric_date"  // alias 대신 base column 사용

sortKeys의 "days_from_end" alias 사용은 정렬 목적으로 적절하므로 유지해도 무방합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingProcessor.kt (1)

20-24: 주간 프로세서와 달리 기본값이 없어 설정 누락 시 실패 가능

ProductWeeklyRankingProcessor는 가중치에 기본값(:1, :5, :10)을 제공하지만, 이 월간 프로세서는 기본값이 없습니다. 프로퍼티가 누락되면 애플리케이션 시작 시 예외가 발생합니다. 일관성을 위해 기본값을 추가하거나, 두 프로세서 모두 기본값 없이 필수 설정으로 통일하는 것이 좋습니다.

🔎 기본값 추가 예시
 class ProductMonthlyRankingProcessor(
-    @Value("\${batch.ranking.weights.view}") private val viewWeight: Double,
-    @Value("\${batch.ranking.weights.like}") private val likeWeight: Double,
-    @Value("\${batch.ranking.weights.sold}") private val soldWeight: Double,
+    @Value("\${batch.ranking.weights.view:1}") private val viewWeight: Double,
+    @Value("\${batch.ranking.weights.like:5}") private val likeWeight: Double,
+    @Value("\${batch.ranking.weights.sold:10}") private val soldWeight: Double,
 ) : ItemProcessor<ProductMonthlyMetricsAggregate, RankedProduct> {
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt (1)

45-46: createdAtvar로 선언되어 있어 변경 가능

createdAt은 엔티티 생성 시점에 한 번만 설정되어야 하므로 val로 선언하는 것이 더 적절합니다. 현재 var로 되어 있어 의도치 않게 수정될 수 있습니다.

🔎 val로 변경
     @Column(name = "created_at", nullable = false)
-    var createdAt: ZonedDateTime = ZonedDateTime.now(),
+    val createdAt: ZonedDateTime = ZonedDateTime.now(),
apps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductWeeklyRankingJobTest.kt (1)

36-38: 테스트의 날짜 범위가 7일로 고정됨

weekEnd = LocalDate.now().minusDays(1)weekStart = weekEnd.minusDays(6)으로 항상 7일 범위입니다. 프로세서의 else -> 0.0 분기를 테스트하려면 7일 초과 범위 테스트도 고려해 보세요.

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

19-26: 복합 인덱스 추가 고려

product_idmetric_date에 개별 인덱스가 있지만, 배치 쿼리에서 두 컬럼을 함께 조회하는 경우가 많다면 복합 인덱스가 더 효율적일 수 있습니다. 기본 키가 이미 (productId, metricDate) 복합 키이므로 대부분의 DB에서 자동으로 인덱스가 생성되지만, 쿼리 패턴에 따라 명시적 복합 인덱스가 필요할 수 있습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt (1)

36-47: @Modifying 쿼리에 @Transactional 또는 clearAutomatically 고려 필요

@Modifying 어노테이션이 적용된 삭제 쿼리는 영속성 컨텍스트와 DB 간 불일치가 발생할 수 있습니다. 배치 Writer에서 호출 시 트랜잭션 내에서 실행되면 문제없지만, 명시적으로 clearAutomatically = true를 추가하면 더 안전합니다.

제안된 수정
     @Modifying
+    @Modifying(clearAutomatically = true)
     @Query(
apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingReader.kt (1)

78-81: saveState(true)와 Job의 preventRestart() 설정 간 불일치

Reader에서 saveState(true)를 설정했지만, Job 설정에서는 preventRestart()가 적용되어 있습니다. 재시작이 불가능한 Job에서 상태 저장은 불필요한 오버헤드입니다. 일관성을 위해 saveState(false)로 변경하거나, Job에서 재시작을 허용하는 것을 고려해 주세요.

제안된 수정
             .pageSize(100)
             .rowMapper(rowMapper)
-            .saveState(true)
+            .saveState(false)
             .build()
apps/commerce-batch/src/main/kotlin/com/loopers/batch/BatchJobController.kt (2)

44-69: Job 실행 예외 처리 누락

jobLauncher.run()JobExecutionAlreadyRunningException, JobRestartException, JobInstanceAlreadyCompleteException, JobParametersInvalidException 등 여러 예외를 throw할 수 있습니다. 현재 코드에서는 이러한 예외가 처리되지 않아 500 에러로 전파됩니다.

또한, 동기 실행 방식으로 인해 배치 작업이 오래 걸릴 경우 HTTP 요청 타임아웃이 발생할 수 있습니다.

🔎 예외 처리 및 비동기 실행 고려
+import org.springframework.batch.core.launch.JobExecutionNotRunningException
+import org.springframework.batch.core.JobExecutionException
+
     @PostMapping("/weekly-ranking")
     fun runWeeklyRanking(
         @RequestParam weekStart: String,
         @RequestParam weekEnd: String,
     ): ResponseEntity<Map<String, Any>> {
+        // 날짜 형식 검증
+        try {
+            LocalDate.parse(weekStart)
+            LocalDate.parse(weekEnd)
+        } catch (e: DateTimeParseException) {
+            return ResponseEntity.badRequest().body(mapOf("error" to "Invalid date format"))
+        }
+
         val params = JobParametersBuilder()
             .addString("weekStart", weekStart)
             .addString("weekEnd", weekEnd)
             .addLong("timestamp", System.currentTimeMillis())
             .toJobParameters()

-        val execution = jobLauncher.run(productWeeklyRankingJob, params)
+        val execution = try {
+            jobLauncher.run(productWeeklyRankingJob, params)
+        } catch (e: JobExecutionException) {
+            return ResponseEntity.internalServerError().body(
+                mapOf("error" to e.message)
+            )
+        }

83-105: 월간 배치 엔드포인트에도 동일한 예외 처리 필요

runWeeklyRanking과 마찬가지로 yearMonth 파라미터 검증 및 Job 실행 예외 처리가 필요합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt (1)

33-35: 파라미터 이름 불일치

findByWeekRange 메서드의 파라미터 이름이 monthStart, monthEnd로 되어 있지만, 실제로는 주간 범위를 조회하는 메서드입니다. 코드 가독성을 위해 weekStart, weekEnd로 변경하는 것이 좋습니다.

🔎 파라미터 이름 수정 제안
-    override fun findByWeekRange(monthStart: LocalDate, monthEnd: LocalDate): List<ProductWeeklyRanking> {
-        return productWeeklyRankingJpaRepository.findByWeekRange(monthStart, monthEnd)
+    override fun findByWeekRange(weekStart: LocalDate, weekEnd: LocalDate): List<ProductWeeklyRanking> {
+        return productWeeklyRankingJpaRepository.findByWeekRange(weekStart, weekEnd)
     }
apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingWriter.kt (1)

82-100: Step 완료 상태 확인 로직 개선 고려

현재 exitStatus.exitCode를 문자열 "COMPLETED"와 비교하고 있습니다. ExitStatus.COMPLETED.exitCode를 사용하면 더 안전합니다.

🔎 상수 사용 제안
+import org.springframework.batch.core.ExitStatus
+
     @AfterStep
     fun afterStep(stepExecution: StepExecution) {
-        if (stepExecution.exitStatus.exitCode != "COMPLETED") {
+        if (stepExecution.exitStatus.exitCode != ExitStatus.COMPLETED.exitCode) {
             log.warn("Step did not complete successfully. Skipping monthly ranking save.")
             return
         }
apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingWriter.kt (1)

84-89: Step 완료 상태 확인 - 상수 사용 권장

월간 Writer와 동일하게 문자열 리터럴 대신 ExitStatus.COMPLETED.exitCode를 사용하는 것이 좋습니다.

🔎 상수 사용 제안
+import org.springframework.batch.core.ExitStatus
+
     @AfterStep
     fun afterStep(stepExecution: StepExecution) {
-        if (stepExecution.exitStatus.exitCode != "COMPLETED") {
+        if (stepExecution.exitStatus.exitCode != ExitStatus.COMPLETED.exitCode) {
             log.warn("Step did not complete successfully. Skipping weekly ranking save.")
             return
         }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e74a229 and f54ba4c.

📒 Files selected for processing (57)
  • apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingRepositoryImpl.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiSpec.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1Controller.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/RankingServiceTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/RankingRedisRepositoryOrderingTest.kt
  • apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiTest.kt
  • apps/commerce-batch/build.gradle.kts
  • apps/commerce-batch/src/main/kotlin/com/loopers/CommerceBatchApplication.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/BatchJobController.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingProcessor.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingReader.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingWriter.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingJobConfig.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingProcessor.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingReader.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingWriter.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductMonthlyMetricsAggregate.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductWeeklyMetricsAggregate.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/RankedProduct.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingRepositoryImpl.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/kotlin/com/loopers/IntegrationTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductMonthlyRankingJobTest.kt
  • apps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductWeeklyRankingJobTest.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsRepository.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.kt
  • settings.gradle.kts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.

Applied to files:

  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductMonthlyMetricsAggregate.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt
📚 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-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.kt
  • apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt
📚 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/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.kt
  • apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.kt
  • apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.kt
📚 Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.

Applied to files:

  • apps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.kt
📚 Learning: 2025-12-22T16:33:50.678Z
Learnt from: jikimee64
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 71
File: apps/commerce-streamer/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt:167-173
Timestamp: 2025-12-22T16:33:50.678Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository, using system default timezone (ZonedDateTime.now() without explicit ZoneId) in date key calculations is an intentional design choice. The deployment ensures all instances share the same timezone configuration.

Applied to files:

  • apps/commerce-batch/src/main/kotlin/com/loopers/CommerceBatchApplication.kt
🔇 Additional comments (61)
apps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/RankingRedisRepositoryOrderingTest.kt (1)

29-29: 헬퍼 함수 사용이 올바릅니다.

두 테스트 모두에서 toMember 헬퍼를 일관되게 사용하여 Redis zset 멤버를 생성하고 있습니다. 제로 패딩 형식을 통해 동점일 때 상품 ID 기준 내림차순 정렬을 올바르게 검증할 수 있습니다.

Also applies to: 50-50

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

20-26: LGTM!

랭킹 가중치 설정이 명확하고 적절합니다. 총 가중치가 1.0으로 합산되며, 판매수에 가장 높은 가중치(0.7)를 부여하는 것은 비즈니스 관점에서 합리적입니다. 외부 설정으로 관리되어 조정이 용이합니다.


14-18: 임포트된 설정 파일이 모두 존재합니다.

jpa.yml, logging.yml, monitoring.yml 파일이 각각의 모듈(modules/jpa, supports/logging, supports/monitoring)에 존재하며, Spring Boot의 클래스패스 임포트 메커니즘을 통해 올바르게 해석됩니다. 구성에 문제가 없습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductMonthlyMetricsAggregate.kt (1)

1-18: LGTM!

월간 메트릭 집계를 위한 간결한 DTO입니다. KDoc 문서화가 잘 되어 있고, Reader의 GROUP BY 결과를 담는 용도로 적절합니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.kt (1)

21-30: LGTM!

복합키(EmbeddedId)를 사용한 일자별 메트릭 이력 관리 설계가 적절합니다. 테이블 인덱스도 metric_dateproduct_id에 올바르게 설정되어 있습니다.

settings.gradle.kts (1)

6-6: LGTM!

commerce-batch 모듈이 올바르게 추가되었습니다. apps 그룹 내에 적절하게 배치되어 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt (1)

1-9: LGTM!

도메인 레이어의 repository 인터페이스로 적절합니다. Spring Data의 PagePageable 타입 사용은 기존 코드베이스의 아키텍처 결정을 따르고 있습니다. Based on learnings, 이는 허용된 패턴입니다.

apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/RankedProduct.kt (1)

1-12: LGTM!

Processor에서 Writer로 전달되는 랭킹 결과를 담는 간결한 DTO입니다. KDoc 문서화가 잘 되어 있습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.kt (1)

1-7: LGTM!

복합 키(ProductMetricsId)를 사용하는 표준 Spring Data JPA 리포지토리 인터페이스입니다. 구현이 명확하고 올바릅니다.

apps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/RankingServiceTest.kt (1)

21-27: LGTM!

RankingService 생성자 시그니처 변경을 반영하여 주간 및 월간 랭킹 리포지토리를 올바르게 주입하도록 테스트 설정이 업데이트되었습니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.kt (1)

1-21: LGTM!

JPA 복합 키 모범 사례를 따르는 올바른 구현입니다:

  • Serializable 구현
  • @Embeddable 어노테이션
  • Data class로 equals/hashCode 자동 제공
  • Non-nullable 필드로 키 무결성 보장
apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.kt (1)

1-9: LGTM!

월간 랭킹 조회를 위한 명확한 도메인 리포지토리 인터페이스입니다. YearMonth를 기간 파라미터로 사용하는 것이 의미적으로 적절하며, 페이징 지원도 올바르게 구현되었습니다.

apps/commerce-batch/src/test/kotlin/com/loopers/IntegrationTest.kt (1)

1-22: 데이터베이스 정리 및 외래 키 처리가 적절합니다

통합 테스트를 위한 기본 클래스 구현이 좋습니다. DatabaseCleanUp은 각 테스트 후 truncateAllTables()를 호출하여 테스트 격리를 보장하며, 외래 키 제약 조건을 올바르게 처리합니다. SET FOREIGN_KEY_CHECKS = 0으로 제약 조건을 비활성화한 후 테이블을 정리하고 SET FOREIGN_KEY_CHECKS = 1로 재활성화하는 방식으로 안전하게 구현되어 있습니다.

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

22-27: 기간 파라미터 처리 확인 완료

기간(period) 파라미터 추가와 RankingPeriod.from() 파싱 로직이 올바르게 구현되었습니다. defaultValue = "daily" 설정도 적절하며, RankingPeriod 열거형의 DAILY 값과 일치합니다.

RankingPeriod.from() 메서드는 유효하지 않은 값에 대해 CoreException(ErrorType.BAD_REQUEST) 예외를 발생시키므로 예외 처리가 적절하게 구현되어 있습니다. null이나 공백 값의 경우 기본값 DAILY를 반환하도록 처리되어 있어 견고한 구현입니다.

apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt (1)

9-11: LGTM!

주간 랭킹 조회를 위한 JPA 리포지토리 인터페이스가 올바르게 정의되었습니다. Spring Data JPA의 메서드 네이밍 규칙을 따르고 있으며, 페이지네이션을 적절히 지원합니다.

apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.kt (1)

36-50: LGTM!

메트릭을 날짜별로 분리하는 새로운 모델로 잘 마이그레이션되었습니다. metricDate 기반 조회와 복합 aggregateId 구성이 일관되게 적용되어 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.kt (1)

7-17: LGTM!

YearMonth 타입을 데이터베이스에 문자열로 저장하는 컨버터가 올바르게 구현되었습니다. autoApply = true 설정으로 모든 YearMonth 필드에 자동 적용되며, 표준 ISO 포맷(yyyy-MM)을 사용합니다.

apps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.kt (1)

88-94: LGTM!

Kafka 컨슈머 통합 테스트가 새로운 날짜 기반 메트릭 모델에 맞게 잘 업데이트되었습니다. metricDate 기반 조회와 복합 aggregateId 패턴이 일관되게 적용되어 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.kt (1)

9-11: LGTM!

월간 랭킹 조회를 위한 JPA 리포지토리 인터페이스가 올바르게 정의되었습니다. 페이지네이션을 지원하는 메서드 시그니처가 적절합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductWeeklyMetricsAggregate.kt (1)

1-20: LGTM!

간결하고 명확한 DTO 설계입니다. KDoc 주석으로 각 필드의 의미가 잘 문서화되어 있으며, 불변 데이터 클래스로 적절하게 구현되었습니다.

apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.kt (1)

6-21: LGTM!

from() 팩토리 메서드가 잘 구현되었습니다. null/blank 입력에 대한 기본값 처리, 대소문자 무시 매칭, 그리고 명확한 오류 메시지가 포함되어 있어 좋은 사용자 경험을 제공합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/CommerceBatchApplication.kt (1)

13-17: 타임존 설정 방식 확인

TimeZone.setDefault()는 JVM 전역 설정을 변경합니다. Retrieved learnings에 따르면 이는 의도된 설계 결정이며, 배포 환경에서 모든 인스턴스가 동일한 타임존을 사용하도록 보장합니다. 테스트 환경에서 다른 애플리케이션과 함께 실행될 경우 영향을 받을 수 있으니 참고하시기 바랍니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.kt (2)

33-68: LGTM - 날짜 기반 메트릭 집계로의 전환

날짜별 메트릭 분리가 일관되게 구현되었습니다. aggregateId에 날짜를 포함시켜 멱등성을 보장하고, 락 범위를 날짜별 레코드로 좁혀 동시성 경합을 줄였습니다. 모든 메서드에서 동일한 패턴이 적용되어 있습니다.


99-101: 좋아요 감소 시 음수 값 처리는 이미 구현됨

decreaseLikeCount() 메서드는 이미 음수 값 방지 로직을 포함하고 있습니다. 메서드 내 if (this.likeCount > 0) 조건으로 0 이상의 값만 감소시키므로, 새 레코드 생성 후 감소 이벤트 호출 시나리오에서도 음수로 내려가지 않습니다. 이벤트 순서가 뒤바뀌어 도착하는 경우도 안전하게 처리됩니다.

apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.kt (1)

8-24: 인터페이스 설계 LGTM

CRUD 및 조회 메서드가 명확하게 정의되어 있습니다. Upsert를 위한 findByProductIdAndWeekStartAndWeekEnd 메서드와 배치 삭제를 위한 deleteByWeekStartAndWeekEnd 메서드가 적절히 포함되어 있습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingRepositoryImpl.kt (1)

8-24: LGTM!

간결한 repository 구현입니다. JPA repository로의 위임이 명확하고, YearMonth 타입을 사용하여 월별 기간을 적절히 표현하고 있습니다.

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

19-31: LGTM - 기간별 랭킹 조회 지원

RankingPeriod 파라미터 추가와 scorePage 기반 페이징으로의 리팩토링이 깔끔하게 구현되었습니다. 빈 페이지 처리와 페이지네이션 메타데이터가 올바르게 전달됩니다.

apps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiTest.kt (3)

73-118: LGTM - 주간 랭킹 테스트

TemporalAdjusters를 사용한 주간 날짜 계산(월요일~일요일)이 정확하고, 테스트 시나리오가 명확합니다. 랭킹 데이터 설정, API 호출, 응답 검증이 잘 구성되어 있습니다.


120-161: LGTM - 월간 랭킹 테스트

YearMonth를 사용한 월간 기간 설정과 테스트가 적절합니다. 기존 일별 테스트와 일관된 패턴을 따르고 있습니다.


39-42: DB 클린업은 베이스 클래스에서 자동으로 처리됩니다

ApiTest 베이스 클래스의 @AfterEach tearDown() 메서드가 databaseCleanUp.truncateAllTables()를 호출하므로, ProductWeeklyRankingProductMonthlyRanking 테이블을 포함한 모든 테이블이 각 테스트 후 자동으로 정리됩니다. 별도의 정리 로직이 필요하지 않습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingProcessor.kt (1)

26-35: LGTM!

점수 계산 로직이 명확하고, 가중치 공식이 문서화되어 있습니다.

apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt (1)

16-26: LGTM!

인덱스와 유니크 제약조건이 적절히 구성되어 있고, YearMonthAttributeConverter를 사용한 YearMonth 타입 변환이 잘 적용되었습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingProcessor.kt (2)

37-46: 감쇠 가중치 패턴이 비선형적임 - 의도된 설계인지 확인

D+2 → D+3에서 가중치가 0.8 → 0.4로 급격히 감소합니다. 이것이 비즈니스 요구사항에 따른 의도된 설계라면 괜찮지만, 만약 선형 또는 지수 감쇠를 의도했다면 재검토가 필요합니다.

또한 else -> 0.0은 7일 이상 차이나는 데이터를 완전히 무시합니다. weekStart/weekEnd 파라미터가 항상 7일 범위임을 보장하는 검증 로직이 있는지 확인해 주세요.


29-33: LGTM!

가중치에 기본값이 제공되어 설정 누락 시에도 안전하게 동작합니다.

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

1-25: LGTM!

배치 모듈의 의존성 구성이 깔끔합니다. Spring Batch, JPA, 테스트 의존성이 적절히 설정되어 있습니다.

apps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductWeeklyRankingJobTest.kt (2)

44-97: 테스트 커버리지가 우수함

전체 배치 흐름, 상위 100개 제한, 감쇠 가중치 적용, 빈 데이터 처리 등 다양한 시나리오를 검증하는 포괄적인 테스트입니다.


77-78: 문제 없음. findByWeekStartAndWeekEnd() 메서드는 ProductWeeklyRankingRepository 인터페이스에 정의되어 있습니다. 컴파일 에러가 발생하지 않으며, 테스트는 유효한 메서드를 호출하고 있습니다.

Likely an incorrect or invalid review comment.

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

27-53: LGTM!

복합 키를 사용한 일별 메트릭 엔티티 설계가 적절합니다. companion object의 팩토리 메서드로 생성 로직이 깔끔하게 캡슐화되어 있습니다.

apps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductMonthlyRankingJobTest.kt (4)

1-42: 테스트 설정이 잘 구성되어 있습니다.

JobLauncherTestUtils@Qualifier를 사용하여 올바른 Job을 주입하고, @BeforeEach에서 job을 설정하는 패턴이 적절합니다.


100-134: Top 100 테스트가 올바르게 구현되었습니다.

점수가 높은 순으로 productId 1이 가장 높고, productId 100까지가 Top 100에 포함되어야 합니다. productId > 100인 상품이 제외되는지 검증하는 로직이 정확합니다.


136-183: 멱등성 테스트가 적절합니다.

두 번째 실행에서 더 높은 점수를 가진 새 데이터가 추가되었을 때, 기존 랭킹이 올바르게 업데이트되고 Top 100만 유지되는지 검증합니다.


185-232: 엣지 케이스 테스트가 잘 구현되었습니다.

데이터가 없는 경우의 정상 종료 및 monthPeriod 저장 정확성 검증이 배치 작업의 신뢰성을 보장합니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsRepository.kt (1)

5-14: 날짜 기반 조회 메서드로의 전환이 적절합니다.

복합 키 모델(productId + metricDate)에 맞춰 리포지토리 인터페이스가 올바르게 업데이트되었습니다. 락을 포함한 조회와 날짜 범위 조회 메서드가 일관성 있게 제공됩니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.kt (1)

23-34: LGTM - 주간 랭킹의 월 범위 조회 쿼리

weekStart <= :monthEnd AND weekEnd >= :monthStart 조건은 해당 월과 겹치는 모든 주를 올바르게 찾습니다. 파라미터 명명이 약간 혼동될 수 있지만 로직은 정확합니다.

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

17-24: API 확장이 하위 호환성을 유지하며 잘 문서화되었습니다.

period 파라미터의 기본값 "daily"로 기존 클라이언트 호환성을 유지하고, 각 기간별 날짜 파라미터 해석 방식이 명확하게 설명되어 있습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingReader.kt (1)

34-57: SQL 집계 쿼리 및 RowMapper 구현이 적절합니다.

월별 메트릭을 product_id로 그룹화하고 SUM으로 집계하는 방식이 올바릅니다. RowMapper가 결과를 ProductMonthlyMetricsAggregate DTO로 정확하게 매핑합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingJobConfig.kt (1)

27-61: 배치 Job 설정이 표준 패턴을 따르고 있습니다.

청크 크기(100)가 Reader의 페이지 크기와 일치하여 효율적인 처리가 가능합니다. preventRestart()는 멱등성 있는 배치 작업에 적합한 선택입니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.kt (1)

11-23: 복합 키 기반 JPA Repository 구현이 올바릅니다.

ProductMetricsId 복합 키에 맞춰 Spring Data JPA의 네이밍 컨벤션(findByIdProductIdAndIdMetricDate)을 올바르게 사용하고 있으며, JPQL 락 쿼리도 복합 키 필드를 정확하게 참조합니다.

apps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.kt (2)

38-56: 복합 aggregateId 형식으로의 전환이 일관되게 적용되었습니다.

${productId}_$metricDate 형식의 aggregateId가 이벤트 처리 및 중복 검사에 일관되게 사용됩니다. 날짜 기반 메트릭 조회로의 변경이 테스트 전반에 올바르게 반영되었습니다.


214-232: EventProcessingTimestamp 검증 테스트가 새 aggregateId 형식을 올바르게 사용합니다.

타임스탬프 추적이 ${productId}_$metricDate 형식의 aggregateId로 정확하게 수행되는지 검증합니다.

apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt (3)

75-93: LGTM - 일간 랭킹 조회 로직

getDailyScores 메서드에서 빈 결과, 오프셋 오버플로우 등 엣지 케이스를 적절히 처리하고 있습니다. Page.empty()PageImpl 사용이 올바릅니다.


109-113: LGTM - 주간 범위 계산 로직

weekRange 헬퍼 메서드가 TemporalAdjusters를 사용하여 월요일~일요일 범위를 정확히 계산합니다.


95-100: 코드는 올바르게 작성되어 있습니다. ProductWeeklyRankingRepositoryfindByWeekRange 메서드는 Page<ProductWeeklyRanking>을 반환하며, ProductWeeklyRankingRepositoryImpl의 구현도 이 시그니처를 정확히 따릅니다. RankingService.kt의 호출은 인터페이스 정의와 일치하며 문제가 없습니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingJobConfig.kt (2)

37-41: 재시작 불가 설정 확인 필요

preventRestart()가 설정되어 있어 Job이 실패해도 재시작이 불가능합니다. 동일한 파라미터로 재실행하려면 새로운 timestamp가 필요합니다. 이것이 의도된 동작인지 확인해 주세요.

멱등성이 Writer에서 보장되므로 실패 시 동일 파라미터로 재실행이 가능하도록 preventRestart()를 제거하는 것도 고려해 볼 수 있습니다.


43-55: LGTM - Step 구성

Reader → Processor → Writer 흐름이 올바르게 구성되어 있으며, @Qualifier를 통해 의존성이 명확하게 주입됩니다.

apps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.kt (1)

8-40: LGTM - Repository 구현

JPA Repository로의 위임 패턴이 일관되게 적용되어 있습니다.

apps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.kt (1)

13-35: LGTM - 날짜 기반 조회 메서드 추가

ProductMetrics의 복합 키(productId + metricDate) 구조에 맞게 리포지토리 메서드들이 올바르게 업데이트되었습니다. 날짜 범위 조회를 위한 findByProductIdAndMetricDateBetween 메서드도 배치 집계 작업에 적합합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingWriter.kt (1)

122-140: LGTM - 트랜잭션 처리 및 임시 테이블 정리

삭제와 저장을 하나의 트랜잭션으로 처리하여 멱등성을 보장하고, 임시 테이블을 정리하는 로직이 적절합니다.

apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingWriter.kt (3)

60-82: LGTM - ON DUPLICATE KEY UPDATE를 통한 점수 누적

동일 productId가 여러 청크에 걸쳐 나타나더라도 점수가 정확히 누적됩니다. 올바른 구현입니다.


126-145: LGTM - 멱등성 및 리소스 정리

트랜잭션 내에서 기존 데이터 삭제 후 새 데이터 저장으로 멱등성을 보장하고, 임시 테이블 정리가 적절히 수행됩니다.


91-92: 날짜 파싱 예외 처리 고려

LocalDate.parse(weekStart) 호출 시 잘못된 형식의 문자열이 전달되면 DateTimeParseException이 발생합니다. Controller에서 입력 검증이 없으므로, 여기서 예외가 발생할 수 있습니다.

Controller 레벨에서의 입력 검증을 권장드립니다 (앞서 BatchJobController 리뷰 참조).

Comment thread apps/commerce-batch/src/main/resources/application.yml
RankingAggregator 컴포넌트를 domain 패키지에서 application 패키지로
이동하여 계층별 책임을 명확히 했습니다.

- com.loopers.domain.ranking.RankingAggregator ->
  com.loopers.application.ranking.RankingAggregator로 이동
- RankingAggregator는 순수 도메인 로직보다는 도메인 서비스를 활용하여
  데이터를 집계하는 역할이므로, 애플리케이션 계층에 더 적합합니다.
- 관련된 import 문이 업데이트되었습니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingAggregator.kt (1)

35-37: 데이터 불일치 시 로깅 추가 고려

pageScores에 데이터가 있지만 해당 상품이 존재하지 않는 경우(예: 상품 삭제) 조용히 빈 결과를 반환합니다. 운영 환경에서 데이터 정합성 문제를 파악하기 어려울 수 있으므로, 경고 로그 추가를 고려해 보세요.

🔎 로깅 추가 제안
+import org.slf4j.LoggerFactory
+
 @Component
 class RankingAggregator(
     private val productService: ProductService,
     private val brandService: BrandService,
 ) {
+    private val log = LoggerFactory.getLogger(javaClass)
 
     fun aggregate(pageScores: List<RankingScore>, startRank: Long): List<RankingResult.RankedInfo> {
         if (pageScores.isEmpty()) {
             return emptyList()
         }
 
         val productIds = pageScores.map { it.productId }
         val products = productService.getProducts(productIds)
 
         if (products.isEmpty()) {
+            log.warn("No products found for ranking scores: productIds={}", productIds)
             return emptyList()
         }

Also applies to: 62-64

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f54ba4c and f96f212.

📒 Files selected for processing (2)
  • apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingAggregator.kt
  • apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt
🔇 Additional comments (3)
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingAggregator.kt (1)

1-1: 패키지 이동 승인

도메인 서비스들을 조합하여 데이터를 집계하는 컴포넌트이므로, application 레이어로 이동한 것은 레이어 책임 분리 원칙에 부합합니다. RankingScore import 추가도 패키지 이동에 따른 필수적인 변경입니다.

Also applies to: 8-8

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

19-29: 기간별 랭킹 조회 로직 LGTM

RankingPeriod 파라미터 추가와 scorePage 추상화를 통한 페이징 처리가 적절합니다. 빈 페이지 처리와 결과 집계 로직이 올바르게 구현되었습니다.


33-39: getProductRank 메서드의 기간 지원 여부 확인 필요

getRankings는 이제 DAILY, WEEKLY, MONTHLY 기간을 지원하지만, getProductRank는 여전히 todayDateKey()를 사용하여 일간 랭킹만 조회합니다. 이것이 의도된 설계인지, 아니면 getProductRank에도 기간 파라미터를 추가해야 하는지 확인해 주세요.

ProductMonthlyRankingRepositoryImpl의 findByMonthPeriod 메서드에서
Sort.by("ranking").ascending()을 명시적으로 적용하는 로직을 제거했습니다.
이는 Pageable 객체에 포함된 정렬 정보를 직접 사용하거나,
JPA 저장소 쿼리에서 정렬을 처리하도록 하기 위함입니다.
불필요한 중복 정렬 적용을 방지하여 코드의 명확성을 높입니다.
ProductMonthlyRankingWriter에서 temp_monthly_ranking 테이블에 데이터를
삽입할 때, ON DUPLICATE KEY UPDATE 절을 추가했습니다.

기존에는 동일한 product_id가 이미 존재할 경우 오류가 발생하거나
예상치 못한 동작이 발생할 수 있었습니다. 이제는 중복되는
product_id가 있을 경우, 기존 score에 새로운 score 값을 더하여
업데이트하도록 하여 데이터 일관성을 유지하고 정확한 랭킹
점수 집계를 보장합니다.
@jikimee64

Copy link
Copy Markdown
Collaborator Author

@coderabbitai
아래 요구 사항이 이슈없이 구현 되었는지 확인해주세요.

### (1) Spring Batch Job 구현

- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다.
    - 대상 테이블 : `product_metrics`
    - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요.

### (2) Materialized View 설계

- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다.
    - `mv_product_rank_weekly` : 주간 TOP 100 랭킹
    - `mv_product_rank_monthly` : 월간 TOP 100 랭킹

### (3) Ranking API 확장

- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다.

---

## ✅ Checklist

### 🧱 Spring Batch

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

### 🧩 Ranking API

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

@coderabbitai

coderabbitai Bot commented Dec 31, 2025

Copy link
Copy Markdown

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check weekly ranking reader - does it read from product_metrics?
echo "=== Weekly Ranking Reader ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingReader.kt

Length of output: 3150


🏁 Script executed:

#!/bin/bash
# Check monthly ranking reader - does it read from product_metrics?
echo "=== Monthly Ranking Reader ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingReader.kt

Length of output: 3008


🏁 Script executed:

#!/bin/bash
# Check weekly ranking writer - does it save top 100?
echo "=== Weekly Ranking Writer ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingWriter.kt

Length of output: 4849


🏁 Script executed:

#!/bin/bash
# Check monthly ranking writer - does it save top 100?
echo "=== Monthly Ranking Writer ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingWriter.kt

Length of output: 4662


🏁 Script executed:

#!/bin/bash
# Check job configurations
echo "=== Weekly Job Config ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingJobConfig.kt

echo -e "\n=== Monthly Job Config ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingJobConfig.kt

Length of output: 4766


🏁 Script executed:

#!/bin/bash
# Check API implementation
echo "=== API Controller ==="
cat apps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1Controller.kt

echo -e "\n=== Ranking Facade ==="
cat apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.kt

echo -e "\n=== Ranking Service (period routing) ==="
cat apps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.kt | head -100

Length of output: 6570


🏁 Script executed:

#!/bin/bash
# Check entity definitions for MV tables
echo "=== ProductWeeklyRanking Entity ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.kt

echo -e "\n=== ProductMonthlyRanking Entity ==="
cat apps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.kt

Length of output: 3843


@jikimee64

네, 요구 사항이 모두 이슈 없이 구현되었음을 확인했습니다! 각 항목별로 확인한 내용을 정리해드리겠습니다.

✅ (1) Spring Batch Job 구현

대상 테이블: product_metrics 읽기

주간 랭킹 Reader:

// ProductWeeklyRankingReader.kt
val fromClause = "FROM product_metrics"
val whereClause = "WHERE metric_date BETWEEN :weekStart AND :weekEnd"

월간 랭킹 Reader:

// ProductMonthlyRankingReader.kt
val fromClause = "FROM product_metrics"
val whereClause = "WHERE metric_date BETWEEN :monthStart AND :monthEnd"

✅ 두 Reader 모두 product_metrics 테이블에서 데이터를 읽어옵니다.

Chunk-Oriented 방식

주간/월간 Job 설정:

// Chunk 크기: 100
const val CHUNK_SIZE = 100

// Reader -> Processor -> Writer 패턴
.chunk<InputType, RankedProduct>(CHUNK_SIZE, transactionManager)
    .reader(reader)
    .processor(processor)
    .writer(writer)

✅ 두 Job 모두 청크 크기 100으로 대량 데이터 처리를 위한 Chunk-Oriented Processing을 구현했습니다.


✅ (2) Materialized View 설계

mv_product_rank_weekly 테이블

@Table(
    name = "mv_product_rank_weekly",
    indexes = [
        Index(name = "idx_week", columnList = "week_start, week_end"),
        Index(name = "idx_score", columnList = "score")
    ],
    uniqueConstraints = [
        UniqueConstraint(name = "uq_weekly_rank", 
                        columnNames = ["product_id", "week_start", "week_end"])
    ]
)

mv_product_rank_monthly 테이블

@Table(
    name = "mv_product_rank_monthly",
    indexes = [
        Index(name = "idx_month_period", columnList = "month_period"),
        Index(name = "idx_score", columnList = "score")
    ],
    uniqueConstraints = [
        UniqueConstraint(name = "uq_monthly_rank", 
                        columnNames = ["product_id", "month_period"])
    ]
)

TOP 100만 저장

Writer의 afterStep에서 TOP 100 추출:

val top100 = jdbcTemplate.query(
    """
    SELECT product_id, score,
           ROW_NUMBER() OVER (ORDER BY score DESC) as ranking
    FROM temp_ranking_table
    ORDER BY score DESC
    LIMIT 100
    """.trimIndent()
)

✅ 두 MV 테이블 모두 적절한 인덱스와 제약조건을 가지고 있으며, TOP 100만 저장합니다.


✅ (3) Ranking API 확장

API 파라미터 확장

@GetMapping
override fun getRankings(
    @RequestParam(required = false, defaultValue = "daily") period: String,
    @RequestParam date: String,
    @PageableDefault(size = 20) pageable: Pageable
)

기간별 데이터 소스 라우팅

fun getRankingScores(period: RankingPeriod, date: String, pageable: Pageable): Page<RankingScore> {
    return when (period) {
        RankingPeriod.DAILY -> getDailyScores(parsedDate, pageable)    // Redis 기반
        RankingPeriod.WEEKLY -> getWeeklyScores(parsedDate, pageable)  // mv_product_rank_weekly
        RankingPeriod.MONTHLY -> getMonthlyScores(parsedDate, pageable) // mv_product_rank_monthly
    }
}

✅ API가 period 파라미터를 통해 일간/주간/월간 랭킹을 제공하며, 각 기간에 맞는 데이터 소스에서 조회합니다.


🎉 종합 결과

모든 체크리스트 항목이 올바르게 구현되었습니다:

  • ✅ Spring Batch Job 파라미터 기반 동작
  • ✅ Chunk Oriented Processing (Reader/Processor/Writer)
  • ✅ Materialized View 구조 설계 및 TOP 100 적재
  • ✅ 일간/주간/월간 랭킹 제공 API

구현이 요구 사항을 모두 충족하며, 테스트 코드도 각 시나리오를 검증하고 있어 안정적으로 동작할 것으로 판단됩니다!

    (\(\
    (='.')  구현 확인 완료!
    (")_(")

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.

2 participants