Skip to content

[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 #410

Open
pable91 wants to merge 21 commits intoLoopers-dev-lab:pable91from
pable91:feature/round10/round10
Open

[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 #410
pable91 wants to merge 21 commits intoLoopers-dev-lab:pable91from
pable91:feature/round10/round10

Conversation

@pable91
Copy link
Copy Markdown

@pable91 pable91 commented Apr 17, 2026

✅ ReviewPoint

상황

배치(commerce-batch)가 다른 모듈의 테이블을 다뤄야 하는 상황이 두 번 있었고, Entity를 어디에 둘지 매번 달리 선택했습니다.

케이스 테이블 Entity의 "주인" 선택한 방식
A. product_metrics 읽기 이미 있던 테이블 commerce-streamer가 이미 가짐 JDBC로 읽기 (Entity 안 씀)
B. MV 테이블 쓰기/읽기 이번에 새로 만든 테이블 없음 commerce-batch / commerce-apiEntity를 각각 정의

제가 세운 기준: "이미 다른 모듈이 가진 Entity냐(A), 이번에 새로 만드는 Entity냐(B)"로 나눴습니다.

  • A는 다른 모듈 Entity를 끌어오면 모듈 간 의존 방향이 꼬여서 JDBC로 우회
  • B는 원래 주인이 없으니 각 모듈이 용도(쓰기/읽기)에 맞게 따로 정의

질문

  • Q1. 이 기준(이미 있던 Entity vs 새로 만드는 Entity)이 실무에서도 쓸 만한 판단인가요? 더 흔히 쓰는 기준이 있다면 궁금합니다.
  • Q2. B에서 "공유 모듈을 새로 만들기"도 후보였지만 Entity 2개뿐이라 과하다고 생각해서 접었습니다. 실무에서는 공유 모듈을 만들 만하다고 판단하는 기준(Entity 개수, 변경 빈도 등)이
    어떻게 되나요?

🔁 Flow Diagram

Batch Job 실행 흐름

sequenceDiagram
    participant Scheduler as CronScheduler
    participant Job as RankingWeeklyJob / MonthlyJob
    participant MV as MV 테이블

    Scheduler->>Job: run(targetDate, period)

    Note over Job, MV: Step 1 — 점수 계산 및 적재
    Job->>MV: UPSERT (product_id, score) × chunk 반복

    Note over Job, MV: Step 2 — 순위 확정
    Job->>MV: rank 재정렬 후 TOP 100 초과 삭제
Loading

Ranking API 조회 흐름

sequenceDiagram
    participant Client as Client
    participant API as Controller / Facade / Repo
    participant Storage as Redis or MV 테이블
    participant DB as Product / Brand DB

    Client->>API: GET /api/v1/rankings?date=&period=&size=&page=
    API->>Storage: period에 따라 분기 조회 (daily→Redis / weekly·monthly→MV)
    Storage-->>API: 랭킹 목록 (productId, score, rank)
    API->>DB: Product + Brand Bulk 조회
    DB-->>API: 상품명, 브랜드명
    API-->>Client: ApiResponse<List<RankingResponse>>
Loading

🗄️ 핵심 자료구조

product_metrics — 날짜별 증분 저장

변경 전: 상품별 전체 누적값만 저장 (날짜 기준 집계 불가)
product_id | like_count | view_count | sales_count

변경 후: 날짜 컬럼 추가, 하루 증분 저장
product_id | metrics_date | like_count | view_count | total_quantity
101        | 2026-04-07   | 10         | 80         | 3
101        | 2026-04-08   | 25         | 120        | 7
101        | 2026-04-13   | 30         | 95         | 5
  • UNIQUE 제약: product_id(product_id, metrics_date)
  • total_quantity 추가 — 일간과 동일한 점수 공식 유지

MV 테이블 — 배치 결과 스냅샷

mv_product_rank_weekly
product_id | score  | year_week | rank | created_at          | updated_at
101        | 2.341  | 20260414  | 1    | 2026-04-15 01:00:00 | 2026-04-15 01:00:00

mv_product_rank_monthly
product_id | score  | year_month | rank | created_at          | updated_at
101        | 9.872  | 202604     | 1    | 2026-04-15 01:00:00 | 2026-04-15 01:00:00
  • year_week: 해당 주 월요일 날짜 (yyyyMMdd)
  • year_month: 해당 월 (yyyyMM)
  • TOP 100만 유지 (Tasklet에서 정리)

점수 공식 — 일간/주간/월간 통일

score = 0.1 * view_count + 0.2 * like_count + 0.7 * log1p(total_quantity)

🧭 Context & Decision

1. 월간 집계 원천 선택 — 주간 MV 파생 vs 원본 독립 집계

고민: 이미 주간 집계 결과(mv_product_rank_weekly)가 있으므로, 이 데이터를 활용해서 월간 랭킹을 집계하는것도 생각해봤습니다 (저희팀 진수님이 질문남겨주신 덕분에 생각해봤습니다)

고려한 방식

# 방식 비고
주간 MV에서 파생 (주간 score 합산) 점수 합산 불가, 데이터 누락, 장애 전파 문제
product_metrics 원본에서 독립 집계 단일 원천, 재집계 단순, 모듈 간 의존 없음

결정: ② product_metrics(원본)에서 weekly, monthly가 각자 독립 집계합니다

① 주간 MV 파생을 선택하지 않은 이유

  1. 점수를 합산할 수 없음 — 점수 공식에 log1p가 포함되어 있어서, 주간 점수를 단순히 더해도 월간 점수가 나오지 않습니다
  2. 데이터 누락 위험 — 주간 MV 테이블은 TOP 100만 저장하고 나머지는 삭제합니다. 1주차에 101등이었던 상품은 주간 MV에서 지워지므로, 이후 판매량이 늘어 월간 기준으로는 상위권에 들 수 있어도 1주차 데이터가 없어 월간 집계 결과가 불완전해집니다. 원본(product_metrics)에서 직접 집계하면 이런 문제가 없습니다.
  3. 장애 전파 — 주간 배치가 실패하면 월간 배치도 함께 깨집니다

2. product_metrics Reader 구현 방식 선택 — JPA vs JDBC

고민: commerce-batch에서 product_metrics 테이블을 읽어야 합니다. 그런데 해당 테이블의 Entity(ProductMetricsEntity)는 commerce-streamer 모듈에 정의되어 있고, commerce-batchcommerce-streamer를 의존하지 않습니다. 즉, commerce-batch 빌드 시 ProductMetricsEntity.class가 클래스패스에 존재하지 않습니다.

고려한 방식

# 방식 비고
JPA (JPQL) ProductMetricsEntity.class 가 있어야 컴파일 가능
batch에 Entity 중복 정의 스키마 변경 시 두 곳 동기화 필요
JDBC 테이블명 문자열 참조, 클래스 불필요

결정: ③ JDBC 채택. 테이블명을 문자열로 참조하므로 클래스패스 문제 없이 DB 연결만으로 읽기 가능합니다

①② 를 선택하지 않은 이유

  1. 컴파일 불가 (①) — JPA는 Entity 클래스를 직접 참조(FROM ProductMetricsEntity)하므로, 클래스가 클래스패스에 없으면 빌드 자체가 실패합니다
  2. Entity 중복 정의 회피 (②) — batch에 Entity를 별도 정의하면 스키마 변경 시 두 곳을 동기화해야 하는 부담이 생깁니다

3. MV Entity 공유 방식 선택 — 공유 모듈 vs 각 모듈 별도 정의

고민: commerce-batchcommerce-api가 같은 MV 테이블을 사용하므로, Entity를 어디에 둘지 고민했습니다. 2번과 달리 MV 테이블은 기존 모듈이 소유한 Entity가 아니라 이번 작업에서 새로 만드는 결과물이므로, 어느 모듈이 "원래 주인"이라고 볼 수 없습니다. 또한 batch(쓰기)와 api(읽기)의 역할이 달라 Entity도 목적에 맞게 다르게 구성할 수 있습니다.

고려한 방식

# 방식 비고
modules/jpa에 추가 설정 모듈에 비즈니스 도메인 Entity가 섞임
신규 공유 모듈 생성 Entity 2개를 위한 모듈은 과함
각 모듈에 별도 정의 쓰기(batch) / 읽기(api) 목적에 맞는 분리

결정: ③ commerce-batch(쓰기용), commerce-api(읽기용) 각각에 Entity를 별도 정의. 두 모듈이 서로 의존하지 않는 구조를 유지합니다

①② 를 선택하지 않은 이유

  1. modules/jpa 오염 (①)modules/jpa는 JPA 설정과 공통 기반(BaseEntity)만 담당하는 모듈입니다. 비즈니스 도메인 Entity를 넣으면 역할이 섞입니다
  2. 과도한 모듈화 (②) — Entity 2개를 공유하기 위해 모듈을 새로 만드는 것은 복잡도 대비 효과가 없습니다

트레이드오프: 스키마 변경 시 두 곳 모두 수정이 필요합니다


4. RankingRepository 메서드 설계 — 통합 vs 기간별 분리

고민: period 파라미터를 추가해 getTopN(LocalDate date, String period, int size, int page) 형태로 통합하면 인터페이스가 단순해집니다.

고려한 방식

# 방식 비고
period 파라미터로 통합 인터페이스 단순, 구현체 내부에서 분기 필요
기간별 메서드 분리 (getTopN / getWeeklyTopN / getMonthlyTopN) 각 메서드가 저장소와 1:1 대응

결정: ② 기간별 메서드 분리

① 을 선택하지 않은 이유

  • 기존 RankingRepositoryImpl은 Redis 기반으로 daily만 처리합니다. period 파라미터를 받으면 Redis 구현체가 weekly/monthly 케이스를 처리해야 하는데, Redis는 daily 전용이라 구현체 내부 분기가 어색해집니다. 메서드를 분리하면 각 메서드가 저장소와 1:1 대응되어 구현체 책임이 명확해집니다

5. 배치 Job을 두 단계(Chunk + Tasklet)로 나눴습니다

고민: 점수 계산과 순위 확정을 하나의 Step에서 처리하는 방법도 있습니다. 하지만 순위를 매기려면 전체 데이터를 한 번에 봐야 합니다.

고려한 방식

# 방식 비고
단일 Step (Chunk만 사용) 청크 단위 처리라 전체 순위 파악 불가
Step1(Chunk) + Step2(Tasklet) 전체 적재 완료 후 순위 확정 가능

결정: ② Step1(Chunk)에서 score 적재, Step2(Tasklet)에서 순위 확정

① 을 선택하지 않은 이유

  • Chunk는 데이터를 일부씩 반복 처리합니다. 청크 경계 안에서는 전체 데이터를 알 수 없어 1등부터 순서대로 번호를 매길 수 없습니다. Tasklet을 별도 Step으로 분리하면 "전체 적재 완료 → 순위 확정"의 순서가 보장됩니다.

🔨 작업 순서

Phase 1 — product_metrics 구조 변경

목표: 기간별 집계를 위한 날짜 컬럼 추가. 기존 테이블은 누적값만 저장해 "이번 주에 발생한 이벤트"를 구분할 방법이 없었습니다.


1-1. ProductMetricsEntitymetricsDate, totalQuantity 필드 추가

왜 이런 작업을 진행했는가?:

  • 배치가 날짜 범위로 집계하려면 하루 단위로 저장된 데이터가 필요합니다. totalQuantity는 주문 수량 합산으로, 배치가 일간과 동일한 점수 공식(0.7 * log1p(qty))을 쓰기 위해
    추가했습니다. totalQuantity 없이 기존 salesCount(판매 건수)를 쓰면 점수가 왜곡됩니다. 예를 들어 A 상품이 1건 주문에 100개, B 상품이 100건 주문에 1개씩 팔렸다면
    salesCount는 동일하지만 실제 판매 수량은 A가 압도적으로 많습니다. 건수 기준으로 랭킹을 매기면 실제 인기를 반영하지 못합니다.

Before

@Table(
    name = "product_metrics",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_product_metrics_product_id", columnNames = {"product_id"})
    }
)
public class ProductMetricsEntity extends BaseEntity {
    private Long productId;
    private int likeCount;
    private int viewCount;
    private int salesCount;
    // metricsDate, totalQuantity 없음
}

After

@Table(
    name = "product_metrics",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_product_metrics_product_id_date", columnNames = {"product_id", "metrics_date"})
    }
)
public class ProductMetricsEntity extends BaseEntity {
    private Long productId;

    @Column(name = "metrics_date", nullable = false, updatable = false)
    private LocalDate metricsDate;      // 추가: 날짜별 증분 저장 기준

    private int likeCount;
    private int viewCount;
    private int salesCount;

    @Column(name = "total_quantity", nullable = false)
    private int totalQuantity;          // 추가: 주문 수량 합산
}

1-2. ProductMetricsJpaRepositorymetrics_date 조건 추가 및 메서드 통합

왜 이런 작업을 진행했는가?:

  1. metrics_date 조건 추가 — UNIQUE 제약이 product_id(product_id, metrics_date)로 변경됐습니다. 날짜 조건 없이 product_id만으로 조회하면 여러 날짜 행 중 어떤 행을 갱신할지 특정할 수 없습니다.
  2. upsertIfAbsent 추가 (버그 수정) — increment 쿼리는 행이 이미 존재할 때만 동작합니다. 해당 날짜 행이 없는 상태에서 incrementViewCount를 호출하면 에러 없이 0건 업데이트로 조용히 실패하여 데이터가 유실됩니다.
  3. incrementSalesAndQuantity 통합 — 기존에는 판매건수(salesCount)만 증가시켰습니다. totalQuantity가 추가되면서 두 값을 함께 갱신해야 하는데, 쿼리를 두 번 날리는 대신 하나로 통합해 DB 왕복을 줄였습니다.

Before

@Modifying
@Query("UPDATE ProductMetricsEntity m SET m.viewCount = m.viewCount + 1 WHERE m.productId = :productId")
void incrementViewCount(@Param("productId") Long productId);

@Modifying
@Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1 WHERE m.productId = :productId")
void incrementSalesCount(@Param("productId") Long productId);
// upsertIfAbsent 없음
// incrementSalesAndQuantity 없음

After

@Transactional
@Modifying
@Query(value = """
    INSERT INTO product_metrics (product_id, metrics_date, like_count, view_count, sales_count, total_quantity, created_at, updated_at)
    VALUES (:productId, :metricsDate, 0, 0, 0, 0, NOW(), NOW())
    ON DUPLICATE KEY UPDATE product_id = product_id
    """, nativeQuery = true)
void upsertIfAbsent(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate);
// 이유: 이벤트 처리 전 해당 날짜 행이 없으면 INSERT, 있으면 no-op — increment 누락 방지

@Modifying
@Query("UPDATE ProductMetricsEntity m SET m.viewCount = m.viewCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate")
void incrementViewCount(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate);

@Modifying
@Query("UPDATE ProductMetricsEntity m SET m.salesCount = m.salesCount + 1, m.totalQuantity = m.totalQuantity + :quantity WHERE m.productId = :productId AND m.metricsDate = :metricsDate")
void incrementSalesAndQuantity(@Param("productId") Long productId, @Param("metricsDate") LocalDate metricsDate, @Param("quantity") int quantity);
// 이유: 판매건수 + 수량을 쿼리 1번으로 처리

