Skip to content

[10주차] Spring Batch 기반 주간/월간 랭킹 시스템 구현 - 정인철#401

Open
incheol789 wants to merge 18 commits into
Loopers-dev-lab:incheol789from
incheol789:round-10
Open

[10주차] Spring Batch 기반 주간/월간 랭킹 시스템 구현 - 정인철#401
incheol789 wants to merge 18 commits into
Loopers-dev-lab:incheol789from
incheol789:round-10

Conversation

@incheol789
Copy link
Copy Markdown

@incheol789 incheol789 commented Apr 16, 2026

📌 Summary

  • 선행 PR: R8에서 구현한 Kafka → product_metrics 일간 집계 파이프라인, R9에서 구현한 Redis ZSET 기반 실시간 일간 랭킹은 이미 머지된 상태입니다.
  • 배경: 일간 랭킹은 Redis ZSET(ranking:all:{yyyyMMdd})으로 실시간 제공되지만, TTL이 2일이라 "이번 주"/"이번 달" 같은 장기 집계를 유지하기 어려웠습니다. 이벤트가 쌓일수록 ZSET 메모리가 선형 증가하기 때문에 기간이 길수록 운영 비용도 부담스러워집니다.
  • 목표: product_metrics 테이블을 Spring Batch로 집계하여 주간/월간 TOP 100 랭킹을 MV(Materialized View) 테이블에 적재하고, 기존 GET /api/v1/rankings API에 period=daily|weekly|monthly 분기를 추가했습니다.
  • 결과: weeklyRankingJob, monthlyRankingJob 두 개의 독립 배치 Job 구성. 일간(ZSET) + 주간·월간(MV) 하이브리드 랭킹 아키텍처 확립. 재실행 시 멱등성을 보장하고, API 입력값 검증 및 Facade 중복 로직 제거까지 완료했습니다.

🧭 Context & Decision

문제 정의

항목 현재 동작 (R9까지) 한계
일간 랭킹 Redis ZSET 실시간 누적 (ZINCRBY) TTL 2일 이후 소멸, 기간 집계 불가
주간 랭킹 없음 "이번 주 인기 상품" 제공 불가
월간 랭킹 없음 "이번 달 인기 상품" 제공 불가
장기 보존 비용 ZSET 상주 메모리 상품 수 × 기간 일수만큼 선형 증가

실시간성이 필수인 일간과 달리, 주간/월간은 조회는 자주·갱신은 가끔 일어나는 패턴입니다. 이 차이를 인식하고 두 경로를 분리하는 것이 이번 라운드의 핵심 판단이었습니다.

저장 방식 선택지

대안 설명 문제
A. ZSET 기간 확장 (TTL 30일) 월간까지 Redis 하나로 메모리 비용 폭증, TTL 만료 시 일괄 소실 리스크
B. 매 요청마다 DB 집계 product_metrics를 on-the-fly로 ORDER BY score 조회 N회 × 풀스캔 = DB 부하. 트래픽 증가 시 한계
C. Materialized View + Batch (채택) 배치로 사전 집계 → MV 테이블에 TOP 100만 저장 쓰기 1회 / 읽기 N회 패턴에 최적

왜 Chunk-Oriented Processing인가

flowchart LR
    subgraph Tasklet["Tasklet 단일 트랜잭션"]
        T1["전체 데이터 메모리 적재"] --> T2["한 번에 처리/커밋"]
    end

    subgraph Chunk["Chunk-Oriented (채택)"]
        R["Reader\nCursor 스트리밍"] --> P["Processor\n1건씩 변환"]
        P --> W["Writer\nchunk=100 단위 커밋"]
    end

    style T1 fill:#e57373,color:#fff
    style T2 fill:#e57373,color:#fff
    style R fill:#81c784,color:#fff
    style P fill:#81c784,color:#fff
    style W fill:#81c784,color:#fff
Loading
방식 장점 단점 선택
Tasklet 단일 트랜잭션 구현 단순 상품 수 증가 시 OOM, 롤백 비용 폭발
Chunk-Oriented 스트리밍 + 단위 커밋, 메모리 일정 Reader/Processor/Writer 구성 필요
Partitioned Step 병렬 처리 TOP 100 규모엔 과설계

현재는 LIMIT 100이라 1회 커밋이면 끝나지만, 향후 TOP N을 확장해도 스트리밍 구조는 그대로 동작합니다. "지금의 단순함"과 "나중의 확장성"을 동시에 얻는 구조라고 판단했습니다.

Clear + Aggregate 2-Step 분리 (멱등성)

flowchart LR
    Start(["Job 시작"]) --> S1
    S1["Step 1\nClear Tasklet\nDELETE FROM mv WHERE yearWeek='2026W15'"] --> S2
    S2["Step 2\nAggregate Chunk\nproduct_metrics → MV TOP 100"] --> End(["Job 완료"])

    style S1 fill:#f5a742,color:#fff
    style S2 fill:#4287f5,color:#fff
Loading

의도: 동일 yearWeek·yearMonth 재실행 시 이전 배치 결과를 완전 대체합니다. 재계산 중에 기존 데이터가 남아있으면 응답에 중복이 섞이므로, 먼저 지우고 다시 채우는 순서로 분리했습니다. Step을 나누면 단계별 실패 복구가 쉬워지는 부수 효과도 있습니다 (Clear 실패 vs Aggregate 실패를 로그로 명확히 구분 가능).

🏗️ Design Overview

변경 범위

구분 모듈 파일 수 내용
프로덕션 (신규) commerce-batch 8개 Job Config, Tasklet, MV Entity, Repository, Row DTO
프로덕션 (신규) commerce-api 4개 읽기 전용 Entity(@immutable), JpaRepository
프로덕션 (수정) commerce-api 7개 Domain/Infra 확장, Facade 리팩토링, Controller 검증
프로덕션 (삭제) commerce-batch 2개 Demo Batch Job / DemoJobE2ETest 제거
테스트 commerce-batch 2개 WeeklyRankingJobE2ETest, CommerceBatchApplicationTest 수정

전체 아키텍처

sequenceDiagram
    participant Sched as 스케줄러/수동
    participant Batch as commerce-batch
    participant PM as product_metrics (일간 집계)
    participant MV as mv_product_rank_weekly / monthly
    participant API as commerce-api
    participant Client as 클라이언트

    Note over Sched, MV: 쓰기 경로 (Batch)

    Sched->>Batch: bootRun --spring.batch.job.name=weeklyRankingJob --requestDate=20260412
    Batch->>MV: Step 1. DELETE FROM mv WHERE yearWeek='2026W15'
    Batch->>PM: Step 2. SELECT ... ORDER BY score DESC LIMIT 100 (cursor)
    PM-->>Batch: rows (스트리밍)
    Batch->>Batch: Processor: 순위 부여 + yearWeek 계산
    Batch->>MV: Writer: JPA batch insert (chunk=100)

    Note over API, Client: 읽기 경로 (API)

    Client->>API: GET /api/v1/rankings?period=weekly&date=20260412
    API->>MV: SELECT ... WHERE year_week='2026W15' ORDER BY ranking ASC LIMIT size OFFSET ...
    MV-->>API: TOP N rows
    API->>API: 상품/브랜드 IN 쿼리로 Aggregation
    API-->>Client: 랭킹 + 상품/브랜드 정보
Loading

쓰기 경로: Spring Batch Job

WeeklyRankingJobConfig — Job/Step 구성

WeeklyRankingJobConfig.java

@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME)
@Configuration
public class WeeklyRankingJobConfig {

    public static final String JOB_NAME = "weeklyRankingJob";
    private static final int CHUNK_SIZE = 100;

    @Bean(JOB_NAME)
    public Job weeklyRankingJob() {
        return new JobBuilder(JOB_NAME, jobRepository)
                .incrementer(new RunIdIncrementer())
                .start(clearWeeklyRankingStep())      // Step 1: 기존 주차 MV DELETE
                .next(aggregateWeeklyRankingStep())    // Step 2: 집계 + 적재
                .listener(jobListener)
                .build();
    }

    @JobScope @Bean
    public Step aggregateWeeklyRankingStep() {
        return new StepBuilder("aggregateWeeklyRankingStep", jobRepository)
                .<ProductMetricsRow, MvProductRankWeeklyModel>chunk(CHUNK_SIZE, transactionManager)
                .reader(weeklyRankingReader(null))
                .processor(weeklyRankingProcessor(null))
                .writer(weeklyRankingWriter(null))
                .build();
    }
}

