Skip to content

[volume-10] Spring Batch 랭킹 집계 - 김평숙#404

Open
katiekim17 wants to merge 146 commits into
mainfrom
volume-10
Open

[volume-10] Spring Batch 랭킹 집계 - 김평숙#404
katiekim17 wants to merge 146 commits into
mainfrom
volume-10

Conversation

@katiekim17
Copy link
Copy Markdown

@katiekim17 katiekim17 commented Apr 16, 2026

Summary

  • 배경: 기존 구조는 일간 랭킹은 Redis, 주간/월간 랭킹은 별도 MV 또는 누적 판매량 기준으로 분리되어 있어 기간별 데이터 의미가 완전히 일치하지 않았다.
  • 목표: Redis 일간 랭킹 점수를 기준으로 DAILY, WEEKLY, MONTHLY 랭킹을 모두 다시 집계해 하나의 Materialized View 에 적재하고, API 도 같은 구조를 기준으로 조회하도록 정리한다.
  • 결과: ranking_materialized_view 공통 테이블을 도입했고, Spring Batch 가 기간별 랭킹을 파라미터 기반으로 적재하며, Ranking API 가 periodType 기준으로 일간/주간/월간 랭킹을 일관되게 제공하도록 구현했다.

Context & Decision

1. 왜 기간별 랭킹 저장소를 하나의 MV 로 통합했는가?

항목 기간별 개별 테이블 공통 MV ranking_materialized_view (채택)
조회 경로 기간마다 리포지토리/엔티티 분기 필요 하나의 조회 경로로 처리 가능
데이터 구조 중복 구조 반복 period_type, target_date 로 통합
확장성 분기 추가 시 테이블/코드 증가 기간 타입만 추가하면 확장 가능
  • 결정: ranking_materialized_view(period_type, target_date, product_id, score, rank_no) 구조 채택
  • 근거: 일간/주간/월간 랭킹 모두 같은 형태의 순위 데이터다. 테이블을 분리할 이유보다 통합했을 때의 조회 일관성과 유지보수 이점이 더 크다.
  • 트레이드오프: 단일 테이블로 관리하면 기간별 데이터가 함께 쌓이므로 인덱스 설계가 중요하다. 이를 위해 period_type,target_date,rank_noperiod_type,target_date,product_id 인덱스를 두었다.

2. 주간/월간 랭킹 점수를 무엇으로 계산할 것인가?

항목 product_metrics.sales_count 누적값 Redis 일간 랭킹 점수 합산 (채택)
기간 의미 누적 판매량이라 특정 주/월 점수가 아님 기간 범위 점수를 직접 계산
일간과의 일관성 일간 점수 체계와 다름 같은 점수 체계 유지
체크리스트 충족 부분 충족 완전 충족
  • 결정: 주간/월간도 Redis 일간 ZSET ranking:all:{yyyyMMdd} 를 읽어 기간 범위 합산
  • 근거: “조회해야 하는 형태에 따라 적절한 데이터 기반으로 랭킹 제공” 조건을 만족하려면, 주간/월간 역시 그 기간의 랭킹 점수로 재계산돼야 한다.
  • 트레이드오프: 배치 시 여러 날짜 키를 순회해야 해서 적재 비용이 늘어난다. 대신 API 조회는 이미 집계된 MV 를 읽기만 하면 된다.

3. 기간 기준일은 어떻게 정규화할 것인가?

기간 입력 날짜 예시 저장/조회 기준일
DAILY 20260417 2026-04-17
WEEKLY 20260417 해당 주 월요일
MONTHLY 20260417 해당 월 1일
  • 결정: RankingPeriodDateResolver 로 기간별 기준일 정규화
  • 근거: 같은 주간 랭킹인데 20260415, 20260416, 20260417 요청마다 다른 키를 조회하면 안 된다. 저장과 조회가 같은 규칙을 써야 한다.

4. 배치 구현은 Chunk 가 아니라 Tasklet 이어도 되는가?

항목 Reader/Processor/Writer Tasklet (채택)
처리 대상 대량 row streaming 에 적합 기간 집계형 작업에 적합
구현 복잡도 상대적으로 높음 단순
체크리스트 충족 가능 가능
  • 결정: RankingMaterializedViewJob, weeklyRankingJob, monthlyRankingJob 모두 Tasklet 기반
  • 근거: 체크리스트는 Reader/Processor/Writer or Tasklet 이다. 이번 작업은 Redis 기간 범위 집계 후 일괄 적재이므로 Tasklet 이 더 직접적이다.