Phase 2 — MV 테이블 생성

목표: 배치 결과를 저장할 MV 테이블 정의. MySQL은 MV 기능이 없으므로 JPA Entity로 테이블을 생성하고 배치가 직접 적재합니다.


2-1. MvProductRankWeeklyEntity (신규 — commerce-batch)

왜 이런 작업을 진행했는가?: commerce-batch가 집계 결과를 적재하는 쪽이므로 쓰기에 필요한 생성자, update(), @PrePersist/@PreUpdate를 포함했습니다. BaseEntity를 상속하지 않는 이유는 soft delete / audit 기능이 랭킹 스냅샷 테이블에 불필요하기 때문입니다.

@Entity
@Table(name = "mv_product_rank_weekly")
public class MvProductRankWeeklyEntity {

    @Id
    @Column(name = "product_id")
    private Long productId;     

    private double score;
    private String yearWeek;        
    private int productRank;        

    // @PrePersist / @PreUpdate 로 created_at, updated_at 직접 관리
    // update(score, yearWeek, productRank) — Writer에서 upsert 시 사용
}

2-2. MvProductRankWeeklyJpaRepository (신규 — commerce-batch)

왜 이런 작업을 진행했는가?: 순위 확정은 Tasklet으로 처리합니다. Chunk는 데이터를 일부씩 반복 처리하기 때문에 전체 순서를 알 수 없어 순위를 매길 수 없습니다. Tasklet은 execute() 한 번에 전체 목록을 불러와 1등부터 순서대로 번호를 할당할 수 있습니다.

이를 위해 두 메서드가 필요합니다.

  1. findAllByYearWeekOrderByScoreDesc — Tasklet이 전체 목록을 score 내림차순으로 한 번에 가져와 순위를 순서대로 할당합니다
  2. deleteAllByYearWeekNot — MV 테이블에는 현재 주 데이터만 유지합니다. 새 주 배치가 끝나면 이전 주 데이터는 불필요하므로 삭제합니다
public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyEntity, Long> {
    List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByScoreDesc(String yearWeek);
    void deleteAllByYearWeekNot(String yearWeek);   // 이전 주 데이터 정리
}

2-3. MvProductRankMonthlyEntity / MvProductRankMonthlyJpaRepository (신규 — commerce-batch)

weekly와 동일한 구조입니다.


Phase 3 — Spring Batch Job 구현

목표: product_metrics를 읽어 MV 테이블에 점수와 순위를 적재하는 Job 구성. 구현 순서는 의존 방향(DTO → Reader → Processor → Writer → Tasklet → JobConfig)으로 진행했습니다.

컴포넌트 역할
ProductMetricsItemReader product_metrics를 JDBC로 날짜 범위 집계
RankingItemProcessor 집계 수치 → score 변환
WeeklyMvRankingItemWriter score를 MV 테이블에 upsert (rank = 0 임시)
WeeklyRankAssignTasklet 전체 정렬 후 rank 확정, TOP 100 초과 삭제
RankingWeeklyJobConfig Step1(Chunk) → Step2(Tasklet) 순서 조립

3-1. DTO 정의 (신규)

왜 이런 작업을 진행했는가?: Spring Batch의 Chunk 파이프라인은 Reader → Processor → Writer 사이에 타입 안전한 데이터 전달이 필요합니다. 각 경계마다 목적에 맞는 DTO를 분리해 의존성을 명확히 했습니다.

// ProductMetricsAggregatedDto — Reader 출력
// product_metrics에서 SUM 집계된 결과를 담는다
public class ProductMetricsAggregatedDto {
    private final Long productId;
    private final long totalViewCount;
    private final long totalLikeCount;
    private final long totalQuantity;
}

// RankedProductDto — Processor 출력
// score 계산 완료된 상품 정보를 담는다
public class RankedProductDto {
    private final Long productId;
    private final double score;
}

3-2. ProductMetricsItemReader (신규)

왜 이런 작업을 진행했는가?: Context & Decision 2번에서 결정한 대로 JDBC를 채택했습니다. @StepScope로 선언해 Job 파라미터(targetDate, period)를 런타임에 주입받고, resolveStartDate/resolveEndDate로 기간에 맞는 날짜 범위를 계산합니다.

@StepScope
@Bean("productMetricsItemReader")
public JdbcPagingItemReader<ProductMetricsAggregatedDto> reader(
        DataSource dataSource,
        @Value("#{jobParameters['targetDate']}") String targetDate,
        @Value("#{jobParameters['period']}") String period) {

    LocalDate startDate = resolveStartDate(target, period);   // weekly: 월요일, monthly: 1일
    LocalDate endDate   = resolveEndDate(target, period);     // weekly: 일요일, monthly: 말일

    return new JdbcPagingItemReaderBuilder<ProductMetricsAggregatedDto>()
            .selectClause("product_id, SUM(view_count), SUM(like_count), SUM(total_quantity)")
            .fromClause("product_metrics")
            .whereClause("metrics_date BETWEEN :startDate AND :endDate")
            .groupClause("product_id")
            .parameterValues(Map.of("startDate", Date.valueOf(startDate), "endDate", Date.valueOf(endDate)))
            // ...
            .build();
}

3-3. RankingItemProcessor (신규)

왜 이런 작업을 진행했는가?: Reader가 반환한 집계 수치를 점수로 변환하는 단일 책임 컴포넌트입니다. 일간 랭킹(ProductMetricsProcessor)과 동일한 공식을 사용해 기간 간 일관성을 유지합니다.

@StepScope
@Component
public class RankingItemProcessor implements ItemProcessor<ProductMetricsAggregatedDto, RankedProductDto> {

    @Override
    public RankedProductDto process(ProductMetricsAggregatedDto dto) {
        double score = 0.1 * dto.getTotalViewCount()
                + 0.2 * dto.getTotalLikeCount()
                + 0.7 * Math.log1p(dto.getTotalQuantity());
        return new RankedProductDto(dto.getProductId(), score);
    }
}

3-4. WeeklyMvRankingItemWriter (신규)

왜 이런 작업을 진행했는가?: chunk 단위로 수신한 RankedProductDto를 MV 테이블에 upsert합니다. findById로 기존 행이 있으면 update(), 없으면 save()로 처리해 배치 재실행 시에도 멱등하게 동작합니다. productRank는 이 시점에서 전체 데이터가 아직 적재 중이라 순위를 알 수 없으므로 0으로 임시 저장하고, 모든 점수 적재가 끝난 뒤 Tasklet에서 최종 확정합니다.

@Override
public void write(Chunk<? extends RankedProductDto> chunk) {
    String yearWeek = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE)
            .with(DayOfWeek.MONDAY)
            .format(DateTimeFormatter.BASIC_ISO_DATE);

    for (RankedProductDto item : chunk.getItems()) {
        weeklyRepository.findById(item.getProductId())
                .ifPresentOrElse(
                        existing -> existing.update(item.getScore(), yearWeek, 0),   // 기존 행 갱신
                        () -> weeklyRepository.save(new MvProductRankWeeklyEntity(item.getProductId(), item.getScore(), yearWeek, 0))
                );
    }
}

3-5. WeeklyRankAssignTasklet (신규)

왜 이런 작업을 진행했는가?: Step1에서 0으로 임시 저장된 productRank를 최종 확정합니다. 이전 주 데이터 정리 → 전체 목록 score 내림차순 조회 → 순위 할당 → TOP 100 초과 삭제 순서로 실행합니다.

@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    String yearWeek = /* targetDate 기반 계산 */;

    weeklyRepository.deleteAllByYearWeekNot(yearWeek);   // 이전 주 데이터 정리

    List<MvProductRankWeeklyEntity> ranked = weeklyRepository.findAllByYearWeekOrderByScoreDesc(yearWeek);

    for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) {
        ranked.get(i).update(ranked.get(i).getScore(), yearWeek, i + 1);   // rank 확정
    }

    if (ranked.size() > TOP_RANK_LIMIT) {
        weeklyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size()));   // TOP 100 초과 삭제
    }

    return RepeatStatus.FINISHED;
}

3-6. RankingWeeklyJobConfig@StepScope 빈 주입 방식 수정

왜 이런 작업을 진행했는가? (버그수정): 초기 구현에서 JobConfigProductMetricsItemReader 클래스를 주입받아 메서드를 직접 호출했습니다.

Before (버그)

private final ProductMetricsItemReader productMetricsItemReader;  // Configuration 클래스 주입

.reader(productMetricsItemReader.reader(null, null, null))
// 문제: .reader() 메서드를 null 파라미터로 직접 호출
//       → Spring 프록시를 우회해 @StepScope가 적용되지 않음
//       → @Value("#{jobParameters[...]}") 주입 안 됨
//       → 런타임 NullPointerException

After (수정)

private final JdbcPagingItemReader<ProductMetricsAggregatedDto> productMetricsItemReader;  // 빈 직접 주입

.reader(productMetricsItemReader)
// 이유: Spring이 @StepScope 프록시 빈을 관리하므로
//       Step 실행 시점에 jobParameters가 정상 주입됨

Phase 4 — Ranking API 확장

목표: 기존 일간 랭킹 API에 period 파라미터를 추가해 주간/월간 MV 조회를 지원합니다. 하위 호환(기본값 daily)을 유지하면서 최소한의 변경으로 확장했습니다.


4-1. MvProductRankWeeklyEntity (신규 — commerce-api)

왜 이런 작업을 진행했는가?:

  • commerce-apicommerce-batch를 의존하지 않으므로 batch의 Entity를 그대로 쓸 수 없습니다. 읽기 전용이므로 update(), 삭제 메서드 없이 getter와
    @PrePersist만 포함했습니다.
// commerce-api 전용 — 읽기 목적, 구조는 batch와 동일
@Entity
@Table(name = "mv_product_rank_weekly")
public class MvProductRankWeeklyEntity {
    @Id private Long productId;
    private double score;
    private String yearWeek;
    private int productRank;
    // update(), 삭제 메서드 없음 — 읽기 전용
}

4-2. MvProductRankWeeklyJpaRepository (신규 — commerce-api)

왜 이런 작업을 진행했는가?: batch와 api는 같은 테이블을 사용하지만 용도가 달라 Repository를 각각 정의했습니다.

  • 정렬 기준이 다릅니다 — batch는 Tasklet이 순위를 매기기 위해 score 내림차순으로 전체를 가져옵니다. api는 이미 Tasklet이 순위를 확정해놨으므로 productRank 오름차순으로 페이징하면 됩니다.
  • 하나로 합치면 책임이 섞입니다 — batch의 deleteAllByYearWeekNot(쓰기)이 api에 노출되고, api의 Pageable 메서드가 batch에 노출됩니다.
// batch Repository와 메서드 시그니처가 다름
// batch: findAllByYearWeekOrderByScoreDesc(String yearWeek)         — 전체 반환
// api  : findAllByYearWeekOrderByProductRankAsc(String, Pageable)   — 페이징
public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyEntity, Long> {
    List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable);
}

4-3. RankingRepository — 인터페이스 확장

왜 이런 작업을 진행했는가?:

  • getTopNperiod 파라미터를 통합하면 Redis 구현체가 weekly/monthly 케이스를 처리해야 해 구현체 책임이 섞입니다. 저장소(Redis / MV 테이블)와 1:1로 대응되는 메서드로 분리했습니다.

Before

public interface RankingRepository {
    List<RankedProduct> getTopN(LocalDate date, int size, int page);
    Long getRank(Long productId, LocalDate date);
    void carryOver(LocalDate from, LocalDate to, double ratio);
}

After

public interface RankingRepository {
    List<RankedProduct> getTopN(LocalDate date, int size, int page);       // daily — Redis
    List<RankedProduct> getWeeklyTopN(LocalDate date, int size, int page);  // weekly — MV 테이블
    List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page); // monthly — MV 테이블
    Long getRank(Long productId, LocalDate date);
    void carryOver(LocalDate from, LocalDate to, double ratio);
}

4-4. RankingRepositoryImpl — weekly/monthly 구현 추가

왜 이런 작업을 진행했는가?:

  • 배치 Writer(WeeklyMvRankingItemWriter)가 yearWeek 키를 해당 주 월요일 날짜(yyyyMMdd)로 저장합니다. 조회 시도 동일하게 date.with (DayOfWeek.MONDAY)로 변환해야 키가 일치합니다. 이렇게 하면 클라이언트가 주중 어느 날짜를 넘겨도 항상 같은 주의 랭킹을 조회할 수 있습니다.

Before

public RankingRepositoryImpl(
        RedisTemplate<String, String> redisTemplate,
        @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate
) { ... }
// getWeeklyTopN, getMonthlyTopN 없음

After

public RankingRepositoryImpl(
        RedisTemplate<String, String> redisTemplate,
        @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate,
        MvProductRankWeeklyJpaRepository weeklyRepository,    // 추가
        MvProductRankMonthlyJpaRepository monthlyRepository   // 추가
) { ... }

@Override
public List<RankedProduct> getWeeklyTopN(LocalDate date, int size, int page) {
    String yearWeek = date.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE);
    // 이유: 어느 날짜를 넘겨도 해당 주 월요일로 정규화 → 주 내 어느 날짜든 같은 랭킹 조회
    return weeklyRepository.findAllByYearWeekOrderByProductRankAsc(yearWeek, PageRequest.of(page, size))
            .stream()
            .map(e -> new RankedProduct(e.getProductId(), e.getScore()))
            .toList();
}

@Override
public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
    String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
    return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
            .stream()
            .map(e -> new RankedProduct(e.getProductId(), e.getScore()))
            .toList();
}

4-5. RankingFacade — period 분기 추가

왜 이런 작업을 진행했는가?:

  • period에 따라 다른 저장소를 사용하는 분기는 응용 레이어(Facade)에서 담당합니다. Controller가 저장소를 알거나 Repository가 period를 알 필요 없이, Facade가 한
    곳에서 책임집니다.

Before

public List<RankingInfo> getRankings(LocalDate date, int size, int page) {
    List<RankedProduct> rankedProducts = rankingRepository.getTopN(date, size, page);
    // ...
}

After

public List<RankingInfo> getRankings(LocalDate date, String period, int size, int page) {
    List<RankedProduct> rankedProducts = switch (period) {
        case "weekly"  -> rankingRepository.getWeeklyTopN(date, size, page);
        case "monthly" -> rankingRepository.getMonthlyTopN(date, size, page);
        default        -> rankingRepository.getTopN(date, size, page);   // "daily" 포함 기본값
    };
}

4-6. RankingV1Controllerperiod 파라미터 추가

왜 이런 작업을 진행했는가?: defaultValue = "daily"를 설정해 기존 클라이언트가 period 없이 호출해도 그대로 동작합니다. 하위 호환 유지가 목적입니다.

Before

public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
    @RequestParam String date,
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size
) {
    List<RankingInfo> rankings = rankingFacade.getRankings(localDate, size, page);
    // ...
}

After

