[volume-10] Collect, Stack, Zip #233
Conversation
* feat: batch 처리 모듇 분리 * feat: batch 모듈에 ProductMetrics 도메인 추가 * feat: ProudctMetrics의 Repository 추가 * test: Product Metrics 배치 작업에 대한 테스트 코드 추가 * feat: ProductMetrics 배치 작업 구현 * test: Product Rank에 대한 테스트 코드 추가 * feat: Product Rank 도메인 구현 * feat: Product Rank Repository 추가 * test: Product Rank 배치에 대한 테스트 코드 추가 * feat: Product Rank 배치 작업 추가 * feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가 * refractor: 랭킹 집계 로직을 여러 step으로 분리함 * chore: db 초기화 로직에서 발생하는 오류 수정 * test: 랭킹 집계의 각 step에 대한 테스트 코드 추가
Walkthrough새로운 배치 처리 모듈(commerce-batch)을 추가하고 상품 순위를 일일(Redis), 주간/월간(구체화된 뷰)으로 처리하도록 확장했습니다. RankingService에 다중 기간 쿼리 지원, ProductMetrics 및 ProductRank 엔티티, 배치 작업 및 관련 저장소를 도입했습니다. Changes
Sequence DiagramssequenceDiagram
participant Client
participant RankingV1Controller
participant RankingService
participant Redis as Redis<br/>(DAILY)
participant MV as Materialized View<br/>(WEEKLY/MONTHLY)
participant ProductDB as Product<br/>Repository
Client->>RankingV1Controller: getRankings(date, period, page, size)
RankingV1Controller->>RankingV1Controller: parsePeriodType(period)
RankingV1Controller->>RankingService: getRankings(date, PeriodType, page, size)
alt period == DAILY
RankingService->>Redis: Redis ZSET 조회
Redis-->>RankingService: 순위 데이터
else period == WEEKLY or MONTHLY
RankingService->>MV: periodStartDate, PeriodType로 조회
MV-->>RankingService: TOP 100 상품 순위
end
RankingService->>ProductDB: 상품/브랜드 정보 조회
ProductDB-->>RankingService: 상품 데이터
RankingService->>RankingService: 순위 항목 구성
RankingService-->>RankingV1Controller: RankingsResponse (페이지네이션)
RankingV1Controller-->>Client: ApiResponse<RankingsResponse>
sequenceDiagram
participant Scheduler as Job Scheduler
participant BatchApp as Batch<br/>Application
participant MetricsJob as ProductMetrics<br/>Aggregation Job
participant RankJob as ProductRank<br/>Aggregation Job
participant MetricsDB as ProductMetrics<br/>Table
participant ScoreTable as ProductRankScore<br/>(Temp)
participant RankMV as ProductRank MV<br/>(mv_product_rank)
Scheduler->>BatchApp: productMetricsAggregationJob(targetDate)
BatchApp->>MetricsJob: Step 1: 메트릭 읽기/처리/쓰기
MetricsJob->>MetricsDB: updatedAt 범위로 조회
MetricsDB-->>MetricsJob: ProductMetrics 청크
MetricsJob->>MetricsJob: pass-through (처리)
MetricsJob->>MetricsJob: 로깅
Scheduler->>BatchApp: productRankAggregationJob(periodType, targetDate)
BatchApp->>RankJob: Step 1: 메트릭 -> 점수 집계
RankJob->>MetricsDB: 범위별 메트릭 읽기
MetricsDB-->>RankJob: ProductMetrics
RankJob->>RankJob: 제품별 그룹화<br/>가중치 점수 계산<br/>(좋아요 0.3, 판매 0.5, 조회 0.2)
RankJob->>ScoreTable: ProductRankScore 저장 (UPSERT)
ScoreTable-->>RankJob: 저장 완료
BatchApp->>RankJob: Step 2: 점수 -> 순위 변환
RankJob->>ScoreTable: 점수 내림차순 조회
ScoreTable-->>RankJob: ProductRankScore (모든 항목)
RankJob->>RankJob: TOP 100 선택<br/>1-100 순위 할당<br/>(ThreadLocal 카운터)
RankJob->>RankMV: ProductRank 저장<br/>(기간별 UPSERT)
RankMV-->>RankJob: 저장 완료
RankJob->>ScoreTable: 임시 테이블 정리
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 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 |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (24)
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java (1)
43-57: 모든 예외를 무시하는 것은 디버깅을 어렵게 만들 수 있습니다.테이블 존재 여부를 먼저 확인하는 로직은 좋지만, 모든 예외를 로깅 없이 삼키는 것은 실제 문제(DB 연결 오류, 권한 문제 등)를 숨길 수 있습니다. 테스트 실패 원인 파악이 어려워질 수 있으니 최소한의 디버그 로깅을 고려해주세요.
또한, 테이블 존재 여부를 확인한 후에도 try-catch가 필요한 이유가 불분명합니다. 존재 확인이 성공하면 TRUNCATE도 성공할 가능성이 높으므로, 예외 발생 시 원인을 파악할 수 있도록 로깅이 더욱 중요합니다.
🔎 최소 로깅 추가 제안
+import org.slf4j.Logger; +import org.slf4j.LoggerFactory;@Component public class DatabaseCleanUp implements InitializingBean { + private static final Logger log = LoggerFactory.getLogger(DatabaseCleanUp.class); @PersistenceContext private EntityManager entityManager;} catch (Exception e) { - // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행 - // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음) + // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행 + log.debug("테이블 {} 정리 중 예외 발생 (무시됨): {}", table, e.getMessage()); }apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (2)
64-71: 테스트 가능성을 위해 시간 주입 고려생성자에서
LocalDateTime.now()를 직접 호출하면 테스트 작성이 어렵고 시스템 시간에 결합됩니다.테스트 가능성과 도메인 순수성을 개선하려면 다음을 고려하세요:
- 옵션 1:
updatedAt을 생성자 파라미터로 받기- 옵션 2:
Clock객체를 주입받아 사용🔎 시간 주입 예시
-public ProductMetrics(Long productId) { +public ProductMetrics(Long productId, LocalDateTime now) { this.productId = productId; this.likeCount = 0L; this.salesCount = 0L; this.viewCount = 0L; this.version = 0L; - this.updatedAt = LocalDateTime.now(); + this.updatedAt = now; }
76-113: 메서드 내 시간 생성 일관성모든 변경 메서드에서
LocalDateTime.now()를 직접 호출하는 패턴이 반복됩니다. 생성자에서 언급한 것과 동일한 테스트 가능성 문제가 있습니다.긍정적인 점:
decrementLikeCount()의 음수 방지 가드 로직이 적절합니다incrementSalesCount()의 수량 검증이 올바릅니다apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java (1)
38-50: 중복 테스트 케이스
processesNonNullItem테스트는processesItem_andReturnsSameItem테스트와 중복됩니다. 첫 번째 테스트가 이미 동일한 검증(동일 객체 반환, non-null)을 더 포괄적으로 수행하고 있습니다.테스트 스위트를 간결하게 유지하기 위해 이 중복 테스트를 제거하는 것을 고려하세요.
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (2)
13-15: 사용되지 않는 import 문이 있습니다.
LocalDateTime과LocalTime이 import되었지만 실제로 사용되지 않습니다.🔎 수정 제안
import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime;
94-114: 테스트 검증이 불완전합니다.
parsesDateCorrectly_andSetsDateTimeRange테스트에서expectedStart와expectedEnd변수를 선언했지만 실제 검증에 사용되지 않습니다. 주석에서 "간접적으로 검증"이라고 언급하고 있으나, Reader 내부 상태를 직접 검증하거나 Repository 호출 시 전달된 인자를 검증하는 것이 더 명확합니다.현재 구현에서는 Repository 메서드 호출 시 ArgumentCaptor를 사용하거나, Reader의 내부 상태에 접근할 수 있다면 해당 값을 검증하는 것을 권장합니다. 만약 현재 구조에서 검증이 어렵다면, 사용되지 않는 변수는 제거하고 테스트 이름과 주석을 실제 검증 내용에 맞게 수정하는 것이 좋습니다.
apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java (1)
54-82: 시간 기반 테스트에서Thread.sleep사용
Thread.sleep(1)을 사용하여 타임스탬프 차이를 보장하고 있습니다. 현재 접근 방식은 동작하지만, 테스트가 간헐적으로 실패할 가능성이 있습니다.더 결정적인 테스트를 위해
java.time.Clock을 주입하여 시간을 제어하는 방식을 고려해볼 수 있습니다. 다만, 현재 1ms sleep은 실용적으로 충분히 안정적입니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)
3-15: 사용되지 않는 import 문이 있습니다.
ProductMetrics,ItemProcessor,Comparator,List,Map,Collectors,IntStream등의 import가 사용되지 않습니다. 리팩토링 후 남은 것으로 보입니다.🔎 정리 제안
package com.loopers.infrastructure.batch.rank; -import com.loopers.domain.metrics.ProductMetrics; import com.loopers.domain.rank.ProductRank; import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.ItemProcessor; import org.springframework.stereotype.Component; import java.time.LocalDate; import java.time.temporal.TemporalAdjusters; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.IntStream;apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)
100-150: 날짜 범위 계산 로직 검증이 누락되었습니다.
weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek와monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth테스트는 "모두 같은 주/월의 시작일부터 시작해야 함"이라고 주석으로 명시하지만, 이를 검증하는 assertion이 없습니다.날짜 범위 계산 로직을 별도 헬퍼 메서드로 추출하면 단위 테스트가 용이해집니다:
// ProductRankAggregationReader에 추가 public LocalDate calculateWeekStart(LocalDate targetDate) { return targetDate.with(DayOfWeek.MONDAY); } public LocalDate calculateMonthStart(LocalDate targetDate) { return targetDate.with(TemporalAdjusters.firstDayOfMonth()); }그런 다음 이 메서드들을 직접 테스트할 수 있습니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)
69-72: 불변 Map 사용을 고려해주세요.단일 항목만 포함하는 정렬 기준에
HashMap을 사용하고 있습니다. Java 9+의Map.of()를 사용하면 더 간결하고 불변성을 보장합니다.🔎 수정 제안
- // 정렬 기준 설정 (product_id 기준 오름차순) - Map<String, Sort.Direction> sorts = new HashMap<>(); - sorts.put("productId", Sort.Direction.ASC); + // 정렬 기준 설정 (product_id 기준 오름차순) + Map<String, Sort.Direction> sorts = Map.of("productId", Sort.Direction.ASC);이 경우
java.util.HashMapimport도 제거할 수 있습니다.apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java (2)
34-41: 인덱스 정의 방식에 대한 참고사항입니다.
@Index의unique = true와@Column의unique = true가 모두 적용되어 있습니다 (Line 38과 Line 54). 두 설정 모두 유니크 제약조건을 생성하므로 중복 정의일 수 있습니다.일관성을 위해 둘 중 하나만 유지하는 것을 고려해주세요. 일반적으로
@Column(unique = true)는 단일 컬럼 유니크 제약에,@Index(unique = true)는 복합 인덱스의 유니크 제약에 사용됩니다.
95-101:setMetrics()메서드가 public으로 노출되어 있습니다.Javadoc에서 "Repository에서만 사용하는 내부 메서드"라고 명시했지만 접근 제어자가
public입니다. 의도치 않은 외부 접근을 방지하려면 package-private 또는protected로 변경하는 것을 고려해주세요.🔎 수정 제안
- public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) { + void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) {apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)
76-79: 매 Chunk마다 전체 데이터 삭제+삽입은 비효율적현재 구현은 매 Chunk마다 delete + insert를 수행합니다. Step 완료 시점에 한 번만 저장하는 방식이 더 효율적입니다.
StepExecutionListener.afterStep()에서 최종 저장을 수행하는 것을 고려해 보세요.apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)
146-168: Java record로 간소화 가능
AggregatedMetrics내부 클래스를 Java record로 대체하면 보일러플레이트 코드를 줄일 수 있습니다.🔎 record 사용 제안
- private static class AggregatedMetrics { - private final Long likeCount; - private final Long salesCount; - private final Long viewCount; - - public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) { - this.likeCount = likeCount; - this.salesCount = salesCount; - this.viewCount = viewCount; - } - - public Long getLikeCount() { - return likeCount; - } - - public Long getSalesCount() { - return salesCount; - } - - public Long getViewCount() { - return viewCount; - } - } + private record AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {}apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)
35-42: 관리 상태 엔티티에merge()호출 불필요
existingScore는findByProductId에서 조회된 관리(managed) 상태 엔티티입니다.@Transactional컨텍스트 내에서setMetrics()로 값을 변경하면 JPA dirty checking에 의해 자동으로 flush됩니다.merge()호출은 불필요합니다.🔎 수정 제안
ProductRankScore existingScore = existing.get(); existingScore.setMetrics( score.getLikeCount(), score.getSalesCount(), score.getViewCount(), score.getScore() ); - entityManager.merge(existingScore); log.debug("ProductRankScore 업데이트: productId={}", score.getProductId());Based on learnings, 이 코드베이스에서는 트랜잭션 컨텍스트 내에서 JPA dirty checking을 통한 자동 영속화를 선호합니다.
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
102-109: JPA 라이프사이클 콜백으로 타임스탬프 관리 권장
createdAt과updatedAt을 수동으로 설정하는 대신@PrePersist와@PreUpdate를 사용하면 일관성 있는 타임스탬프 관리가 가능합니다.🔎 JPA 콜백 사용 제안
+ @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + public ProductRank( PeriodType periodType, LocalDate periodStartDate, Long productId, Integer rank, Long likeCount, Long salesCount, Long viewCount ) { this.periodType = periodType; this.periodStartDate = periodStartDate; this.productId = productId; this.rank = rank; this.likeCount = likeCount; this.salesCount = salesCount; this.viewCount = viewCount; - this.createdAt = LocalDateTime.now(); - this.updatedAt = LocalDateTime.now(); } public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) { this.rank = rank; this.likeCount = likeCount; this.salesCount = salesCount; this.viewCount = viewCount; - this.updatedAt = LocalDateTime.now(); }Also applies to: 138-139, 155-155
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java (2)
36-38: 대량 데이터 저장 시 성능 개선 고려개별
persist()호출 대신 일정 간격으로flush()와clear()를 수행하면 대량 데이터 처리 시 메모리 효율이 향상됩니다. TOP 100으로 제한되어 현재는 문제없지만, 향후 확장 시 고려해 주세요.🔎 배치 플러시 패턴 제안
public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List<ProductRank> ranks) { // 기존 데이터 삭제 deleteByPeriod(periodType, periodStartDate); // 새 데이터 저장 - for (ProductRank rank : ranks) { - entityManager.persist(rank); - } + for (int i = 0; i < ranks.size(); i++) { + entityManager.persist(ranks.get(i)); + if (i > 0 && i % 50 == 0) { + entityManager.flush(); + entityManager.clear(); + } + } + entityManager.flush(); log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}", periodType, periodStartDate, ranks.size()); }
68-77:getResultList()를 사용한 간결한 구현 고려
getSingleResult()와 예외 처리 대신getResultList()를 사용하면 코드가 더 간결해집니다.🔎 간결한 구현 제안
- try { - ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) - .setParameter("periodType", periodType) - .setParameter("periodStartDate", periodStartDate) - .setParameter("productId", productId) - .getSingleResult(); - return Optional.of(rank); - } catch (jakarta.persistence.NoResultException e) { - return Optional.empty(); - } + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getResultList() + .stream() + .findFirst();apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)
46-52:Thread.sleep()대신 더 안정적인 시간 검증 방법 고려
Thread.sleep(1)은 간헐적으로 테스트 실패를 유발할 수 있습니다. 시스템 부하에 따라 1ms 내에 두 작업이 동일한 시각에 완료될 수 있습니다.
Clock주입 패턴이나isAfterOrEqualTo()같은 완화된 검증을 고려해 주세요.🔎 대안 제안
- Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 metrics.incrementLikeCount(); // assert assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); - assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + assertThat(metrics.getUpdatedAt()).isAfterOrEqualTo(initialUpdatedAt);apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)
104-106:Map.of()사용으로 간결화 가능단일 엔트리의 경우
Map.of()를 사용하면 더 간결하고 불변성을 보장합니다.🔎 제안
- // 정렬 기준 설정 (product_id 기준 오름차순) - Map<String, Sort.Direction> sorts = new HashMap<>(); - sorts.put("productId", Sort.Direction.ASC); + // 정렬 기준 설정 (product_id 기준 오름차순) + Map<String, Sort.Direction> sorts = Map.of("productId", Sort.Direction.ASC);apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)
187-188: Reader Bean에서 Processor 상태 설정은 결합도가 높음Reader bean 생성 시
productRankAggregationProcessor.setPeriod()를 호출하는 것은 암시적 의존성을 만듭니다. Step 리스너나 별도의 초기화 빈을 사용하는 것이 더 명시적입니다.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (3)
49-49: 완전한 클래스명(FQCN) 대신 import 문 사용을 권장합니다.
com.loopers.domain.rank.ProductRankRepository와com.loopers.domain.rank.ProductRank클래스가 여러 곳(라인 375-378, 380, 396, 399-400, 422)에서 완전한 클래스명으로 사용되고 있습니다. 가독성을 위해 상단에 import 문을 추가하는 것이 좋습니다.🔎 제안하는 수정 사항
파일 상단 import 영역에 추가:
import com.loopers.domain.rank.ProductRank; import com.loopers.domain.rank.ProductRankRepository;그 후 해당 클래스명을 간략하게 수정:
- private final com.loopers.domain.rank.ProductRankRepository productRankRepository; + private final ProductRankRepository productRankRepository;
480-487: PeriodType enum 중복 정의 검토.
RankingService.PeriodType과ProductRank.PeriodType두 개의 enum이 존재하며, 라인 375-378에서 수동으로 변환하고 있습니다. 이러한 구조는 향후 새로운 기간 타입 추가 시 동기화 누락 위험이 있습니다.다음 방안을 고려해 볼 수 있습니다:
- 도메인 enum 재사용:
ProductRank.PeriodType을 API 계층에서도 사용- 공통 enum 추출: 별도 공통 모듈에 enum 정의 후 양쪽에서 참조
현재 기능 동작에는 문제 없으므로 향후 리팩토링으로 고려하셔도 됩니다.
380-396: 페이지네이션을 DB 쿼리로 위임하는 것을 고려할 수 있습니다.현재 100건 전체를 조회한 후 메모리에서
subList로 페이징하고 있습니다. TOP 100 제한이 있어 성능상 큰 문제는 없지만,ProductRankRepository.findByPeriod에 offset/limit 파라미터를 추가하면 불필요한 데이터 전송을 줄일 수 있습니다.현재 구현도 동작에 문제없으므로 필요시 최적화로 검토하시면 됩니다.
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (42)
apps/commerce-api/build.gradle.ktsapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.javaapps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/main/resources/application.ymlapps/commerce-batch/build.gradle.ktsapps/commerce-batch/src/main/java/com/loopers/BatchApplication.javaapps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.javaapps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.javaapps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.javamodules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.javasettings.gradle.kts
💤 Files with no reviewable changes (2)
- apps/commerce-api/src/main/resources/application.yml
- apps/commerce-api/build.gradle.kts
🧰 Additional context used
🧠 Learnings (8)
📓 Common learnings
Learnt from: junoade
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 0
File: :0-0
Timestamp: 2025-12-02T08:12:06.383Z
Learning: ProductQueryService에서 상품 목록 조회 시 Redis 캐시를 적용했으며, 캐시 키는 brandId, sortType, pageNumber, pageSize의 조합으로 구성되고 TTL은 5분으로 설정되어 있다.
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.javaapps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.javaapps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.javaapps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.javaapps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
📚 Learning: 2025-12-19T09:30:12.459Z
Learnt from: HongChangMo
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 198
File: apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventListener.java:0-0
Timestamp: 2025-12-19T09:30:12.459Z
Learning: In the loopers-spring-java-template repository's commerce-api module, when entities are managed within a transactional context (e.g., Transactional methods), prefer relying on JPA dirty checking for automatic persistence rather than explicit save() calls. Both Payment and Order entities in PaymentEventListener use this pattern, with state changes automatically flushed on transaction commit.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.javaapps/commerce-batch/src/main/resources/application.yml
📚 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/java/com/loopers/domain/metrics/ProductMetrics.java
📚 Learning: 2025-12-19T03:23:51.674Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:51.674Z
Learning: Maintain the use of static initialization blocks in test fixtures for testcontainers configurations (e.g., MySQL, Redis, Kafka) to ensure consistency and predictable initialization across the loopers-spring-java-template project. Apply this pattern to all test-fixture Java files under src/testFixtures/java to preserve uniform lifecycle behavior, unless there's a compelling, well-justified reason to diverge.
Applied to files:
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
🧬 Code graph analysis (10)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java (2)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java (1)
Slf4j(25-44)apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
Component(30-72)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (3)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java (1)
Slf4j(30-86)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)
Slf4j(33-81)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)
Slf4j(65-256)
apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
Entity(35-165)
apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)
main(22-24)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (1)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)
ExtendWith(21-151)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
result(7-19)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (5)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)
Slf4j(27-73)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)
Slf4j(36-122)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java (1)
Slf4j(30-86)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java (1)
Slf4j(33-81)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)
Slf4j(36-169)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
Component(30-72)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java (1)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)
Slf4j(20-99)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java (4)
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java (1)
ExtendWith(23-133)apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java (1)
ExtendWith(21-151)apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java (1)
ExtendWith(27-250)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
result(7-19)
🔇 Additional comments (40)
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java (1)
45-49: 테이블 존재 확인 쿼리는 적절합니다.
information_schema.tables조회 시 파라미터화된 쿼리를 사용하여 SQL 인젝션을 방지하고 있으며,DATABASE()함수를 사용하여 현재 스키마 내에서만 검색하는 것이 올바릅니다. 배치 작업으로 인한 추가 테이블(mv_product_rank등)이 일부 테스트 컨텍스트에서만 존재할 수 있는 상황에서 이 방어적 접근은 합리적입니다.settings.gradle.kts (1)
5-5: 모듈 등록이 올바르게 수행되었습니다.새로운
commerce-batch모듈이 프로젝트 구조에 정확하게 추가되었습니다.apps/commerce-batch/src/main/resources/application.yml (2)
1-13: 배치 애플리케이션 기본 설정이 적절합니다.웹 서버 비활성화(
web-application-type: none)와 필요한 설정 파일 임포트가 올바르게 구성되어 있습니다.
14-18: 프로덕션 환경에서 스키마 자동 초기화 설정을 검토하세요.
initialize-schema: always설정이 모든 환경에 적용됩니다. 프로덕션 환경에서는 이 설정이 예기치 않은 스키마 재생성을 유발하거나 기존 배치 메타데이터를 손상시킬 수 있습니다.프로필별로 설정을 분리하여 local/test 환경에서만
always를 사용하고, dev/qa/prd 환경에서는never를 사용하도록 수정하는 것을 권장합니다.🔎 프로필별 설정 분리 예시
- batch: - jdbc: - initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 - job: - enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화 + batch: + job: + enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화 --- spring: config: activate: on-profile: local, test + batch: + jdbc: + initialize-schema: always --- spring: config: activate: on-profile: dev + batch: + jdbc: + initialize-schema: never --- spring: config: activate: on-profile: qa + batch: + jdbc: + initialize-schema: never --- spring: config: activate: on-profile: prd + batch: + jdbc: + initialize-schema: never⛔ Skipped due to learnings
Learnt from: minor7295 Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99 File: apps/commerce-api/src/main/resources/application.yml:26-30 Timestamp: 2025-11-21T03:38:07.494Z Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.apps/commerce-batch/build.gradle.kts (1)
1-21: 의존성 구성이 적절합니다.배치 모듈에 필요한 Spring Batch, QueryDSL, 테스트 픽스처 등의 의존성이 올바르게 설정되어 있습니다.
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
33-67: 도메인 저장소 메서드가 적절하게 설계되었습니다.
save,findByProductId,findByUpdatedAtBetween메서드가 배치 처리 요구사항을 잘 반영하고 있으며, Spring Data의Page/Pageable사용도 코드베이스의 아키텍처 결정에 부합합니다.Based on learnings, Spring Data의 Page/Pageable 타입 사용은 이 코드베이스에서 허용된 아키텍처 결정입니다.
apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java (1)
13-38: 인터페이스는 올바르게 설계되었습니다 - 모듈별 책임 분리가 적절합니다.검증 결과, 이 인터페이스(
apps/commerce-api)는 의도적으로 조회 메서드만 포함하도록 설계되었습니다. 저장 및 삭제 메서드(saveRanks,deleteByPeriod)는 배치 모듈(apps/commerce-batch)의 별도 인터페이스에만 정의되어 있으며, 실제로 배치 작업에서만 사용됩니다. 이는 DIP 위반이 아니라 모듈별 책임 분리의 올바른 예입니다:
- API 모듈: 읽기 전용 (findByPeriod, findByPeriodAndProductId)
- 배치 모듈: 읽기 + 쓰기 (모든 4개 메서드 포함)
AI 요약이 두 모듈의 인터페이스를 혼동했으나, 코드 설계 자체는 문제가 없습니다.
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
125-132: 버전 기반 업데이트 체크 로직 확인이벤트 버전 비교 로직이 올바르게 구현되어 있습니다. null 처리를 통한 하위 호환성도 좋습니다.
참고:
version필드가 JPA의@Version(낙관적 락)과 혼동될 수 있습니다. 이 필드는 이벤트 버전 관리용이므로 명확히 구분됩니다.apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java (1)
14-86: Pass-through 프로세서 테스트 커버리지Pass-through 로직을 위한 테스트 커버리지가 충분합니다. PR 목표에 명시된 대로 전체 Job 통합 테스트가 아닌 핵심 로직의 단위 테스트에 집중하는 전략과 일치합니다.
테스트가 명확하고 프로세서의 예상 동작을 검증합니다.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (2)
56-64: 기간 파라미터 기본값 처리
@RequestParam의defaultValue = "DAILY"와parsePeriodType의 DAILY 기본값이 중복되지만 방어적이고 안전합니다.현재 구현은 잘못된 period 값을 조용히 DAILY로 폴백합니다. 이는 사용자 친화적이지만, API 사용자가 오타를 발견하기 어려울 수 있습니다. 프로젝트의 에러 처리 방침에 따라 다음을 고려하세요:
- 현재 방식 유지: 관대한 처리로 사용자 경험 개선
- 또는 400 에러 반환: 명시적 피드백으로 API 계약 강화
현재 API 에러 처리 방침(관대한 기본값 vs 명시적 에러)을 확인하세요.
112-123: 기간 타입 파싱 로직 구현
parsePeriodType메서드가 잘 구현되었습니다:
- null/blank 처리
- 대소문자 구분 없는 파싱 (
toUpperCase())- 안전한 예외 처리
코드가 명확하고 견고합니다.
apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java (1)
25-32: 배치 애플리케이션 진입점 구현배치 애플리케이션이 올바르게 구성되었습니다:
@SpringBootApplication의scanBasePackages가 적절히 설정됨@EnableJpaRepositories와@EntityScan이 인프라 및 도메인 패키지를 올바르게 지정SpringApplication.exit패턴이 배치 작업 완료 후 적절한 종료 코드를 반환하도록 보장Javadoc의 실행 예시도 명확하고 유용합니다.
apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java (2)
28-38: 기간별 랭킹 조회 쿼리 구현JPQL 쿼리가 올바르게 구현되었습니다:
- 적절한 파라미터 바인딩
ORDER BY pr.rank ASC로 순위 정렬setMaxResults로 결과 제한PR 목표에 명시된 복합 인덱스(period_type, period_start_date, rank)가 있으면 이 쿼리의 성능이 최적화됩니다.
40-61: 특정 상품 랭킹 조회 구현개별 상품 랭킹 조회가 잘 구현되었습니다:
NoResultException예외를 적절히 처리Optional패턴을 올바르게 사용- 명확한 쿼리 로직
apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java (1)
1-120: LGTM! 테스트 구성이 잘 되어 있습니다.
ProductRankAggregationProcessor의 기간 설정 로직에 대한 테스트가 체계적으로 작성되었습니다. 주간/월간 기간 계산, 다양한 날짜 입력에 대한 경계 케이스, 그리고 여러 번 설정 시 상태 업데이트가 잘 검증되고 있습니다.apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java (1)
100-116: LGTM! 테스트 헬퍼 메서드가 잘 구현되었습니다.
createProductMetricsList헬퍼 메서드가 테스트 데이터 생성을 효과적으로 지원합니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java (1)
25-44: LGTM! 확장 포인트로서의 역할이 명확합니다.현재 pass-through 구현이지만, Javadoc에서 향후 집계/변환/필터링 로직 추가를 위한 확장 포인트임을 잘 설명하고 있습니다.
@Slf4j어노테이션이 선언되어 있지만 현재 사용되지 않습니다. 향후 로직 추가 시 사용될 것으로 보이므로 유지해도 무방합니다.apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java (1)
182-233: LGTM! PeriodType enum과 랭킹 범위 테스트가 잘 작성되었습니다.
PeriodTypeenum 검증과 TOP 100/1위 랭킹 경계 테스트가 적절하게 구현되었습니다.apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java (2)
92-129: ThreadLocal 정리 동작 테스트에 대한 참고 사항이 테스트는
ThreadLocal정리 동작을 검증하기 위해 내부 구현에 의존하고 있습니다. 주석에서 설명하듯이 101번째 처리가 실제 배치 동작과 다르지만,ThreadLocal정리가 올바르게 수행되는지 확인하는 목적입니다.실제 배치 실행 시에는 100개 이후 항목이 처리되지 않으므로, 이 테스트가 구현 변경 시 깨질 수 있음을 인지하시기 바랍니다. 주석이 이미 이 점을 잘 설명하고 있습니다.
255-261: LGTM! 테스트 헬퍼 메서드가 올바르게 구현되었습니다.
createProductRankScore헬퍼가 점수 계산 공식(가중치 0.3, 0.5, 0.2)을ProductRankScoreAggregationWriterTest의 공식과 일치하게 구현하고 있습니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java (1)
41-56: 현재 상태는 의도적인 설계입니다.
ProductMetricsItemWriter가 실제 데이터를 저장하지 않는 것은 확인되었습니다. 그러나ProductMetricsJobConfig의 Javadoc에서 "Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)"이라고 명시된 대로 이는 의도적인 설계입니다.데이터 유실 우려는 없습니다.
ProductRankScoreAggregationWriter는 별개의 Step에서ProductMetrics를 읽어ProductRankScore를 계산하여productRankScoreRepository.saveAll()로 실제 DB에 저장하므로, 역할 구분이 명확합니다:
ProductMetricsItemWriter: ProductMetrics 로깅 (현재 상태), 향후 Materialized View 저장 예정ProductRankScoreAggregationWriter: ProductMetrics를 ProductRankScore로 변환하여 실제 DB 저장 (이미 구현)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java (1)
27-53: 동시 Job 실행 시 스레드 안전성 검토가 필요합니다.이 클래스는 싱글톤
@Component이지만 가변 인스턴스 필드(periodType,periodStartDate)를 가지고 있습니다. 동일한 Job이 다른 파라미터로 동시에 실행될 경우 경쟁 상태가 발생할 수 있습니다.현재 구조에서는
productRankReader가@StepScope이지만 이 Processor는 싱글톤이므로, 동시 실행 시 한 Job의setPeriod()호출이 다른 Job의 기간 정보를 덮어쓸 수 있습니다.동시 배치 Job 실행이 예상되지 않는다면 현재 구현으로 충분하지만, 동시 실행이 필요하다면
@StepScope로 변경하거나ThreadLocal을 사용하는 것을 고려해주세요.apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
35-118: LGTM!엔티티 구조가 적절합니다. API 모듈에서는 조회 전용으로 사용되므로 생성자 없이
@NoArgsConstructor(access = AccessLevel.PROTECTED)만 있는 것이 의도된 설계입니다. 인덱스 전략도 기간별 랭킹 조회와 특정 상품 조회에 최적화되어 있습니다.apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java (3)
36-93: LGTM!집계 로직 테스트가 잘 작성되었습니다. 같은
product_id를 가진 메트릭들의 집계, 다른product_id처리, 그리고 결과 검증이 명확합니다.
95-159: LGTM!점수 가중치 계산 테스트와 기존 데이터 누적 테스트가 잘 작성되었습니다. 가중치 공식(
like * 0.3 + sales * 0.5 + view * 0.2)이 명확하게 검증됩니다.
161-249: LGTM!빈 Chunk 처리, 다중 product_id 처리, 새 데이터 생성 테스트가 edge case를 잘 커버합니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java (1)
97-109: 날짜 파싱 실패 시 오늘 날짜로 대체하는 동작을 재고해주세요.잘못된 날짜 파라미터가 전달되면 경고만 로깅하고 오늘 날짜로 진행합니다. 스케줄링된 배치 작업에서 이는 잘못된 데이터 처리로 이어질 수 있습니다.
의도적인 설계라면 현재 상태로 유지해도 되지만, 엄격한 파라미터 검증이 필요한 경우 예외를 던지는 것을 고려해주세요:
private LocalDate parseDate(String dateStr) { if (dateStr == null || dateStr.isEmpty()) { throw new IllegalArgumentException("날짜 파라미터가 필수입니다."); } try { return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); } catch (DateTimeParseException e) { throw new IllegalArgumentException("잘못된 날짜 형식: " + dateStr, e); } }apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java (1)
124-138: LGTM!생성자가 모든 필수 필드를 초기화하고 타임스탬프를 설정합니다. 배치 처리의 임시 테이블용 엔티티로 적절한 구조입니다.
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java (1)
7-58: LGTM!Repository 인터페이스가 잘 설계되었습니다. 기간별 랭킹 조회, 특정 상품 조회, 저장/삭제 메서드가 Materialized View 패턴에 적합하게 정의되어 있습니다. Javadoc도 명확합니다.
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java (1)
12-67: LGTM!인터페이스 설계가 명확하고, UPSERT 동작 및 용도가 Javadoc에 잘 문서화되어 있습니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java (1)
59-71:NoResultException처리 방식 적절예외 기반 Optional 처리가 정확하게 구현되어 있습니다.
apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java (1)
35-42: 인덱스 설계 적절기간별 랭킹 조회 및 특정 상품 랭킹 조회 쿼리 패턴에 맞는 복합 인덱스가 잘 설계되어 있습니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
30-72: LGTM!JPA 레포지토리에 대한 위임 패턴이 깔끔하게 구현되어 있습니다.
getJpaRepository()는 Spring Batch의RepositoryItemReaderAPI 요구사항을 충족하기 위한 적절한 접근 방식입니다.apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java (1)
162-215: shouldUpdate 로직 테스트 커버리지 우수이벤트 버전과 메트릭 버전 비교 로직에 대한 테스트가 잘 구성되어 있습니다. null 하위 호환성, 초기 버전 처리 등 다양한 엣지 케이스를 다루고 있습니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java (2)
74-109: Job 및 Step 구성이 적절합니다Chunk 크기 100, StepScope Reader 활용, 명확한 문서화 등 Spring Batch 모범 사례를 따르고 있습니다.
120-126: 문제 없음 - null 파라미터 처리가 이미 구현되어 있습니다
ProductMetricsItemReader.createReader()메서드 내부의parseDate()메서드(라인 97-109)에서 이미 null 및 빈 문자열에 대한 처리가 구현되어 있습니다. null인 경우 오늘 날짜를 반환하며, 파싱 실패 시에도 동일하게 처리됩니다. 이는ProductRankJobConfig에서 사용되는 패턴과 동일합니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java (1)
52-65: 주간 날짜 범위 계산 정확월요일부터 다음 주 월요일 00:00:00까지의 범위가 정확하게 계산되어 있습니다. exclusive end date 패턴을 올바르게 사용하고 있습니다.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java (1)
93-102: 2-Step Job 구조 적절Step 1에서 점수 집계 후 Step 2에서 랭킹 계산하는 구조가 명확합니다.
start().next()체인으로 순차 실행이 보장됩니다.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java (2)
79-87: LGTM!기간 타입에 따른 분기 처리가 명확하고, 기존 Redis 기반 일간 랭킹과 새로운 Materialized View 기반 주간/월간 랭킹을 깔끔하게 분리했습니다.
474-478: 배치 작업과의 점수 계산 로직은 이미 일관성을 유지하고 있습니다.API의
calculateScore메서드는 배치 작업의ProductRankScoreAggregationWriter에서 사용하는 것과 동일한 가중치(좋아요 0.3, 판매량 0.5, 조회수 0.2)를 적용하고 있습니다. 유일한 차이는 API에서 null 값을 처리하기 위해 삼항 연산자를 사용하는 것인데, 이는 Materialized View 조회 결과에 null 값이 올 수 있기 때문에 적절합니다.Likely an incorrect or invalid review comment.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
Outdated
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
Show resolved
Hide resolved
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
Outdated
Show resolved
Hide resolved
...tch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
Outdated
Show resolved
Hide resolved
...commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
Show resolved
Hide resolved
...h/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
Show resolved
Hide resolved
...erce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
Outdated
Show resolved
Hide resolved
...tch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java
Show resolved
Hide resolved
...ch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
Show resolved
Hide resolved
* 트랜젝션 어노테이션 추가 * 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리 * @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성 * 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지 * 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정 * n+1 쿼리 개선
eaff8f0 to
37a09a9
Compare
📌 Summary
Spring Batch를 활용하여
product_metrics테이블 기반으로 주간/월간 랭킹 시스템을 구현했습니다. 대량 데이터 집계의 정확성과 안정성을 위해 2-Step 구조로 집계와 랭킹을 분리하고, Materialized View에 TOP 100 랭킹을 저장하여 조회 성능을 최적화했습니다.주요 구현 내용:
product_metrics테이블을 읽어 Chunk-Oriented Processing으로 대량 데이터 집계mv_product_rank)에period_type으로 주간/월간 구분하여 TOP 100 저장period파라미터 추가하여 일간(Redis), 주간/월간(Materialized View) 랭킹 제공구현된 기능:
GET /api/v1/rankings?date=yyyyMMdd&period=WEEKLY&size=20&page=1: 주간/월간 랭킹 조회periodType=WEEKLY targetDate=20241215💬 Review Points
1. 2-Step 구조로 집계와 랭킹 분리: 전체 데이터 기반 정확한 TOP 100 선정
배경 및 설계 의도:
대량 데이터를 Chunk 단위로 처리할 때, 각 Chunk마다 TOP 100을 계산하면 전체 데이터를 기반으로 한 정확한 TOP 100을 선정할 수 없습니다. 예를 들어, 첫 번째 Chunk에서 점수가 높은 상품 100개를 선정했지만, 이후 Chunk에서 더 높은 점수를 가진 상품이 나타날 수 있어 결과가 부정확해집니다.
이 문제를 해결하기 위해 Step을 실패 격리와 재시작 단위로 사용하여 집계 계산과 랭킹 적재를 분리했습니다. 이렇게 분리하면:
구조:
관련 코드:
고민한 점 및 의사결정:
Step 분리 vs StepListener 사용
임시 테이블 도입
tmp_product_rank_score)을 도입했습니다.주간/월간 처리 방식
periodType)로 분기하여 별도 실행하는 방식을 선택했습니다.Chunk 단위 처리와 전체 데이터 집계
product_id가 여러 Chunk에 걸쳐 있을 경우 임시 테이블(tmp_product_rank_score)에 UPSERT 방식으로 누적했습니다.findAllByProductIdIn)하여 누적합니다.productRankScoreRepository.saveAll()로 저장하며, Repository 구현체에서entityManager.merge()를 사용하여 UPSERT 방식으로 저장합니다.Materialized View 저장 방식: delete+insert
saveRanks()메서드에서deleteByPeriod()호출 후entityManager.persist()로 저장합니다.saveRanks()가 delete+insert를 수행하므로 중복 저장 문제가 없습니다.2. Materialized View 설계: 하나의 테이블에 period_type으로 구분
배경 및 문제 상황:
요구사항에서는
mv_product_rank_weekly와mv_product_rank_monthly를 별도 테이블로 설계하라고 했습니다. 하지만 실제 구현에서는 하나의 테이블(mv_product_rank)에period_type컬럼으로 주간/월간을 구분하는 방식으로 구현했습니다.해결 방안:
논리적으로는 별도 테이블처럼 동작하지만, 물리적으로는 하나의 테이블에
period_type으로 구분하는 방식을 선택했습니다:mv_product_rank테이블에period_type(WEEKLY/MONTHLY) 컬럼으로 구분(period_type, period_start_date, rank)복합 인덱스로 기간별 랭킹 조회 최적화period_type과period_start_date로 필터링하여 조회이 방식의 장점:
관련 코드:
고민한 점:
period_type으로 구분하는 방식이 더 유연하고 관리하기 쉽다고 판단했습니다. 논리적으로는 별도 테이블처럼 동작하므로 요구사항의 의도는 충족한다고 봅니다.3. 배치 모듈 분리: API와 배치를 독립적인 애플리케이션으로 분리
배경 및 문제 상황:
API 요청 처리와 배치 집계는 실행 주기, 트랜잭션 성격, 장애 대응 방식이 다릅니다. API는 실시간 요청 처리에 최적화되어 있고, 배치는 대량 데이터 처리에 최적화되어 있습니다. 하나의 모듈에 두 가지를 모두 포함하면 설정, Job/Step 구성, 테스트 전략이 섞여 관리 복잡도가 증가합니다.
분리의 핵심 이유:
실행 주기의 차이
트랜잭션 성격의 차이
장애 대응 방식의 차이
독립적 실행, 재실행, 관측
해결 방안:
commerce-batch모듈을 별도로 분리하여 독립적인 애플리케이션으로 구성했습니다:BatchApplication을 통해 배치만 독립적으로 실행 가능application.yml에서 배치 전용 설정 관리 (웹 서버 비활성화, Job 자동 실행 비활성화)com.loopers.domain패키지의 도메인은 공유하되, Repository 구현은 모듈별로 분리구조:
관련 코드:
분리의 효과:
고민한 점:
4. 배치 테스트 전략: 비즈니스 로직 중심의 단위 테스트
배경 및 설계 의도:
멘토링 세션에서 배치 전체를 exec해서 잘 실행되는지를 확인하는 것보다 그 안에 있는 processor같은 의미있는 비즈니스 로직에 대한 테스트로 처리하는 게 낫다는 조언을 받았습니다. 따라서 배치 전체 실행 테스트 대신, 비즈니스 로직이 있는 컴포넌트에 대한 단위 테스트에 초점을 두었습니다:
테스트 예시:
고민한 점:
✅ Checklist
Spring Batch
Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다
periodType(WEEKLY/MONTHLY),targetDate(yyyyMMdd)apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.javaChunk Oriented Processing (Reader/Processor/Writer) 기반의 배치 처리를 구현했다
apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다
mv_product_rank(period_type으로 주간/월간 구분)delete + insert(TOP 100만 저장)apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.javaRanking API
GET /api/v1/rankings?date=yyyyMMdd&period=WEEKLY&size=20&page=1apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.javaapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java📎 References
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선
✏️ Tip: You can customize this high-level summary in your review settings.