5. 참고 문서 반영 범위

  • 현재 워크스페이스에서 확인된 참고 문서는 ranking-flush-batch-followup.md 하나였다.
  • 이 문서의 핵심 방향 중 아래를 구현에 반영했다.
    • 날짜 단위 Redis 키를 기준으로 기간 점수를 다시 집계한다.
    • flush 책임은 배치가 가진다.
    • 최종 저장소는 조회 최적화된 형태로 유지한다.
  • ranking-flush-batch-class-design.md, ranking-flush-batch-easy-explanation.md 는 현재 워크스페이스에서 확인되지 않아 직접 반영 여부를 문서상 단정하지 않았다.

Design Overview

변경 범위

분류 파일 변경 요약
jpa RankingMaterializedView 공통 MV 엔티티 추가
jpa RankingPeriodDateResolver 기간 기준일 정규화 유틸 추가
batch RankingMaterializedViewBatchService Redis 기간 합산 후 MV 적재
batch RankingMaterializedViewJobConfig 공통 MV 적재 Job 추가
batch WeeklyRankingJobConfig 주간 랭킹 Job 을 MV 적재 기반으로 정리
batch MonthlyRankingJobConfig 월간 랭킹 Job 을 MV 적재 기반으로 정리
api RankingRepositoryImpl 모든 기간 랭킹을 공통 MV 에서 조회
api RankingFacade 랭킹 결과와 상품/브랜드 정보 aggregation
api RankingV1Controller periodType, date 기반 조회 지원
api RankingV1Dto periodType, targetDate, items 응답 구조 정리

MV 적재 흐름

// RankingMaterializedViewBatchService.java
LocalDate normalizedDate = RankingPeriodDateResolver.normalize(periodType, targetDate);
LocalDate endDate = RankingPeriodDateResolver.endDate(periodType, normalizedDate);

for (LocalDate date = normalizedDate; !date.isAfter(endDate); date = date.plusDays(1)) {
    String key = "ranking:all:" + date.format(DATE_FORMAT);
    Set<TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1);
    ...
    scoreByProductId.merge(productId, tuple.getScore(), Double::sum);
}
// 집계 후 MV 재적재
jdbcTemplate.update(
    "DELETE FROM ranking_materialized_view WHERE period_type = ? AND target_date = ?",
    periodType,
    normalizedDate
);
jdbcTemplate.batchUpdate(
    """
    INSERT INTO ranking_materialized_view
    (period_type, target_date, product_id, score, rank_no, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())
    """,
    ...
);

API 조회 흐름

// RankingV1Controller.java
LocalDate requestDate = resolveDate(date);
RankingPeriod rankingPeriod = RankingPeriod.from(periodType);
LocalDate targetDate = RankingPeriodDateResolver.normalize(rankingPeriod.name(), requestDate);
List<RankingInfo> infos = rankingFacade.getRankings(targetDate, rankingPeriod, page, size);
// RankingRepositoryImpl.java
return rankingMaterializedViewJpaRepository
    .findAllByPeriodTypeAndTargetDateOrderByRankNoAsc(period.name(), targetDate, PageRequest.of(page - 1, size))
    .stream()
    .map(this::toRankedProduct)
    .toList();

API 응답 예시

GET /api/v1/rankings?periodType=WEEKLY&date=20260417&size=10&page=1
{
  "periodType": "WEEKLY",
  "targetDate": "2026-04-14",
  "items": [
    {
      "rank": 1,
      "productId": 101,
      "productName": "Example Product",
      "brandName": "Example Brand",
      "price": 10000,
      "score": 12.4
    }
  ]
}

Tests

테스트 코드는 현재 구조에 맞게 함께 수정했다.

  • RankingV1ApiE2ETest
  • ProductRankingE2ETest
  • WeeklyRankingJobE2ETest
  • MonthlyRankingJobE2ETest
  • RankingMaterializedViewJobE2ETest

다만 이번 요청에서는 테스트 결과를 포함하지 않았다. 이전에 실행을 시도했지만 완료 전에 중단되어, 이 문서는 구현 기준 정리 문서다.


Flow Diagram

sequenceDiagram
    actor User
    participant API as commerce-api
    participant Redis
    participant Batch as commerce-batch
    participant DB as MySQL

    Note over Redis: ranking:all:{yyyyMMdd} 에 일간 점수 누적

    Batch->>Redis: 기간 범위 ZSET 조회
    Redis-->>Batch: (productId, score) 목록
    Batch->>Batch: 기간별 점수 합산 및 rank 계산
    Batch->>DB: DELETE ranking_materialized_view where period_type=?, target_date=?
    Batch->>DB: INSERT period_type, target_date, product_id, score, rank_no

    User->>API: GET /api/v1/rankings?periodType=WEEKLY&date=20260417
    API->>API: targetDate = weekly normalize(2026-04-17)
    API->>DB: SELECT * FROM ranking_materialized_view WHERE period_type='WEEKLY' AND target_date='2026-04-14' ORDER BY rank_no
    DB-->>API: ranked rows
    API->>DB: 상품/브랜드 정보 조회
    API-->>User: RankingPageResponse
Loading