public ApiResponse<List<RankingV1Dto.RankingResponse>> getRankings(
    @RequestParam String date,
    @RequestParam(defaultValue = "daily") String period,   // 추가: 기본값 "daily"로 하위 호환
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size
) {
    List<RankingInfo> rankings = rankingFacade.getRankings(localDate, period, size, page);
    // ...
}

✅ CheckList

🏗️ Batch Infrastructure

  • product_metrics 테이블에 metrics_date, total_quantity 컬럼이 추가되었다
  • UNIQUE 제약이 product_id(product_id, metrics_date)로 변경되었다
  • MV 테이블(mv_product_rank_weekly, mv_product_rank_monthly)이 생성된다
  • MV 테이블에 product_id 기준 TOP 100만 유지된다

⚙️ Batch Job

  • targetDate, period Job 파라미터로 날짜 범위가 올바르게 계산된다 (weekly: 월일, monthly: 1일말일)
  • 점수 공식 0.1*view + 0.2*like + 0.7*log1p(qty)가 일간 랭킹과 동일하게 적용된다
  • Step1(점수 적재) → Step2(순위 확정)가 순서대로 실행된다
  • @StepScope 빈이 JobConfig에서 프록시를 통해 올바르게 주입된다

📈 Ranking API

  • period=daily일 때 기존 Redis ZSET 조회가 동작한다 (하위 호환)
  • period=weekly일 때 mv_product_rank_weekly에서 조회된다
  • period=monthly일 때 mv_product_rank_monthly에서 조회된다
  • period 파라미터 미입력 시 daily가 기본값으로 동작한다
  • 랭킹 응답에 상품명, 브랜드명이 Bulk Aggregation되어 반환된다

변경 목적

기존 일일 단위 랭킹 시스템을 주간(weekly), 월간(monthly) 단위로 확장하면서, 상품 메트릭 데이터에 날짜 차원을 추가하여 더 정교한 시계열 집계가 가능하도록 개선합니다.

핵심 변경점

  1. 메트릭 데이터 구조 개선: ProductMetrics에 metricsDate(LocalDate)와 totalQuantity 필드 추가, DB 제약조건을 product_id 단독 unique에서 (product_id, metrics_date) composite unique로 변경하여 일별 집계 지원
  2. Materialized View 도입: mv_product_rank_weekly, mv_product_rank_monthly 테이블 추가로 주간/월간 랭킹 데이터 사전 계산 및 조회 최적화
  3. 배치 작업 구현: RankingWeeklyJobConfig, RankingMonthlyJobConfig로 주간/월간 랭킹 계산 자동화 (Reader→Processor→Writer 및 Rank Assign Tasklet 패턴)
  4. API 확장: RankingFacade, RankingV1Controller에 period 파라미터(daily/weekly/monthly) 추가로 다중 단위 조회 지원

리스크/주의사항

  • 스키마 마이그레이션 필수: 기존 ProductMetrics 테이블의 제약조건 및 필드 변경으로 인한 데이터 마이그레이션 SQL이 필요하나 PR에 포함되지 않음. 기존 product_id만의 유니크 제약을 새 composite 제약으로 어떻게 전환할지 확인 필요
  • 배치 스케줄 미구성: 배치 작업이 @ConditionalOnProperty로 조건부 활성화되지만, 실제 job.name 파라미터 설정 및 스케줄(cron)이 명시되지 않음. 누가 언제 어떤 job을 실행하는지 확인 필요
  • 테스트 커버리지 부족: E2E 테스트 2개와 단위 테스트 1개만 있고, 배치 작업 자체에 대한 통합 테스트가 없음 (ProductMetricsItemReader, RankingItemProcessor, 각 Writer/Tasklet 검증 필요)

테스트/검증 방법

  1. RankingV1ApiE2ETest 실행으로 daily/weekly/monthly 조회 기능 검증
  2. ProductMetricsProcessorTest 실행으로 메트릭 계산 로직 검증
  3. 배치 작업 통합 테스트 추가: job.name=rankingWeeklyJob / rankingMonthlyJob으로 실행하여 MV 데이터 적재 및 rank 할당 검증
  4. 마이그레이션 스크립트 작성 후 기존 데이터와의 일관성 검증

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

기간별(일간/주간/월간) 랭킹 조회를 지원하기 위해 API 파라미터 추가, 저장소 인터페이스 확장, 주간/월간 전용 JPA 엔티티와 저장소 구현, 배치 작업 추가, 메트릭 수집에 날짜 차원을 도입하는 변경이다.

Changes

Cohort / File(s) Summary
API 계층
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
getRankings 엔드포인트에 period 쿼리 파라미터 추가(기본값: "daily"). 파라미터를 파사드 호출 시 전달.
비즈니스 로직 및 저장소 인터페이스
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
getRankings의 메서드 시그니처에 period 파라미터 추가. 저장소 인터페이스에 getWeeklyTopN(...)getMonthlyTopN(...) 메서드 선언.
저장소 구현
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
주간/월간 조회 메서드 구현. 주간은 yearWeek 형식(BASIC_ISO_DATE), 월간은 yearMonth 형식(yyyyMM)으로 날짜 변환 후 JPA 저장소 호출.
주간 랭킹 JPA 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
mv_product_rank_weekly 테이블 매핑 엔티티 및 Spring Data JPA 저장소. 페이지네이션 지원 조회 메서드 구현.
월간 랭킹 JPA 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
mv_product_rank_monthly 테이블 매핑 엔티티 및 Spring Data JPA 저장소. 페이지네이션 지원 조회 메서드 구현.
주간 랭킹 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java
주간 스코어 계산 및 랭크 할당 배치 잡 구성. 점수 계산 스텝과 랭크 할당 태스크렛 2단계 구성.
월간 랭킹 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java
월간 스코어 계산 및 랭크 할당 배치 잡 구성. 점수 계산 스텝과 랭크 할당 태스크렛 2단계 구성.
배치 공통 컴포넌트
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java
상품 메트릭 JDBC 조회, 랭킹 스코어 계산, DTO 정의. 스코어 계산 가중식: viewCount + likeCount + log1p(quantity).
배치 계층 엔티티 및 저장소
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
배치에서 사용하는 주간/월간 랭킹 엔티티. findAllByYearX...OrderByScoreDesc(...), deleteAllByYearXNot(...) 메서드 선언.
메트릭 데이터 모델
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
메트릭 엔티티에 metricsDatetotalQuantity 필드 추가. 복합 유니크 제약 {product_id, metrics_date} 도입. 저장소 메서드에 날짜 파라미터 추가 및 upsert 로직 구현.
메트릭 수집 프로세서
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java
이벤트 처리 시 metricsDate 추출(occurredAt.toLocalDate()) 및 모든 메트릭 갱신 메서드에 날짜 파라미터 전달.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as RankingV1Controller
    participant Facade as RankingFacade
    participant Repository as RankingRepository
    participant JpaRepo as JPA Repository<br/>(Weekly/Monthly)
    participant Database as Database

    Client->>Controller: GET /rankings?date=YYYY-MM-DD&period=weekly
    Controller->>Controller: Parse date to LocalDate
    Controller->>Facade: getRankings(localDate, "weekly", size, page)
    
    alt period == "weekly"
        Facade->>Repository: getWeeklyTopN(localDate, size, page)
    else period == "monthly"
        Facade->>Repository: getMonthlyTopN(localDate, size, page)
    else
        Facade->>Repository: getTopN(localDate, size, page)
    end
    
    Repository->>Repository: Derive yearWeek/yearMonth
    Repository->>JpaRepo: findAllByYearX...OrderByProductRankAsc(yearX, Pageable)
    JpaRepo->>Database: SELECT * FROM mv_product_rank_weekly/monthly
    Database-->>JpaRepo: RankedProducts
    JpaRepo-->>Repository: RankedProducts
    Repository-->>Facade: List<RankedProduct>
    
    Facade->>Facade: Fetch Product & Brand details
    Facade->>Facade: Build RankingInfo entries
    Facade-->>Controller: List<RankingInfo>
    Controller-->>Client: HTTP 200 with rankings