설계 포인트

  • @ConditionalOnProperty실행 대상 Job만 Bean 등록 → 여러 Job이 동시에 로드되어 오동작하는 것 방지
  • @JobScope·@StepScope를 통한 Job Parameter late binding (#{jobParameters['requestDate']})
  • RunIdIncrementer로 Spring Batch 내부 JobInstance 중복 회피, 비즈니스 멱등성은 별도 Clear Step이 담당

Reader — JdbcCursorItemReader

WeeklyRankingJobConfig.java L113-136

@StepScope @Bean
public JdbcCursorItemReader<ProductMetricsRow> weeklyRankingReader(DataSource dataSource) {
    return new JdbcCursorItemReaderBuilder<ProductMetricsRow>()
            .name("weeklyRankingReader")
            .dataSource(dataSource)
            .sql("""
                SELECT pm.product_id,
                       pm.view_count,
                       pm.like_count,
                       pm.sales_count,
                       (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score
                FROM product_metrics pm
                ORDER BY score DESC
                LIMIT 100
                """)
            .rowMapper((rs, rowNum) -> new ProductMetricsRow(
                    rs.getLong("product_id"),
                    rs.getLong("view_count"),
                    rs.getLong("like_count"),
                    rs.getLong("sales_count"),
                    rs.getDouble("score")))
            .build();
}

왜 JdbcCursorItemReader인가

  • commerce-batch 모듈에는 ProductMetricsModel JPA 엔티티가 없습니다 (해당 엔티티는 commerce-api 소유)
  • JpaPagingItemReader를 쓰려면 batch 모듈에도 엔티티를 중복 선언해야 함 → JDBC 커서로 raw SQL 직접 실행
  • 커서 기반 단방향 스트리밍이므로 상품 수가 많아져도 메모리 일정
  • 가중치는 R9 ZSET과 동일하게 view * 0.1 + like * 0.2 + sales * 0.7

Processor — 순위 부여 + yearWeek 계산

WeeklyRankingJobConfig.java L147-164

@StepScope @Bean
public ItemProcessor<ProductMetricsRow, MvProductRankWeeklyModel> weeklyRankingProcessor(
        @Value("#{jobParameters['requestDate']}") String requestDate
) {
    AtomicInteger rankCounter = new AtomicInteger(0);
    String yearWeek = ClearWeeklyRankingTasklet.computeYearWeek(requestDate);

    return item -> new MvProductRankWeeklyModel(
            item.productId(),
            item.viewCount(),
            item.likeCount(),
            item.salesCount(),
            item.score(),
            rankCounter.incrementAndGet(),  // 1, 2, 3, ... 순위 부여
            yearWeek
    );
}

Reader가 이미 score DESC로 정렬해서 내려주므로 Processor는 읽은 순서대로 AtomicInteger로 1-based 순위를 부여합니다. @StepScope가 있어 Step마다 새 인스턴스가 생성되고 rankCounter가 0으로 리셋됩니다.

ProductMetricsRow — Reader/Processor 간 DTO

ProductMetricsRow.java

public record ProductMetricsRow(
        Long productId,
        long viewCount,
        long likeCount,
        long salesCount,
        double score
) {}

Java record로 불변 + 한 파일 20줄. Reader의 RowMapper에서 생성되어 Processor로 흘러갑니다.

Writer — JpaItemWriter

WeeklyRankingJobConfig.java L168-176

@StepScope @Bean
public JpaItemWriter<MvProductRankWeeklyModel> weeklyRankingWriter(
        EntityManagerFactory entityManagerFactory
) {
    return new JpaItemWriterBuilder<MvProductRankWeeklyModel>()
            .entityManagerFactory(entityManagerFactory)
            .build();
}

chunk 단위(=100)로 entityManager.persist() + flush()를 자동 수행합니다. hibernate.jdbc.batch_size와 맞추면 하나의 JDBC batch로 적재됩니다.

Clear Tasklet — 멱등성 보장

ClearWeeklyRankingTasklet.java

@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
    String yearWeek = computeYearWeek(requestDate);       // "20260412" → "2026W15"
    log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek);
    weeklyJpaRepository.deleteByYearWeek(yearWeek);
    return RepeatStatus.FINISHED;
}

public static String computeYearWeek(String dateStr) {
    LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
    int year = date.get(WeekFields.ISO.weekBasedYear());
    int week = date.get(WeekFields.ISO.weekOfWeekBasedYear());
    return String.format("%dW%02d", year, week);
}

WeekFields.ISO를 사용하여 ISO 8601 주차(월요일 시작, 1월 첫 목요일이 포함된 주 = 1주차)를 계산합니다. 단순 getYear()를 쓰면 연말/연초 주차 경계에서 오류가 발생합니다 (예: 12월 31일이 다음 해 1주차에 속하는 경우).

월간도 구조는 동일하고 yearMonth = dateStr.substring(0, 6)로 더 단순합니다.
ClearMonthlyRankingTasklet.java

Materialized View 엔티티

MvProductRankWeeklyModel.java

@Entity
@Table(name = "mv_product_rank_weekly", indexes = {
    @Index(name = "idx_mv_weekly_year_week_ranking", columnList = "year_week, `ranking`")
})
public class MvProductRankWeeklyModel extends BaseEntity {
    @Column(nullable = false)                                    private Long productId;
    @Column(nullable = false)                                    private long viewCount;
    @Column(nullable = false)                                    private long likeCount;
    @Column(nullable = false)                                    private long salesCount;
    @Column(nullable = false)                                    private double score;
    @Column(name = "`ranking`", nullable = false)                private int ranking;
    @Column(name = "year_week", nullable = false, length = 10)   private String yearWeek;  // "2026W15"
}
설계 포인트 설명
ranking 예약어 MySQL 8.0에서 Window Function 도입으로 예약어가 되어 backtick escape 필수
복합 인덱스 (year_week, ranking) "해당 주차의 N위~M위" 쿼리를 Covering Index로 처리
year_month 컬럼명 MySQL 예약어는 아니지만 EXTRACT(YEAR_MONTH FROM ...) 함수와의 파싱 혼동을 피하기 위해 월간도 backtick escape

월간 버전: MvProductRankMonthlyModel.java


읽기 경로: Ranking API

MV 읽기 전용 엔티티 (@immutable)

MvProductRankWeeklyReadModel.java

@Entity
@Immutable
@Table(name = "mv_product_rank_weekly")
public class MvProductRankWeeklyReadModel {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // ... 동일한 컬럼 맵핑
}

왜 엔티티를 2개 만드는가

  • 같은 테이블을 batch(쓰기)와 api(읽기)가 공유 → commerce-api 쪽에서 실수로 쓰기 발생하는 것을 물리적으로 차단
  • @Immutable을 붙이면 Hibernate가 dirty checking을 건너뛰어 읽기 성능 최적화 + 쓰기 시도 시 SnapshotDiff 없음
  • 모듈 간 엔티티 공유 시 발생하는 양방향 의존성을 읽기/쓰기 관점별 별도 모델로 끊었다

RankingService — 기간별 조회 분기

RankingService.java

/** 일간 랭킹: R9 ZSET */
public List<RankingEntry> getTopRankings(String date, int page, int size) {
    String key = KEY_PREFIX + date;
    int offset = (page - 1) * size;
    return rankingRepository.getTopN(key, offset, size);
}

/** 주간 랭킹: date(yyyyMMdd)에서 yearWeek를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsWeekly(String date, int page, int size) {
    String yearWeek = computeYearWeek(date);
    int offset = (page - 1) * size;
    return rankingRepository.getTopNWeekly(yearWeek, offset, size);
}

/** 월간 랭킹: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
    String yearMonth = date.substring(0, 6);
    int offset = (page - 1) * size;
    return rankingRepository.getTopNMonthly(yearMonth, offset, size);
}

API가 전달한 date=yyyyMMdd를 도메인 개념(yearWeek, yearMonth)으로 변환하는 책임은 Service 레이어가 집니다. 컨트롤러는 format만 검증합니다.

RankingRedisRepository — DB-level Pagination

RankingRedisRepository.java L60-76

@Override
public List<RankingEntry> getTopNWeekly(String yearWeek, int offset, int size) {
    int page = offset / size;
    return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size))
            .stream()
            .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore()))
            .toList();
}

개선 전 → 개선 후

Before After
findByYearWeekOrderByRankingAsc(yearWeek) → TOP 100 전체 로딩 findByYearWeekOrderByRankingAsc(yearWeek, PageRequest)
애플리케이션 메모리에서 subList(offset, offset+size) DB LIMIT size OFFSET offset
100개 전체 네트워크 전송 필요한 size개만 전송

코드 리뷰에서 발견한 메모리 페이징 이슈를 PageRequest.of() 기반 DB-level pagination으로 수정했습니다.

RankingFacade — Aggregation 로직 공통화

RankingFacade.java

@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankings(String date, int page, int size) {
    List<RankingEntry> entries = rankingService.getTopRankings(date, page, size);
    return assembleResponse(entries, page, size);
}

@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankingsWeekly(String date, int page, int size) {
    List<RankingEntry> entries = rankingService.getTopRankingsWeekly(date, page, size);
    return assembleResponse(entries, page, size);
}

@Transactional(readOnly = true)
public RankingInfo.RankingPageResponse getRankingsMonthly(String date, int page, int size) {
    List<RankingEntry> entries = rankingService.getTopRankingsMonthly(date, page, size);
    return assembleResponse(entries, page, size);
}

/**
 * RankingEntry 목록을 상품/브랜드 정보와 조합하여 응답을 구성한다.
 * 일간/주간/월간 모두 동일한 Aggregation 로직이므로 공통 메서드로 추출.
 */
private RankingInfo.RankingPageResponse assembleResponse(
        List<RankingEntry> entries, int page, int size
) {
    // 상품 일괄 조회(IN 쿼리) → 브랜드 일괄 조회(IN 쿼리) → 순위 계산
}

Before / After

항목 Before After
Aggregation 로직 daily/weekly/monthly/db 4곳에 중복 assembleResponse() 단일 메서드
중복 라인 ~50줄 × 4 = 200줄 50줄 × 1
수정 지점 4군데 동시 수정 필요 1군데만

RankingV1Controller — period 분기 + 입력 검증

RankingV1Controller.java

private static final Set<String> VALID_PERIODS = Set.of("daily", "weekly", "monthly");