Checklist

Spring Batch

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

Ranking API

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

판정 근거

  • rankingMaterializedViewJob, weeklyRankingJob, monthlyRankingJob 이 모두 파라미터 기반으로 동작한다.
  • 배치 구현은 Tasklet 기반이며, 체크리스트의 허용 범위에 포함된다.
  • ranking_materialized_view 구조를 설계했고, Redis 일간 랭킹을 기간별로 재집계해 적재한다.
  • API 는 periodType=DAILY|WEEKLY|MONTHLY 를 지원하며, 기간 기준일 정규화 후 MV 를 조회한다.

Review Points

  1. 주간/월간 랭킹 집계 방식
    현재는 RankingMaterializedViewBatchService 가 기간 범위의 Redis 일간 키 ranking:all:{yyyyMMdd} 를 순회하면서 상품별 점수를 합산해 주간/월간 랭킹을 계산하도록 구현했습니다. 이 방식이 도메인적으로 적절한지 리뷰 부탁드립니다.

  2. Materialized View 모델링
    현재는 ranking_materialized_view(period_type, target_date, product_id, score, rank_no) 단일 테이블에 일간/주간/월간 데이터를 함께 저장하고, period_type + target_date + rank_no 중심으로 조회하도록 구현했습니다. 이 구조와 인덱스 구성이 조회/확장 관점에서 적절한지 확인 부탁드립니다.

  3. 배치 적재 전략
    현재는 배치 실행 시 같은 period_type, target_date 조합의 기존 데이터를 먼저 삭제한 뒤 새 집계 결과를 일괄 insert 하는 방식으로 구현했습니다. 이 DELETE + INSERT 전략이 운영상 안전한지, 또는 upsert 나 스왑 테이블 방식이 더 적절한지 의견 부탁드립니다.

SukheeChoi and others added 30 commits February 2, 2026 23:40
- MemberModel 엔티티 및 MemberRepository 인터페이스 추가
- MemberService: 로그인 ID 중복 검증, 비밀번호 규칙 검증, 암호화 저장
- MemberV1Controller: POST /api/v1/members API
- PasswordEncoderConfig: BCrypt 설정
- ApiControllerAdvice: @Valid 검증 예외 핸들러 추가
- 단위 테스트 6개 추가 (Service 4개, Controller 2개)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- AuthMember 어노테이션 및 AuthMemberResolver 추가
- 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw)
- GET /api/v1/members/me API 추가
- 이름 마스킹 로직 (홍길동 → 홍길*)
- ErrorType.UNAUTHORIZED 추가
- 단위 테스트 5개 추가 (Controller 3개, DTO 2개)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberModel에 changePassword() 메서드 추가
- MemberService에 비밀번호 변경 로직 구현
  - 현재 비밀번호 검증
  - 동일 비밀번호 사용 불가
  - 비밀번호 규칙 검증 (8~16자, 영문/숫자/특수문자)
  - 생년월일 포함 불가
- MemberV1Controller에 PATCH /me/password 엔드포인트 추가
- CLAUDE.md를 .gitignore에 추가 (git 추적 제외)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- MemberModel → Member 엔티티 리네이밍 (DDD 네이밍)
- Value Object 도입: LoginId, Email, BirthDate, Password (@embeddable, 자가 검증)
- Gender enum 추가 및 회원가입 시 성별 필수 처리
- PasswordPolicy 도메인 정책 분리 (순수 함수)
- Service 얇은 조율 계층으로 리팩토링 (검증 로직 VO/Policy로 이동)
- 포인트 조회 API 신규 구현 (GET /api/v1/points)
- AuthMemberResolver 보안 에러 메시지 통일
- 단위 테스트 (LoginIdTest, EmailTest, BirthDateTest 등)
- 통합 테스트 (MemberServiceIntegrationTest, @spybean)
- E2E 테스트 (MemberV1ApiE2ETest, PointV1ApiE2ETest)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
DDD 리팩토링 + Value Object 도입 + 포인트 조회 구현
기능 요구사항에 해당하지 않는 Gender enum, 포인트 조회 API를 제거한다.
Member 엔티티에서 gender/point 필드를 제거하고 관련 테스트를 정리한다.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 01-requirements.md: 액터 정의, 미결정 사항 섹션 추가
- 02-sequence-diagrams.md: 트랜잭션 경계 rect 블록, 읽는 법, 잠재 리스크 추가
- 03-class-diagram.md: 다이어그램 읽는 법, 잠재 리스크 추가
- 04-erd.md: 잠재 리스크 섹션 추가