Loading
sequenceDiagram
    participant Batch as Spring Batch
    participant Reader as ProductMetricsItemReader
    participant Processor as RankingItemProcessor
    participant Writer as Weekly/MonthlyMvRankingItemWriter
    participant Database as Database

    Batch->>Batch: JobParameters: targetDate, period
    Batch->>Reader: read()
    Reader->>Reader: Derive startDate/endDate from period
    Reader->>Database: SELECT SUM(metrics) FROM product_metrics WHERE date BETWEEN startDate AND endDate
    Database-->>Reader: ProductMetricsAggregatedDto items (paginated)
    Reader-->>Batch: Chunk of ProductMetricsAggregatedDto

    loop Per item in chunk
        Batch->>Processor: process(ProductMetricsAggregatedDto)
        Processor->>Processor: Calculate score = viewCount + likeCount + log1p(quantity)
        Processor-->>Batch: RankedProductDto
    end

    Batch->>Writer: write(Chunk<RankedProductDto>)
    Writer->>Writer: Derive yearWeek/yearMonth from targetDate
    Writer->>Database: Find existing MvProductRankWeekly/MonthlyEntity by productId
    Writer->>Database: INSERT or UPDATE entity with score, yearX, rank=0
    Database-->>Writer: Persisted

    Batch->>Batch: Next step: RankAssignTasklet
    Batch->>Database: Fetch all entities for yearX, ordered by score DESC
    Batch->>Database: Assign rank (i+1) to top 100
    Batch->>Database: DELETE entities beyond rank 100
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명이 상세한 기술 문서 형식이나, 제공된 템플릿의 필수 섹션이 채워지지 않아 검증 어려움. 다음 섹션을 구체적으로 작성하시오: 1) Summary의 배경/목표/결과를 3~5줄로 정리, 2) Context & Decision의 '현재 동작', '문제', '성공 기준' 명시, 3) Design Overview의 '영향 받는 모듈', '신규 추가', '제거/대체' 항목 구체화
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 Spring Batch 기반 주간/월간 랭킹 시스템 구현이라는 주요 변경을 명확히 요약하고 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pable91 pable91 changed the title Feature/round10/round10 [volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 Apr 17, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

46-58: ⚠️ Potential issue | 🟠 Major

랭킹에 포함된 상품이 삭제/숨김 처리된 경우 NPE 위험이 있다.

productMap.get(rp.productId())null을 반환하면 49번째 라인 product.getRefBrandId()에서 NPE가 발생하여 API 전체 응답이 500으로 실패한다. 랭킹 MV는 배치로 생성되고 그 사이에 상품이 삭제/숨김될 수 있어 근접 불일치(near-cache skew)가 현실적인 운영 시나리오다. product == null인 엔트리는 결과에서 스킵하고, 스킵 건수는 로그/메트릭으로 관측 가능하게 남기길 권장한다. 브랜드도 동일하게 방어적 처리가 필요하다.

추가 테스트: (1) productService.getByIds가 일부만 반환하는 경우 응답 크기/순위가 올바른지, (2) 브랜드 조회 실패 시 응답이 정상 반환되는지.

🛡️ 제안 수정
         for (int i = 0; i < rankedProducts.size(); i++) {
             RankedProduct rp = rankedProducts.get(i);
             Product product = productMap.get(rp.productId());
+            if (product == null) {
+                log.warn("랭킹에 존재하나 상품 조회 실패. productId={}", rp.productId());
+                continue;
+            }
             Brand brand = brandMap.get(product.getRefBrandId());
+            if (brand == null) {
+                log.warn("브랜드 조회 실패. productId={}, brandId={}", rp.productId(), product.getRefBrandId());
+                continue;
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 46 - 58, The loop that builds RankingInfo may NPE when
productMap.get(rp.productId()) or brandMap.get(...) returns null; update the
logic in RankingFacade where rankedProducts is iterated (the block creating new
RankingInfo) to skip entries whose product is null (and also handle null brand
defensively by using a fallback name like "Unknown" or empty string), increment
a skip counter/metric for monitoring, and emit a debug/warn log mentioning
rp.productId() and reason skipped; keep the rest of ranking order intact (do not
abort on missing entries) so RankingInfo list length and baseRank semantics
remain correct.
🟡 Minor comments (10)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java-48-52 (1)

48-52: ⚠️ Potential issue | 🟡 Minor

update 호출 시 score가 0으로 덮어써지는 경합을 점검해야 한다.

WeeklyRankAssignTasklet에서는 existing.update(existing.getScore(), yearWeek, i+1)로 기존 score를 다시 전달하여 보존하지만, WeeklyMvRankingItemWriter에서는 existing.update(item.getScore(), yearWeek, 0)productRank를 0으로 초기화한다. 같은 배치 실행 내에서 writer가 먼저 rank=0으로 리셋하고 뒤이은 tasklet이 rank를 다시 매기므로 현재 흐름은 정상 동작하지만, 잡 실패로 writer 스텝만 성공하고 tasklet이 실행되지 않으면 mv_product_rank_weekly 전체 productRank가 0으로 남아 ORDER BY product_rank ASC 질의 결과가 비결정적이 된다. 운영 관점에서는 최소한 다음 중 하나가 필요하다.

  • writer 단계에서 update(score, yearWeek, existing.getProductRank())로 기존 rank를 유지하거나,
  • 잡 설정에 writer 스텝 실패 시 tasklet까지 롤백 가능한 재시도 정책을 명시한다.

추가 테스트: writer 성공 후 tasklet이 실행되지 않는 시나리오(잡 중단)에서 읽기 API(getWeeklyTopN)가 동일 rank=0 다수 레코드에 대해 어떤 순서를 반환하는지, 그리고 재실행 시 정상 복구되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java`
around lines 48 - 52, MvProductRankWeeklyEntity.update가 writer에서 productRank를
0으로 덮어써서 잡 실패 시 모든 rank가 0으로 남을 수 있으므로, WeeklyMvRankingItemWriter에서 기존 rank를
보존하도록 수정하세요: 현재 writer가 호출하는 existing.update(item.getScore(), yearWeek, 0) 대신
existing.update(item.getScore(), yearWeek, existing.getProductRank())로 변경하여
productRank를 유지하거나(권장), 불가할 경우 잡 설정에 writer 실패 시
tasklet(WeeklyRankAssignTasklet)까지 롤백/재시도되도록 트랜잭션/재시도 정책을 추가해
mv_product_rank_weekly의 productRank가 0으로 초기화되는 위험을 제거하고 getWeeklyTopN 호출 결과의
비결정성을 방지하세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java-9-9 (1)

9-9: ⚠️ Potential issue | 🟡 Minor

페이지네이션 시 ORDER BY product_rank ASC 단일 정렬은 동률 발생 시 결과가 비결정적이 된다.

WeeklyMvRankingItemWriter는 신규 upsert 시 productRank를 0으로 저장하고, WeeklyRankAssignTaskletTOP_RANK_LIMIT(=100) 초과 항목에 대해 productRank를 재할당하지 않는다(관련 스니펫: WeeklyRankAssignTasklet.java:45-47). 결과적으로 mv_product_rank_weekly에는 product_rank = 0인 레코드가 다수 존재할 수 있으며, 본 파생 쿼리로 페이지를 넘길 때 동일 rank 내부 순서가 DB/옵티마이저에 따라 달라져 같은 사용자가 이어서 페이지를 요청할 때 항목이 누락/중복될 수 있다.

운영 관점에서 2차 정렬 키(예: score DESC, productId ASC)를 추가하여 결정성을 보장하는 편이 안전하다.

♻️ 제안 diff
-    List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable);
+    List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAscScoreDescProductIdAsc(String yearWeek, Pageable pageable);

또는 파생 이름이 길어져 가독성이 떨어지므로 @Query("... order by e.productRank asc, e.score desc, e.productId asc") 명시 쿼리로 대체하는 방안을 권장한다. 월간 리포지토리에도 동일하게 적용한다.

추가 테스트: 동일 product_rank 값을 가진 레코드가 존재할 때 연속 페이지 요청에서 항목 중복/누락이 없는지 검증한다.

As per coding guidelines: "쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java`
at line 9, The repository method findAllByYearWeekOrderByProductRankAsc
currently sorts only by productRank which is non-deterministic when many rows
have productRank=0; update the query to include deterministic secondary sort
keys (e.g. order by productRank ASC, score DESC, productId ASC) either by
changing the method to an explicit `@Query`("... order by e.productRank asc,
e.score desc, e.productId asc") on MvProductRankWeeklyJpaRepository (and apply
the same change to the monthly repository counterpart) and ensure
WeeklyMvRankingItemWriter / WeeklyRankAssignTasklet behavior is noted; after the
change add an integration test that inserts multiple rows with identical
productRank and verifies consecutive pageable requests return no duplicates or
gaps.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java-13-18 (1)

13-18: ⚠️ Potential issue | 🟡 Minor

가중치 매직 넘버 상수화와 productId null 방어가 필요하다.

두 가지 관점에서 개선이 필요하다.

  1. 운영/튜닝 관점: 0.1, 0.2, 0.7 가중치가 메서드 본문에 하드코딩되어 있어 튜닝 시 재배포가 강제되고, 동일 로직을 공유하는 주간/월간 잡에서 가중치 일관성을 검증하기 어렵다. 또한 view/like는 원시 카운트이고 quantitylog1p로 압축되어, 좋아요 수천 건인 상품이 실제 판매량 상위 상품을 쉽게 앞지를 수 있다. 스케일 균형에 대한 의사결정이 코드/문서에 남아 있지 않으면 추후 랭킹 품질 이슈가 발생해도 원인 추적이 어렵다. 최소한 private static final double W_VIEW/W_LIKE/W_QTY 상수로 추출하고 주석으로 근거를 남기거나, 추후 application.yml 바인딩으로 외부화할 것을 권장한다.

  2. 장애 관점: dto.getProductId()가 null일 수 있는데(상류 DTO는 nullable), 그대로 RankedProductDto에 실려 writer의 findById에서 예외를 일으킨다. 다음과 같이 프로세서 단계에서 필터링하는 편이 안전하다.

♻️ 제안 diff
+    private static final double W_VIEW = 0.1;
+    private static final double W_LIKE = 0.2;
+    private static final double W_QTY  = 0.7;
+
     `@Override`
     public RankedProductDto process(ProductMetricsAggregatedDto dto) {
-        double score = 0.1 * dto.getTotalViewCount()
-                + 0.2 * dto.getTotalLikeCount()
-                + 0.7 * Math.log1p(dto.getTotalQuantity());
+        if (dto.getProductId() == null) {
+            return null; // Spring Batch filters null items out
+        }
+        double score = W_VIEW * dto.getTotalViewCount()
+                + W_LIKE * dto.getTotalLikeCount()
+                + W_QTY  * Math.log1p(dto.getTotalQuantity());
         return new RankedProductDto(dto.getProductId(), score);
     }

추가 테스트: (a) productId null 입력 시 process가 null을 반환하여 writer로 전달되지 않는지, (b) 동일 입력에 대해 weekly/monthly 프로세서가 동일 score를 산출하는지 단위 테스트를 추가한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java`
around lines 13 - 18, Extract the hardcoded weights in process(...) into named
constants (e.g., private static final double W_VIEW, W_LIKE, W_QTY) with a brief
comment explaining the choice and a TODO/note about externalizing to
configuration (application.yml) for tuning; then update the score calculation in
RankedProductDto process(ProductMetricsAggregatedDto dto) to use those
constants. Also add a null-check for dto.getProductId() inside process(...) and
return null when productId is null so the item is filtered out of the write
phase (preventing downstream findById NPE). Finally, add unit tests verifying
that process returns null for null productId and that the same inputs produce
consistent scores for weekly/monthly processors.
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java-38-39 (1)

38-39: ⚠️ Potential issue | 🟡 Minor

시간대(Zone) 가정이 암묵적이다.

occurredAt.toLocalDate()ZonedDateTime의 zone을 그대로 사용한다. 이벤트 producer의 타임존이 UTC인지 KST인지에 따라 자정 경계에서 다른 일자로 집계되어 일별 랭킹이 흔들릴 수 있다. 운영/집계 일관성을 위해 집계 기준 zone을 명시적으로 지정(예: occurredAt.withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDate())하고 팀 내 기준 문서화를 권장한다.

추가 테스트: UTC 23:30, KST 08:30 이벤트가 동일 일자로 집계되는지(또는 기준대로 분리되는지) 경계 테스트를 추가한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java`
around lines 38 - 39, The code uses occurredAt.toLocalDate() which relies on the
event's implicit zone; change the aggregation to convert occurredAt to an
explicit aggregation ZoneId (e.g., use
occurredAt.withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDate() or a
configurable ZoneId) before calling ensureMetricsExists(productId, metricsDate)
so date boundaries are consistent; update the ProductMetricsProcessor logic that
computes metricsDate (references: occurredAt, ensureMetricsExists) and add
boundary unit tests (UTC 23:30 vs KST 08:30) to verify expected day-bucketing.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java-25-32 (1)

25-32: ⚠️ Potential issue | 🟡 Minor

period 입력 검증이 부재하다.

period는 어떤 값이든 Facade로 통과되고, Facade는 알 수 없는 값을 조용히 일간 랭킹으로 처리한다(위 RankingFacade 코멘트 참조). 운영 관점에서 잘못된 클라이언트 사용이 4xx 대신 200으로 응답되어 관측되지 않는 버그로 남는다. 또한 LocalDate.parse가 던지는 DateTimeParseExceptionCoreException으로 매핑되지 않아 500으로 노출될 수 있다.

개선안은 위 Facade 코멘트의 enum 도입과 함께, date 파싱도 try/catch로 감싸 CoreException(BAD_REQUEST)으로 변환하는 방향이다. As per coding guidelines "상태 코드와 에러 응답 포맷이 일관되는지 점검하고, @ControllerAdvice 기반 표준 처리로 유도한다".

추가 테스트: (1) 잘못된 period → 400, (2) 잘못된 date 포맷 → 400, (3) period 미지정 시 일간 기본값 동작.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`
around lines 25 - 32, Validate the incoming period and handle date parse errors
in getRankings: add an enum (or use existing one in RankingFacade) for allowed
period values and check the `@RequestParam` period against it (preserve the
default "daily" when absent) and throw a CoreException with BAD_REQUEST when the
value is invalid; wrap LocalDate.parse(date, DATE_FORMATTER) in a try/catch
catching DateTimeParseException and convert it to a CoreException(BAD_REQUEST)
so malformed date formats return 400; keep using
rankingFacade.getRankings(localDate, period, size, page) after validation.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java-35-64 (1)

35-64: ⚠️ Potential issue | 🟡 Minor

BaseEntity 상속은 불가능하며, 대신 타임존 정보를 포함한 타임스탬프 관리가 필요하다.

MvProductRankMonthlyEntityproductId를 ID로 사용하는 Materialized View 엔티티이므로 BaseEntity를 상속할 수 없다(BaseEntity@GeneratedValue(strategy = GenerationType.IDENTITY)로 자동 생성되는 Long id를 기대함).

현재 @PrePersist/@PreUpdate 구현 자체는 BaseEntity와 동일한 패턴이므로 기능상 문제는 없으나, LocalDateTime 대신 ZonedDateTime을 사용하여 타임존 정보를 보존해야 한다. LocalDateTime으로 저장된 값은 데이터베이스 시간대 설정에 따라 다르게 해석될 수 있으므로, 운영 환경에서 시간대 관련 버그가 발생할 위험이 있다. BaseEntityZonedDateTime 사용과 일관성을 맞춰서 개선하면 테스트 시 시간 고정이 용이해지고, 전체 프로젝트의 타임스탬프 정책이 일원화된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 35 - 64, MvProductRankMonthlyEntity currently stores timestamps as
LocalDateTime; change createdAt and updatedAt to type ZonedDateTime and update
their declarations and imports accordingly, then modify prePersist() to set both
fields using ZonedDateTime.now(ZoneOffset.UTC) (or the same ZoneId used by your
BaseEntity) and preUpdate() to set updatedAt = ZonedDateTime.now(ZoneOffset.UTC)
so timezone info is preserved and consistent with BaseEntity; keep the `@Column`
annotations and the existing prePersist/preUpdate method names (prePersist,
preUpdate) unchanged.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java-12-20 (1)

12-20: ⚠️ Potential issue | 🟡 Minor

ProductMetricsRepository 인터페이스에 트랜잭션 계약을 명시하는 Javadoc 추가가 필요하다.

인터페이스에 메서드 수준 문서가 전혀 없어서, upsertIfAbsent 이후 increment 메서드 호출 간 원자성을 보장하는지 불명확하다. 현재 구현은 ProductMetricsProcessor.process() 메서드의 @Transactional 래핑으로 인해 모든 저장소 호출이 단일 트랜잭션 내에서 실행되므로 실제로는 안전하지만, 이 계약이 문서화되지 않으면 향후 재사용 시 트랜잭션 경계 밖에서 메서드를 호출하는 실수가 발생할 수 있다.

추가로 ProductMetricsJpaRepository.upsertIfAbsent()@Transactional 주석은 상위 레이어의 트랜잭션에서 호출되면 중복되므로, 필요성을 검토하거나 클래스 레벨 주석으로 통일하는 것을 권장한다. 각 메서드의 Javadoc에는 다음을 명시한다: (1) 이 메서드는 트랜잭션 내에서 호출되어야 함, (2) upsertIfAbsent + increment 조합은 단일 SQL 레벨이 아닌 트랜잭션 레벨의 원자성을 보장함, (3) 트랜잭션 격리 수준 하에서 동시성 동작.

아울러 현재 테스트는 Mock 기반이므로, 동일 상품에 대한 동시 이벤트 처리 시나리오(예: 동시에 여러 뷰/좋아요 이벤트)를 검증하는 통합 테스트를 추가하여 경쟁 조건 안전성을 확보한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java`
around lines 12 - 20, Add Javadoc to ProductMetricsRepository and each method
(upsertIfAbsent, incrementLikeCount, decrementLikeCount, incrementViewCount,
incrementSalesAndQuantity) that states these methods must be invoked within a
transaction, that the combination of upsertIfAbsent + any increment is atomic
only at the transaction level (not single-SQL), and describes expected
concurrency behavior under typical isolation levels; mention that callers such
as ProductMetricsProcessor.process wrap these calls in `@Transactional` so they
are safe. Review the `@Transactional` on
ProductMetricsJpaRepository.upsertIfAbsent and either remove the method-level
annotation or consolidate it at the repository/class level to avoid redundant
transaction boundaries when called from a transactional service. Finally, add an
integration test that simulates concurrent events for the same product
(concurrent views/likes/sales) to validate race-condition safety.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java-1-10 (1)

1-10: ⚠️ Potential issue | 🟡 Minor

배치 작업 장애 시 페이징 안정성 확보를 위해 productId를 tie-breaker로 추가하길 권장한다.

findAllByYearMonthOrderByProductRankAscproductRank만으로 정렬하는데, 정상 운영 중에는 productRank가 1-100으로 순차 배정되어 유일하다. 그러나 배치 작업(monthlyScoreCalculationStep → monthlyRankAssignStep) 실행 중 Step 2가 중단되거나 실패할 경우, 일부 레코드는 여전히 productRank=0을 유지하거나 중복된 productRank 값을 가지게 된다. 이 상태에서 API 페이징 쿼리가 실행되면 페이지 경계에서 누락이나 중복이 발생할 수 있다.

productId를 보조 정렬 키로 추가하면 동일한 productRank에 대해서도 결과 순서가 결정되어 페이징 안정성이 보장된다.

ORDER BY product_rank ASC, product_id ASC

추가 테스트: 배치 작업 중단 시나리오에서 일부 레코드의 productRank가 0 또는 중복된 상태에서 페이지를 순회할 때 전체 집합이 중복/누락 없이 조회되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java`
around lines 1 - 10, The repository method
findAllByYearMonthOrderByProductRankAsc in MvProductRankMonthlyJpaRepository
sorts only by productRank which can be non-unique during batch interruptions;
update the query/signature to include productId as a secondary sort key (ORDER
BY productRank ASC, productId ASC) so pagination is stable—locate the interface
MvProductRankMonthlyJpaRepository and modify the method name or query to reflect
tie-breaker ordering and then add tests that simulate batch interruption with
productRank duplicates/zeros to verify no duplicates or omissions across pages.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java-24-38 (1)

24-38: ⚠️ Potential issue | 🟡 Minor

JPQL 벌크 UPDATE는 감사 컬럼 갱신과 반환값 검증이 필요하다.

@Modifying JPQL UPDATE는 @PreUpdate 라이프사이클 콜백을 호출하지 않고 영속성 컨텍스트를 갱신하지 않는다. 운영 관점에서 다음 개선이 필요하다.

  1. updated_at 정지 문제: upsertIfAbsent()는 native query로 NOW()를 설정하지만, 이후 incrementLikeCount/incrementViewCount/incrementSalesAndQuantity는 UPDATE 절에 updatedAt을 포함하지 않는다. 각 메서드 쿼리에 m.updatedAt = CURRENT_TIMESTAMP 추가가 필요하다.

  2. 영속성 컨텍스트 동기화 리스크: 향후 코드 변경으로 같은 트랜잭션 내에서 ProductMetricsEntity를 먼저 로드한 후 increment를 호출하면, 영속성 컨텍스트 값과 DB 값이 불일치할 수 있다. 각 메서드에 @Modifying(clearAutomatically = true, flushAutomatically = true) 추가를 권고한다.

  3. decrementLikeCount 반환값 부재: AND m.likeCount > 0 가드가 있으나 메서드가 void를 반환하므로, 호출부에서 "조건을 만족하지 않아 업데이트 0건"인지 "정상 업데이트 1건"인지 구분할 수 없다. 반환 타입을 int로 변경하여 업데이트 행 수를 반환하도록 수정하고, 호출부(ProductMetricsProcessor 라인 51)에서 반환값이 1인지 검증하는 로직 추가가 필요하다.

제안 수정
- `@Modifying`
- `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate")
- void incrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);
+ `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+ `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount + 1, m.updatedAt = CURRENT_TIMESTAMP WHERE m.productId = :productId AND m.metricsDate = :metricsDate")
+ int incrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);
- `@Modifying`
- `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1 WHERE m.productId = :productId AND m.metricsDate = :metricsDate AND m.likeCount > 0")
- void decrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);
+ `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+ `@Query`("UPDATE ProductMetricsEntity m SET m.likeCount = m.likeCount - 1, m.updatedAt = CURRENT_TIMESTAMP WHERE m.productId = :productId AND m.metricsDate = :metricsDate AND m.likeCount > 0")
+ int decrementLikeCount(`@Param`("productId") Long productId, `@Param`("metricsDate") LocalDate metricsDate);

나머지 incrementViewCount, incrementSalesAndQuantity도 동일하게 clearAutomatically = true, flushAutomatically = true 추가 및 updatedAt = CURRENT_TIMESTAMP 포함, 반환형 int로 변경 필요.

추가 테스트 권고

  • decrementLikeCountlikeCount = 0인 행에 호출될 때 0을 반환하고, likeCount > 0인 행에 호출될 때 1을 반환하는지 검증하는 단위 테스트.
  • 각 increment 메서드 호출 후 updated_at이 갱신되는지 확인하는 회귀 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java`
around lines 24 - 38, The JPQL bulk UPDATEs in ProductMetricsJpaRepository
(methods incrementLikeCount, decrementLikeCount, incrementViewCount,
incrementSalesAndQuantity) do not update the updatedAt audit column, don't
synchronize the persistence context, and decrementLikeCount returns void so
callers can't tell if the guarded update ran; update each `@Query` to set
m.updatedAt = CURRENT_TIMESTAMP, change each `@Modifying` to
`@Modifying`(clearAutomatically = true, flushAutomatically = true), change
decrementLikeCount's return type to int (and likewise make increment* methods
return int if you want update-count feedback), and update the caller
ProductMetricsProcessor (the code currently around line 51) to check the
returned int == 1 to detect whether the decrement actually applied.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java-59-75 (1)

59-75: ⚠️ Potential issue | 🟡 Minor

입력 파라미터 검증 부재로 인한 비정상 에러 응답 반환

page, size 파라미터에 대한 가드가 없다. PageRequest.of(page, size)page < 0 또는 size < 1일 때 IllegalArgumentException을 던지는데, 이는 ApiControllerAdvice의 범용 Throwable 핸들러에 의해 포착되어 INTERNAL_ERROR(500)로 반환된다. 입력 검증 오류는 BAD_REQUEST(400)으로 반환되어야 한다.

또한 size에 상한이 없어 과도한 페이지 크기 요청 시 JPA가 대량 로우를 한 번에 페치하면서 메모리 압박이 발생할 수 있다.

다른 DTO들(AdminCouponV1Dto, OrderV1Dto, ProductV1Dto)은 @Min, @Max 검증 애노테이션을 사용하는데, RankingV1Controller는 적용되지 않았다.

수정안

  • RankingV1Controller의 page, size 파라미터에 @Min(0), @PositiveOrZero (또는 @Positive for size) 추가
  • 선택사항: size@Max(100) 같은 상한 제약 추가
  • 입력 검증 실패 시 BAD_REQUEST(400)이 반환되는지 확인

추가 테스트 권고

  • 음수 page, 0/음수 size 입력 시 400 응답 확인
  • size 상한이 설정된 경우 초과 요청 시 거동 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java`
around lines 59 - 75, Add parameter validation to the RankingV1Controller
endpoints that call getWeeklyTopN/getMonthlyTopN: annotate the page and size
request parameters with validation annotations (e.g., `@Min`(0) on page and
`@Positive` on size, optionally `@Max`(100) on size) and ensure the controller class
is annotated with `@Validated` so Spring returns 400 on constraint violations;
update any controller method signatures that pass page/size to
RankingRepositoryImpl.getWeeklyTopN/getMonthlyTopN accordingly and add tests
asserting negative page or non-positive/oversized size produce BAD_REQUEST(400).
🧹 Nitpick comments (14)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java (1)

3-20: record 전환 또는 Lombok @Getter/@RequiredArgsConstructor 사용을 권장한다.

불변 DTO임에도 수동으로 필드/생성자/게터를 작성하고 있어 유지보수 비용이 불필요하게 발생한다. 프로젝트가 Java 17 이상이라면 public record RankedProductDto(Long productId, double score) {}로 교체하는 편이 가독성과 equals/hashCode/toString 안정성 측면에서 유리하다. 코딩 가이드라인상 "equals/hashCode/toString 구현 안정성"을 점검하도록 되어 있는데, 현재 클래스는 이들을 모두 상속 기본값에 의존하므로 로그 출력/비교 시 가독성이 낮다.

As per coding guidelines: "null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java`
around lines 3 - 20, Replace the verbose immutable POJO RankedProductDto with a
concise immutable representation: either convert the class to a Java record
(public record RankedProductDto(Long productId, double score) {}) if using Java
17+, or annotate the class with Lombok’s `@Getter` and `@RequiredArgsConstructor`
(and `@ToString`, `@EqualsAndHashCode`) to auto-generate boilerplate; ensure you
handle nulls for productId (add `@NonNull` or explicit checks) and preserve
immutability/defensive-copy semantics as required by the project guidelines.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)

