[10주차] 주간,월간 랭킹 (Spring Batch) - 김우철 - #78
Conversation
새로운 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 추가. - 관련 테스트 코드 수정.
Walkthrough기간(일/주/월) 기반 상품 랭킹을 도입했습니다. API 파라미터와 Facade/Service 시그니처가 변경되었고, 주간·월간 JPA 엔터티·저장소, YearMonth 변환기, 배치 모듈(리더·프로세서·라이터), 관련 테스트와 빌드 설정이 추가되었습니다. Changes
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60분 Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
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. Comment |
There was a problem hiding this comment.
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_score가ProductMonthlyRanking과 중복됩니다. 데이터베이스 레벨에서 인덱스 이름 충돌을 방지하려면 테이블 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:createdAt이var로 선언되어 있어 변경 가능
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_id와metric_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
📒 Files selected for processing (57)
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingFacade.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingPeriod.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/RankingService.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingRepositoryImpl.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.ktapps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiSpec.ktapps/commerce-api/src/main/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1Controller.ktapps/commerce-api/src/test/kotlin/com/loopers/domain/ranking/RankingServiceTest.ktapps/commerce-api/src/test/kotlin/com/loopers/infrastructure/ranking/RankingRedisRepositoryOrderingTest.ktapps/commerce-api/src/test/kotlin/com/loopers/interfaces/api/v1/ranking/RankingV1ApiTest.ktapps/commerce-batch/build.gradle.ktsapps/commerce-batch/src/main/kotlin/com/loopers/CommerceBatchApplication.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/BatchJobController.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingJobConfig.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingProcessor.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingReader.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductMonthlyRankingWriter.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingJobConfig.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingProcessor.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingReader.ktapps/commerce-batch/src/main/kotlin/com/loopers/batch/productmetrics/ranking/ProductWeeklyRankingWriter.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRanking.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRanking.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/ProductWeeklyRankingRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductMonthlyMetricsAggregate.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductWeeklyMetricsAggregate.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/RankedProduct.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/converter/YearMonthAttributeConverter.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingRepositoryImpl.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.ktapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/kotlin/com/loopers/IntegrationTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductMonthlyRankingJobTest.ktapps/commerce-batch/src/test/kotlin/com/loopers/batch/productmetrics/batch/ranking/ProductWeeklyRankingJobTest.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsId.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsRepository.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.ktapps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.ktapps/commerce-streamer/src/main/kotlin/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.ktsettings.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.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.ktapps/commerce-streamer/src/test/kotlin/com/loopers/interfaces/consumer/KafkaConsumerIntegrationTest.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/ranking/dto/ProductMonthlyMetricsAggregate.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.ktapps/commerce-batch/src/main/kotlin/com/loopers/domain/metrics/ProductMetrics.ktapps/commerce-streamer/src/main/kotlin/com/loopers/domain/metrics/ProductMetricsService.ktapps/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.ktapps/commerce-api/src/main/kotlin/com/loopers/domain/ranking/ProductMonthlyRankingRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingRepositoryImpl.ktapps/commerce-api/src/main/kotlin/com/loopers/infrastructure/ranking/ProductMonthlyRankingJpaRepository.ktapps/commerce-batch/src/main/kotlin/com/loopers/infrastructure/ranking/ProductWeeklyRankingJpaRepository.ktapps/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.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceIdempotencyTest.ktapps/commerce-streamer/src/test/kotlin/com/loopers/domain/metrics/ProductMetricsServiceOutOfOrderTest.ktapps/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_date와product_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의
Page와Pageable타입 사용은 기존 코드베이스의 아키텍처 결정을 따르고 있습니다. 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: 인터페이스 설계 LGTMCRUD 및 조회 메서드가 명확하게 정의되어 있습니다. 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()를 호출하므로,ProductWeeklyRanking과ProductMonthlyRanking테이블을 포함한 모든 테이블이 각 테스트 후 자동으로 정리됩니다. 별도의 정리 로직이 필요하지 않습니다.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가 결과를ProductMonthlyMetricsAggregateDTO로 정확하게 매핑합니다.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: 코드는 올바르게 작성되어 있습니다.ProductWeeklyRankingRepository의findByWeekRange메서드는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리뷰 참조).
RankingAggregator 컴포넌트를 domain 패키지에서 application 패키지로 이동하여 계층별 책임을 명확히 했습니다. - com.loopers.domain.ranking.RankingAggregator -> com.loopers.application.ranking.RankingAggregator로 이동 - RankingAggregator는 순수 도메인 로직보다는 도메인 서비스를 활용하여 데이터를 집계하는 역할이므로, 애플리케이션 계층에 더 적합합니다. - 관련된 import 문이 업데이트되었습니다.
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
apps/commerce-api/src/main/kotlin/com/loopers/application/ranking/RankingAggregator.ktapps/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 레이어로 이동한 것은 레이어 책임 분리 원칙에 부합합니다.
RankingScoreimport 추가도 패키지 이동에 따른 필수적인 변경입니다.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 값을 더하여 업데이트하도록 하여 데이터 일관성을 유지하고 정확한 랭킹 점수 집계를 보장합니다.
|
@coderabbitai |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 3150 🏁 Script executed: Length of output: 3008 🏁 Script executed: Length of output: 4849 🏁 Script executed: Length of output: 4662 🏁 Script executed: Length of output: 4766 🏁 Script executed: Length of output: 6570 🏁 Script executed: Length of output: 3843 네, 요구 사항이 모두 이슈 없이 구현되었음을 확인했습니다! 각 항목별로 확인한 내용을 정리해드리겠습니다. ✅ (1) Spring Batch Job 구현대상 테이블:
|
📌 Summary
Spring Batch를 활용한 주간/월간 상품 랭킹 시스템을 구축했습니다. 기존 product_metrics 일간 집계 데이터를 기반으로 주간/월간 랭킹을 배치로 집계하고, Materialized View에 저장하여 조회 성능을 최적화했습니다.
주요 변경사항:
💬 Review Points
ProductWeeklyRankingJob (주간 랭킹)
ProductMonthlyRankingJob (월간 랭킹)
✅ Checklist
🧱 Spring Batch
🧩 Ranking API
📎 References
Summary by CodeRabbit
New Features
Chores
Tests
✏️ Tip: You can customize this high-level summary in your review settings.