브랜드/상품/좋아요/주문 도메인의 요구사항, 시퀀스, 클래스, ERD 설계 완료

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
판단 기준을 명확히 함: "주문 상세 화면을 독립적으로 렌더링할 수 있는가?"
- 필수/권장/제외 항목 분류 및 근거 추가
- image_url 제외 이유 명시 (현재 상품 스펙에 없음, 오버엔지니어링 방지)
- 트레이드오프 설명 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 주문 취소 API 명세 추가 (POST /api/v1/orders/{orderId}/cancel)
- 대고객 브랜드 목록 API 추가 (GET /api/v1/brands)
- order_item 삭제 정책 수정 (Order와 생명주기 공유)
- 시퀀스 다이어그램 URI prefix 통일 (/api-admin/v1)
- 상품 삭제 유스케이스 추가 (US-P05)
- 좋아요 목록 N+1 의도 명시
- 주문 취소 시 삭제된 상품 처리 리스크 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 주문 취소 API 제거 (요구사항에 없음)
- US-O04 유스케이스 제거
- 시퀀스 다이어그램 8번 (주문 취소) 제거
- 대고객 브랜드 목록 API 제거 (요구사항에 없음)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- 도메인 & 객체 설계 전략 (Entity/VO/Domain Service 구분 기준)
- 아키텍처 & 패키지 전략 (DIP 실무 타협 기준, 의존 방향)
- DIP 인사이트: 정석 vs 실무 타협 정리 (DDD 저자 명언 포함)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- domain/member/MemberService → application/member/MemberFacade
- 레이어드 아키텍처 원칙에 맞게 유스케이스 조율을 Application Layer에서 담당
- Controller, 통합 테스트 import 경로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Brand: Entity + CRUD (삭제 시 연관 상품 cascade soft delete)
- Product: Entity + Price/Stock VO + CRUD (N+1 해결: JPQL JOIN)
- Like: Entity (hard delete) + 좋아요 등록(멱등)/취소
- Order: Aggregate Root + OrderItem 스냅샷 + 재고 차감
- DIP 적용: Repository Interface(Domain) ← Impl(Infrastructure)
- ProductWithBrand 조회 전용 모델로 읽기/쓰기 관심사 분리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- VO 테스트: Price, Stock (생성 검증, 비즈니스 규칙)
- Entity 테스트: Brand, Product, Order, OrderItem, Like
- Facade 테스트: Fake Repository 기반 순수 단위 테스트
  - BrandFacadeTest, ProductFacadeTest, LikeFacadeTest, OrderFacadeTest
- DIP 이점 활용: Spring 컨텍스트 없이 도메인 로직 검증

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
P0 요구사항 미구현 수정:
- 브랜드/상품 삭제 시 좋아요 hard delete 연쇄 처리
- 주문 취소 + 재고 복원 API 추가 (POST /orders/{id}/cancel)
- 좋아요 목록/주문 상세 조회 권한 검증 추가 (FORBIDDEN)
- 좋아요 취소 멱등성 보장 (예외 → 조기 리턴)

P1 설계 결함 수정:
- 상품 목록 정렬 지원 (latest/price_asc/likes_desc)
- findByIdWithBrand LEFT JOIN 조건 버그 수정