8-9: period를 enum으로 승격하는 방안을 제안한다.

현재 getTopN/getWeeklyTopN/getMonthlyTopN 세 메서드를 별도로 두고 RankingFacade에서 문자열 period("weekly"/"monthly"/그 외)로 분기하고 있다. 운영 관점에서는 (1) 신규 기간(예: daily 명시, quarterly)을 추가할 때마다 인터페이스가 팽창하고, (2) 파사드의 문자열 분기가 대소문자/오타/공백에 취약하여 잘못된 값 전달 시 의도치 않은 일간 폴백이 일어나므로 사용자에게 "조회 성공"으로 보이면서 전혀 다른 결과를 반환할 위험이 있다.

RankingPeriod enum을 도입하여 단일 시그니처 getTopN(RankingPeriod period, LocalDate date, int size, int page)로 통합하고, 컨트롤러 입력 검증 단계에서 알 수 없는 값은 CoreException으로 거절하는 편이 안전하다.

추가 테스트: 알 수 없는 period 값("WEEKLY ", "day", null) 전달 시 4xx 에러가 반환되는지, 그리고 유효한 weekly/monthly에 대해 각각의 구현 메서드가 호출되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java`
around lines 8 - 9, Introduce a RankingPeriod enum and consolidate the three
repository methods into a single signature: replace
getWeeklyTopN(LocalDate,int,int) and getMonthlyTopN(LocalDate,int,int) (and any
existing getTopN overloads) with getTopN(RankingPeriod period, LocalDate date,
int size, int page) on RankingRepository; update implementations to switch on
RankingPeriod to call the existing weekly/monthly logic. Update RankingFacade to
accept RankingPeriod (parse/validate controller input earlier) and throw
CoreException for unknown/null/invalid values instead of string-based fallback;
add tests to assert 4xx on invalid inputs ("WEEKLY ", "day", null) and that
valid RankingPeriod.WEEKLY and .MONTHLY trigger the corresponding internal
paths.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

26-31: period 문자열 리터럴 분기는 검증 공백/오타 위험이 있다.

period가 Controller에서 문자열로 그대로 전달되어 switch에서 리터럴 비교만 하므로, 대소문자 차이나 오타("Weekly", "day" 등)가 조용히 default(일간)로 흡수된다. 운영 관점에서 잘못된 파라미터가 감지되지 않고 일간 랭킹으로 응답되면 디버깅이 어렵고 모니터링 지표에도 잡히지 않는다. RankingPeriod enum을 도입해 Controller/Facade/Repository 계층에서 타입 안전하게 전달하고, 미지원 값은 CoreException으로 거부하도록 일원화하길 권장한다.

추가 테스트: (1) 지원 기간 각각에 대해 대응 repository 메서드가 호출되는지 Mockito로 검증, (2) 알 수 없는 값 입력 시 CoreException 발생 테스트.

♻️ 제안 수정
-    public List<RankingInfo> getRankings(LocalDate date, String period, int size, int page) {
-        List<RankedProduct> rankedProducts = switch (period) {
-            case "weekly" -> rankingRepository.getWeeklyTopN(date, size, page);
-            case "monthly" -> rankingRepository.getMonthlyTopN(date, size, page);
-            default -> rankingRepository.getTopN(date, size, page);
-        };
+    public List<RankingInfo> getRankings(LocalDate date, RankingPeriod period, int size, int page) {
+        List<RankedProduct> rankedProducts = switch (period) {
+            case WEEKLY -> rankingRepository.getWeeklyTopN(date, size, page);
+            case MONTHLY -> rankingRepository.getMonthlyTopN(date, size, page);
+            case DAILY -> rankingRepository.getTopN(date, size, page);
+        };

As per coding guidelines "Controller는 요청 검증(Bean Validation)과 응답 조립에 집중하고 비즈니스 로직은 Service로 이동한다" — 문자열 → enum 변환은 컨트롤러 또는 Facade 진입부에서 수행해 불변 도메인 값으로 전파하는 것이 적합하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java`
around lines 26 - 31, The getRankings method currently switches on the raw
String period which is error-prone; add a RankingPeriod enum and change the
controller (or the Facade entry) to convert/validate the incoming period string
into RankingPeriod (throw CoreException for unknown values), then update
RankingFacade.getRankings to switch on RankingPeriod and call the specific
repository methods (rankingRepository.getWeeklyTopN, getMonthlyTopN, getTopN)
accordingly; also add unit tests to verify each enum branch calls the correct
repository method and that invalid inputs cause CoreException.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java (1)

48-57: writer upsert 로직과 chunk 트랜잭션 경계의 재처리 안전성 점검이 필요하다.

관련 스니펫(WeeklyMvRankingItemWriter)을 보면 writer가 findById 후 존재 여부에 따라 update 또는 save를 수행한다. chunk 도중 예외로 롤백·재처리되면 동일 productId에 대해 update가 멱등적으로 수행되므로 기능상 안전하지만, findById가 청크 크기만큼 1+N 쿼리를 유발한다. CHUNK_SIZE=100이면 청크당 최대 100회 SELECT + 100회 UPSERT로 DB 왕복이 과다하다. 운영 관점에서 상품 수가 수만 건이면 배치 시간이 선형 증가하므로, saveAll 기반 일괄 upsert(MySQL INSERT ... ON DUPLICATE KEY UPDATE 또는 JPA batch insert + dirty update) 도입을 권장한다.

추가 테스트: 청크 내 동일 productId 중복 처리가 없는지, 실패/재시도 시 score가 누적되지 않고 최신 값으로 덮어쓰이는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java`
around lines 48 - 57, The writer (WeeklyMvRankingItemWriter) currently does
per-item findById + update/save inside the chunk configured by
weeklyScoreCalculationStep with CHUNK_SIZE=100, causing 1+N queries and poor
performance; change the writer to perform batch upserts (e.g. collect chunk
items and call a repository.saveAll with a DB-level upsert strategy such as
MySQL INSERT ... ON DUPLICATE KEY UPDATE or JPA bulk insert + bulk update) to
reduce round trips, ensure idempotency for retries by making upserts overwrite
with the latest score (not accumulate), and add tests to cover duplicate
productId within a chunk and retry scenarios to verify scores are replaced
rather than accumulated.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java (2)

32-38: 청크당 N회 findById 로 인한 SELECT N+1 문제다.

청크 크기 100 기준으로 매 write 호출마다 100회의 단건 SELECT 가 발생한다. 운영 관점에서는 배치 처리량이 저하되고 DB 커넥션 점유 시간이 늘어나 장애 상황(트래픽 증가, 슬로우 쿼리 누적) 에서 배치 SLA 미달로 이어질 수 있다. findAllById 로 한 번에 조회하여 Map 으로 분기하거나, MySQL upsert (INSERT ... ON DUPLICATE KEY UPDATE) JPQL/Native 쿼리로 단일 벌크 연산으로 전환하는 것이 바람직하다. 추가 테스트로는 (1) 기존 엔티티 일부 + 신규 엔티티 일부가 섞인 청크에서 최종 yearMonth/score 가 올바르게 반영되는지, (2) 청크 크기만큼의 아이템 처리 시 SELECT 호출 수가 1~2회 수준인지 검증하는 테스트가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java`
around lines 32 - 38, The current write loop in MonthlyMvRankingItemWriter
issues a findById per item causing SELECT N+1; change it to fetch all existing
entities in the chunk with monthlyRepository.findAllById(chunkIds) and build a
Map<productId, MvProductRankMonthlyEntity>, then iterate items: if map contains
productId call existing.update(...), else collect new MvProductRankMonthlyEntity
instances and saveAll at once; alternatively implement a single bulk upsert via
a native INSERT ... ON DUPLICATE KEY UPDATE or JPQL bulk update to set yearMonth
and score in one operation; add tests covering mixed existing+new chunks and
assert DB SELECT count is 1 (or O(1)) per chunk and final yearMonth/score
correctness.

24-30: targetDate 파싱 실패 메시지 개선 및 NPE 방어가 필요하다.

targetDate 가 누락되거나 포맷이 다를 경우 DateTimeParseException/NullPointerException 가 그대로 전파되어 운영 알람에서 원인(잡 파라미터 누락) 을 즉시 식별하기 어렵다. 파싱을 write 마다 반복 수행하는 것도 비효율적이다. @PostConstruct 혹은 Step 시작 시점에 한 번만 파싱하여 yearMonth 를 필드로 캐싱하고, 파싱 실패 시 원인(cause) 을 보존한 채 명확한 메시지를 던지도록 하는 것이 바람직하다. 추가 테스트로 targetDate 가 null/비정상 문자열일 때 사람이 읽을 수 있는 예외 메시지가 노출되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java`
around lines 24 - 30, Parse/validation of targetDate should be moved out of
write and done once at step initialization: add a private field (e.g.,
cachedYearMonth) and parse targetDate in a `@PostConstruct` (or
StepExecutionListener beforeStep) using LocalDate.parse(targetDate,
DateTimeFormatter.BASIC_ISO_DATE). If targetDate is null or parsing fails, throw
an IllegalStateException with a clear human-readable message like "Invalid or
missing job parameter 'targetDate': expected yyyyMMdd" and include the original
exception as the cause to preserve details. Replace the parsing in write(...)
with use of cachedYearMonth (format with
RankingMonthlyJobConfig.YEAR_MONTH_FORMAT if needed). Add unit tests for null
and malformed targetDate to assert the thrown message contains the helpful text.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java (1)

1-81: commerce-api 측 MvProductRankMonthlyEntity 와 중복된다.

같은 테이블(mv_product_rank_monthly) 을 매핑하는 엔티티가 apps/commerce-apiapps/commerce-batch 양쪽에 각각 존재한다. 운영 관점에서 스키마 변경 시 한쪽만 수정되면 런타임에 조용히 컬럼 누락/타입 불일치로 드러나는 장애가 발생한다. 공통 모듈로 추출하거나, 한 쪽을 읽기 전용 프로젝션으로 축소하는 리팩터링을 권장한다. 동일 이슈가 MvProductRankWeeklyEntity 에도 적용된다. 추가 테스트로 두 모듈의 엔티티가 동일 스키마를 바라보는지 검증하는 컨트랙트 테스트를 두는 것이 바람직하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 1 - 81, Duplicate entity mappings exist for mv_product_rank_monthly
(MvProductRankMonthlyEntity) across apps/commerce-api and apps/commerce-batch
causing drift risk; refactor by extracting the shared entity
(MvProductRankMonthlyEntity) into a common library/module used by both projects
or convert the batch-side MvProductRankMonthlyEntity into a read-only projection
(e.g., DTO or `@Immutable/read-only` mapping) and remove the duplicate class from
one module, apply the same change for MvProductRankWeeklyEntity, and add a
contract test that loads both modules' metadata to assert identical column
names/types for these entities to prevent future schema divergence.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java (1)

37-39: yearWeek 변수명과 실제 포맷(yyyyMMdd) 이 불일치한다.

BASIC_ISO_DATEyyyyMMdd 를 출력하므로 "yearWeek" 라는 이름과 저장 값(예: 20260413) 이 의미적으로 맞지 않는다. 추후 유지보수자가 YYYY'W'ww 같은 실제 ISO 주차 포맷으로 오인하여 변경하면, writer(WeeklyMvRankingItemWriter) 와 tasklet 간 yearWeek 값 불일치로 전량 삭제 사고가 발생할 수 있다. 컬럼/필드명을 weekStartDate 로 바꾸거나 포맷을 실제 xxxx-Www 로 통일하는 방향을 권장한다. 추가 테스트로 writer 와 tasklet 이 동일 targetDate 로 동일한 키를 생성하는지 공유 유틸을 통해 검증하는 테스트가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java`
around lines 37 - 39, The variable yearWeek in WeeklyRankAssignTasklet is
misnamed because it stores a yyyyMMdd string (using
DateTimeFormatter.BASIC_ISO_DATE); either rename it to weekStartDate (and update
usages) or change the formatter to produce an ISO week string (e.g., YYYY-'W'ww)
so the name and value align; ensure WeeklyMvRankingItemWriter and
WeeklyRankAssignTasklet produce the identical key by extracting the shared
key-generation logic into a common utility (used by both) and add a unit test
that verifies the same targetDate yields the same key in both
WeeklyRankAssignTasklet and WeeklyMvRankingItemWriter.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java (1)