@GetMapping
public ApiResponse<RankingV1Dto.RankingPageResponse> getRankings(
        @RequestParam(defaultValue = "daily") String period,
        @RequestParam(required = false) String date,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "1") int page
) {
    if (!VALID_PERIODS.contains(period))
        throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다: " + period);
    if (page < 1)
        throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다.");
    if (size < 1 || size > 100)
        throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~100 범위여야 합니다.");

    if (date == null || date.isBlank()) {
        date = LocalDate.now().format(DATE_FORMAT);
    } else {
        try {
            LocalDate.parse(date, DATE_FORMAT);
        } catch (DateTimeParseException e) {
            throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
        }
    }

    RankingInfo.RankingPageResponse info = switch (period) {
        case "weekly"  -> rankingFacade.getRankingsWeekly(date, page, size);
        case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size);
        default        -> rankingFacade.getRankings(date, page, size);  // daily → ZSET
    };

    return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(info));
}

Clean Architecture에서 경계 검증은 interfaces 레이어의 책임이라는 컨벤션(CLAUDE.md)을 따랐습니다. Facade는 이미 유효한 입력만 받는 전제에서 동작합니다.

API 엔드포인트

Method Path 설명 응답
GET /api/v1/rankings?period=daily&date=yyyyMMdd&size=20&page=1 일간 랭킹 (Redis ZSET) 순위 + 점수 + 상품/브랜드
GET /api/v1/rankings?period=weekly&date=yyyyMMdd&size=20&page=1 주간 랭킹 (MV yearWeek) 순위 + 점수 + 상품/브랜드
GET /api/v1/rankings?period=monthly&date=yyyyMMdd&size=20&page=1 월간 랭킹 (MV yearMonth) 순위 + 점수 + 상품/브랜드
GET /api/v1/rankings/db?size=20&page=1 DB ORDER BY (R9 비교용) 순위 + 점수 + 상품/브랜드

🧪 테스트

테스트 매트릭스

# 테스트 소스 모듈 기대 결과
1 주간 배치 성공 시 MV 적재 + 순위 부여 + yearWeek 계산 WeeklyRankingJobE2ETest L62 (batch) E2E 3건 적재, 1등 점수 > 2등, yearWeek="2026W15"
2 동일 주차 재실행 시 이전 데이터 덮어쓰기 (멱등성) WeeklyRankingJobE2ETest L97 (batch) E2E 중복 없이 1건만 존재
3 Batch 모듈 ApplicationContext 로드 CommerceBatchApplicationTest 통합 Job 자동실행 없이 Context 로드 성공

멱등성 E2E 테스트 — rerun_replacesOldData

WeeklyRankingJobE2ETest.java L97-127

@Test
@DisplayName("배치 재실행 시 기존 데이터가 삭제되고 새로 적재된다")
void weeklyRankingJob_rerun_replacesOldData() throws Exception {
    jdbcTemplate.execute("""
        INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, sales_amount, version, updated_at)
        VALUES (1, 100, 50, 10, 1000000, 0, NOW())
        """);

    // 1차 실행 (requestDate=20260412, run.id=10)
    var p1 = new JobParametersBuilder()
            .addString("requestDate", "20260412").addLong("run.id", 10L).toJobParameters();
    jobLauncherTestUtils.launchJob(p1);

    // 2차 실행 (동일 requestDate, 다른 run.id)
    var p2 = new JobParametersBuilder()
            .addString("requestDate", "20260412").addLong("run.id", 11L).toJobParameters();
    var jobExecution2 = jobLauncherTestUtils.launchJob(p2);

    // then — 중복 없이 1건만 존재
    assertThat(jobExecution2.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());
    assertThat(weeklyJpaRepository.findAll()).hasSize(1);
}

Clear Step이 먼저 기존 yearWeek="2026W15" 데이터를 DELETE한 뒤 Aggregate Step이 새로 INSERT하므로, 두 번 실행해도 최종 상태는 동일 파라미터에 대해 동일한 결과가 나옵니다.

테스트 시 만난 이슈와 해결

증상 원인 해결
contextLoads() 실패 — "No job found with name 'NONE'" spring.batch.job.name=NONE default가 JobLauncherApplicationRunner를 기동시킴 @TestPropertySource("spring.batch.job.enabled=false") 로 자동실행 비활성화
rerun 테스트 JobInstanceAlreadyCompleteException RunIdIncrementer 없이 동일 파라미터 재실행 run.id 파라미터를 1차=10L, 2차=11L 명시적 부여
product_metrics 테이블 없음 해당 테이블은 api 모듈의 엔티티로 Hibernate가 생성, batch 모듈은 생성하지 않음 @BeforeEach에서 JdbcTemplate으로 수동 생성

WeeklyRankingJobE2ETest.java L43-60

✅ 과제 체크리스트

구분 요건 충족
🧱 Spring Batch Job을 작성하고 파라미터 기반으로 동작시킬 수 있다 --requestDate=yyyyMMdd Job Parameter 기반
Chunk-Oriented Processing 기반의 배치 처리를 구현 ✅ Reader(Cursor) + Processor + Writer(JPA), chunk size=100
집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재 mv_product_rank_weekly, mv_product_rank_monthly TOP 100
🧩 Ranking API 일간·주간·월간 랭킹을 적절한 데이터로 제공 ✅ daily→ZSET / weekly·monthly→MV ?period= 분기
검증 재실행 멱등성 ✅ Clear Step으로 해당 기간 데이터 선삭제 (E2E로 검증)
API 입력 검증 (period, page, size, date) ✅ CoreException(BAD_REQUEST)
ISO 주차 경계 정확성 WeekFields.ISO 사용 (12/31 → 다음 해 1주차 케이스 처리)

📁 파일 변경 요약

신규 (commerce-batch)
├── batch/job/ranking/
│   ├── ProductMetricsRow.java              # Reader → Processor DTO (record)
│   ├── weekly/
│   │   ├── WeeklyRankingJobConfig.java     # Job/Step/Reader/Processor/Writer
│   │   └── step/ClearWeeklyRankingTasklet.java  # yearWeek 계산 + DELETE
│   └── monthly/
│       ├── MonthlyRankingJobConfig.java
│       └── step/ClearMonthlyRankingTasklet.java
├── domain/ranking/
│   ├── MvProductRankWeeklyModel.java       # JPA Entity (`ranking` backtick)
│   └── MvProductRankMonthlyModel.java
└── infrastructure/ranking/
    ├── MvProductRankWeeklyJpaRepository.java   # deleteByYearWeek (@Modifying)
    └── MvProductRankMonthlyJpaRepository.java

신규 (commerce-api)
├── domain/ranking/
│   ├── MvProductRankWeeklyReadModel.java   # @Immutable 읽기 전용 엔티티
│   └── MvProductRankMonthlyReadModel.java
└── infrastructure/ranking/
    ├── MvProductRankWeeklyJpaRepository.java   # findByYearWeekOrderByRankingAsc(yearWeek, Pageable)
    └── MvProductRankMonthlyJpaRepository.java

수정 (commerce-api)
├── application/ranking/RankingFacade.java      # 중복 제거 + weekly/monthly 추가
├── domain/ranking/
│   ├── RankingRepository.java                  # getTopNWeekly, getTopNMonthly 인터페이스
│   └── RankingService.java                     # getTopRankingsWeekly/Monthly (yearWeek 계산)
├── infrastructure/ranking/RankingRedisRepository.java  # DB-level pagination(PageRequest)
├── interfaces/api/ranking/
│   ├── RankingV1ApiSpec.java                   # period 파라미터 추가
│   └── RankingV1Controller.java                # period 분기 + 입력 검증

삭제 (commerce-batch)
├── batch/job/demo/DemoJobConfig.java           # 튜토리얼 Job 제거
└── test/.../job/demo/DemoJobE2ETest.java

테스트
├── commerce-batch/.../CommerceBatchApplicationTest.java  # spring.batch.job.enabled=false
└── commerce-batch/.../job/ranking/WeeklyRankingJobE2ETest.java  # 신규 E2E (성공 + rerun)

커밋 이력 (18개)

fb8c99b chore: Demo Batch Job 제거
9db58e6 feat: 주간 랭킹 MV 엔티티 및 Repository 추가
8abb653 feat: 월간 랭킹 MV 엔티티 및 Repository 추가
781df41 feat: 주간 랭킹 Clear Tasklet 구현
ffad6f0 feat: 주간 랭킹 Batch Job 구성 (Chunk Processing)
933b4cb feat: 월간 랭킹 Clear Tasklet 구현
d7652e0 feat: 월간 랭킹 Batch Job 구성 (Chunk Processing)
6b0e5f1 feat: Batch Reader용 ProductMetricsRow DTO 추가
4cb6777 feat: 주간 MV 읽기 전용 엔티티 추가
3be29f7 feat: 월간 MV 읽기 전용 엔티티 추가
3f247eb feat: 랭킹 도메인 주간/월간 조회 인터페이스 확장
d37ea7b feat: 랭킹 인프라 주간/월간 조회 구현
eb70ab7 feat: 랭킹 Facade 주간/월간 조회 메서드 추가
62ccbca feat: 랭킹 API period 파라미터 분기 적용
1efd47b refactor: 랭킹 Facade 중복 Aggregation 로직 통합
eca8412 fix: 랭킹 API 입력값 검증 추가 (period, page, size, date)
0b117cb test: 배치 E2E 테스트 product_metrics 테이블 생성 보완
e529603 test: CommerceBatchApplicationTest Job 자동실행 비활성화

🔄 트레이드오프 분석