P2 테스트 품질 보강:
- Fake Repository soft delete 필터링 반영
- 주문 취소, 연쇄 삭제, 멱등성 등 누락 테스트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Order.ItemSnapshot record 도입, Order.create()가 스냅샷을 받아 내부에서 OrderItem 생성
- OrderItem 생성자를 package-private로 변경하여 외부 패키지에서 직접 생성 차단
- OrderFacade는 더 이상 OrderItem을 직접 생성하지 않고 ItemSnapshot만 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 레이어드 아키텍처 Mermaid 다이어그램 추가 (Facade별 책임 명시)
- 전체 클래스 다이어그램 갱신 (ItemSnapshot, package-private 반영)
- Aggregate 라이프사이클 통제 점검 결과 및 Entity vs VO 통제 기준 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
findByIdWithBrand() JOIN 쿼리를 제거하고, ProductFacade에서
Product와 Brand를 각각 조회 후 조합하도록 리팩토링.
목록 조회는 성능을 위해 기존 JOIN 방식 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Order.create()에 빈 주문 방지 guard 추가 (도메인 불변식)
- Price, Stock에 @EqualsAndHashCode 추가 (VO 값 동등성 보장)
- OrderTest에 빈 항목/null 항목 테스트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 상품 일괄 비관적 락 쿼리 도입 (DB 라운드트립 N→1)
- 데드락 방지를 위한 락 순서 보장 및 동시성 테스트 추가
- Order 금액 불변식 검증 (discountAmount <= originalTotalPrice)
- Clock 주입으로 도메인 시간 의존성 제거 (테스트 안정성)
- 쿠폰 주문 연동 로직을 CouponFacade로 위임 (OrderFacade 의존성 5→4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
좋아요 — Product.likeCount 제거, UNIQUE 제약 + COUNT(*) 파생으로 락 불필요 구조 전환
쿠폰 — 비관적 락 제거, 조건부 UPDATE(markAsUsed) + affected rows 검증으로 원자적 상태 전이
재고 — 비관적 락 유지 (다중 자원 원자성 + 높은 경합 특성)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- BrandFacade.deleteBrand(): 좋아요 루프 삭제 → deleteAllByProductIdIn() 배치 삭제
- ProductFacade.enrichWithLikeCount(): 개별 COUNT → countByProductIds() GROUP BY 배치 조회
- LikeRepository/LikeJpaRepository: 배치 메서드 추가
- LikeRepositoryImpl: Object[] → Map 변환 위임 구현
- FakeLikeRepository: 테스트용 배치 구현 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 01-requirements: 쿠폰 유저스토리, 동시성 전략, 쿠폰 API 추가
- 02-sequence-diagrams: 주문 생성(비관적 락+쿠폰), 좋아요(UNIQUE 기반), 쿠폰 발급/주문 취소 신규 추가
- 03-class-diagram: Coupon Aggregate 추가, Product likeCount 제거, Order 쿠폰 필드 추가
- 04-erd: coupon/coupon_issue 테이블, DDL, FK 정책, 잠재 리스크 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
고객 단순변심 취소 시 이미 만료된 쿠폰까지 AVAILABLE로 복원되던 문제 수정.
cancelUse(ZonedDateTime now)로 시그니처 변경하여 만료 여부를 판단한 뒤,
만료됐으면 EXPIRED, 아니면 AVAILABLE로 분기 처리.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
비관적 락 + 엔티티 변경(read-modify-write) 방식에서
조건부 UPDATE(SET stock = stock - :qty WHERE stock >= :qty)로 전환.
쿠폰(조건부 UPDATE), 좋아요(UNIQUE + COUNT)와 함께
모든 동시성 제어가 read-modify-write를 피하는 구조로 통일됨.

동시성 테스트 threadCount를 10 → 100으로 강화.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sukhee added 11 commits April 16, 2026 21:24
- Chunk Best Practice를 이론/우리 설계/배치 프로젝트 3관점으로 비교
- ExponentialBackOffPolicy, allowStartIfComplete 설계 반영
- Cursor Reader 멀티스레드 제약 명시
- Writer skip 시 chunk scan 문제 분석
- 배치 90개 Job의 retry/skip 미사용에 대한 해석
- GROUP BY 집계 쿼리에서 PagingReader의 치명적 문제 (페이지마다 집계 재실행)
- CursorReader 멀티스레드 불가 원인 (ResultSet 공유 상태)
- 커넥션 점유 해법: Replica DataSource 분리 (배치 프로젝트 패턴)
- 병렬화 시 전환 경로: PagingReader가 아닌 Partitioning
- 설계 문서 Best Practice 테이블에 Cursor 선택 근거 반영
- Job 구조를 3-Step(cleanup → partitioned aggregate → merge)으로 변경
- Partitioner: product_id 범위 분할, Worker별 독립 CursorReader
- 스테이징 테이블 도입: 병렬 집계 결과 수집 → mergeStep에서 Global TOP 100
- 성능 산정: 상품 100만 기준 단일 30초 → Partitioning 12초 (3배)
- 블로그 소재: 고민 흐름(Chunk→Cursor→멀티스레드 한계→Partitioning) 기록
- Partitioning 도입 후 멱등성: cleanup에서 스테이징도 함께 DELETE → 전체 재실행이 안전
- RunIdIncrementer 결정: 파라미터 보존 + run.id 증가로 재실행 허용
- 설계 결정 요약 테이블 갱신: Partitioning, 스테이징, RunIdIncrementer 반영
- 블로그 소재 10, 11번 추가
- 소재 1~11 모두 대화 흐름에서 어떤 질문/반론이 고민을 촉발했는지 기록
- 소재 1: 전시 기간 편향 보정 논의에서 시작
- 소재 2: carry-over vs 캘린더 기간 질문에서 시작
- 소재 3: "Chunk가 보편적이라는데?" 반론으로 시각 교정
- 소재 4: "Redis에 이미 있는데 왜 MV를 만드는가?"
- 소재 5: "전부 INSERT하고 삭제하는 게 비효율 아닌가?"
- 소재 6: "사전 집계가 있어야 Chunk가 유용한 건가?"
- 소재 7: Best Practice 문서 받고 3관점 교차 분석
- 소재 8: "CursorReader를 선택한 이유가 뭐야?"
- weekly/monthly를 MV 단일 소스로 변경 (Redis fallback 제거)
- 다른 공식(감쇠 vs 균등)의 결과를 fallback으로 쓰면 데이터 불일치
- API 구조: daily→Redis, weekly/monthly→MV (fallback 없이 빈 결과 반환)
- 블로그 소재 4에 단일 소스 원칙 판단 과정 기록
- 전체 재계산 유지 근거: Late-Arriving Fact(지연 취소)로 과거 데이터 변경 발생
- 증분 계산의 전제("과거 불변")가 이커머스에서 깨지는 이유 분석
- 전일 MV fallback: 같은 공식의 1일 stale 결과 (데이터 불일치 아닌 시간 지연)
- 데이터 보존 정책: 3일분 보존, 이전 데이터 정리
- Redis weekly/monthly: 검증 후 제거, daily carry-over만 유지
- ProductRankingMvJobConfig: cleanup → partitioned aggregate → merge
- CleanupTasklet: 당일 MV/staging DELETE + 3일 이전 데이터 정리
- Partitioner: product_id 범위 분할 (gridSize=4)
- Worker: JdbcCursorItemReader(GROUP BY + LOG10 score) → staging INSERT
- mergeStep: ROW_NUMBER() OVER → Global TOP 100 → MV INSERT
- faultTolerant + retry(3) + ExponentialBackOffPolicy
- DDL: mv_product_rank_weekly/monthly + staging 테이블
- 설계 문서 Phase 업데이트
- MvProductRank 엔티티 (MappedSuperclass + Weekly/Monthly 분리)
- MvProductRankRepository 인터페이스 + JPA 구현체
- RankingFacade 수정: daily→Redis, weekly/monthly→MV 단일 소스
- 전일 MV fallback: 당일 데이터 없으면 전일 period_key로 조회
- Redis weekly/monthly prefix 제거 (daily만 유지)
- 주간 Job 정상 실행: 150개 상품 시드 → TOP 100 적재 검증
- 월간 Job 정상 실행: 30일 데이터 집계 검증
- 멱등성: 같은 파라미터 2회 실행 → 데이터 2배 아닌 동일
- 엣지케이스: 데이터 없음, 7일 미만, 100개 미만 상품
- 취소 반영: cancel_amount가 score에 반영되어 순위 변동
- Phase 2, 3 상태 ✅로 업데이트 (구현 완료, 테스트 코드 작성)
- 테스트 실행 가이드: 사전 조건, 명령어, 실패 시 확인사항
- 시나리오 검증 절차: 인프라 기동 → 시드 → 배치 실행 → API 검증 → 멱등성/fallback 확인
- PR 리뷰 포인트 후보 3개
- 블로그 구조 가이드: 소재 문서 → 블로그 섹션 매핑
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Important

Review skipped

Too many files!

This PR contains 297 files, which is 147 over the limit of 150.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bc356689-2820-4fc8-9b3a-102b3dc28d68

📥 Commits

Reviewing files that changed from the base of the PR and between 2a442e0 and cb451ac.

⛔ Files ignored due to path filters (3)
  • .codeguide/dip-insights.md is excluded by !**/*.md and included by **
  • .codeguide/loopers-1-week.md is excluded by !**/*.md and included by **
  • CLAUDE.md is excluded by !**/*.md and included by **
📒 Files selected for processing (297)
  • .coderabbit.yaml
  • .gitignore
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponApplyResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewKafkaPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/ProvisionalOrderService.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCachePort.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProperties.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
  • apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssue.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequest.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueRequestStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponIssueStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/coupon/DiscountType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEventPublisher.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutbox.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/EventOutboxRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/LikeCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/LikeRemovedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCancelledEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OrderCreatedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/OrderItemSnapshot.java
  • apps/commerce-api/src/main/java/com/loopers/domain/event/ProductViewedEvent.java
  • apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/policy/PasswordPolicy.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/vo/BirthDate.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Email.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/vo/LoginId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/member/vo/Password.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInbox.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/CallbackInboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutbox.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentOutboxStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistory.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatusHistoryRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatch.java
  • apps/commerce-api/src/main/java/com/loopers/domain/payment/ReconciliationMismatchRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWithBrand.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Price.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/vo/Stock.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponIssueRequestJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/event/DomainEventPublisherImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/event/EventOutboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/AsyncConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/KafkaTopicConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/CallbackInboxRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentOutboxRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentStatusHistoryRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentWalWriter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ReconciliationMismatchRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgCallbackPayload.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgHealthChecker.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentRequest.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentResponse.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgPaymentStatusResponse.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgRouter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignClient.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorFeignConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/simulator/SimulatorPgClient.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossFeignClient.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClient.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/toss/TossSandboxPgConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheAdapter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/EntryTokenInterceptor.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistry.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlySpringDataRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklySpringDataRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/CouponIssueRequestRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/EntryTokenRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/ProvisionalOrderRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/StockReservationRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/redis/WaitingQueueRedisRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/PaymentRateLimiterInterceptor.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/ProgressiveBackoffCustomizer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/QueueFallbackRateLimiterConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/CallbackDlqScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/OutboxPollerScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpiryScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/QueueAdmissionScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/StockReconcileScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/WalRecoveryScheduler.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductBenchmarkController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/queue/QueueDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingDto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/listener/CacheEvictionEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/listener/LikeCountEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMember.java
  • apps/commerce-api/src/main/java/com/loopers/support/auth/AuthMemberResolver.java
  • apps/commerce-api/src/main/java/com/loopers/support/auth/PasswordEncoderConfig.java
  • apps/commerce-api/src/main/java/com/loopers/support/auth/RequireEntryToken.java
  • apps/commerce-api/src/main/java/com/loopers/support/auth/WebMvcConfig.java
  • apps/commerce-api/src/main/java/com/loopers/support/config/ClockConfig.java
  • apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/event/ProductViewKafkaPublisherTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/order/ProvisionalOrderServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/CallbackMissFaultTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/DbFailureFaultTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/GhostPaymentFaultTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/ManualRecoveryTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentCallbackTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentRecoveryServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/payment/ServerCrashFaultTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/CouponIssueConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/CouponUseConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/LikeConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/OrderCancelConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/concurrency/StockConcurrencyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponIssueTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/policy/PasswordPolicyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/vo/BirthDateTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/vo/EmailTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/member/vo/PasswordTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/payment/CallbackInboxTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusHistoryTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentStatusTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/vo/PriceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/vo/StockTest.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeBrandRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeCallbackInboxRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponIssueRequestRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeCouponRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeOrderRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentOutboxRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakePaymentStatusHistoryRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakePgClient.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeProductCachePort.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeProductRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeProvisionalOrderRedisRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeReconciliationMismatchRepository.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeStockReservationRedisRepository.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/event/DomainEventPublisherImplTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentWalWriterTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/PgRouterTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/toss/TossSandboxPgClientTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductCacheAdapterTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/product/MultiLayerProductCacheAdapterTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/EntryTokenInterceptorTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseEmitterRegistryTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/queue/QueueSseIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/redis/StockReservationTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/resilience/SlidingWindowRateLimiterTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/CallbackDlqSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/OutboxPollerTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/ProvisionalOrderExpirySchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/QueueAdmissionSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/scheduler/StockReconcileSchedulerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/member/MemberV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/payment/PaymentE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/queue/QueueControllerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/listener/CacheEvictionEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/listener/LikeCountEventListenerTest.java
  • apps/commerce-api/src/test/java/com/loopers/performance/ProductPerformanceTest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/EventHandledCleanupJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/eventhandledcleanup/step/EventHandledCleanupTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/MetricsReconcileJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/metricsreconcile/step/MetricsReconcileTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/OutboxCleanupJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/outboxcleanup/step/OutboxCleanupTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/PaymentRecoveryJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/paymentrecovery/step/PaymentRecoveryTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionProperties.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/ProductRankingMvJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankingmv/step/CleanupTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentCouponReconciliationJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PaymentOrderReconciliationJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/PgPaymentReconciliationJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentCouponReconciliationTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PaymentOrderReconciliationTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/reconciliation/step/PgPaymentReconciliationTasklet.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rankingcorrection/RankingCorrectionScoreTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/payment/CouponReconciliationJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/payment/PaymentRecoveryJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/rankingmv/ProductRankingMvJobE2ETest.java
  • apps/commerce-batch/src/test/resources/schema-batch-test.sql
  • apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/MetricsDelta.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingProperties.java

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch volume-10

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.

@katiekim17 katiekim17 changed the title feat: Spring Batch 랭킹 집계 + 주간/월간 Ranking API 확장 [volume-10] Spring Batch 랭킹 집계 - 김평숙 Apr 16, 2026
SukheeChoi and others added 17 commits April 17, 2026 03:46
- Partitioner를 @bean에서 private 메서드로 변경하여 @SpringBatchTest 충돌 해결
- runJob 반환 타입을 JobExecution → BatchStatus로 변경 (JobScopeTestExecutionListener 스캔 회피)
- @nested 제거 후 flat 구조 7개 테스트로 재구성 (7/7 PASSED)
- 1,020개 상품 × 30일 메트릭 기반 실환경 배치 + API 검증 결과 문서화
- 일간/주간/월간 시간 윈도우별 랭킹 차이 분석 블로그 소재 정리
- 10-technical-writing-plan.md: 과제 가이드 기준 방향 조정, 글 구조, 소재 배치
- velog-techwriting-vol10.md: 일간/주간/월간 랭킹 배치 설계기 초안
- DISTINCT product_id를 사전 조회하여 행 수 기준으로 파티션 경계 결정
- product_id에 gap이 있어도 파티션 간 처리량이 균등하게 분배
- 기존 MIN/MAX ID 범위 균등 분할은 gap 비율에 따라 불균형 발생
- PR Summary에서 "실환경 검증" 표현 제거
- 리뷰 포인트에서 참고자료/사례를 PR 본문으로 이동
- 블로그 소재 문서에 참고자료 4건 + 참고사례 5건 + 작성 참고 2건 정리
- 10만 건 대규모 테스트 프롬프트 작성 (다른 환경에서 실행용)
- 100,000 상품 + 3,000,000 메트릭 행 벌크 시드 (batchUpdate 1,000건 단위)
- 6가지 트렌드 패턴 (급상승/장기강자/하락/바이럴/취소높음/일반)
- Weekly 2,205ms, Monthly 2,564ms — 4 Partition 균등 분배 확인
- Weekly 1위=급상승(p=5000), Monthly 1위=장기강자(p=15000) 시간 윈도우 검증
- Testcontainers innodb-buffer-pool-size=256M, Gradle -Xmx2g 설정
- BeforeEach DELETE→TRUNCATE 전환 (대규모 테스트 후 정리 성능)
- gddp: 47→49 Job (SapItemSync 추가), Stub 5개 식별
- mbod: 43→48 Job, 통계 11개, memberSyncJob(3 Step) 추가
- UniqueRunIdIncrementer: addLong(timestamp) → addString(UUID+timestamp)
- 비교 테이블/부록 수치 갱신, MV 단일 소스 원칙 반영
- 캡처 파일 경로 수정
- new JdbcTemplate(dataSource) → 필드 주입 jdbcTemplate으로 통일
- Javadoc을 기존 RankingCorrectionJobConfig 수준으로 간소화
- gridSize=1 vs gridSize=4 성능 비교 테스트 프롬프트 작성
- PR 테스트 시나리오 8개로 정리 (시각화를 대규모 테스트에 통합)
- 성능 예시를 실제 측정값(10만 상품 기준)으로 교체
- Summary: 배경/목표/결과 구조로 변경
- Context & Decision: 문제 정의 + 선택지 5개(대안→결정→트레이드오프)
- Design Overview: 변경 범위, 컴포넌트 책임
- Flow Diagram: Mermaid 2개 (배치 Job + API 조회)
- 리뷰 포인트 내용 갱신
- 선택지 순서: Chunk/Tasklet → Reader/병렬 → fallback → 재계산 → Score
- 지수 감쇠의 전시 기간 희석 특징 추가
- 문제 정의에서 Redis 언급 제거 (주간/월간 집계는 이번에 신규)
…s), 2.1x 향상

- GRID_SIZE를 @value로 외부 주입 가능하게 변경 (ReflectionTestUtils로 테스트 내 동적 변경)
- partitionBenchmark 테스트 추가: 동일 데이터(10만×30일)에서 gridSize만 교체하여 측정
- PR draft, batch-test-results, blog 문서에 벤치마크 결과 반영 (10/10 PASSED)
- 성능 테이블을 규모별 비교표(150 / 1,020 / 100,000)로 교체
- 섹션 6에 10만 건 테스트의 1위 검증 결과 추가
- 1,020개 데이터는 실환경 API 검증 맥락으로 유지
4곳에 분산된 Score 공식을 ScoreFormula 1곳으로 통일.
MV Job SQL에서 누락된 categoryPriority를 Java Processor로 반영.
- 수식 Javadoc을 ScoreFormula.java에만 유지, 나머지 3곳은 @see 참조로 축소
- 테스트 클래스 Javadoc 제거 (DisplayName으로 충분)
- 미사용 within import 제거 (RankingScoreUpdaterTest)
- 블로그에 ScoreFormula 중앙화 내용 반영
- RankingCarryOverScheduler: buildWeeklyRanking/buildMonthlyRanking 제거 (MV가 담당)
- RankingScoreUpdater: weeklyKey/monthlyKey, RANKING_WEEKLY/MONTHLY_PREFIX, AGGREGATED_TTL 제거
- RankingRedisRepository: 미사용 RANKING_WEEKLY/MONTHLY_PREFIX 상수 제거
- 관련 테스트 정리 (WeeklyRanking/MonthlyRanking 테스트 클래스 등)
- 블로그: Lambda Architecture 용어 제거 → scope별 데이터 소스 분리로 수정
- 블로그: Score 공식 중앙화를 섹션 3으로 이동 (이미 구현된 내용)
- 블로그: Speed Layer/Batch Layer → 실시간 경로/배치 경로
- PR: Summary 수치 정확도 수정 (weekly 1.7초, monthly 2.2초)
- PR: 벤치마크와 중복되는 성능 테이블 제거, 리뷰 포인트 정리
- 벤치마크: monthly 측정 추가 (weekly 2.1x, monthly 1.8x)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 제목: '테크니컬 라이팅 소재 모음' → '설계 판단 근거 모음'
- 참고자료 섹션: 블로그 관련 문구 정리, 토스 라이팅 가이드 삭제

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

3 participants