56-70: period 화이트리스팅/대소문자 정책을 정리해야 한다.

"weekly"/"monthly" 만 지원하며 그 외에는 예외가 발생한다. 운영상 오타(Weekly, WEEKLY) 로 인한 잡 전체 실패는 원인 추적에 시간이 소요된다. (1) Locale.ROOT 기준 toLowerCase 정규화, (2) enum Period 도입으로 컴파일 타임 검증, (3) 예외 메시지에 지원되는 값 목록 포함을 권장한다. 또한 resolveStartDate/resolveEndDate 에 동일 switch 가 중복되므로 Period.range(target) 형태로 묶으면 변경점이 하나가 된다. 추가 테스트로 지원되지 않는 period 값 입력 시 예외 메시지에 허용 값이 포함되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java`
around lines 56 - 70, The current resolveStartDate/resolveEndDate only accept
exact "weekly"/"monthly" strings and duplicate switch logic; normalize the
incoming period string with period = period.toLowerCase(Locale.ROOT) and replace
the string switches by introducing an enum Period { WEEKLY, MONTHLY } with a
static from(String) that normalizes/validates and throws
IllegalArgumentException listing allowed values; move the start/end logic into
Period methods (e.g., Period.startOf(target) and Period.endOf(target) or
Period.range(target) returning both dates) and update
resolveStartDate/resolveEndDate to delegate to the enum; add unit tests that
assert valid inputs work and that invalid inputs produce an exception containing
the allowed value list.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java (2)

43-45: total_quantity NOT NULL 컬럼 추가 시 기존 데이터 기본값 전략이 필요하다.

기존 product_metrics 테이블에 레코드가 존재하는 환경에서 total_quantity NOT NULL 을 그대로 추가하면 DDL 이 실패하거나 디폴트 0 으로 일괄 채워진다. 전자는 배포 실패, 후자는 과거 집계를 0 으로 왜곡한다. 외부 마이그레이션 파이프라인 스크립트에서 (1) 초기 DEFAULT 0 으로 컬럼 추가 → (2) 과거 데이터 백필(주문 이력 재집계) → (3) 애플리케이션 배포의 3단계 순서로 나누는 것이 안전하다. 추가 테스트로 기존 행이 있는 DB 에 대해 Testcontainers 기반 마이그레이션 시뮬레이션 테스트가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java`
around lines 43 - 45, Adding a NOT NULL column totalQuantity to
ProductMetricsEntity (product_metrics.total_quantity) must be performed as a
three-step migration outside a single deploy: (1) add the column with NULL
allowed or with DEFAULT 0 (migration script creates column total_quantity
DEFAULT 0 NULLABLE), (2) run a backfill job that recalculates/hydrates
total_quantity from historical orders for existing rows (use your ETL/migration
pipeline to reaggregate and update product_metrics.total_quantity), and only
after backfill (3) alter the column to NOT NULL (and remove the temporary
default if you added one) and then deploy the application change that maps
ProductMetricsEntity.totalQuantity to a non-null int; also add a
Testcontainers-based migration integration test that simulates an existing DB
with rows and validates the three-step migration/backfill sequence succeeds and
preserves historical totals.

14-19: 기존 데이터/동시성 관점에서 복합 유니크 제약 전환을 확인해야 한다.

유니크 키가 product_id 단일에서 {product_id, metrics_date} 로 변경되었다. 운영 관점에서 (1) 기존 데이터에 metrics_date 가 없던 행의 백필 전략, (2) 인덱스명 변경(uk_product_metrics_product_id_date) 에 따른 기존 인덱스 DROP/CREATE 순서와 온라인 DDL 소요 시간, (3) 멀티 인스턴스 스트리머가 동일 {product_id, metrics_date} 에 동시 upsert 할 때 경쟁조건(중복 키 예외 → 재시도) 처리 경로 확인이 필요하다. 외부 마이그레이션 파이프라인 변경 PR 과의 배포 순서(스키마 선반영 → 코드 배포) 도 운영 체크리스트에 포함해야 한다. 추가 테스트로 동일 {product_id, metrics_date} 동시 삽입 시 유니크 위반 예외가 정상적으로 upsert 로 귀결되는지 통합 테스트가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java`
around lines 14 - 19, ProductMetricsEntity의 테이블 유니크 제약이 product_id 단일에서
{product_id, metrics_date}로 변경된 점을 반영해 운영·동시성 검증을 수행하세요: 기존 데이터에 metrics_date가
비어있는 행에 대한 백필 전략 수립 및 마이그레이션(백필 스크립트)을 준비하고, 새로운 인덱스명
uk_product_metrics_product_id_date로 인덱스 교체 시 기존 인덱스 DROP/CREATE 순서와 온라인 DDL 소요
시간을 확인·문서화하세요; 멀티 인스턴스에서 ProductMetricsEntity를 upsert 하는 코드 경로(특히 upsert/insert
처리 로직과 retry/duplicate-key 예외 처리)를 점검해 동시 삽입 시 재시도 로직이 정상적으로 작동하도록 보완하고, 스키마 변경
선반영 → 코드 배포 순서에 대한 배포 체크리스트를 추가하며, 동일 {product_id, metrics_date}에 대한 동시 삽입 통합
테스트를 만들어 중복 키 예외가 올바르게 upsert로 귀결되는지 검증하세요.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java (1)

21-41: MonthlyMvRankingItemWriter 와 로직이 사실상 동일하여 중복되어 있다.

yearWeek/yearMonth 계산식만 다르고 조회·업서트 흐름이 완전히 동일하다. 운영 관점에서 한쪽만 수정(예: 성능 최적화를 위해 findAllById 도입) 될 때 다른 주기에서는 회귀가 발생한다. 제네릭 기반 AbstractMvRankingItemWriter<E> 를 두어 repository, keyFn, entityFactory, updater 를 주입받는 템플릿으로 통합하거나, 전략 패턴으로 기간별 키 계산만 분리하는 것을 권장한다. 또한 이 파일에도 MonthlyMvRankingItemWriter 와 동일하게 청크 당 N회 findById 이슈가 존재하므로 동일한 벌크 조회/upsert 개선이 필요하다. 추가 테스트로 두 writer 가 동일 계약(신규 저장/기존 업데이트 경계) 을 지키는지 파라미터라이즈드 테스트가 유효하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java`
around lines 21 - 41, WeeklyMvRankingItemWriter duplicates
MonthlyMvRankingItemWriter behavior (only yearWeek vs yearMonth differs) and
performs N findById calls per chunk; refactor into a generic
AbstractMvRankingItemWriter<E> (or use a strategy for period-key calculation)
that accepts the repository (MvProductRankWeeklyJpaRepository / monthly
equivalent), a key function derived from targetDate, an entityFactory and an
updater so both writers reuse write(Chunk<? extends RankedProductDto>) logic;
replace the per-item weeklyRepository.findById(...) loop in write(...) with a
bulk fetch (findAllById on the chunk productIds), build a map of existing
entities, then perform updates or create new entities and use saveAll to persist
in bulk; add parameterized tests exercising the new writer contract (new vs
existing boundary) to ensure both weekly and monthly behaviors remain identical.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java (1)

41-48: 중간 단계 실패 시 재시작 정책이 명시되지 않았다.