항목 선택 트레이드오프 판단
주간/월간 저장 방식 MV + Batch vs ZSET 기간 확장 (메모리↑) / vs on-the-fly 집계(DB 부하↑) 쓰기 1회 / 읽기 N회 패턴에 적합. 일간은 ZSET 유지(하이브리드)
Reader 구현 JdbcCursorItemReader (raw SQL) vs JpaPagingItemReader batch 모듈에 ProductMetricsModel 엔티티 없음. 커서 스트리밍으로 충분
Chunk size 100 작으면 커밋 횟수↑ / 크면 메모리·롤백 범위↑ 현재 LIMIT 100이라 1회 커밋. 향후 확장 대비 기본값
멱등성 전략 Clear Step 분리 vs UPSERT Step 분리 시 실패 지점 명확화. UPSERT는 복합키·락 별도 고려 필요
엔티티 모델 쓰기용(batch) + 읽기용(api) 분리 엔티티 2벌 관리 모듈 경계 침범 방지 + @Immutable 최적화
API 입력 검증 Controller 레이어 Facade로 내리면 중복 가능 Clean Architecture — 경계 검증은 interfaces 책임
Job 자동실행 @ConditionalOnProperty 모든 Job이 동시 로드되지 않음 특정 Job만 Bean 등록 → 테스트 격리, 오동작 방지
ISO 주차 계산 WeekFields.ISO vs 단순 getYear() 연말/연초 주차 경계 케이스 처리 (12/31 → 다음 해 1주차)

문제점 해결 사례: 코드 리뷰에서 발견한 MySQL 예약어 이슈

초기 구현에서는 MV 테이블의 ranking 컬럼을 별도 처리 없이 선언했는데, MySQL 8.0에서 RANK() Window Function 도입과 함께 RANK·RANKING이 예약어가 되었습니다. Hibernate가 생성하는 DDL에서 컬럼명이 escape 없이 나가면 다음과 같은 에러가 발생합니다.

-- Hibernate가 생성한 DDL (에러)
CREATE TABLE mv_product_rank_weekly (
    ...
    ranking INT NOT NULL,   -- ❌ MySQL 8.0 예약어
    ...
)
flowchart LR
    subgraph before["수정 전"]
        B1["@Column(nullable = false)\nprivate int ranking;"]
        B2["DDL: ranking INT"]
        B3["❌ SQL syntax error"]
        B1 --> B2 --> B3
    end

    subgraph after["수정 후"]
        A1["@Column(name = "`ranking`", nullable = false)\nprivate int ranking;"]
        A2["DDL: `ranking` INT"]
        A3["✅ OK"]
        A1 --> A2 --> A3
    end

    style B3 fill:#e57373,color:#fff
    style A3 fill:#81c784,color:#fff
Loading

동시에 복합 인덱스도 컬럼명을 맞추지 않으면 DDL은 생성되지만 인덱스가 엉뚱한 컬럼을 바라보는 정적 버그가 발생합니다.

// Before: columnList의 'ranking'이 Hibernate 기본 네이밍 'ranking'을 찾지 못함
@Index(name = "idx_mv_weekly", columnList = "yearWeek, ranking")

// After: 실제 DB 컬럼명과 일치 (backtick 포함)
@Index(name = "idx_mv_weekly_year_week_ranking", columnList = "year_week, `ranking`")

코드 리뷰에서 이 이슈를 발견해 fix 커밋으로 수정했습니다. 프로덕션 배포 전 발견해서 다행이지만, **"Hibernate가 생성한 DDL이 실제로 인덱스를 제대로 만들었는지 검증하는 테스트"**의 필요성을 느꼈습니다.

💬 리뷰 포인트

1. Chunk size는 어떤 기준으로 정하는 게 맞는지

제가 내린 답은 "지금은 LIMIT 100이라 100으로 두되, 확장 대비 가이드가 필요하다"입니다.

flowchart TD
    subgraph factors["Chunk size에 영향을 주는 요인"]
        F1["메모리\n크면 OOM 위험"]
        F2["커밋 주기\n작으면 I/O 왕복↑"]
        F3["롤백 범위\n크면 실패 시 손실↑"]
        F4["JDBC batch_size\nHibernate 설정과 연동"]
    end

    subgraph current["현재 선택"]
        C["chunk=100\n(LIMIT 100이라 1회 커밋으로 종료)"]
    end

    factors --> C

    style C fill:#f5a742,color:#fff
Loading

현재는 LIMIT 100이 Reader의 한계이므로 chunk=100이면 Step당 1회 커밋이 일어나고 끝납니다. 하지만 향후 TOP 1,000 또는 TOP 10,000으로 확장되면 chunk size 선택이 실제 성능에 영향을 미치게 됩니다.

옵션 chunk 크기 커밋 횟수 메모리 언제 선택?
작게 (10) 1,000회 소량 메모리 제약이 심한 환경
중간 (100) 100회 보통 현재 선택 — 범용 기본값
크게 (1,000) 10회 다량 Bulk Insert 최적화

질문: 실무에서 chunk size를 정할 때 JDBC hibernate.jdbc.batch_size와 맞추는 게 정석이라는 얘기를 봤는데, 그게 항상 옳은지 궁금합니다. 둘이 불일치할 경우 어떤 증상(하나의 chunk가 여러 JDBC batch로 쪼개져서 flush가 많아짐)이 나타나는지, 실제로 이 두 값을 어떻게 맞추고 계신지 멘토님의 경험이 궁금합니다.


2. MV 재계산 주기 — 매일 전체 재계산 vs 증분 계산

현재는 매일(또는 매주/매월 마지막 날) 전체 재계산 방식입니다. 집계 규모가 커질수록 불리한 구조라는 생각이 들었습니다.

flowchart LR
    subgraph full["현재: 매번 전체 재계산"]
        S1["Clear\nDELETE 전체"] --> S2["Aggregate\nproduct_metrics 풀스캔\n→ LIMIT 100"]
    end

    subgraph delta["대안: 증분 계산"]
        D1["last_run_at 이후\n변경분만 읽기"] --> D2["기존 MV와 MERGE"]
    end

    full -->|"상품 수 증가 시 한계"| delta

    style full fill:#ef9a9a,color:#fff
    style delta fill:#a5d6a7,color:#fff
Loading
방식 장점 단점
전체 재계산 (현재) 단순, 항상 정합성 보장 데이터 커지면 배치 시간↑, DB 부하↑
증분 계산 효율적, 짧은 배치 시간 last_run_at 관리 복잡, MERGE 시 정합성 이슈 가능

현재 규모(상품 수천~수만)에선 전체 재계산이 단순해서 낫지만, 언제 증분 계산으로 전환해야 하는지 그 전환 임계점을 경험으로 가지고 있지 않습니다.

질문: 실무에서 배치 집계를 설계할 때 증분 방식으로 전환하는 **heuristic(소요 시간 > N분, 데이터 > N건, DB 부하 > N% 등)**이 있는지 궁금합니다. 그리고 Spring Batch 생태계에서 이를 구현할 때 흔히 쓰는 패턴(CDC, Event Sourcing, last_run_at 테이블 등)이 있는지도 궁금합니다.


3. ZSET + MV 하이브리드 운영 시 점수 정합성 문제

일간은 실시간 ZSET, 주간·월간은 배치 MV — 이 두 경로가 같은 "가중치 0.1/0.2/0.7"을 쓰긴 하지만, ZSET은 이벤트 발생 시점마다 누적하고 MV는 product_metrics 누적값으로부터 계산한다는 차이가 있습니다.

flowchart TD
    subgraph daily["일간 (ZSET)"]
        E1["이벤트 발생"] --> Z["ZINCRBY\n실시간 누적"]
    end

    subgraph weekly["주간 (MV)"]
        PM["product_metrics\n누적 집계"] --> B["Batch\nORDER BY score"]
    end

    subgraph risk["정합성 리스크"]
        R1["ZSET: 하루 이벤트 실시간 합산"]
        R2["MV: product_metrics 누적값 집계"]
        R1 -.->|"동일 가중치지만\n집계 단위·주기 다름"| R2
    end

    daily --> risk
    weekly --> risk

    style risk fill:#fff3e0,color:#333
Loading

사용자 관점에서 "왜 어제까지 일간 3위였던 상품이 주간에선 50위?" 같은 혼란이 생길 수 있습니다. 원인은 ZSET은 하루치 이벤트만 반영하고, MV는 전체 누적값에서 상위 100을 뽑기 때문입니다. 둘 다 "랭킹"이지만 의미가 다른 지표입니다.

대안 설명 트레이드오프
A. 설명 UI 추가 "일간은 오늘만, 주간은 이번 주 누적" 명시 구현 단순, UX 책임
B. 일간도 DB 집계 ZSET 대신 product_metrics에서 일간 필터링 실시간성 손실
C. 주간도 일별 스냅샷 합산 매일 일간 결과를 저장 후 주간에 합산 저장소·구현 복잡도↑

질문: 실무에서 이처럼 저장 방식이 다른 여러 랭킹이 공존할 때, 사용자 혼란을 어떻게 관리하시는지 궁금합니다. 일간·주간·월간의 산정 방식을 일부러 통일하는지(그래서 실시간성을 일부 양보하는지), 아니면 UI/설명으로 각각의 의미를 명확히 하는지 멘토님의 경험이 궁금합니다.

Spring Batch 기반 주간/월간 랭킹 MV 시스템 구현

변경 목적: Redis ZSET의 TTL 유지 비용 절감을 위해 주간/월간 랭킹을 주기적으로 사전 계산하여 DB 물리화 뷰(MV) 테이블에 저장하고, 기존 daily 랭킹 API를 확장하여 period 파라미터로 daily(Redis)/weekly/monthly(MV) 데이터 소스를 분기 제공.

핵심 변경점:

  • 배치 Job: weeklyRankingJob, monthlyRankingJob 두 개 추가. 각각 Clear step(기존 데이터 삭제) + Chunk-oriented aggregation step(product_metrics에서 TOP 100 읽기 → 가중치 점수 계산 → 순위 부여 → MV 테이블 적재) 구성
  • MV 테이블: mv_product_rank_weekly, mv_product_rank_monthly 엔티티/테이블 신규 추가 (배치 모듈 및 API 모듈에서 각각 쓰기/읽기 전용 엔티티 분리 설계)
  • API 확장: GET /api/v1/rankings?period={daily|weekly|monthly}&date=yyyyMMdd&page=size= - 컨트롤러에서 period 값에 따라 daily(Redis ZSET) 또는 weekly/monthly(MV 테이블) 조회로 분기
  • 중복 제거: RankingFacade의 aggregation 로직을 assembleResponse 메서드로 추출하여 daily/weekly/monthly에서 공통 재사용

테스트 및 검증:

  • WeeklyRankingJobE2ETest에서 동일 requestDate로 배치 재실행 시 기존 MV 데이터 교체 확인 (idempotency 보증)
  • CommerceBatchApplicationTest에 spring.batch.job.enabled=false 적용으로 테스트 시 배치 자동 실행 비활성화
  • 테스트에서 product_metrics 테이블을 JDBC로 동적 생성 (batch 모듈에는 해당 엔티티 미포함)

주의/확인사항:

  • TOP 100 고정: 성능 및 스토리지 고려로 보이나, 추후 변경 필요 시 배치 Job 설정에서 LIMIT 값 수정 필요
  • 기존 demo Batch Job 완전 제거 (DemoJobConfig, DemoJobE2ETest 삭제)
  • product_metrics 테이블은 commerce-streamer 모듈에서 관리 (streamer에서 지속적 업데이트) / batch는 JDBC로 읽기 전용 사용

@incheol789 incheol789 self-assigned this Apr 16, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

📝 Walkthrough

Walkthrough

랭킹 조회 기능을 일일/주간/월간 세 가지 기간으로 확장하기 위해 새로운 읽기 모델(weekly/monthly MV 테이블), JPA 저장소, 배치 작업을 추가하고, API 계층에서 period 파라미터로 라우팅하는 변경을 수행했다.

Changes

Cohort / File(s) Summary
API 컨트롤러 및 스펙
RankingV1Controller, RankingV1ApiSpec
period 파라미터 추가 및 weekly/monthly 라우팅 로직 구현, 날짜 형식 유효성 검증 강화, page/size 범위 검증 추가
도메인 계층 서비스
RankingService, RankingRepository
getTopRankingsWeekly/getTopRankingsMonthly 메서드 추가, 주간/월간 offset 계산 및 연도-주차/월 파싱 로직 구현
애플리케이션 계층 파사드
RankingFacade
기존 getRankings/getRankingsFromDB를 assembleResponse로 위임하여 중복 제거, 새로운 getRankingsWeekly/getRankingsMonthly 메서드 추가
인프라 저장소 및 읽기 모델
RankingRedisRepository, MvProductRankWeeklyReadModel, MvProductRankMonthlyReadModel, MvProductRankWeeklyJpaRepository, MvProductRankMonthlyJpaRepository
Redis 저장소에 weekly/monthly 조회 메서드 추가, 읽기 전용 JPA 엔티티 및 저장소 인터페이스 신규 추가
배치 주간 랭킹 작업
WeeklyRankingJobConfig, ClearWeeklyRankingTasklet, MvProductRankWeeklyModel, MvProductRankWeeklyJpaRepository
주간 랭킹 배치 구성, 청소 태스크릿, 도메인 엔티티, 저장소 추가, 상위 100개 상품 조회 및 순위 산정 후 저장
배치 월간 랭킹 작업
MonthlyRankingJobConfig, ClearMonthlyRankingTasklet, MvProductRankMonthlyModel, MvProductRankMonthlyJpaRepository
월간 랭킹 배치 구성, 청소 태스크릿, 도메인 엔티티, 저장소 추가, 주간과 유사한 흐름으로 구현
배치 공통 및 테스트
ProductMetricsRow, CommerceBatchApplicationTest, WeeklyRankingJobE2ETest, DemoJobConfig (삭제), DemoJobE2ETest (삭제)
ProductMetricsRow 레코드 추가, 배치 비활성화 설정, 주간 랭킹 배치 E2E 테스트 추가, 기존 demo 배치 작업 및 테스트 제거

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as RankingV1Controller
    participant Facade as RankingFacade
    participant Service as RankingService
    participant Repo as RankingRepository
    participant RedisRepo as RankingRedisRepository
    participant JpaRepo as MvProductRankWeeklyJpaRepository
    participant DB as Database

    Client->>Controller: GET /rankings?period=weekly&date=20260412&page=1&size=10
    activate Controller
    Controller->>Controller: validate period, date, page, size
    alt period == "weekly"
        Controller->>Facade: getRankingsWeekly("20260412", 1, 10)
    else period == "monthly"
        Controller->>Facade: getRankingsMonthly(...)
    else default
        Controller->>Facade: getRankings(...)
    end
    deactivate Controller

    activate Facade
    Facade->>Service: getTopRankingsWeekly("20260412", 1, 10)
    deactivate Facade

    activate Service
    Service->>Service: computeYearWeek("20260412") → "2026W15"
    Service->>Service: offset = (1-1)*10 = 0
    Service->>Repo: getTopNWeekly("2026W15", 0, 10)
    deactivate Service

    activate Repo
    Repo->>RedisRepo: getTopNWeekly("2026W15", 0, 10)
    deactivate Repo

    activate RedisRepo
    RedisRepo->>RedisRepo: page = 0/10 = 0
    RedisRepo->>JpaRepo: findByYearWeekOrderByRankingAsc("2026W15", PageRequest.of(0, 10))
    deactivate RedisRepo

    activate JpaRepo
    JpaRepo->>DB: SELECT * FROM mv_product_rank_weekly WHERE year_week='2026W15' ORDER BY ranking ASC LIMIT 10
    DB-->>JpaRepo: List<MvProductRankWeeklyReadModel>
    deactivate JpaRepo

    JpaRepo-->>RedisRepo: RankingEntry list
    RedisRepo-->>Facade: List<RankingEntry>
    Facade->>Facade: assembleResponse(entries, 1, 10) - 제품/브랜드 데이터 병합
    Facade-->>Controller: RankingPageResponse
    Controller-->>Client: ApiResponse<RankingPageResponse>
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 주간/월간 랭킹 시스템 구현이라는 핵심 변경사항을 명확하게 표현하고 있다.
Description check ✅ Passed PR 설명이 요구 템플릿의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram, 테스트, 체크리스트)을 충실하게 포함하고 있다.

✏️ 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.

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: 7

🧹 Nitpick comments (4)
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java (1)

40-48: 생성자에서 월 키/순위 도메인 검증을 즉시 수행해야 한다.

운영 관점에서 잘못된 yearMonth(형식 불일치)나 ranking(0 이하)이 저장되면 월간 랭킹 결과가 배치 재실행 전까지 오염될 수 있다.
수정안으로 생성자에서 productId null 체크, ranking >= 1, yearMonth 정규식(^\d{6}$) 검증을 fail-fast로 적용하는 것이 안전하다.
추가 테스트로 잘못된 yearMonth, ranking=0, productId=null 입력 시 예외 발생을 검증하는 단위 테스트를 추가해야 한다.

제안 diff
+import java.util.Objects;
+import java.util.regex.Pattern;
...
 public class MvProductRankMonthlyModel extends BaseEntity {
+    private static final Pattern YEAR_MONTH_PATTERN = Pattern.compile("^\\d{6}$");
...
     public MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount,
                                      long salesCount, double score, int ranking, String yearMonth) {
-        this.productId = productId;
+        this.productId = Objects.requireNonNull(productId, "productId must not be null");
+        if (ranking < 1) {
+            throw new IllegalArgumentException("ranking must be >= 1");
+        }
+        if (yearMonth == null || !YEAR_MONTH_PATTERN.matcher(yearMonth).matches()) {
+            throw new IllegalArgumentException("yearMonth must match yyyyMM");
+        }
         this.viewCount = viewCount;
         this.likeCount = likeCount;
         this.salesCount = salesCount;
         this.score = score;
         this.ranking = ranking;
         this.yearMonth = yearMonth;
     }
 }

As per coding guidelines **/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.

🤖 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/domain/ranking/MvProductRankMonthlyModel.java`
around lines 40 - 48, In MvProductRankMonthlyModel's constructor (the public
MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, long
salesCount, double score, int ranking, String yearMonth) method) add fail-fast
validation: check productId for null and throw NullPointerException (or
IllegalArgumentException) if null, enforce ranking >= 1 and throw
IllegalArgumentException when <= 0, and validate yearMonth against the regex
^\d{6}$ throwing IllegalArgumentException on mismatch; update the constructor to
perform these checks before assigning fields and add unit tests that assert
exceptions for productId=null, ranking=0, and invalid yearMonth inputs.
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java (1)

45-53: 주간 키/순위 입력 검증이 없어 데이터 오염을 조기에 차단하지 못한다.

운영 관점에서 잘못된 yearWeek 형식이나 비정상 ranking 값이 적재되면 주간 랭킹 결과가 왜곡되고, 원인 파악이 배치 완료 후로 지연된다.
수정안으로 생성자에서 productId null 체크, ranking >= 1, yearWeek 정규식(^\d{4}W\d{2}$) 검증을 추가해야 한다.
추가 테스트로 yearWeek="2026-15", ranking=0, productId=null 케이스에서 예외가 발생하는지 검증해야 한다.

As per coding guidelines **/domain/**/*.java: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.

🤖 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/domain/ranking/MvProductRankWeeklyModel.java`
around lines 45 - 53, In MvProductRankWeeklyModel's constructor add validation
to prevent bad weekly keys and rankings: check productId is not null, check
ranking >= 1, and validate yearWeek against the regex ^\d{4}W\d{2}$; if any
check fails throw an IllegalArgumentException with a clear message. Also make
the model fields immutable (declare them final if not already) to follow domain
immutability/encapsulation rules. Add unit tests for the failure cases
yearWeek="2026-15", ranking=0, and productId=null to assert exceptions are
thrown.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)

17-20: 인터페이스 계약 명확화로 유지보수성을 높여야 한다.

Line 17~20의 getTopNWeekly, getTopNMonthly 메서드는 offset 파라미터를 사용하지만 JavaDoc이 없어 의도가 불명확하다. 현재는 RankingService에서 offset = (page - 1) * size 형태로만 호출되고, 구현에서도 이를 가정하고 offset / size로 페이지를 계산하므로 실제 동작은 안전하다. 그러나 계약이 불명확하면 향후 다른 호출자 추가 시 또는 유지보수 과정에서 오프셋 계산 실수가 발생할 수 있다.

다음 중 하나로 개선한다:

  • JavaDoc에 "offset은 항상 page * size 형태여야 함" 명시
  • 또는 메서드 시그니처를 (String yearWeek, int page, int size)로 리팩토링하여 page-based 의도를 명확히 함
🤖 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 17 - 20, The methods getTopNWeekly and getTopNMonthly currently use
an ambiguous int offset; change their signatures to use int page (e.g.,
getTopNWeekly(String yearWeek, int page, int size) and getTopNMonthly(String
yearMonth, int page, int size)) to express page-based intent, then update all
implementations of RankingRepository and all callers (notably RankingService) to
compute offset = (page - 1) * size before performing data access, and adjust
unit/integration tests and any documentation to reflect the new parameter
semantics.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java (1)

61-75: offset 기반 계약이라면 page 정수 나눗셈에 기대면 안 된다

Line 62와 Line 71은 offset / size를 page로 바꾸므로 offsetsize의 배수가 아닐 때 계약과 다른 구간을 반환한다. 지금은 RankingService(page - 1) * size를 넘겨서 우연히 맞지만, 저장소 시그니처는 offset 기반이라 재사용 시 중복/누락 페이지가 생긴다. 시그니처를 page/size로 바꾸거나, 최소한 offset % size == 0을 검증해 잘못된 호출을 즉시 실패시키는 편이 낫다. 추가 테스트로는 정렬된 주간/월간 MV 데이터에서 offset=10, size=20 같은 입력이 잘못된 범위를 반환하지 않는지 확인해야 한다.

최소 방어선 예시
     `@Override`
     public List<RankingEntry> getTopNWeekly(String yearWeek, int offset, int size) {
+        if (offset % size != 0) {
+            throw new IllegalArgumentException("offset must be a multiple of size");
+        }
         int page = offset / size;
         return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size))
                 .stream()
                 .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore()))
                 .toList();
     }
 
     `@Override`
     public List<RankingEntry> getTopNMonthly(String yearMonth, int offset, int size) {
+        if (offset % size != 0) {
+            throw new IllegalArgumentException("offset must be a multiple of size");
+        }
         int page = offset / size;
         return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size))
                 .stream()
                 .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore()))
                 .toList();
     }
🤖 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/RankingRedisRepository.java`
around lines 61 - 75, The methods getTopNWeekly and getTopNMonthly in
RankingRedisRepository treat the incoming offset as if it were a page by
computing page = offset / size, which breaks the offset-based contract; either
change the repository signature to accept page and size, or validate the offset
is page-aligned and fail fast—add a guard at the start of getTopNWeekly and
getTopNMonthly to check size > 0 and offset % size == 0 and throw an
IllegalArgumentException (or similar) if not, then compute page = offset / size
and proceed to call weeklyJpaRepository.findByYearWeekOrderByRankingAsc and
monthlyJpaRepository.findByYearMonthOrderByRankingAsc respectively; ensure tests
cover a non-aligned example like offset=10,size=20 to catch regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java`:
- Around line 58-63: getTopRankingsMonthly currently derives yearMonth via
date.substring(0,6) which can throw StringIndexOutOfBoundsException for short
inputs or produce invalid months for malformed dates; change
getTopRankingsMonthly to parse the incoming date string into a
java.time.LocalDate (using appropriate DateTimeFormatter patterns or trying
multiple patterns like yyyyMMdd and yyyyMM01), validate the parsed date, then
format it to "yyyyMM" (e.g., DateTimeFormatter.ofPattern("yyyyMM")) and pass
that to rankingRepository.getTopNMonthly; on parse/validation failure throw a
clear IllegalArgumentException with a descriptive message so callers get a
predictable error flow.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java`:
- Around line 50-55: The LocalDate parsing in RankingV1Controller currently uses
a SMART resolver (LocalDate.parse(date, DATE_FORMAT)) which auto-corrects
invalid dates; change the formatter to enforce strict validation by replacing
DATE_FORMAT with DateTimeFormatter.BASIC_ISO_DATE (preferred) or update the
existing DATE_FORMAT to
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
so LocalDate.parse(date, DATE_FORMAT) throws DateTimeParseException for inputs
like 20260230/20250229/202401; update the controller to use the new formatter
and add unit tests asserting that those invalid inputs result in
DateTimeParseException (and therefore a 400 via the existing CoreException
path).

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java`:
- Around line 86-95: ORDER BY 절이 score만 있어 동점인 경우 순서가 비결정적이므로
MonthlyRankingJobConfig에서 사용된 SQL의 ORDER BY를 수정해 score DESC에 안정적인 보조
키(product_id 등)를 추가하고(예: ORDER BY score DESC, product_id ASC) 컷오프( LIMIT 100 )
결과가 재실행 시 일관되도록 만드세요; 또한 동일 score를 가진 여러 상품을 넣고 재실행해도 순서가 유지되는 통합/단위 테스트를 보강하여
변경을 검증하세요.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java`:
- Around line 29-38: The computeYearMonth(String dateStr) method currently uses
substring and can throw unclear NPE/SIOOBE; replace its logic to validate and
parse requestDate using LocalDate.parse(dateStr,
DateTimeFormatter.BASIC_ISO_DATE) (or equivalent validation), then format/return
yearMonth as yyyyMM; if parsing fails or dateStr is null/empty, throw a clear
IllegalArgumentException with a descriptive message. Update any callers (e.g.,
execute in ClearMonthlyRankingTasklet) to rely on the validated computeYearMonth
and add unit tests covering null/missing requestDate, too-short strings, and
invalid dates like "20260231" to assert explicit exception behavior.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java`:
- Around line 36-57: Validate and strictly parse requestDate before computing
the week: ensure the injected field requestDate is non-null/non-blank at step
start (fail fast with a clear error) and replace the loose formatter in
computeYearWeek with a constant DateTimeFormatter defined using
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
to prevent SMART parsing; update computeYearWeek to use that formatter and add
unit/integration tests for missing requestDate, an invalid date like "20240230",
and an ISO boundary case such as "20210101".

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java`:
- Around line 64-69: The current Job defined in weeklyRankingJob() uses two
separate steps clearWeeklyRankingStep() then aggregateWeeklyRankingStep(), which
allows an intermediate committed delete to leave the weekly ranking empty on
failure; change the flow so deletion and insertion are atomic: either combine
clearWeeklyRankingStep() and aggregateWeeklyRankingStep() into a single Step
that performs delete+bulk-insert within one transaction (or implement a snapshot
approach using a temp table and a single atomic swap/rename or a version column
update) and update the JobBuilder to start that single atomic step instead of
separate steps; also add an integration test that injects a failure into the
item writer for the aggregate phase to assert the previous week snapshot remains
served when the job fails.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 87-94: The assertions rely on unstable ordering from
weeklyJpaRepository.findAll(); update the test in WeeklyRankingJobE2ETest to
fetch results with a deterministic order (e.g., call a repository method that
orders by ranking such as findAllByOrderByRankingAsc or pass a Sort to findAll)
or explicitly sort the returned List<MvProductRankWeeklyModel> by getRanking()
before making assertions on results.get(0), get(1), and getYearWeek(); also add
a separate integration test that covers tie scores to ensure deterministic
behavior when scores are equal.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java`:
- Around line 17-20: The methods getTopNWeekly and getTopNMonthly currently use
an ambiguous int offset; change their signatures to use int page (e.g.,
getTopNWeekly(String yearWeek, int page, int size) and getTopNMonthly(String
yearMonth, int page, int size)) to express page-based intent, then update all
implementations of RankingRepository and all callers (notably RankingService) to
compute offset = (page - 1) * size before performing data access, and adjust
unit/integration tests and any documentation to reflect the new parameter
semantics.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java`:
- Around line 61-75: The methods getTopNWeekly and getTopNMonthly in
RankingRedisRepository treat the incoming offset as if it were a page by
computing page = offset / size, which breaks the offset-based contract; either
change the repository signature to accept page and size, or validate the offset
is page-aligned and fail fast—add a guard at the start of getTopNWeekly and
getTopNMonthly to check size > 0 and offset % size == 0 and throw an
IllegalArgumentException (or similar) if not, then compute page = offset / size
and proceed to call weeklyJpaRepository.findByYearWeekOrderByRankingAsc and
monthlyJpaRepository.findByYearMonthOrderByRankingAsc respectively; ensure tests
cover a non-aligned example like offset=10,size=20 to catch regressions.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java`:
- Around line 40-48: In MvProductRankMonthlyModel's constructor (the public
MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, long
salesCount, double score, int ranking, String yearMonth) method) add fail-fast
validation: check productId for null and throw NullPointerException (or
IllegalArgumentException) if null, enforce ranking >= 1 and throw
IllegalArgumentException when <= 0, and validate yearMonth against the regex
^\d{6}$ throwing IllegalArgumentException on mismatch; update the constructor to
perform these checks before assigning fields and add unit tests that assert
exceptions for productId=null, ranking=0, and invalid yearMonth inputs.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java`:
- Around line 45-53: In MvProductRankWeeklyModel's constructor add validation to
prevent bad weekly keys and rankings: check productId is not null, check ranking
>= 1, and validate yearWeek against the regex ^\d{4}W\d{2}$; if any check fails
throw an IllegalArgumentException with a clear message. Also make the model
fields immutable (declare them final if not already) to follow domain
immutability/encapsulation rules. Add unit tests for the failure cases
yearWeek="2026-15", ranking=0, and productId=null to assert exceptions are
thrown.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4330fb69-f597-4932-926e-9234d9f472ce

📥 Commits

Reviewing files that changed from the base of the PR and between 654b642 and 6b0e5f1.

📒 Files selected for processing (23)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java
💤 Files with no reviewable changes (2)
  • apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java

Comment on lines +58 to +63
/** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM"
int offset = (page - 1) * size;
return rankingRepository.getTopNMonthly(yearMonth, offset, size);
}
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 | 🟡 Minor

월간 키 생성에서 substring에 의존하지 말아야 한다

Line 60은 입력 앞 6자리만 잘라 yearMonth를 만들기 때문에, 이 서비스가 다른 진입점에서 재사용되면 짧은 값은 StringIndexOutOfBoundsException, 잘못된 값은 존재하지 않는 월 키로 흘러갈 수 있다. 운영에서는 같은 날짜 입력인데 주간/월간 경로의 실패 방식이 달라져 원인 추적이 어려워진다. 주간 경로처럼 날짜를 먼저 해석한 뒤 yyyyMM으로 포맷하도록 맞추는 편이 안전하다. 추가 테스트로는 "202604", "20261301", "20260230" 입력에서 월간 경로가 예측 가능한 예외 흐름을 가지는지 확인해야 한다.

예시 수정안
     /** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */
     public List<RankingEntry> getTopRankingsMonthly(String date, int page, int size) {
-        String yearMonth = date.substring(0, 6);   // "yyyyMMdd" → "yyyyMM"
+        LocalDate parsedDate = LocalDate.parse(date, DATE_FORMAT);
+        String yearMonth = parsedDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
         int offset = (page - 1) * size;
         return rankingRepository.getTopNMonthly(yearMonth, offset, 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/domain/ranking/RankingService.java`
around lines 58 - 63, getTopRankingsMonthly currently derives yearMonth via
date.substring(0,6) which can throw StringIndexOutOfBoundsException for short
inputs or produce invalid months for malformed dates; change
getTopRankingsMonthly to parse the incoming date string into a
java.time.LocalDate (using appropriate DateTimeFormatter patterns or trying
multiple patterns like yyyyMMdd and yyyyMM01), validate the parsed date, then
format it to "yyyyMM" (e.g., DateTimeFormatter.ofPattern("yyyyMM")) and pass
that to rankingRepository.getTopNMonthly; on parse/validation failure throw a
clear IllegalArgumentException with a descriptive message so callers get a
predictable error flow.

Comment on lines +50 to +55
} else {
try {
LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)");
}
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:

In Java, does LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) reject invalid dates strictly by default? If not, what is the recommended formatter for strict validation of an 8-digit yyyyMMdd request parameter?

💡 Result:

No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not reject invalid dates strictly by default. It uses SMART resolver style, which adjusts invalid day-of-month values to the last valid day of the month (e.g., "20240230" parses to 2024-02-29). For strict validation of an 8-digit yyyyMMdd string, use DateTimeFormatter.BASIC_ISO_DATE, which matches the exact format and uses STRICT resolver style internally, rejecting invalid dates like "20240230" with DateTimeParseException. Alternatively, create a custom formatter: DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT), which also rejects invalid dates. DateTimeFormatter.BASIC_ISO_DATE is recommended as it is predefined, official for basic ISO date (yyyyMMdd), and ensures strict parsing without lenient adjustments.

Citations:


엄격한 날짜 검증으로 불가능한 입력을 400으로 거부해야 한다

현재 50번 줄의 LocalDate.parse(date, DATE_FORMAT)은 기본값인 SMART 리졸버 스타일을 사용하므로 존재하지 않는 날짜를 자동 보정한다. 예를 들어 20240230은 2024-02-29로 파싱되어 DateTimeParseException이 발생하지 않는다. 잘못된 요청이 400 에러로 거부되지 않고 다른 일자의 랭킹 데이터를 반환하므로 운영에서 입력값과 결과가 어긋나는 장애를 야기한다. 엄격한 검증이 필요하다.

포맷터를 두 가지 방법 중 선택하여 수정한다:

  1. 권장: DateTimeFormatter.BASIC_ISO_DATE로 변경 (공식 ISO 8601 기본 형식, 기본 내장되어 엄격한 검증 제공)
  2. 대안: 기존 방식 유지 시 DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT) 적용

이후 20260230, 비윤년 20250229, 자리수 부족 입력(202401 등)이 모두 DateTimeParseException으로 거부되는지 단위 테스트로 검증한다.

방법 1: BASIC_ISO_DATE 적용 (권장)
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 ...
     private static final DateTimeFormatter DATE_FORMAT =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+            DateTimeFormatter.BASIC_ISO_DATE;
방법 2: STRICT 리졸버 스타일 명시
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
 ...
     private static final DateTimeFormatter DATE_FORMAT =
-            DateTimeFormatter.ofPattern("yyyyMMdd");
+            DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);
🤖 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 50 - 55, The LocalDate parsing in RankingV1Controller currently
uses a SMART resolver (LocalDate.parse(date, DATE_FORMAT)) which auto-corrects
invalid dates; change the formatter to enforce strict validation by replacing
DATE_FORMAT with DateTimeFormatter.BASIC_ISO_DATE (preferred) or update the
existing DATE_FORMAT to
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
so LocalDate.parse(date, DATE_FORMAT) throws DateTimeParseException for inputs
like 20260230/20250229/202401; update the controller to use the new formatter
and add unit tests asserting that those invalid inputs result in
DateTimeParseException (and therefore a 400 via the existing CoreException
path).

Comment on lines +86 to +95
.sql("""
SELECT pm.product_id,
pm.view_count,
pm.like_count,
pm.sales_count,
(pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score
FROM product_metrics pm
ORDER BY score DESC
LIMIT 100
""")
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

동점 score에서 정렬 기준이 없어 월간 순위가 재실행마다 흔들릴 수 있다.

ORDER BY score DESC만 두면 동점 상품의 상대 순서와 100위 컷이 실행 계획에 따라 달라질 수 있어, 같은 데이터로 재실행해도 사용자에게 다른 순위가 노출될 수 있다. product_id 같은 안정적인 보조 정렬 키를 추가해 순위를 결정적으로 만들어야 한다. 동일 score 상품 여러 건을 넣고 반복 실행해도 순서가 유지되는 테스트를 보강하는 편이 좋다.

보조 정렬 키를 추가하는 예시다
-                    ORDER BY score DESC
+                    ORDER BY score DESC, pm.product_id ASC
                     LIMIT 100
🤖 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/monthly/MonthlyRankingJobConfig.java`
around lines 86 - 95, ORDER BY 절이 score만 있어 동점인 경우 순서가 비결정적이므로
MonthlyRankingJobConfig에서 사용된 SQL의 ORDER BY를 수정해 score DESC에 안정적인 보조
키(product_id 등)를 추가하고(예: ORDER BY score DESC, product_id ASC) 컷오프( LIMIT 100 )
결과가 재실행 시 일관되도록 만드세요; 또한 동일 score를 가진 여러 상품을 넣고 재실행해도 순서가 유지되는 통합/단위 테스트를 보강하여
변경을 검증하세요.

Comment on lines +29 to +38
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String yearMonth = computeYearMonth(requestDate);
log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth);
monthlyJpaRepository.deleteByYearMonth(yearMonth);
return RepeatStatus.FINISHED;
}

/** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */
public static String computeYearMonth(String dateStr) {
return dateStr.substring(0, 6);
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

requestDate를 substring으로 자르면 배치 실패 원인이 불명확해진다.

파라미터가 누락되거나 yyyyMMdd 형식이 아니면 여기서 NullPointerException 또는 StringIndexOutOfBoundsException이 발생해 운영 로그만으로 원인 파악이 어렵다. LocalDate.parse(..., DateTimeFormatter.BASIC_ISO_DATE)로 먼저 검증한 뒤 yyyyMM을 생성하고, 잘못된 값이면 명시적인 예외로 실패시키는 편이 낫다. requestDate 누락, 길이 부족, 존재하지 않는 날짜(20260231) 케이스를 테스트로 추가해야 한다.

명시적 검증으로 바꾸는 예시다
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
...
     /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */
     public static String computeYearMonth(String dateStr) {
-        return dateStr.substring(0, 6);
+        try {
+            return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE)
+                    .format(DateTimeFormatter.ofPattern("yyyyMM"));
+        } catch (DateTimeParseException | NullPointerException ex) {
+            throw new IllegalArgumentException("requestDate must be yyyyMMdd", ex);
+        }
     }

As per coding guidelines **/*.java: 예외 흐름이 명확한지 점검한다.

📝 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
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String yearMonth = computeYearMonth(requestDate);
log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth);
monthlyJpaRepository.deleteByYearMonth(yearMonth);
return RepeatStatus.FINISHED;
}
/** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */
public static String computeYearMonth(String dateStr) {
return dateStr.substring(0, 6);
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String yearMonth = computeYearMonth(requestDate);
log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth);
monthlyJpaRepository.deleteByYearMonth(yearMonth);
return RepeatStatus.FINISHED;
}
/** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */
public static String computeYearMonth(String dateStr) {
try {
return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE)
.format(DateTimeFormatter.ofPattern("yyyyMM"));
} catch (DateTimeParseException | NullPointerException ex) {
throw new IllegalArgumentException("requestDate must be yyyyMMdd", ex);
}
}
🤖 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/monthly/step/ClearMonthlyRankingTasklet.java`
around lines 29 - 38, The computeYearMonth(String dateStr) method currently uses
substring and can throw unclear NPE/SIOOBE; replace its logic to validate and
parse requestDate using LocalDate.parse(dateStr,
DateTimeFormatter.BASIC_ISO_DATE) (or equivalent validation), then format/return
yearMonth as yyyyMM; if parsing fails or dateStr is null/empty, throw a clear
IllegalArgumentException with a descriptive message. Update any callers (e.g.,
execute in ClearMonthlyRankingTasklet) to rely on the validated computeYearMonth
and add unit tests covering null/missing requestDate, too-short strings, and
invalid dates like "20260231" to assert explicit exception behavior.

Comment on lines +36 to +57
@Value("#{jobParameters['requestDate']}")
private String requestDate;

@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
String yearWeek = computeYearWeek(requestDate);
log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek);

weeklyJpaRepository.deleteByYearWeek(yearWeek);

return RepeatStatus.FINISHED;
}

/**
* "yyyyMMdd" 형식의 날짜에서 ISO 주차를 계산한다.
* 예: "20260412" → 2026년 15주차 → "2026W15"
*/
public static String computeYearWeek(String dateStr) {
LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
int year = date.get(WeekFields.ISO.weekBasedYear());
int week = date.get(WeekFields.ISO.weekOfWeekBasedYear());
return String.format("%dW%02d", year, week);
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:

In Java, does LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) validate dates strictly by default, or can invalid calendar dates still be resolved because of the default ResolverStyle? What formatter is recommended for strict yyyyMMdd validation?

💡 Result:

No, LocalDate.parse(date, DateTimeFormatter.ofPattern("yyyyMMdd")) does not validate dates strictly by default. The default ResolverStyle is SMART, which intelligently resolves dates by adjusting invalid values (e.g., "20240230" would parse to 2024-02-29). Invalid calendar dates can still be resolved to the nearest valid date. For strict yyyyMMdd validation that rejects invalid dates like February 30, use DateTimeFormatter.BASIC_ISO_DATE (which uses STRICT ResolverStyle by design) or explicitly set ResolverStyle.STRICT: DateTimeFormatter.ofPattern("yyyyMMdd").withResolverStyle(ResolverStyle.STRICT). Note: prefer "uuuuMMdd" over "yyyyMMdd" to avoid year-of-era ambiguity, though it often works with defaults.

Citations:


requestDate를 엄격하게 검증해야 한다

requestDate가 누락되면 Line 54에서 스텝이 늦게 실패하고, 현재 DateTimeFormatter.ofPattern("yyyyMMdd")는 기본값으로 SMART 해석기를 사용하여 엄격한 검증을 보장하지 않는다. SMART 해석기는 잘못된 달력 날짜를 가장 가까운 유효한 날짜로 조정하므로(예: "20240230" → "2024-02-29"), 의도하지 않은 주차로 해석될 여지가 있다. 운영 배치에서는 잘못된 yearWeek에 대한 주간 MV를 삭제하거나 갱신이 실패하여 다음 조회까지 랭킹이 비는 문제가 발생한다. 스텝 진입 시 null/blank를 명시적으로 차단하고, DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)를 상수로 정의하여 주차 계산을 고정하는 것이 필요하다. 추가 테스트로는 requestDate 누락, "20240230" (존재하지 않는 날짜), ISO 주차 경계인 "20210101" 케이스를 검증해야 한다.

수정 예시
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
+import java.time.format.ResolverStyle;
 import java.time.temporal.WeekFields;
 ...
 public class ClearWeeklyRankingTasklet implements Tasklet {
+
+    private static final DateTimeFormatter REQUEST_DATE_FORMAT =
+            DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT);
 
     private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
 ...
     `@Override`
     public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+        if (requestDate == null || requestDate.isBlank()) {
+            throw new IllegalArgumentException("requestDate 배치 파라미터는 필수입니다.");
+        }
         String yearWeek = computeYearWeek(requestDate);
         log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek);
 ...
     public static String computeYearWeek(String dateStr) {
-        LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
+        LocalDate date = LocalDate.parse(dateStr, REQUEST_DATE_FORMAT);
         int year = date.get(WeekFields.ISO.weekBasedYear());
         int week = date.get(WeekFields.ISO.weekOfWeekBasedYear());
         return String.format("%dW%02d", year, week);
     }
 }
🤖 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/weekly/step/ClearWeeklyRankingTasklet.java`
around lines 36 - 57, Validate and strictly parse requestDate before computing
the week: ensure the injected field requestDate is non-null/non-blank at step
start (fail fast with a clear error) and replace the loose formatter in
computeYearWeek with a constant DateTimeFormatter defined using
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
to prevent SMART parsing; update computeYearWeek to use that formatter and add
unit/integration tests for missing requestDate, an invalid date like "20240230",
and an ISO boundary case such as "20210101".

Comment on lines +64 to +69
public Job weeklyRankingJob() {
return new JobBuilder(JOB_NAME, jobRepository)
.incrementer(new RunIdIncrementer())
.start(clearWeeklyRankingStep()) // Step 1: 기존 데이터 삭제
.next(aggregateWeeklyRankingStep()) // Step 2: 집계 + 적재
.listener(jobListener)
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

삭제와 적재를 별도 Step로 커밋하면 실패 순간에 주간 랭킹이 비게 된다.

현재 구조는 clear step이 먼저 커밋되고 aggregate step이 뒤따르므로, 실행 중이거나 aggregate step이 실패하면 같은 yearWeek 조회가 빈 결과를 반환할 수 있다. 상위 100건만 다루므로 임시 스냅샷 테이블이나 버전 컬럼으로 원자적 교체를 하거나, 최소한 삭제와 삽입을 하나의 트랜잭션 경계에서 처리하는 구조로 바꾸는 편이 안전하다. 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/weekly/WeeklyRankingJobConfig.java`
around lines 64 - 69, The current Job defined in weeklyRankingJob() uses two
separate steps clearWeeklyRankingStep() then aggregateWeeklyRankingStep(), which
allows an intermediate committed delete to leave the weekly ranking empty on
failure; change the flow so deletion and insertion are atomic: either combine
clearWeeklyRankingStep() and aggregateWeeklyRankingStep() into a single Step
that performs delete+bulk-insert within one transaction (or implement a snapshot
approach using a temp table and a single atomic swap/rename or a version column
update) and update the JobBuilder to start that single atomic step instead of
separate steps; also add an integration test that injects a failure into the
item writer for the aggregate phase to assert the previous week snapshot remains
served when the job fails.

Comment on lines +87 to +94
List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll();
assertAll(
() -> assertThat(results).hasSize(3),
() -> assertThat(results.get(0).getRanking()).isEqualTo(1),
() -> assertThat(results.get(0).getScore())
.isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수
() -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15")
);
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

findAll() 반환 순서에 의존한 단언은 플래키하다.

weeklyJpaRepository.findAll()은 정렬을 보장하지 않으므로 환경에 따라 results.get(0)이 1등이 아닐 수 있다. ranking 기준으로 조회하거나 테스트에서 명시적으로 정렬한 뒤 단언해야 한다. 동점 score 데이터가 있어도 동일한 순서로 검증되는지 통합 테스트를 하나 더 추가하는 편이 안전하다.

정렬을 고정하는 예시다
+import org.springframework.data.domain.Sort;
...
-        List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll();
+        List<MvProductRankWeeklyModel> results =
+                weeklyJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "ranking"));

As per coding guidelines **/*Test*.java: 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다.

📝 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
List<MvProductRankWeeklyModel> results = weeklyJpaRepository.findAll();
assertAll(
() -> assertThat(results).hasSize(3),
() -> assertThat(results.get(0).getRanking()).isEqualTo(1),
() -> assertThat(results.get(0).getScore())
.isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수
() -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15")
);
List<MvProductRankWeeklyModel> results =
weeklyJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "ranking"));
assertAll(
() -> assertThat(results).hasSize(3),
() -> assertThat(results.get(0).getRanking()).isEqualTo(1),
() -> assertThat(results.get(0).getScore())
.isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수
() -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15")
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 87 - 94, The assertions rely on unstable ordering from
weeklyJpaRepository.findAll(); update the test in WeeklyRankingJobE2ETest to
fetch results with a deterministic order (e.g., call a repository method that
orders by ranking such as findAllByOrderByRankingAsc or pass a Sort to findAll)
or explicitly sort the returned List<MvProductRankWeeklyModel> by getRanking()
before making assertions on results.get(0), get(1), and getYearWeek(); also add
a separate integration test that covers tie scores to ensure deterministic
behavior when scores are equal.

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