monthlyScoreCalculationStep 성공 후 monthlyRankAssignStep 이 실패하면, MV 테이블에는 productRank=0 인 레코드가 남아 API 가 비정상 순위를 노출할 수 있다. 운영 관점에서 (1) 잡 재시작 시 chunk step 의 allowStartIfComplete(true) 혹은 두 스텝의 멱등성 보장, (2) 실패 시 알람/대시보드 지표(StepMonitorListener 이외 별도 Micrometer 지표) 추가, (3) rank 미할당 레코드(productRank = 0) 를 API 가 필터링하도록 하는 방어로직이 필요하다. 추가 테스트로 score step 성공 + assign step 실패 후 재시작 시 최종 상태가 정상 top100 으로 수렴하는지 E2E 테스트가 필요하다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java`
around lines 41 - 48, 현재 rankingMonthlyJob 구성은 monthlyScoreCalculationStep 성공 후
monthlyRankAssignStep 실패 시 재시작/운영 보호가 없다; monthlyScoreCalculationStep과
monthlyRankAssignStep의 스텝 정의를 수정해 chunk step에 allowStartIfComplete(true)를
설정하거나(또는 두 스텝를 멱등하게 구현) 재시작 정책을 명시하고, 실패 모니터링을 위해 StepMonitorListener 외에 별도의
Micrometer 지표/알람을 monthlyRankAssignStep에 등록하도록 변경하며(예: 별도 MeterRegistry
카운터/타이머), API 쪽 방어로직을 추가해 productRank == 0 레코드를 필터링하도록 하고, 마지막으로
monthlyScoreCalculationStep 성공 후 monthlyRankAssignStep 실패 시 잡 재시작 후 최종 top100이
정상 수렴하는지 검증하는 E2E 테스트를 작성하라; 관련 식별자는 rankingMonthlyJob,
monthlyScoreCalculationStep, monthlyRankAssignStep, StepMonitorListener,
JOB_NAME, allowStartIfComplete이다.
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

5-23: 값 객체의 동등성/표현 관점 보완 권고.

ProductMetrics는 모든 필드가 final인 불변 도메인이나, equals/hashCode/toString이 부재하여 테스트 어서션·로그 진단·컬렉션 보관 시 식별성이 떨어진다. 운영 관점에서 메트릭 불일치 조사 시 로그에 인스턴스 주소만 찍혀 원인 추적이 어려워질 수 있다. @Value(lombok) 또는 record 전환, 혹은 수동 구현을 권고한다.

As per coding guidelines: "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다. equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."

추가 테스트 권고

  • 동일 (productId, metricsDate, counts...)로 생성된 두 인스턴스가 equals로 동등한지 검증.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 5 - 23, ProductMetrics is an immutable value object but lacks
equals/hashCode/toString, which harms comparisons, logging and collection
behavior; implement value-based equals and hashCode (use productId, metricsDate
and the count fields like likeCount, viewCount, salesCount, totalQuantity) and a
descriptive toString, or alternatively convert ProductMetrics to a Java record
or annotate with Lombok `@Value` to get these auto-generated; update or add tests
to assert two instances with identical productId/metricsDate/counts are equal
and produce consistent hashCodes and readable toString output.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e29deba6-c9d2-44fa-9bbc-e68b0f6295c9

📥 Commits

Reviewing files that changed from the base of the PR and between 551746b and da56e8b.

⛔ Files ignored due to path filters (1)
  • docs/10-batch-ranking.md is excluded by !**/*.md and included by **
📒 Files selected for processing (28)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingMonthlyJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyMvRankingItemWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductMetricsItemReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankedProductDto.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingItemProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyMvRankingItemWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsAggregatedDto.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java

Comment on lines +16 to +46
public class MvProductRankMonthlyEntity {

@Id
@Comment("상품 id (ref)")
@Column(name = "product_id", nullable = false, updatable = false)
private Long productId;

@Comment("집계 점수")
@Column(name = "score", nullable = false)
private double score;

@Comment("집계 기준 월 (e.g. 202604)")
@Column(name = "year_month", nullable = false, length = 6)
private String yearMonth;

@Comment("랭킹 순위")
@Column(name = "product_rank", nullable = false)
private int productRank;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) {
this.productId = productId;
this.score = score;
this.yearMonth = yearMonth;
this.productRank = productRank;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

단일 productId PK 구조는 월별 이력 보존이 불가능하다.

@IdproductId 단독이므로 동일 상품의 월별 스냅샷을 보관할 수 없고, WeeklyMvRankingItemWriter 등과 동일한 패턴으로 매월 갱신 시 이전 월 데이터가 덮어쓰여 과거 월 랭킹 조회 API가 항상 최신 월 데이터만 반환한다. 컨트롤러/Facade의 period="monthly" + date 파라미터 설계(과거 날짜로 조회 가능)와 정합하지 않는다. (productId, yearMonth) 복합키 또는 별도 PK + unique 제약으로 변경하고, reader/writer/삭제 전략도 함께 재검토가 필요하다.

추가 테스트: (1) 서로 다른 yearMonth로 동일 productId 저장 시 두 행이 공존하는지, (2) 과거 월 날짜로 API 조회 시 해당 월의 랭킹이 반환되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 16 - 46, MvProductRankMonthlyEntity currently uses productId as the
sole `@Id` which prevents preserving monthly snapshots; change the key strategy so
that productId and yearMonth form a composite primary key (or introduce a
surrogate PK plus a unique constraint on (productId, yearMonth)) and update
persistence mappings accordingly (adjust annotations on productId and yearMonth
in MvProductRankMonthlyEntity); also review and update related components such
as WeeklyMvRankingItemWriter/reader/delete logic to insert/update by the new key
strategy, and add tests verifying that saving the same productId with different
yearMonth produces two rows and that querying by past month returns that month’s
ranking.

Comment on lines +28 to +29
@Column(name = "year_month", nullable = false, length = 6)
private String yearMonth;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Is YEAR_MONTH a reserved keyword in MySQL 8.0 and does it require quoting when used as a column identifier?

💡 Result:

Yes, YEAR_MONTH is a reserved keyword in MySQL 8.0. It requires quoting (using backticks) when used as a column identifier.

Citations:


🏁 Script executed:

# 먼저 현재 파일의 위치 확인
find . -name "MvProductRankMonthlyEntity.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 288


🏁 Script executed:

# 관련 엔티티 찾기 - 주간 랭킹 엔티티
find . -name "*MvProductRankWeekly*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 502


🏁 Script executed:

# year_month 또는 yearMonth 필드 검색
rg "yearMonth|year_month" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4745


🏁 Script executed:

# DDL 스크립트나 데이터베이스 설정 파일 확인
find . -name "*.sql" -o -name "*schema*" -o -name "*migration*" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 107


🏁 Script executed:

# Check the WeeklyEntity for similar issues
cat ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2187


🏁 Script executed:

# Check the database schema/DDL
cat ./db/seed-products.sql

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1357


🏁 Script executed:

# Search for other `@Column` usages with backticks or special characters in the codebase
rg "@Column.*\`" --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Search for Hibernate/Spring configuration that might affect column naming
find . -name "application*.properties" -o -name "application*.yml" | xargs cat 2>/dev/null | grep -i "hibernate\|jpa" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 132


🏁 Script executed:

# Search for Flyway or Liquibase migration files
find . -path "*/db/migration*" -o -path "*/db/flyway*" | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Look for any V*.sql or similar migration patterns
find . -name "V*.sql" -o -name "U*.sql" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check application configuration
find . -name "application*.yml" -o -name "application*.yaml" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

# Search for any CREATE TABLE statements in the codebase
rg "CREATE TABLE.*rank" --type sql -i

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check if there's any documentation about the schema
find . -name "*.md" -o -name "*.txt" | xargs grep -l "year_month" 2>/dev/null

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 111


🏁 Script executed:

# Check the application configuration for Hibernate settings
cat ./apps/commerce-api/src/main/resources/application.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1670


🏁 Script executed:

# Check the batch ranking documentation
cat ./docs/10-batch-ranking.md

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 8489


🏁 Script executed:

# Search for any SQL or DDL hints in comments
rg "year_month|year_week" --type java -B 2 -A 2 | head -50

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2635


🏁 Script executed:

# Check for any Hibernate configuration that might handle reserved keywords
cat ./apps/commerce-api/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 163


🏁 Script executed:

# Verify if there's any Hibernate documentation or configuration for column name quoting
rg "hibernate.*naming|dialect|PhysicalNamingStrategy" --type yaml --type properties

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 119


🏁 Script executed:

# Search for other reserved keywords usage in the codebase
rg "@Column.*name.*=" --type java | head -30

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4522


🏁 Script executed:

# Check if the batch module has the same issue - compare both files
diff -u ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Search for Hibernate documentation or examples with reserved keywords
rg "backtick|escape|reserved" --type java --type yaml -i

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check if there are any test files that might show database interactions
find . -name "*Test*.java" -path "*/ranking/*" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Look for any database initialization or schema creation code
rg "CREATE TABLE|year_month" --type java -B 2 -A 2

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1353


🏁 Script executed:

# Check for jpa.yml content - it might be in a different location
find . -name "*.yml" -o -name "*.yaml" | xargs grep -l "jpa\|hibernate" | head -10

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 297


🏁 Script executed:

# Look for application-jpa.yml or similar
find . -name "application*" -type f | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 256


🏁 Script executed:

# Check if there's Hibernate dialect configuration
rg "hibernate.dialect|hibernate.query|quoteIdentifier" --type yaml --type java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Verify what exact Hibernate/Jakarta version is used
find . -name "pom.xml" | head -3 | xargs grep -A 5 "jakarta.persistence\|hibernate-core"

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 69


🏁 Script executed:

# Read the jpa.yml configuration file
cat ./modules/jpa/src/main/resources/jpa.yml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2231


🏁 Script executed:

# Check pom.xml for Jakarta/Hibernate versions
cat ./pom.xml | head -100

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 126


🏁 Script executed:

# Look specifically for spring-boot-starter-data-jpa and related dependencies
rg "spring-boot-starter-data-jpa|jakarta.persistence" pom.xml

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 132


🏁 Script executed:

# Find the Maven build files
find . -name "pom.xml" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 67


🏁 Script executed:

# Check the root directory structure
ls -la | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1228


🏁 Script executed:

# Look for Gradle build files instead
find . -name "build.gradle" -o -name "build.gradle.kts" | head -5

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 252


🏁 Script executed:

# Check root build.gradle.kts for dependency versions
cat ./build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3761


🏁 Script executed:

# Check modules/jpa build.gradle.kts
cat ./modules/jpa/build.gradle.kts

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 763


year_month는 MySQL 8.0 예약어이므로 백틱으로 이스케이프해야 한다.

YEAR_MONTH는 MySQL 8.0의 INTERVAL 단위 키워드로 예약되어 있어 DDL/DML 파싱 에러가 발생할 수 있다. 현재 코드의 @Column(name = "year_month", ...) 형태로는 예약어 충돌 위험이 있으므로, Hibernate에서 명시적으로 인식하도록 백틱으로 감싸야 한다.

commerce-api와 commerce-batch 두 모듈의 MvProductRankMonthlyEntity 파일 모두에서 동일하게 수정이 필요하다.

📝 수정안
-    `@Column`(name = "year_month", nullable = false, length = 6)
+    `@Column`(name = "`year_month`", nullable = false, length = 6)
     private String yearMonth;

테스트: Testcontainers MySQL 8.0 환경에서 persist/update/derived query(findAllByYearMonth 등) 모두 정상 실행되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java`
around lines 28 - 29, The column name year_month is a MySQL 8.0 reserved keyword
(INTERVAL unit) and must be escaped; update the MvProductRankMonthlyEntity class
so the `@Column` maps to a backticked identifier (replace `@Column`(name =
"year_month", ...) with `@Column`(name = "`year_month`", ...)) to ensure Hibernate
emits the escaped identifier, and apply the same change in both commerce-api and
commerce-batch copies of MvProductRankMonthlyEntity; after changes run
persistence/update and derived queries (e.g., findAllByYearMonth) against
Testcontainers MySQL 8.0 to verify no DDL/DML parsing errors.

Comment on lines +68 to +75
@Override
public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
.stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore()))
.toList();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

월 단위 포맷 상수를 배치와 공유해 포맷 드리프트를 차단하도록 한다.

배치 writer(apps/commerce-batch/.../MonthlyMvRankingItemWriter.java)는 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 사용해 yearMonth를 적재하는데, 조회 측(본 구현)은 리터럴 "yyyyMM"을 인라인으로 중복 정의하고 있다. 한쪽이 "yyyy-MM"·"yyyyMMM" 등으로 변경되면 조회 결과가 조용히 공집합이 되어 랭킹 화면이 빈 페이지로 노출되는 운영 장애로 번질 수 있다. 주간 BASIC_ISO_DATE 역시 같은 위험이 있어 상수 추출 또는 공유 상수 참조를 권고한다.

♻️ 제안 수정
-    `@Override`
-    public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
-        String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
+    private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
+
+    `@Override`
+    public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
+        String yearMonth = date.format(YEAR_MONTH_FORMAT);
         return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
                 .stream()
                 .map(e -> new RankedProduct(e.getProductId(), e.getScore()))
                 .toList();
     }

공용 포맷 상수(예: com.loopers.domain.ranking 하위 상수 클래스)로 끌어올려 배치/조회 양측이 동일 심볼을 참조하게 만들거나, 배치의 RankingMonthlyJobConfig.YEAR_MONTH_FORMAT을 상향 이동하는 방식을 권고한다.

추가 테스트 권고

  • 2026-01-05 같은 경계 날짜에 대해 주간 키가 20251229(전년도 마지막 주 월요일)로 산출되는지, 동일 입력에 대해 배치 writer와 조회 구현이 같은 키를 생성하는지 확인하는 계약(contract) 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java`
around lines 68 - 75, getMonthlyTopN uses an inline DateTimeFormatter pattern
"yyyyMM" causing format drift with the batch writer; replace the literal with a
shared constant to ensure both writer and reader use the same symbol (e.g.
reference the existing RankingMonthlyJobConfig.YEAR_MONTH_FORMAT or move a
common constant into a shared package such as com.loopers.domain.ranking) and
update getMonthlyTopN to format the date using that shared constant; also verify
similar weekly formatting uses the shared BASIC_ISO_DATE constant if applicable.

Comment on lines +39 to +46
@Bean(JOB_NAME)
public Job rankingWeeklyJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.start(weeklyScoreCalculationStep())
.next(weeklyRankAssignStep())
.listener(jobListener)
.build();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Job incrementer/재실행 정책이 부재하다.

rankingWeeklyJob.incrementer(...)와 실패 시 재시작 전략이 없어 동일 파라미터(targetDate, period)로 재실행 시 JobInstanceAlreadyCompleteException이 발생하거나 반대로 실수로 중복 집계될 수 있다. 운영 관점에서 주간 배치는 재시도가 잦은 구간이므로 RunIdIncrementer 또는 명시적 run-id 파라미터 설계가 필요하다. 또한 weeklyRankAssignStep은 MV 전체를 다루는 tasklet으로 추정되는데, chunk step 실패 후 rank assign만 재실행되는 경우 데이터 정합성 측면에서 문제가 없는지 확인이 필요하다.

추가 테스트: (1) 동일 targetDate로 두 번 실행했을 때 예외/중복 집계 여부, (2) chunk step 실패 후 재실행 시 마지막 청크 재처리 시 MV 합산값이 중복되지 않는지 검증한다.

♻️ 제안 수정
     public Job rankingWeeklyJob() {
         return new JobBuilder(JOB_NAME, jobRepository)
                 .start(weeklyScoreCalculationStep())
                 .next(weeklyRankAssignStep())
+                .incrementer(new RunIdIncrementer())
                 .listener(jobListener)
                 .build();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingWeeklyJobConfig.java`
around lines 39 - 46, The Job configuration lacks an incrementer and explicit
restart policy causing JobInstanceAlreadyCompleteException or duplicate runs;
update the rankingWeeklyJob() to attach a RunIdIncrementer (or require an
explicit run-id parameter) via .incrementer(...) and configure a restart policy
so identical targetDate/period runs are treated as distinct or rejected; also
mark or protect weeklyRankAssignStep() (the MV-wide tasklet) so it is idempotent
or non-restartable (e.g., make it step-allow-start-if-complete=false or add a
guard/transactional check) to prevent MV double-aggregation when upstream chunk
steps are retried. Ensure you run the suggested tests: re-run with same
targetDate and simulate chunk failure + restart to validate no duplicate
aggregation.

Comment on lines +36 to +54
String yearMonth = LocalDate.parse(targetDate, DateTimeFormatter.BASIC_ISO_DATE)
.format(RankingMonthlyJobConfig.YEAR_MONTH_FORMAT);

monthlyRepository.deleteAllByYearMonthNot(yearMonth);

List<MvProductRankMonthlyEntity> ranked = monthlyRepository.findAllByYearMonthOrderByScoreDesc(yearMonth);

for (int i = 0; i < Math.min(ranked.size(), TOP_RANK_LIMIT); i++) {
ranked.get(i).update(ranked.get(i).getScore(), yearMonth, i + 1);
}

if (ranked.size() > TOP_RANK_LIMIT) {
monthlyRepository.deleteAll(ranked.subList(TOP_RANK_LIMIT, ranked.size()));
log.info("월간 랭킹 {}위 이후 {}건 삭제", TOP_RANK_LIMIT, ranked.size() - TOP_RANK_LIMIT);
}

log.info("월간 랭킹 확정: {}건 (yearMonth={})", Math.min(ranked.size(), TOP_RANK_LIMIT), yearMonth);
return RepeatStatus.FINISHED;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

전체 로직을 단일 트랜잭션으로 처리할 때 메모리/락 위험이 있다.

(1) deleteAllByYearMonthNot 이전 단계의 writer 가 방금 저장한 현재월 레코드는 유지되지만, 과거 targetDate 로 재실행 시 최신월 데이터가 삭제될 수 있다. 운영상 장애 복구 시 더 큰 장애로 번질 수 있으므로 targetDate 유효성(현재 월 ± 허용범위) 검증과, 삭제 전 건수 로깅이 필요하다. (2) findAllByYearMonthOrderByScoreDesc 가 수만~수십만 건을 전량 적재하면 OOM 위험과 장 시간 락이 발생한다. Pageable 로 Top 100 + 이후 삭제용 ID 만 조회하는 방식으로 쪼개는 것이 안전하다. (3) ranked.subList(...) 로 넘기는 deleteAll 은 엔티티 단건 delete N회를 유발할 수 있으니 @Modifying JPQL 벌크 delete 로 전환을 권장한다. 추가 테스트로 100건 초과 시나리오, 과거 날짜 재실행 시나리오, 동일 yearMonth 재실행 멱등성 시나리오가 필요하다. WeeklyRankAssignTasklet 도 동일하게 적용된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java`
around lines 36 - 54, Validate targetDate is within an allowed window (e.g.,
current month ± configured tolerance) before calling
monthlyRepository.deleteAllByYearMonthNot, and log the count that would be
deleted; replace the full-materialization call to
monthlyRepository.findAllByYearMonthOrderByScoreDesc with paged queries to first
fetch TOP_RANK_LIMIT (Pageable) for ranking updates (use the existing
TOP_RANK_LIMIT constant and the update(...) on MvProductRankMonthlyEntity) and
then fetch only IDs for the remainder in pages to delete; replace
monthlyRepository.deleteAll(ranked.subList(...)) with a bulk JPQL/@Modifying
delete (e.g., deleteByIdIn or deleteByYearMonthAndIdIn) or
repository.deleteAllByIdInBatch to avoid N x DELETE operations and OOM/locks;
apply the same changes to WeeklyRankAssignTasklet and add tests for >100 items,
past-date re-run, and idempotent re-run of same yearMonth, plus ensure detailed
logging before destructive deletes.


List<MvProductRankMonthlyEntity> findAllByYearMonthOrderByScoreDesc(String yearMonth);

void deleteAllByYearMonthNot(String yearMonth);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd MonthlyRankAssignTasklet -e java | xargs -I{} cat {}

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2415


🏁 Script executed:

find . -name "MvProductRankMonthlyJpaRepository.java" -type f

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 302


🏁 Script executed:

cat -n ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 548


파생 쿼리 기반 bulk delete는 운영상 위험하다.

deleteAllByYearMonthNot는 Spring Data 파생 쿼리로 구현되어 있으며, 기본 동작상 엔티티를 모두 로딩한 뒤 개별 삭제를 수행하므로 MV 테이블 규모가 커질수록 성능이 급락하고 영속성 컨텍스트 오염/메모리 압박이 발생한다. 또한 "현재 yearMonth가 아닌 전부"를 한 번에 파기하는 파괴적 연산이므로, 배치 부분 실패 후 재실행 시 의도치 않은 데이터 손실을 초래한다. @Modifying + JPQL로 단일 DML 쿼리로 변경하고, 삭제 건수를 반환받아 로깅하며, 재실행 시 idempotency를 보장하는 테스트가 필요하다.

♻️ 제안 수정
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
...
-    void deleteAllByYearMonthNot(String yearMonth);
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+    `@Query`("delete from MvProductRankMonthlyEntity e where e.yearMonth <> :yearMonth")
+    int deleteAllByYearMonthNot(`@Param`("yearMonth") String yearMonth);

변경 후 MonthlyRankAssignTasklet에서 삭제 건수를 로깅하고, (1) 쿼리 로그로 단일 DML 확인, (2) 현재 yearMonth만 남는지 검증, (3) 동일 배치 2회 실행 시 두 번째는 0건 삭제되는지(idempotent) 확인하는 테스트를 추가한다.

📝 Committable suggestion

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

Suggested change
void deleteAllByYearMonthNot(String yearMonth);
`@Modifying`(clearAutomatically = true, flushAutomatically = true)
`@Query`("delete from MvProductRankMonthlyEntity e where e.yearMonth <> :yearMonth")
int deleteAllByYearMonthNot(`@Param`("yearMonth") String yearMonth);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java`
at line 10, 현재 Spring Data 파생 쿼리로 선언된 MvProductRankMonthlyJpaRepository의
deleteAllByYearMonthNot는 엔티티를 로드해 개별삭제하므로 성능·메모리·데이터 손실 위험이 있어, 이 메서드를
`@Modifying` 어노테이션과 JPQL 단일 DML DELETE 쿼리로 교체하고 int(또는 long) 삭제건수 반환하도록 변경하십시오; 변경
후 MonthlyRankAssignTasklet에서 반환된 삭제 건수를 로깅하고(단일 DML 실행 확인), 변경된 리포지토리 메서드명이든
기존명을 그대로 쓰든 JPQL을 사용해 "DELETE FROM MvProductRankMonthly m WHERE m.yearMonth <>
:yearMonth" 형태로 구현해 현재 yearMonth만 남는지 검증하는 통합테스트와 동일 배치 2회 실행 시 두 번째 실행에서 0건
삭제되는 idempotency 테스트를 추가하세요.

Comment on lines +16 to +81
public class MvProductRankWeeklyEntity {

@Id
@Comment("상품 id (ref)")
@Column(name = "product_id", nullable = false, updatable = false)
private Long productId;

@Comment("집계 점수")
@Column(name = "score", nullable = false)
private double score;

@Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)")
@Column(name = "year_week", nullable = false, length = 8)
private String yearWeek;

@Comment("랭킹 순위")
@Column(name = "product_rank", nullable = false)
private int productRank;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) {
this.productId = productId;
this.score = score;
this.yearWeek = yearWeek;
this.productRank = productRank;
}

public void update(double score, String yearWeek, int productRank) {
this.score = score;
this.yearWeek = yearWeek;
this.productRank = productRank;
}

@PrePersist
private void prePersist() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}

@PreUpdate
private void preUpdate() {
this.updatedAt = LocalDateTime.now();
}

public Long getProductId() {
return productId;
}

public double getScore() {
return score;
}

public String getYearWeek() {
return yearWeek;
}

public int getProductRank() {
return productRank;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

@Id 엔티티에 equals/hashCode 부재와 commerce-api 측 동일 엔티티와의 중복을 해소해야 한다.

  1. equals/hashCode 누락: productId가 PK인 JPA 엔티티인데 equals/hashCode가 정의되지 않아 기본 Object 구현에 의존한다. 배치에서 Set/Map 자료구조로 중복 제거를 시도하거나 영속성 컨텍스트 바깥에서 동일성 비교를 할 때 오동작 가능성이 있다. 식별자 기반으로 일관되게 구현할 것을 권장한다.

  2. 모듈 간 엔티티 중복: 동일한 매핑의 MvProductRankWeeklyEntityapps/commerce-apiapps/commerce-batch 양쪽에 중복 정의되어 있다(apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java 참조). 테이블 스키마/컬럼 길이/제약이 어느 한쪽만 바뀌면 조용히 드리프트가 발생하여 읽기(API)와 쓰기(배치) 간 런타임 매핑 불일치로 이어질 수 있고, 장애 발생 시 어느 모듈의 정의가 '정'인지 추적이 어려워진다. 공통 모듈(예: modules/commerce-domain 또는 commerce-infra)로 추출하거나, 최소한 한쪽을 소유자로 지정하고 다른 쪽은 해당 모듈에 의존하도록 구성할 것을 권장한다. 월간 엔티티에도 동일한 지적이 적용된다.

추가 테스트: 통합 테스트에서 commerce-batch가 쓴 레코드를 commerce-api의 엔티티로 조회하여 컬럼 길이/타입이 동일하게 역직렬화되는지 확인한다.

As per coding guidelines: "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.", "equals/hashCode는 식별자 기반 또는 값 기반으로 일관되게 설계한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.java`
around lines 16 - 81, MvProductRankWeeklyEntity is missing equals/hashCode and
duplicates exist across modules; implement consistent identifier-based equality
(override equals and hashCode in MvProductRankWeeklyEntity to use productId as
the identity, handling null id per JPA best-practice so transient instances
compare appropriately), and remove the duplicate by extracting the entity into a
shared module (e.g., commerce-domain/commerce-infra) or make one module depend
on the other so both use the single canonical MvProductRankWeeklyEntity
definition; apply the same change to the monthly entity counterpart and add an
integration test that writes via commerce-batch and reads via commerce-api to
validate column lengths/types match.


List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByScoreDesc(String yearWeek);

void deleteAllByYearWeekNot(String yearWeek);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Modifying 어노테이션이 붙은 delete 쿼리가 이미 있는지, 그리고 호출 지점의 트랜잭션 경계 확인
rg -nP -C3 '@Modifying|deleteAllByYearWeekNot|deleteAllByYearMonthNot'
rg -nP -C5 'WeeklyRankAssignTasklet|MonthlyRankAssignTasklet'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 23837


🏁 Script executed:

# WeeklyRankAssignTasklet의 전체 메서드 구조와 트랜잭션 경계 확인
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankAssignTasklet.java

# MonthlyRankAssignTasklet 확인
cat -n apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankAssignTasklet.java

# 엔티티 필드 구조 확인
rg -nP 'class MvProductRankWeeklyEntity|class MvProductRankMonthlyEntity' -A 15

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 13865


deleteAllByYearWeekNot 파생 쿼리는 select-then-delete로 동작하므로 대량 삭제 성능 리스크가 있다.

Spring Data JPA의 파생 삭제 쿼리는 대상 엔티티를 먼저 SELECT한 뒤 영속화 상태에서 row-by-row로 DELETE를 발행한다(@Modifying + 벌크 DELETE JPQL과는 다르다). 주간 MV 정리에서 이전 주 전체 레코드를 지울 때 수만~수십만 건이 쌓이면 (1) 메모리 사용량 급증, (2) 트랜잭션 로그/플러시 지연, (3) 배치 스텝 타임아웃으로 이어질 수 있다. 또한 호출 지점(WeeklyRankAssignTasklet 41행)이 findAllByYearWeekOrderByScoreDesc(43행)보다 먼저 실행되므로 영속성 컨텍스트가 오염된 상태로 이후 조회가 수행되어 1차 캐시 비효율이 겹친다.

♻️ 제안 diff
-    void deleteAllByYearWeekNot(String yearWeek);
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
+    `@Query`("delete from MvProductRankWeeklyEntity e where e.yearWeek <> :yearWeek")
+    int deleteByYearWeekNot(`@Param`("yearWeek") String yearWeek);

월간 쪽 MvProductRankMonthlyJpaRepository.deleteAllByYearMonthNot()도 동일 개선이 필요하다. 추가 테스트: 이전 주/월 레코드가 10k+ 존재하는 시나리오에서 tasklet 실행 시간이 임계치(예: 수 초) 내에 완료되는지, 실행 후 최신 주/월 데이터만 반환되는지 통합 테스트를 추가한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java`
at line 10, deleteAllByYearWeekNot 파생 삭제는 select-then-delete로 대량 삭제 시 메모리·성능
리스크가 있으니 MvProductRankWeeklyJpaRepository의 deleteAllByYearWeekNot를 `@Modifying`
JPQL bulk delete 또는 EntityManager/JdbcTemplate를 사용하는 명시적 bulk delete로 교체하고, 삭제
후에는 영속성 컨텍스트를 clear(예: EntityManager.clear() 또는
`@Transactional`(propagation=REQUIRES_NEW) 경계 후 clear)하여 WeeklyRankAssignTasklet에서
이어지는 findAllByYearWeekOrderByScoreDesc 조회가 1차 캐시 오염 없이 최신 데이터만 조회하도록 보장하세요; 동일한
방식으로 MvProductRankMonthlyJpaRepository.deleteAllByYearMonthNot도 수정하고, 이전 주/월
레코드가 10k+인 시나리오에서 tasklet 실행 시간과 결과 유효성을 검증하는 통합 테스트를 추가하세요.

Comment on lines 54 to 57
case "ORDER_CONFIRMED" -> {
productMetricsRepository.incrementSalesCount(productId);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), occurredAt.toLocalDate());
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

quantity null 역참조 및 음수/0 방어가 필요하다.

ORDER_CONFIRMED 분기에서 quantityInteger(박싱 타입)이므로 업스트림 이벤트에 수량 필드가 누락되면 Math.log1p(quantity) 오토언박싱과 incrementSalesAndQuantity(productId, metricsDate, quantity) 호출에서 NPE가 발생해 트랜잭션이 롤백되고 consumer offset이 재시도에 들어간다. 손상된 이벤트 한 건으로 인해 처리 지연이 누적되는 독립 실패가 발생할 수 있으므로, quantity == null || quantity <= 0인 경우는 경고 로그 후 스킵 또는 DLQ 처리를 권장한다.

추가 테스트: (1) quantity=null인 ORDER 이벤트 입력 시 예외 없이 스킵되고 메트릭/랭킹이 갱신되지 않는지, (2) quantity<=0일 때 동일 동작.

🛡️ 제안 수정
             case "ORDER_CONFIRMED" -> {
+                if (quantity == null || quantity <= 0) {
+                    log.warn("유효하지 않은 quantity. eventId={}, quantity={}", eventId, quantity);
+                    break;
+                }
                 productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity);
                 rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate);
             }
📝 Committable suggestion

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

Suggested change
case "ORDER_CONFIRMED" -> {
productMetricsRepository.incrementSalesCount(productId);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), occurredAt.toLocalDate());
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate);
}
case "ORDER_CONFIRMED" -> {
if (quantity == null || quantity <= 0) {
log.warn("유효하지 않은 quantity. eventId={}, quantity={}", eventId, quantity);
break;
}
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate, quantity);
rankingRepository.incrementScore(productId, 0.7 * Math.log1p(quantity), metricsDate);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsProcessor.java`
around lines 54 - 57, In the ORDER_CONFIRMED branch, guard against Integer
boxing NPEs and non-positive quantities: before calling
productMetricsRepository.incrementSalesAndQuantity(productId, metricsDate,
quantity) and rankingRepository.incrementScore(productId, 0.7 *
Math.log1p(quantity), metricsDate) check if quantity == null or quantity <= 0;
if so, emit a warning-level log with productId/metricsDate/quantity and then
skip processing (or route the event to DLQ) so neither incrementSalesAndQuantity
nor Math.log1p(quantity) are invoked on invalid input.

Comment on lines +29 to 31
public static ProductMetrics restore(Long id, Long productId, LocalDate metricsDate, int likeCount, int viewCount, int salesCount, int totalQuantity) {
return new ProductMetrics(id, productId, metricsDate, likeCount, viewCount, salesCount, totalQuantity);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# restore()를 5인자로 호출하거나 findByProductId(Long)만 사용하는 잔존 호출부 탐지
rg -nP 'ProductMetrics\.restore\s*\(' -C1
rg -nP '\.findByProductId\s*\(' -C1

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1639


🏁 Script executed:

# ProductMetrics.restore() 정의부 및 모든 호출부 확인
cat -n apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | head -40

# ProductMetricsEntity.java의 restore() 호출부 전체 확인
sed -n '50,65p' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsEntity.java

# 테스트 파일 호출부 확인
sed -n '55,60p' apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsProcessorTest.java

# ProductMetricsRepository 메서드 시그니처 확인
rg -A3 'findByProductId|findByProductIdAndMetricsDate' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3974


🏁 Script executed:

# ProductMetricsRepository 인터페이스와 구현의 전체 메서드 조회
fd -t f ProductMetricsRepository apps/commerce-streamer/src/main/java -exec cat {} \;

# 혹은 더 정확히: findByProductId 메서드가 남아있는지 확인
rg 'public.*findByProductId\s*\(' apps/commerce-streamer/src/main/java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 231


🏁 Script executed:

# ProductMetricsRepository 파일 찾기
find apps/commerce-streamer/src/main/java -name '*ProductMetricsRepository*' -type f

# 해당 파일들 내용 확인
find apps/commerce-streamer/src/main/java -name '*ProductMetricsRepository*' -type f | xargs cat

# findByProductId 메서드 유무 확인
rg 'findByProductId' apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3311


테스트 컴파일이 실패한다. 저장소 메서드와 restore() 시그니처 모두 변경되었으나 테스트는 수정되지 않았다.

ProductMetricsRepository에서 findByProductId(Long)이 제거되고 findByProductIdAndMetricsDate(Long, LocalDate)로 변경되었으며, ProductMetrics.restore()도 5개 인자에서 7개 인자(metricsDate, totalQuantity 추가)로 변경되었다. 그러나 apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsProcessorTest.java:58의 테스트 스텁은 여전히 구 메서드와 인자 개수를 사용 중이다. 빌드 전 필수 수정 대상이다.

🛠️ 제안 수정
- lenient().when(productMetricsRepository.findByProductId(PRODUCT_ID))
-     .thenReturn(Optional.of(ProductMetrics.restore(1L, PRODUCT_ID, 0, 0, 0)));
+ lenient().when(productMetricsRepository.findByProductIdAndMetricsDate(eq(PRODUCT_ID), any(LocalDate.class)))
+     .thenReturn(Optional.of(ProductMetrics.restore(1L, PRODUCT_ID, LocalDate.now(), 0, 0, 0, 0)));

추가 테스트 권고

  • ProductMetrics.create(productId, metricsDate) 호출 시 모든 카운트(likeCount, viewCount, salesCount, totalQuantity)가 0으로 초기화되는지 검증하는 단위 테스트.
  • ProductMetrics.restore(...)에 전달한 모든 필드값이 getter로 정확히 반환되는지 왕복 검증 테스트.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 29 - 31, The failing tests use the old repository method and
restore signature; update the test stub in ProductMetricsProcessorTest to call
ProductMetricsRepository.findByProductIdAndMetricsDate(productId, metricsDate)
instead of findByProductId(...) and construct the fixture via
ProductMetrics.restore(id, productId, metricsDate, likeCount, viewCount,
salesCount, totalQuantity) (include the metricsDate and totalQuantity
arguments), and adjust any mock/when(...) expectations to match the new method
name and parameter list so the mocked repository returns the restored
ProductMetrics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant