diff --git a/.docs/week10/design-notes.md b/.docs/week10/design-notes.md
new file mode 100644
index 0000000000..209180b3c7
--- /dev/null
+++ b/.docs/week10/design-notes.md
@@ -0,0 +1,295 @@
+# Week 10 설계 결정 노트 — Spring Batch 주간/월간 랭킹
+
+## 집계 기간 정의 — Rolling Window vs 캘린더 고정
+
+주간/월간 랭킹의 집계 기간을 어떻게 정의할지. UX("이번 주/달" 기대), 최신성, Spring Batch 멱등성 학습 가치가 각각 다르게 충돌했다.
+
+**검토한 접근들:**
+
+**접근 A: 캘린더 고정 (지난 주/달)**
+매주 월요일·매월 1일에 직전 캘린더 기간 1회 집계. 소스가 완전히 불변이라 Batch 멱등성/재시작 개념 학습엔 이상적이지만, 주·월 초반엔 표시할 데이터가 없고 "지난 주 TOP"이라는 UX 타협이 필요.
+
+**접근 B: Rolling Window (최근 7일/30일)**
+매일 `[today-7, today-1]` / `[today-30, today-1]` 덮어쓰기. 데이터가 항상 꽉 차고 사용자가 느끼는 "이번 주/달"에 더 가깝지만, 의미상 "최근 N일"에 더 가까움.
+
+**접근 C: 하이브리드 / D: 일간 Carry-Over 확장**
+둘 다 기각. C 는 배치 2종 + MV 4개로 과투자, D 는 첫 주 weekly ≈ monthly 중복 + 점수 의미 혼탁(과거 관성 + 현재 활동).
+
+**결론**: 초안 A → 실무 e-commerce 사례 조사 후 **B 로 반전 확정**. `[today-7, today-1]` 로 잡으면 소스가 실행 시점과 무관하게 불변이라 멱등성 리스크도 해소됨. 이 반전이 스키마(기간 식별자), JobParameters, API 설계 전반에 연쇄적으로 영향.
+
+---
+
+## 소스 테이블 전략 — hourly 직접 집계 vs daily 중간 테이블 SOT
+
+`ranking_metrics`(hourly) 에서 주/월을 각자 집계할지, `ranking_metrics_daily` 중간 테이블을 신설해 계층 집계할지.
+
+**검토한 접근들:**
+
+**접근 A: hourly 직접 집계**
+주/월 Job 이 각자 `ranking_metrics` 를 GROUP BY. 상품 1개 기준 주간 스캔량 168행(7×24), 월간 720행(30×24). 단순하고 Spring Batch Chunk 개념 학습에 집중하기 좋지만 규모 증가 시 부담.
+
+**접근 B: daily 중간 테이블 SOT (hourly → daily → weekly/monthly)**
+일간 Job 이 hourly→daily 적재, 주/월은 daily 를 읽음. 스캔량 주간 7행 / 월간 30행 (**24배 감소**). 파이프라인 1단계 추가 + 시각 분산(일간 00:30 → 주/월 01:00 이후) 필요.
+
+**결론**: **1단계 A 채택 → 측정 후 2단계 B 비교 실험**. 학습 초점이 Chunk-Oriented Processing 본질이고, 현재 학습 데이터 규모에선 A 로 충분. "같은 문제를 다른 데이터 모델로 풀 때 무엇이 바뀌는가"를 실측으로 체감하는 학습 가치 우선.
+
+---
+
+## weekly → monthly 체이닝 기각 — 계층 집계의 최소 공통 단위
+
+"주간 랭킹을 모으면 월간 랭킹 아닌가?" 직관으로 weekly MV 를 monthly 의 소스로 재사용할 수 있을지 검토.
+
+**검토한 접근들:**
+
+**접근 A: weekly → monthly 체이닝**
+weekly MV 에서 score 합산해 monthly 산출. 중복 계산 제거 이점.
+
+**접근 B: 각자 일간 단위에서 독립 집계**
+주/월 Job 이 각자 day 단위 소스를 GROUP BY.
+
+**결론**: **A 불가, B 확정**. ISO 주 경계가 캘린더 월 경계와 일치하지 않아 "4월 랭킹"에 3월 말/5월 초 데이터가 섞이거나(주-월 편입 규칙), 경계 주를 빼면 최대 9일치 누락. **계층 집계의 최소 공통 단위는 반드시 일(day)** 이라는 원칙 확정 — 주제 2 의 B안도 이 원칙에 기반.
+
+---
+
+## MV 스키마: 저장 범위 + rank 컬럼 — TOP 100 + rank 저장
+
+활동 있는 상품 전체 저장 vs TOP 100 만, 그리고 rank 를 컬럼에 저장할지 조회 시 계산할지.
+
+**검토한 접근들:**
+
+**접근 A: 전체 저장 + rank 없이 `ORDER BY score LIMIT 100` 계산**
+미래 파생 요구(상품 상세의 "지난 주 N위") 대응 옵션 가치. 가중치 변경 시 rank 갱신 부담 회피.
+
+**접근 B: TOP 100 만 저장 + rank 컬럼 저장**
+과제 요구에 충실, 저장량 최소.
+
+**결론**: **B 확정**. Rolling Window 전환(주제 1)으로 저장 범위 확장 유인이 약화됨. 결정적 논리는: **TOP 100 만 저장 시 가중치가 바뀌면 101위 이하 데이터가 없어 score UPDATE 만으로 새 TOP 100 식별 불가 → 원천 재집계 필수 → 재실행 과정에서 rank 도 자연스럽게 재계산 → rank 저장의 추가 비용 사실상 0.** 가중치 변경 유연성을 훼손하지 않는다.
+
+---
+
+## MV 기간 식별자 + PK — snapshot_date + (snapshot_date, product_id)
+
+MV 의 기간을 무엇으로 식별할지, PK 를 어떻게 잡을지.
+
+**검토한 접근들:**
+
+**접근 A: `year + week_of_year` 복합 식별자, PK=(year, week, rank)**
+의미론적으로 명시적. 하지만 MySQL `YEAR()` ≠ `YEAROFWEEK()` 의 ISO week-year 함정, 연도 경계 범위 쿼리 복잡.
+
+**접근 B: `week_start_date DATE` 단일**
+단순하지만 Rolling Window 전환으로 "주 시작일" 의미가 흐려짐.
+
+**접근 C: `snapshot_date DATE` + PK=(snapshot_date, product_id)**
+Rolling Window 에 정합. "그날 찍힌 스냅샷의 이 상품 기록"이 유일해야 한다는 의미론.
+
+**결론**: **C 확정**. 사용자가 "rank 는 정렬 결과이고 identity 는 (snapshot_date, product_id) 가 맞다"고 지적해 PK 초안 `(snapshot_date, rank)` 에서 교정. rank 는 의미론적으로 정렬 부산물이지 identity 가 아님.
+
+---
+
+## Composite PK JPA 매핑 — Surrogate id + UNIQUE
+
+`(snapshot_date, product_id)` 복합 identity 를 JPA 에 어떻게 매핑할지.
+
+**검토한 접근들:**
+
+**접근 A: `@EmbeddedId` 또는 `@IdClass`**
+도메인 의미론적으로 명시적이지만, 기존 프로젝트는 전부 surrogate `Long id` 관행.
+
+**접근 B: Surrogate `@Id Long id` + `UNIQUE(snapshot_date, product_id)`**
+DB 제약으로 identity 강제 + 코드 관성 유지.
+
+**결론**: **B 확정**. `@EmbeddedId` 가 필수는 아니며, 프로젝트 전체가 surrogate id 관행인 상황에서 MV 만 다른 스타일을 쓰는 건 득보다 실(일관성 훼손)이 큼.
+
+---
+
+## 엔티티/모듈 배치 — 두 app 에 중복 선언 + Repository 분리
+
+MV 테이블을 commerce-batch(쓰기)와 commerce-api(읽기) 양쪽에서 접근해야 함. 기존 구조는 엔티티가 각 app 에 귀속됨(`modules/jpa` 는 설정/BaseEntity 전용).
+
+**검토한 접근들:**
+
+**접근 A: 공유 모듈 (`modules/ranking-mv`) 신설**
+중복 제거 이점. 하지만 현재까진 다른 도메인 공유 사례 없음 → 관행 깨기.
+
+**접근 B: 두 app 에 엔티티 중복 선언 + Repository 각자 분리**
+기존 철학(app 독립성) 존중. batch 는 `MvProductRankWeekly` + `upsertAll`, api 는 `WeeklyRank` + `findBySnapshot...` 로 관점도 이름도 다름.
+
+**결론**: **B 확정**. 같은 테이블을 "다른 역할로 본다"는 점이 이름부터 드러남. Repository 분리는 선호가 아니라 **구조 강제** — Spring Data JPA Repository 가 엔티티 타입으로 파라미터화되므로 엔티티 분리 결정이 Repository 분리를 자동으로 강제. ISP(쓰기·읽기 메서드 무관) + 트랜잭션 경계(batch=chunk tx, api=readOnly+replica) 자연 분리 부수효과.
+
+---
+
+## Job/Step 구조 — 독립 Job 2개 + 단일 Chunk Step
+
+주/월을 한 Job 의 Step 으로 묶을지, 각자 독립 Job 으로 할지. Chunk vs Tasklet.
+
+**검토한 접근들:**
+
+**접근 A: 단일 Job + 주·월 Step 2개**
+한 번 실행으로 양쪽 완료. 단 한쪽 실패 시 전체 재실행/복구 복잡.
+
+**접근 B: 독립 Job 2개 + 각자 단일 Chunk Step**
+실패·재실행·스케줄 개별 가능. JobRepository 이력 깔끔. Tasklet 대신 Chunk 채택 — TOP 100 규모에선 스트리밍 이점 약하지만 학습 초점이 Chunk-Oriented Processing.
+
+**결론**: **B 확정**. Conditional Flow(`.on().to().from()`) 는 현재 Step 1개라 과함 → 추후 daily 중간 테이블 도입 시 검증 Step + fallback Step 등으로 도입 가치 있음(미래 메모).
+
+---
+
+## Reader 방식 — JdbcCursorItemReader + JDBC, Chunk 50
+
+TOP 100 집계를 ItemReader 로 어떻게 흘려줄지, JDBC 와 JPA 중 어느 층을 쓸지, Chunk 크기는.
+
+**검토한 접근들:**
+
+**접근 A: JdbcPagingItemReader / Keyset**
+대규모 데이터 커넥션 대여·반납 패턴으로 안전. 하지만 score 가 GROUP BY 이후 파생값이라 Keyset 키 사용 불가 + LIMIT 100 규모엔 OFFSET 비용 무의미.
+
+**접근 B: JdbcCursorItemReader + Chunk 50 + JDBC**
+100행을 2청크로 순환시켜 Chunk 동작을 직접 체감 가능. Reader 결과가 엔티티가 아닌 집계 DTO, Writer 가 UPSERT(JPA 약점) — 양쪽 모두 JPA 매핑 이점 없음.
+
+**결론**: **B 확정**. Chunk 50 은 학습 목적(2청크 순환) 의도적 선택. MySQL Connector/J 의 스트리밍 함정(`setFetchSize(Integer.MIN_VALUE)` 필요)은 LIMIT 100 규모엔 해당 없음 — 2단계 확장 시 재점검 메모.
+
+---
+
+## Writer 구현 방식 — 커스텀 ItemWriter + Repository.upsertAll 위임
+
+JDBC 기반 Writer 로 확정된 상태에서 구체 구현 방식 선택.
+
+**검토한 접근들:**
+
+**접근 A: `JpaItemWriter` (merge 모드)**
+merge() 가 내부적으로 SELECT 발사 → 청크 50건에 SELECT 50회 추가 왕복. `addBatch()` 로 못 묶여 `hibernate.jdbc.batch_size` 최적화도 무효.
+
+**접근 B: `JpaItemWriter` (persist 모드)**
+INSERT 전용이라 재실행 시 UNIQUE 위반으로 `PersistenceException`. MV 재집계 시나리오와 맞지 않음.
+
+**접근 C: `JdbcBatchItemWriter` 직접 사용**
+동작은 올바르나 SQL 이 Writer Bean 에 박혀 주제 4 의 Repository 분리 결정과 어긋남.
+
+**접근 D: 커스텀 `ItemWriter` + `Repository.upsertAll` 위임**
+주제 4 시그니처(`MvProductRankWeeklyRepository.upsertAll`) 정합. `commerce-streamer` 의 `RankingMetricsJpaRepository.upsertViewCount` 관행과 일관.
+
+**결론**: **D 확정**. 핵심 근거 2가지: (1) 주제 4 의 Repository 분리 결정이 이미 `upsertAll(List)` 시그니처를 낳음 — Writer 는 그걸 호출하는 얇은 어댑터. (2) **SQL 이 infrastructure 레이어에 모이는** 프로젝트 관행 유지. `created_at` 은 `ON DUPLICATE KEY UPDATE` 절에서 제외해 최초 INSERT 시각 보존.
+
+---
+
+## 멱등성 — Rolling Window + run.id + trigger 하이브리드 JobParameter
+
+Rolling Window `[today-7, today-1]` 전환으로 소스 불변성은 확보됨. 그 위에서 재실행 2종(같은 날 복구 / 가중치 변경 재집계)을 어떻게 구분해 처리할지.
+
+**검토한 접근들:**
+
+**접근 A: `run.id` 만 추가**
+재실행마다 값 다르면 새 JobInstance 생성. 단순하지만 "왜 재실행했는지"가 JobRepository 이력에 안 남음.
+
+**접근 B: `run.id` + `trigger` enum 하이브리드**
+`trigger ∈ {WEIGHT_CHANGE, DATA_FIX, MANUAL_RERUN}` 를 `identifying=true` 로 부착. JobInstance 유일성은 `run.id` 로 이미 확보되지만, `trigger` 는 **자기 기록성** + 운영 쿼리용으로 존재.
+
+**결론**: **B 확정**. `SCHEDULED` 값은 미정의 — 정상 스케줄러는 `trigger` 자체를 생략해 "파라미터 비어있음 = 자동 실행" 해석. 2단계 멱등성(JobInstance + SQL UPSERT)이 독립 작동해 분산 락 역할까지 겸함 — `ShedLock` 불필요(다중 인스턴스 시 재검토 메모).
+
+---
+
+## API 엔드포인트 설계 — 경로 분리 (`/weekly`, `/monthly`)
+
+기존 `/rankings` + `/rankings/hourly` 구조에 주/월을 어떻게 추가할지.
+
+**검토한 접근들:**
+
+**접근 A: `/rankings?period=weekly|monthly` 파라미터 통합**
+URL 하나, 내부 분기로 처리.
+
+**접근 B: `/rankings/weekly`, `/rankings/monthly` 경로 분리**
+기존 `/hourly` 분리 선례 일관. 각 엔드포인트 독립 시맨틱.
+
+**결론**: **B 확정**. 내부적으로 저장소가 분기되어야 함(Redis ZSET vs DB MV)이 필연이라 "얕은 통합"은 실익 없음 — 경로 분리가 외부 계약과 내부 구현 양쪽에 자연스러움. 기존 선례 존중.
+
+---
+
+## 캐시 설계 — 두 캐시의 역할 분리
+
+`/rankings/weekly` 응답 캐시를 어떻게 구성할지. Rolling Window 의 "snapshot_date 내부 데이터 불변성" 을 어떻게 활용할지.
+
+**검토한 접근들:**
+
+**접근 A: 단일 캐시 `rankings:weekly:{page}:{size}` + TTL 갱신**
+구조 단순. 새 snapshot 배치 후 수동 invalidate 필요 or 짧은 TTL → 스탬피드 위험.
+
+**접근 B: 두 캐시 분리 — 메인(Immutable) + 메타(Mutable)**
+메인: `rankings:weekly:{snapshot_date}:{page}:{size}` (24h TTL). 키에 snapshot_date 박혀있어 같은 키는 절대 값이 안 바뀜 → 수동 invalidate 불필요, 스탬피드 구조적 회피.
+메타: `rankings:weekly:latest_date` (25h TTL). `date` 파라미터 미지정 요청 시 최신 snapshot 식별용.
+
+**결론**: **B 확정**. "date 명시 요청"은 메타 캐시 건너뛰고 메인 조회, "date 생략 요청"만 메타→메인 2단. 새 배치 완료 → 새 snapshot_date → 자동으로 새 메인 키 생성 → 구 키는 TTL 만료로 자연 삭제. 메인 캐시 Cold Start(배치 완료 직후 첫 요청) 스탬피드는 학습 프로젝트 수준에선 수용 — pre-warming 은 기술부채.
+
+---
+
+## latest_date 캐시 동기화 — JobExecutionListener 기반 put + 25h TTL
+
+`latest_date` 메타 캐시를 언제·누가 갱신할지. UX 에 최신성이 중요.
+
+**검토한 접근들:**
+
+**접근 A: 짧은 TTL (5분) + DB `MAX(snapshot_date)` 폴백**
+캐시 miss 시 DB 폴백, 복잡한 이벤트 없음. 하지만 TTL 만료 순간 다수 요청이 동시에 DB 조회 → **스탬피드**. 5분 지연도 UX 저해.
+
+**접근 B: 캐시 제거하고 매 요청 DB `MAX()` 조회**
+단순. 작은 쿼리지만 "date 생략 요청" 트래픽이 크면 부담.
+
+**접근 C: `JobExecutionListener.afterJob` 에서 put + 긴 TTL(25h)**
+배치 성공 시 Listener 가 Redis 에 새 snapshot_date 덮어쓰기. API 측 MISS 는 cold start(리스너 한 번도 안 돈 상태) 에서만 발생 → DB `findLatestSnapshotDate()` 폴백 후 재 put. TTL 25h 는 다음 배치 이전 자연 만료 방지 안전망.
+
+**결론**: **C 확정**. 배치 FAILED/STOPPED 시 put 생략 — 실패한 Job 의 snapshotDate 를 캐시에 박으면 "존재하지 않는 snapshot 가리킴" 위험. 이전 성공값 유지가 안전. `RankingLatestDateCacheListener` 를 기존 `JobListener`/`StepMonitorListener` 와 **별도 클래스**로 분리(SRP) — 캐시 푸시 실패가 로깅까지 중단시킬 위험 방지.
+
+---
+
+## 랭킹 가중치 관리 — DB SOT 동적 조회 vs 하드코딩
+
+배치 실행 시 점수 계산에 사용되는 가중치(VIEW/LIKE/ORDER별 배율)를 어디서 어떻게 가져올지.
+
+**검토한 접근들:**
+
+**접근 A: 코드 내 하드코딩**
+`view * 0.1 + like * 0.2 + order * 0.00001` 상수 고정. 가중치 변경 시 코드 수정 + 재배포 필요. 단순하지만 가중치 변경 이력이 코드 이외 어디에도 남지 않음.
+
+**접근 B: 환경변수/설정 파일**
+`application.yml` 에 가중치 속성 정의. 배포 없이 설정값 변경 가능. 그러나 여러 인스턴스 간 동기화 문제, 이력 관리 부재.
+
+**접근 C: Redis 캐시에서 조회**
+빠른 조회. 단 캐시 TTL 불일치로 오래된 가중치가 그대로 사용될 위험 — 특히 `trigger=WEIGHT_CHANGE` 재실행 시나리오에서 캐시 갱신 누락이 발생할 수 있음.
+
+**접근 D: DB SOT (`ranking_weight` 테이블) 에서 조회**
+`BatchRankingWeightRepository.findWeightByEventType("VIEW")` 로 실행 시점에 직접 조회. DB가 단일 SOT가 되어 이력 관리가 가능하고, 캐시 불일치 문제가 없음.
+
+**결론**: **D 확정**. 핵심 근거: 배치는 매일 1회만 실행되므로 **신속성(캐시 조회 속도)보다 정확성(SOT 일치)이 우선**이다. Redis 캐시는 TTL 기간 동안 오래된 값이 남아있을 수 있어, `WEIGHT_CHANGE` trigger로 재집계 시 여전히 구 가중치가 사용될 위험이 있다. DB 직접 조회 1회 비용(밀리초 단위)은 전체 배치 실행 시간(수십 초) 대비 무시 가능. 가중치 변경 로그가 `ranking_weight` 테이블에 자연스럽게 남아 운영 이력 추적도 가능.
+
+**점수 공식 추가 결정**: 단순 선형 합산 대신 `LOG(1 + x)` 변환 적용. 매출이 극단적으로 큰 상품이 랭킹을 독점하는 롱테일 왜곡을 완화하고, 다양한 상품이 경쟁 가능한 분포를 만들기 위함. `+1`은 값이 0일 때 `LOG(0) = -∞` 방지.
+
+---
+
+## 배치 실행 주체 — @Scheduled vs 외부 크론
+
+주/월 Job 을 어디서 트리거할지.
+
+**검토한 접근들:**
+
+**접근 A: 외부 크론 (K8s CronJob, AWS EventBridge, Argo Workflow)**
+실무 정답. 실행 주기·실패·스케일링이 배포 리소스로 명시됨. 단 인프라 셋업 부가 비용.
+
+**접근 B: 애플리케이션 내부 `@Scheduled`**
+commerce-batch 가 항시 떠 있다고 가정하면 `@EnableScheduling` + cron 표현식으로 충분. 단일 인스턴스 가정 깨면 ShedLock 또는 외부 크론으로 전환 필요.
+
+**결론**: **B 확정 (단일 인스턴스 가정)**. 실행 시각은 weekly 01:00 KST / monthly 01:30 KST — Rolling Window `[today-7/30, today-1]` 이라 자정 후면 아무 때나 가능하나 30분 간격 분산해 DB/로그 관찰성 확보. `spring.batch.job.enabled=false` 로 부팅 시 Job 자동 실행 막아야 (스케줄러만 돌도록).
+
+---
+
+## 실패 알림 — 이번 주차 기술부채로 명시 (미결)
+
+배치 실패 시 운영 알림 경로.
+
+**검토한 접근들:**
+
+**접근 A: Slack Appender 이번 주차 포함**
+`supports/logging` 에 이미 `maricn/logback-slack-appender` 포함 — `logback-spring.xml` 에 레벨 세팅만 추가하면 `log.error()` 가 자동 Slack 전파. Webhook URL 환경변수 관리 필요.
+
+**접근 B: 이번 주차엔 `log.error()` + `JobExecution.status='FAILED'` 로 관찰성만 확보, Slack 은 후속**
+학습 우선순위(스케줄링·캐시 동기화) 집중. Webhook URL 관리 + 테스트 부담 회피. 현재도 가시성은 0이 아님 — `BATCH_JOB_EXECUTION` 테이블에 실패 이력 남음.
+
+**결론 / 미결**: **이번 주차 B (기술부채), 후속 세션에서 A 로 전환 예정**. 명시적 TODO: `logback-spring.xml` 에 `` 을 `ERROR` 레벨로 부착.
diff --git a/.docs/week10/implementation_plan.md b/.docs/week10/implementation_plan.md
new file mode 100644
index 0000000000..1fda9ec4a0
--- /dev/null
+++ b/.docs/week10/implementation_plan.md
@@ -0,0 +1,1359 @@
+# Week 10 — 주간/월간 랭킹 구현 계획
+
+> 본 문서는 Week 10 주간/월간 랭킹 시스템의 **구현 가이드**다. 설계 결정의 "왜" 는 `/save-design-notes` 로 분리 저장된 design-notes 문서를 참조.
+> 이 문서는 **무엇을 어떤 순서로 어디에 어떤 코드로** 만드는지에 집중. TDD Red → Green → Refactor 루프를 각 Step 에 적용.
+
+## 목표 & 범위
+
+### 산출물
+- **테이블 2개**: `mv_product_rank_weekly`, `mv_product_rank_monthly`
+- **Spring Batch Job 2개**: `weeklyRankingJob`, `monthlyRankingJob` (Reader/Processor/Writer 구조)
+- **Scheduler + Listener**: `@Scheduled` 기반 매일 실행 + `JobExecutionListener` 로 Redis `latest_date` 동기화
+- **API 엔드포인트 2개**: `GET /api/v1/rankings/weekly`, `GET /api/v1/rankings/monthly`
+- **캐시 2종**: 메인 캐시(`rankings:{period}:{snapshot_date}:{page}:{size}`, 24h TTL), 메타 캐시(`rankings:{period}:latest_date`, 25h TTL)
+
+### 이 문서에서 다루지 않는 것 (기술부채)
+- Slack 실패 알림 설정 (logback-spring.xml 수정 — 후속)
+- 오래된 snapshot cleanup 배치
+- 2단계 daily 중간 테이블 도입 (성능 측정 후 판단)
+- 다중 인스턴스 스케줄러 ShedLock
+
+---
+
+## 사전 준비 (Step 0)
+
+### 0-1. MySQL URL 옵션 확인
+
+`rewriteBatchedStatements=true` 가 `application.yml` 의 JDBC URL 에 포함되어 있는지 확인. 없으면 batch UPSERT 가 multi-row 로 묶이지 않음.
+
+```yaml
+# apps/commerce-batch/src/main/resources/application.yml
+datasource:
+ url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/loopers?rewriteBatchedStatements=true&useUnicode=true&characterEncoding=utf8
+```
+
+### 0-2. commerce-batch 에 Redis 의존 추가
+
+```kotlin
+// apps/commerce-batch/build.gradle.kts
+dependencies {
+ implementation(project(":modules:redis")) // ← 추가
+ // 기존 의존 유지
+}
+```
+
+### 0-3. @EnableScheduling 추가
+
+```java
+// apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
+@SpringBootApplication
+@EnableScheduling // ← 추가
+@EnableBatchProcessing
+public class CommerceBatchApplication { ... }
+```
+
+### 0-4. `spring.batch.job.enabled=false` 확인
+
+스케줄러만 돌게 해야 하므로 데몬 모드로 뜰 때 Job 자동 실행 금지.
+
+```yaml
+# apps/commerce-batch/src/main/resources/application.yml
+spring:
+ batch:
+ job:
+ enabled: false # ← 반드시 false — 스케줄러가 trigger
+```
+
+---
+
+## 구현 순서 (Step 1 ~ 7)
+
+각 Step 은 **독립 PR 또는 논리적 커밋 단위**로 분해 가능. 모든 Step 은 TDD 로 진행 (테스트 먼저 → 구현 → 리팩토링).
+
+| Step | 제목 | 주요 산출물 | 의존 |
+|---|---|---|---|
+| 1 | DDL 마이그레이션 | 테이블 2개 생성 | — |
+| 2 | commerce-batch 도메인 레이어 | 엔티티 + Repository 인터페이스 + JDBC UPSERT 구현체 | 1 |
+| 3 | commerce-batch Job 구성 | Reader / Processor / Writer / Job / Step | 2 |
+| 4 | Scheduler + Listener | RankingScheduler + RankingLatestDateCacheListener | 3 |
+| 5 | commerce-api 도메인 레이어 | 엔티티 + Repository (읽기) | 1 |
+| 6 | commerce-api Controller/Facade | Controller + Facade + Cache | 5 |
+| 7 | E2E 통합 검증 | 수동 & 자동 시나리오 | 2~6 |
+
+---
+
+## Step 1. DDL — JPA 엔티티로 테이블 생성
+
+> Flyway SQL 파일 대신 commerce-batch 의 JPA `@Entity` 를 이용해 `ddl-auto=create` 로 테이블을 자동 생성하는 방식을 채택.
+> 이유: 별도 마이그레이션 파일 없이 엔티티 변경만으로 스키마가 동기화되므로 개발 사이클이 단순해짐.
+
+### 1-1. 파일 경로 (이미 구현 완료)
+- `apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java`
+- `apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.java`
+- `apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java` ← `ranking_metrics` 테이블 미러 엔티티 (배치 테스트 환경에서 테이블 생성 목적)
+
+### 1-2. 엔티티가 생성하는 DDL 구조
+
+```
+mv_product_rank_weekly / mv_product_rank_monthly
+ id BIGINT AUTO_INCREMENT PRIMARY KEY
+ snapshot_date DATE NOT NULL
+ product_id BIGINT NOT NULL
+ rank_position INT NOT NULL ← 'rank' 는 MySQL 8.0 예약어(Window Function)라 rank_position 으로 명명
+ score DOUBLE NOT NULL
+ view_count BIGINT NOT NULL
+ like_count BIGINT NOT NULL
+ order_revenue DECIMAL(18,2) NOT NULL
+ created_at DATETIME(6) NOT NULL
+ UNIQUE KEY uk_snapshot_product (snapshot_date, product_id)
+ INDEX idx_snapshot_rank (snapshot_date, rank_position)
+```
+
+### 1-3. 검증
+- `test` 프로파일로 부팅 → `ddl-auto=create` 가 적용돼 테이블 자동 생성
+- MySQL 접속해 `DESC mv_product_rank_weekly` 로 구조 확인
+- UNIQUE KEY / INDEX 존재 확인: `SHOW INDEX FROM mv_product_rank_weekly`
+
+---
+
+## Step 2. commerce-batch 도메인 레이어
+
+### 2-1. 파일 경로
+- `apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java`
+- `apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.java`
+- `apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java`
+- monthly 동일 세트 (`MvProductRankMonthly`, `MvProductRankMonthlyRepository`, `MvProductRankMonthlyRepositoryImpl`)
+
+### 2-2. Red — 테스트 먼저 작성
+
+```java
+// apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java
+@SpringBootTest
+@ActiveProfiles("test")
+class MvProductRankWeeklyRepositoryImplIntegrationTest {
+
+ @Autowired MvProductRankWeeklyRepository repository;
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Nested
+ @DisplayName("upsertAll()")
+ class UpsertAll {
+
+ @Test
+ @DisplayName("최초 호출 시 행이 INSERT 된다")
+ void insertOnFirstCall() {
+ // arrange
+ List rows = List.of(
+ new MvProductRankWeekly(LocalDate.of(2026, 4, 16), 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00")),
+ new MvProductRankWeekly(LocalDate.of(2026, 4, 16), 2L, 2, 90.0, 9L, 4L, new BigDecimal("900.00"))
+ );
+
+ // act
+ repository.upsertAll(rows);
+
+ // assert
+ assertThat(repository.countBySnapshotDate(LocalDate.of(2026, 4, 16))).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("같은 (snapshot_date, product_id) 로 재호출 시 값이 UPDATE 된다 (멱등성)")
+ void updateOnDuplicateKey() {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(new MvProductRankWeekly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))));
+ MvProductRankWeekly updated = new MvProductRankWeekly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"));
+
+ // act
+ repository.upsertAll(List.of(updated));
+
+ // assert
+ MvProductRankWeekly row = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow();
+ assertThat(row.getScore()).isEqualTo(200.0);
+ assertThat(row.getViewCount()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("created_at 은 최초 INSERT 시각을 유지하고 UPDATE 시 갱신되지 않는다")
+ void createdAtIsPreservedOnUpdate() throws InterruptedException {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(new MvProductRankWeekly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))));
+ LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ Thread.sleep(10);
+
+ // act
+ repository.upsertAll(List.of(new MvProductRankWeekly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"))));
+
+ // assert
+ LocalDateTime afterUpdate = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ assertThat(afterUpdate).isEqualTo(firstCreatedAt);
+ }
+ }
+}
+```
+
+### 2-3. Green — 구현
+
+**엔티티**:
+```java
+// domain/rank/MvProductRankWeekly.java
+@Getter
+public class MvProductRankWeekly {
+ private Long id;
+ private final LocalDate snapshotDate;
+ private final Long productId;
+ private final int rank;
+ private final double score;
+ private final long viewCount;
+ private final long likeCount;
+ private final BigDecimal orderRevenue;
+ private LocalDateTime createdAt;
+
+ public MvProductRankWeekly(LocalDate snapshotDate, Long productId, int rank, double score,
+ long viewCount, long likeCount, BigDecimal orderRevenue) {
+ this.snapshotDate = snapshotDate;
+ this.productId = productId;
+ this.rank = rank;
+ this.score = score;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.orderRevenue = orderRevenue;
+ }
+}
+```
+
+- **BaseEntity 미상속** — MV 는 파생 데이터라 updatedAt/deletedAt 불필요.
+- JPA 엔티티가 아닌 순수 POJO — JDBC 로 다루므로 `@Entity` 붙이지 않음. (JPA 로 읽기 엔티티를 정의할지는 테스트 용이성만을 위해 필요 시 별도 검토.)
+
+**Repository 인터페이스**:
+```java
+// domain/rank/MvProductRankWeeklyRepository.java
+public interface MvProductRankWeeklyRepository {
+ void upsertAll(List rows);
+ long countBySnapshotDate(LocalDate snapshotDate);
+ Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId);
+}
+```
+
+**JDBC 구현체**:
+```java
+// infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java
+@Repository
+@RequiredArgsConstructor
+public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ private static final String UPSERT_SQL = """
+ INSERT INTO mv_product_rank_weekly
+ (snapshot_date, product_id, rank, score, view_count, like_count, order_revenue, created_at)
+ VALUES
+ (:snapshotDate, :productId, :rank, :score, :viewCount, :likeCount, :orderRevenue, NOW(6))
+ ON DUPLICATE KEY UPDATE
+ rank = VALUES(rank),
+ score = VALUES(score),
+ view_count = VALUES(view_count),
+ like_count = VALUES(like_count),
+ order_revenue = VALUES(order_revenue)
+ """;
+
+ @Override
+ public void upsertAll(List rows) {
+ SqlParameterSource[] params = rows.stream()
+ .map(r -> new MapSqlParameterSource()
+ .addValue("snapshotDate", r.getSnapshotDate())
+ .addValue("productId", r.getProductId())
+ .addValue("rank", r.getRank())
+ .addValue("score", r.getScore())
+ .addValue("viewCount", r.getViewCount())
+ .addValue("likeCount", r.getLikeCount())
+ .addValue("orderRevenue", r.getOrderRevenue()))
+ .toArray(SqlParameterSource[]::new);
+ jdbc.batchUpdate(UPSERT_SQL, params);
+ }
+
+ @Override
+ public long countBySnapshotDate(LocalDate snapshotDate) {
+ Long count = jdbc.queryForObject(
+ "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE snapshot_date = :snapshotDate",
+ Map.of("snapshotDate", snapshotDate),
+ Long.class);
+ return count != null ? count : 0L;
+ }
+
+ @Override
+ public Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId) {
+ List results = jdbc.query(
+ "SELECT * FROM mv_product_rank_weekly WHERE snapshot_date = :snapshotDate AND product_id = :productId",
+ Map.of("snapshotDate", snapshotDate, "productId", productId),
+ (rs, rowNum) -> {
+ MvProductRankWeekly row = new MvProductRankWeekly(
+ rs.getDate("snapshot_date").toLocalDate(),
+ rs.getLong("product_id"),
+ rs.getInt("rank"),
+ rs.getDouble("score"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue"));
+ row.id = rs.getLong("id");
+ row.createdAt = rs.getTimestamp("created_at").toLocalDateTime();
+ return row;
+ });
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
+ }
+}
+```
+
+### 2-4. Refactor 포인트
+- Monthly 도 동일 구조라 **공통 UPSERT SQL 템플릿 추출 가능** — 단, 테이블명만 다르므로 과한 추상화 지양. 두 클래스로 분리 유지가 단순성 승리.
+- 세 필드만 다르게 받아 같은 SQL 을 생성하는 헬퍼는 네이티브 SQL 가독성을 해치므로 금지.
+
+### 2-5. 체크
+- [ ] Red 테스트 3개 모두 실패 확인
+- [ ] Green 구현 후 모두 통과
+- [ ] `created_at` 이 `NOW(6)` 로 초기화되고 `ON DUPLICATE KEY UPDATE` 절에 없음
+- [ ] Monthly 세트도 동일 테스트 통과
+
+---
+
+## Step 3. commerce-batch Job 구성
+
+### 3-1. 파일 경로
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReader.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java` (Reader 결과 DTO)
+- monthly 세트 (`MonthlyRankingJobConfig`, `MonthlyRankReader`, `MonthlyRankProcessor`, `MonthlyRankWriter`)
+
+### 3-2. Red — 테스트
+
+```java
+// apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java
+@SpringBootTest
+@SpringBatchTest
+@ActiveProfiles("test")
+class WeeklyRankingJobIntegrationTest {
+
+ @Autowired JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired MvProductRankWeeklyRepository repository;
+ @Autowired RankingMetricsTestFixture metricsFixture; // 테스트용 헬퍼 (아래 참고)
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Test
+ @DisplayName("Rolling Window [today-7, today-1] 범위만 집계에 포함된다")
+ void rollingWindowBoundary() throws Exception {
+ // arrange: today-8 과 today 데이터 삽입 (제외 대상)
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(8), 1L, 100, 10, 1000);
+ metricsFixture.insertMetrics(SNAPSHOT, 1L, 100, 10, 1000);
+ // 포함 대상: today-7 ~ today-1
+ for (int d = 1; d <= 7; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), 1L, 10, 1, 100);
+ }
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ MvProductRankWeekly row = repository.findBySnapshotDateAndProductId(SNAPSHOT, 1L).orElseThrow();
+ // 7일 × (view=10, like=1, order=100) 만 집계되어야 함
+ assertThat(row.getViewCount()).isEqualTo(70L);
+ assertThat(row.getLikeCount()).isEqualTo(7L);
+ assertThat(row.getOrderRevenue()).isEqualByComparingTo("700.00");
+ }
+
+ @Test
+ @DisplayName("TOP 100 만 적재한다 (101위 이하는 제외)")
+ void top100Only() throws Exception {
+ // arrange: 150 개 상품의 7일치 메트릭 삽입
+ for (long pid = 1; pid <= 150; pid++) {
+ for (int d = 1; d <= 7; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), pid, (int) pid, 0, 0); // view_count 만 product_id 비례
+ }
+ }
+
+ // act
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("같은 snapshotDate 로 재실행하면 JobInstanceAlreadyCompleteException")
+ void idempotentReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // act & assert
+ assertThatThrownBy(() -> jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT)))
+ .isInstanceOf(JobInstanceAlreadyCompleteException.class);
+ }
+
+ @Test
+ @DisplayName("trigger + run.id 조합으로 재실행 가능하다 (새 JobInstance)")
+ void manualReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ JobParameters manual = new JobParametersBuilder()
+ .addString("snapshotDate", SNAPSHOT.toString())
+ .addString("trigger", "WEIGHT_CHANGE")
+ .addString("run.id", LocalDateTime.now().toString())
+ .toJobParameters();
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(manual);
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(1);
+ }
+
+ private JobParameters jobParams(LocalDate date) {
+ return new JobParametersBuilder()
+ .addString("snapshotDate", date.toString())
+ .toJobParameters();
+ }
+}
+```
+
+### 3-3. Green — 구현
+
+**Reader 결과 DTO**:
+```java
+// batch/ranking/RankingAggregateRow.java
+public record RankingAggregateRow(
+ long productId,
+ long viewCount,
+ long likeCount,
+ BigDecimal orderRevenue
+) {}
+```
+
+**JobTrigger enum**:
+```java
+// batch/ranking/RankingJobTrigger.java
+public enum RankingJobTrigger {
+ WEIGHT_CHANGE,
+ DATA_FIX,
+ MANUAL_RERUN
+}
+```
+
+**Reader**:
+```java
+// batch/ranking/weekly/WeeklyRankReader.java
+@Configuration
+public class WeeklyRankReaderConfig {
+
+ @Bean
+ @StepScope
+ public JdbcCursorItemReader weeklyRankReader(
+ DataSource dataSource,
+ @Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+
+ LocalDate snapshot = LocalDate.parse(snapshotDateStr);
+ LocalDate windowStart = snapshot.minusDays(7);
+ LocalDate windowEnd = snapshot.minusDays(1);
+
+ return new JdbcCursorItemReaderBuilder()
+ .name("weeklyRankReader")
+ .dataSource(dataSource)
+ .sql("""
+ SELECT product_id,
+ SUM(view_count) AS view_count,
+ SUM(like_count) AS like_count,
+ SUM(order_revenue) AS order_revenue
+ FROM ranking_metrics
+ WHERE metrics_date BETWEEN ? AND ?
+ GROUP BY product_id
+ ORDER BY (SUM(view_count) * 0.1 + SUM(like_count) * 0.2 + SUM(order_revenue) * 0.00001) DESC
+ LIMIT 100
+ """)
+ .preparedStatementSetter((ps) -> {
+ ps.setDate(1, Date.valueOf(windowStart));
+ ps.setDate(2, Date.valueOf(windowEnd));
+ })
+ .rowMapper((rs, rowNum) -> new RankingAggregateRow(
+ rs.getLong("product_id"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue")))
+ .build();
+ }
+}
+```
+
+- **가중치는 상수** — 지금은 `0.1 / 0.2 / 0.00001` 하드코딩. 추후 가중치 변경 이슈 발생 시 환경변수화 (주제 7 의 `trigger=WEIGHT_CHANGE` 재실행 흐름).
+- **Chunk 50** 로 100행을 2회에 걸쳐 처리.
+
+**Processor**:
+```java
+// batch/ranking/weekly/WeeklyRankProcessor.java
+@Component
+@StepScope
+public class WeeklyRankProcessor implements ItemProcessor {
+
+ private final LocalDate snapshotDate;
+ private final AtomicInteger rankCounter = new AtomicInteger(0);
+
+ public WeeklyRankProcessor(@Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+ this.snapshotDate = LocalDate.parse(snapshotDateStr);
+ }
+
+ @Override
+ public MvProductRankWeekly process(RankingAggregateRow item) {
+ int rank = rankCounter.incrementAndGet();
+ double score = item.viewCount() * 0.1 + item.likeCount() * 0.2
+ + item.orderRevenue().doubleValue() * 0.00001;
+ return new MvProductRankWeekly(
+ snapshotDate, item.productId(), rank, score,
+ item.viewCount(), item.likeCount(), item.orderRevenue());
+ }
+}
+```
+
+- Reader 가 이미 `ORDER BY score DESC` 로 내림차순 정렬된 TOP 100 을 흘려보내므로 rank 는 **수신 순서대로 부여**.
+- `@StepScope` 필수 — 매 Job 실행마다 `rankCounter` 초기화 돼야 함.
+
+**Writer**:
+```java
+// batch/ranking/weekly/WeeklyRankWriter.java
+@Component
+@RequiredArgsConstructor
+public class WeeklyRankWriter implements ItemWriter {
+
+ private final MvProductRankWeeklyRepository repository;
+
+ @Override
+ public void write(Chunk extends MvProductRankWeekly> chunk) {
+ repository.upsertAll((List) chunk.getItems());
+ }
+}
+```
+
+**JobConfig**:
+```java
+// batch/ranking/weekly/WeeklyRankingJobConfig.java
+@Configuration
+@RequiredArgsConstructor
+public class WeeklyRankingJobConfig {
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+
+ @Bean
+ public Job weeklyRankingJob(Step weeklyRankingStep,
+ RankingLatestDateCacheListener cacheListener,
+ JobListener defaultJobListener) {
+ return new JobBuilder("weeklyRankingJob", jobRepository)
+ .start(weeklyRankingStep)
+ .listener(defaultJobListener)
+ .listener(cacheListener) // Step 4 에서 추가
+ .build();
+ }
+
+ @Bean
+ public Step weeklyRankingStep(JdbcCursorItemReader weeklyRankReader,
+ WeeklyRankProcessor weeklyRankProcessor,
+ WeeklyRankWriter weeklyRankWriter) {
+ return new StepBuilder("weeklyRankingStep", jobRepository)
+ .chunk(50, transactionManager)
+ .reader(weeklyRankReader)
+ .processor(weeklyRankProcessor)
+ .writer(weeklyRankWriter)
+ .build();
+ }
+}
+```
+
+**테스트용 픽스처** (`RankingMetricsTestFixture`):
+```java
+// apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java
+@Component
+@RequiredArgsConstructor
+public class RankingMetricsTestFixture {
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public void insertMetrics(LocalDate date, long productId, long viewCount, long likeCount, long orderRevenue) {
+ jdbc.update("""
+ INSERT INTO ranking_metrics (product_id, metrics_date, metrics_hour, view_count, like_count, order_revenue)
+ VALUES (:productId, :date, 0, :view, :like, :order)
+ """, Map.of(
+ "productId", productId,
+ "date", date,
+ "view", viewCount,
+ "like", likeCount,
+ "order", orderRevenue));
+ }
+}
+```
+
+### 3-4. Refactor 포인트
+- Reader 의 가중치 상수는 **추후 `RankingScoreCalculator` 같은 별도 도메인 객체**로 빼낼 수 있지만, 현재는 Reader/Processor 두 곳에 중복 — YAGNI 로 두고 가중치 변경 이슈가 실제로 발생하면 그때 추출.
+- Monthly 는 Window 만 `[today-30, today-1]` 로 바뀌고 구조 동일 — 복사+수정 먼저, 2번째 구현 후 추상화 여지 재검토.
+
+### 3-5. 체크
+- [ ] Rolling Window 경계 테스트 통과
+- [ ] TOP 100 제한 테스트 통과
+- [ ] JobInstance 재실행 차단 테스트 통과
+- [ ] `trigger + run.id` 수동 재실행 테스트 통과
+- [ ] Monthly 세트도 동일 테스트 통과
+
+---
+
+## Step 4. Scheduler + Listener
+
+### 4-1. 파일 경로
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java`
+- `apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java`
+
+### 4-2. Red — 테스트
+
+```java
+// apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingLatestDateCacheListenerIntegrationTest.java
+@SpringBootTest
+@SpringBatchTest
+@ActiveProfiles("test")
+class RankingLatestDateCacheListenerIntegrationTest {
+
+ @Autowired JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired StringRedisTemplate redisTemplate;
+ @Autowired RankingMetricsTestFixture metricsFixture;
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ redisTemplate.delete("rankings:weekly:latest_date");
+ redisTemplate.delete("rankings:monthly:latest_date");
+ }
+
+ @Test
+ @DisplayName("Job 성공 시 latest_date 캐시에 snapshotDate 가 put 된다")
+ void cachePutOnSuccess() throws Exception {
+ // arrange
+ LocalDate snapshot = LocalDate.of(2026, 4, 16);
+ metricsFixture.insertMetrics(snapshot.minusDays(1), 1L, 10, 0, 0);
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder().addString("snapshotDate", snapshot.toString()).toJobParameters());
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ String cached = redisTemplate.opsForValue().get("rankings:weekly:latest_date");
+ assertThat(cached).isEqualTo("2026-04-16");
+
+ Long ttl = redisTemplate.getExpire("rankings:weekly:latest_date", TimeUnit.HOURS);
+ assertThat(ttl).isBetween(24L, 25L); // 25h 근사
+ }
+
+ @Test
+ @DisplayName("Job 실패 시 캐시는 갱신되지 않는다")
+ void cacheNotUpdatedOnFailure() throws Exception {
+ // arrange: 기존 캐시 값
+ redisTemplate.opsForValue().set("rankings:weekly:latest_date", "2026-04-15");
+ // Reader 가 예외를 던지도록 데이터 준비 (예: 잘못된 DB 상태)
+ // — 실무 테스트에선 TestContainer 에서 테이블 drop 등의 방법 사용
+
+ // act
+ // ... (실패 유도)
+
+ // assert
+ String cached = redisTemplate.opsForValue().get("rankings:weekly:latest_date");
+ assertThat(cached).isEqualTo("2026-04-15"); // 변경되지 않음
+ }
+}
+```
+
+> 실패 시나리오는 현실적으로 `MvProductRankWeeklyRepository` 를 `@MockBean` 으로 대체해 `doThrow()` 로 구성하는 게 단순. 위 테스트의 두 번째 케이스는 그 패턴을 따르도록 조정할 것.
+
+### 4-3. Green — 구현
+
+**Listener**:
+```java
+// batch/ranking/RankingLatestDateCacheListener.java
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class RankingLatestDateCacheListener implements JobExecutionListener {
+
+ private final StringRedisTemplate redisTemplate;
+
+ private static final Duration TTL = Duration.ofHours(25);
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ if (jobExecution.getStatus() != BatchStatus.COMPLETED) {
+ return;
+ }
+ String jobName = jobExecution.getJobInstance().getJobName();
+ String snapshotDate = jobExecution.getJobParameters().getString("snapshotDate");
+ if (snapshotDate == null) return;
+
+ String cacheKey = resolveCacheKey(jobName);
+ if (cacheKey == null) return;
+
+ redisTemplate.opsForValue().set(cacheKey, snapshotDate, TTL);
+ log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDate);
+ }
+
+ private String resolveCacheKey(String jobName) {
+ return switch (jobName) {
+ case "weeklyRankingJob" -> "rankings:weekly:latest_date";
+ case "monthlyRankingJob" -> "rankings:monthly:latest_date";
+ default -> null;
+ };
+ }
+}
+```
+
+**Scheduler**:
+```java
+// batch/ranking/RankingScheduler.java
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class RankingScheduler {
+
+ private final JobLauncher jobLauncher;
+ private final Job weeklyRankingJob;
+ private final Job monthlyRankingJob;
+
+ @Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul")
+ public void runWeekly() {
+ run(weeklyRankingJob, "weeklyRankingJob");
+ }
+
+ @Scheduled(cron = "0 30 1 * * *", zone = "Asia/Seoul")
+ public void runMonthly() {
+ run(monthlyRankingJob, "monthlyRankingJob");
+ }
+
+ private void run(Job job, String jobName) {
+ LocalDate snapshotDate = LocalDate.now(ZoneId.of("Asia/Seoul"));
+ try {
+ jobLauncher.run(job, new JobParametersBuilder()
+ .addString("snapshotDate", snapshotDate.toString())
+ .toJobParameters());
+ } catch (JobInstanceAlreadyCompleteException e) {
+ log.warn("[{}] snapshotDate={} 이미 완료된 JobInstance — 재실행 스킵", jobName, snapshotDate);
+ } catch (Exception e) {
+ log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e);
+ // Slack 알림은 기술부채로 별도 처리
+ }
+ }
+}
+```
+
+### 4-4. 체크
+- [ ] Job 성공 시 Redis 에 `latest_date` key 가 설정됨
+- [ ] TTL 이 25시간 근사
+- [ ] Job FAILED 시 캐시 값 유지
+- [ ] Monthly 키도 weekly 와 독립적으로 설정됨
+- [ ] Scheduler 가 매 분 호출되지 않고 지정 시각에만 트리거 (`application.yml` 에 `logging.level.org.springframework.scheduling=DEBUG` 로 확인)
+
+---
+
+## Step 5. commerce-api 도메인 레이어
+
+### 5-1. 파일 경로
+- `apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyRank.java`
+- `apps/commerce-api/src/main/java/com/loopers/domain/rank/WeeklyRankRepository.java`
+- `apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankJpaRepository.java`
+- `apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/WeeklyRankRepositoryImpl.java`
+- monthly 세트
+
+### 5-2. Red — 테스트
+
+```java
+// apps/commerce-api/src/test/java/com/loopers/infrastructure/rank/WeeklyRankRepositoryImplIntegrationTest.java
+@SpringBootTest
+@ActiveProfiles("test")
+class WeeklyRankRepositoryImplIntegrationTest {
+
+ @Autowired WeeklyRankRepository repository;
+ @Autowired WeeklyRankTestFixture fixture; // test 전용 INSERT 헬퍼
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() { databaseCleanUp.truncateAllTables(); }
+
+ @Test
+ @DisplayName("findLatestSnapshotDate 는 가장 큰 snapshot_date 를 반환한다")
+ void findLatestSnapshotDate() {
+ // arrange
+ fixture.insert(LocalDate.of(2026, 4, 14), 1L, 1);
+ fixture.insert(LocalDate.of(2026, 4, 16), 2L, 1);
+ fixture.insert(LocalDate.of(2026, 4, 15), 3L, 1);
+
+ // act
+ Optional latest = repository.findLatestSnapshotDate();
+
+ // assert
+ assertThat(latest).hasValue(LocalDate.of(2026, 4, 16));
+ }
+
+ @Test
+ @DisplayName("findBySnapshotDateOrderByRankAsc 는 rank 오름차순 페이지를 반환한다")
+ void findBySnapshotDate() {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ for (int rank = 1; rank <= 30; rank++) {
+ fixture.insert(date, (long) rank, rank);
+ }
+
+ // act
+ Page page = repository.findBySnapshotDateOrderByRankAsc(date, PageRequest.of(1, 10));
+
+ // assert
+ assertThat(page.getContent()).hasSize(10);
+ assertThat(page.getContent().get(0).getRank()).isEqualTo(11);
+ assertThat(page.getTotalElements()).isEqualTo(30);
+ }
+}
+```
+
+### 5-3. Green — 구현
+
+**엔티티** (읽기 전용 관점):
+```java
+// domain/rank/WeeklyRank.java
+@Entity
+@Table(name = "mv_product_rank_weekly")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class WeeklyRank {
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "snapshot_date", nullable = false)
+ private LocalDate snapshotDate;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(nullable = false)
+ private int rank;
+
+ @Column(nullable = false)
+ private double score;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "order_revenue", nullable = false)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+}
+```
+
+**Repository 인터페이스**:
+```java
+// domain/rank/WeeklyRankRepository.java
+public interface WeeklyRankRepository {
+ Optional findLatestSnapshotDate();
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
+```
+
+**Spring Data JPA**:
+```java
+// infrastructure/rank/WeeklyRankJpaRepository.java
+public interface WeeklyRankJpaRepository extends JpaRepository {
+
+ @Query("SELECT MAX(w.snapshotDate) FROM WeeklyRank w")
+ Optional findLatestSnapshotDate();
+
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
+```
+
+**Adapter**:
+```java
+// infrastructure/rank/WeeklyRankRepositoryImpl.java
+@Repository
+@RequiredArgsConstructor
+public class WeeklyRankRepositoryImpl implements WeeklyRankRepository {
+ private final WeeklyRankJpaRepository jpa;
+
+ @Override
+ public Optional findLatestSnapshotDate() {
+ return jpa.findLatestSnapshotDate();
+ }
+
+ @Override
+ public Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable) {
+ return jpa.findBySnapshotDateOrderByRankAsc(snapshotDate, pageable);
+ }
+}
+```
+
+### 5-4. 체크
+- [ ] `findLatestSnapshotDate` 테스트 통과
+- [ ] `findBySnapshotDate` 페이지네이션 테스트 통과
+- [ ] Monthly 세트 동일 패턴으로 통과
+
+---
+
+## Step 6. commerce-api Controller + Facade + Cache
+
+### 6-1. 파일 경로
+- `apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java` (수정)
+- `apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java` (수정)
+- `apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` (수정)
+
+### 6-2. Red — 테스트 (Facade 레벨, E2E 는 Step 7)
+
+```java
+// apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java
+@SpringBootTest
+@ActiveProfiles("test")
+class RankingFacadeWeeklyIntegrationTest {
+
+ @Autowired RankingFacade rankingFacade;
+ @Autowired WeeklyRankTestFixture fixture;
+ @Autowired StringRedisTemplate redisTemplate;
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ redisTemplate.getConnectionFactory().getConnection().flushDb();
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 hit → 해당 snapshot 반환")
+ void dateOmitted_cacheHit() {
+ // arrange
+ redisTemplate.opsForValue().set("rankings:weekly:latest_date", SNAPSHOT.toString());
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1); // product 조인 위해 실제 상품도 삽입
+
+ // act
+ RankingListResponse response = rankingFacade.findWeeklyRanking(null, 0, 20);
+
+ // assert
+ assertThat(response.rankings()).hasSize(1);
+ assertThat(response.rankings().get(0).rank()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 miss → DB MAX 쿼리 폴백 + 캐시 put")
+ void dateOmitted_cacheMiss_dbFallback() {
+ // arrange: 캐시 비어있음
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ RankingListResponse response = rankingFacade.findWeeklyRanking(null, 0, 20);
+
+ // assert
+ assertThat(response.rankings()).hasSize(1);
+ String cached = redisTemplate.opsForValue().get("rankings:weekly:latest_date");
+ assertThat(cached).isEqualTo(SNAPSHOT.toString());
+ }
+
+ @Test
+ @DisplayName("date 명시 → 해당 snapshot 반환, latest_date 캐시 조회 없음")
+ void dateSpecified() {
+ // arrange
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ RankingListResponse response = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+
+ // assert
+ assertThat(response.rankings()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("snapshot 없음 → 200 + 빈 리스트")
+ void emptySnapshot() {
+ // act
+ RankingListResponse response = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+
+ // assert
+ assertThat(response.rankings()).isEmpty();
+ assertThat(response.totalElements()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("동일 쿼리 두 번 호출 → 두 번째는 메인 캐시 hit")
+ void mainCacheHit() {
+ // arrange
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20); // 첫 호출 — cache put
+ String cacheKey = "rankings:weekly:" + SNAPSHOT + ":0:20";
+
+ // assert
+ assertThat(redisTemplate.hasKey(cacheKey)).isTrue();
+ }
+}
+```
+
+### 6-3. Green — 구현
+
+**Facade**:
+```java
+// application/ranking/RankingFacade.java (관련 부분만)
+public RankingListResponse findWeeklyRanking(LocalDate date, int page, int size) {
+ LocalDate snapshot = resolveSnapshotDate(date, "rankings:weekly:latest_date",
+ weeklyRankRepository::findLatestSnapshotDate);
+ if (snapshot == null) {
+ return RankingListResponse.empty(page, size);
+ }
+
+ String cacheKey = "rankings:weekly:%s:%d:%d".formatted(snapshot, page, size);
+ return cacheAside(cacheKey, Duration.ofHours(24), () -> {
+ Page ranks = weeklyRankRepository.findBySnapshotDateOrderByRankAsc(
+ snapshot, PageRequest.of(page, size));
+ return toResponse(ranks);
+ });
+}
+
+private LocalDate resolveSnapshotDate(LocalDate given, String latestKey, Supplier> dbFallback) {
+ if (given != null) return given;
+
+ String cached = redisTemplate.opsForValue().get(latestKey);
+ if (cached != null) return LocalDate.parse(cached);
+
+ Optional fromDb = dbFallback.get();
+ fromDb.ifPresent(d -> redisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25)));
+ return fromDb.orElse(null);
+}
+
+private RankingListResponse toResponse(Page ranks) {
+ // Product/Brand 조인해서 RankingItemResponse 조립
+ List productIds = ranks.getContent().stream().map(WeeklyRank::getProductId).toList();
+ Map products = productService.findAllByIds(productIds).stream()
+ .collect(Collectors.toMap(Product::getId, Function.identity()));
+ // ... brandService 조인
+
+ List items = ranks.getContent().stream()
+ .map(r -> RankingItemResponse.from(r, products.get(r.getProductId()) /* , brand */))
+ .toList();
+ return new RankingListResponse(items, (int) ranks.getNumber(), (int) ranks.getSize(), ranks.getTotalElements());
+}
+```
+
+**Controller**:
+```java
+// interfaces/api/ranking/RankingV1Controller.java (추가)
+@GetMapping("/weekly")
+public ApiResponse getWeeklyRanking(
+ @RequestParam(required = false) LocalDate date,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.success(rankingFacade.findWeeklyRanking(date, page, size));
+}
+
+@GetMapping("/monthly")
+public ApiResponse getMonthlyRanking(
+ @RequestParam(required = false) LocalDate date,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.success(rankingFacade.findMonthlyRanking(date, page, size));
+}
+```
+
+**ApiSpec** 업데이트도 동일 시그니처로. (기존 ApiSpec 인터페이스에 메서드 추가)
+
+### 6-4. Refactor 포인트
+- `resolveSnapshotDate` 는 weekly/monthly 공통 — 잘 추출됨.
+- `toResponse` 내부의 Product/Brand 조인 로직이 기존 daily 구현과 중복되면 **private 공통 메서드로 추출**. 중복 정도에 따라 판단.
+- `Supplier` 로 DB 폴백을 주입받는 패턴은 재사용성 높음 — 유지.
+
+### 6-5. 체크
+- [ ] Facade 통합 테스트 5개 통과
+- [ ] latest_date 캐시 miss → DB 폴백 → 캐시 put 검증
+- [ ] 메인 캐시 hit 검증
+- [ ] 빈 snapshot 응답 검증
+- [ ] Monthly 동일 패턴
+
+---
+
+## Step 7. E2E 통합 검증
+
+### 7-1. 자동 E2E 테스트
+
+```java
+// apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyE2ETest.java
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+@ActiveProfiles("test")
+class RankingV1ApiWeeklyE2ETest {
+
+ @Autowired TestRestTemplate restTemplate;
+ @Autowired WeeklyRankTestFixture fixture;
+ @Autowired DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() { databaseCleanUp.truncateAllTables(); }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings/weekly?date=X → 200 + 응답 스키마 일치")
+ void getWeeklyRanking_withDate() {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ fixture.insertWithProduct(date, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?date=" + date + "&page=0&size=20",
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().getData().rankings()).hasSize(1);
+ }
+}
+```
+
+### 7-2. 수동 검증 시나리오
+
+```bash
+# 1. 배치 수동 실행 (스케줄러 대기 없이)
+./gradlew :apps:commerce-batch:bootRun \
+ --args='--spring.batch.job.name=weeklyRankingJob --snapshotDate=2026-04-16'
+
+# 2. 재실행 시도 → JobInstanceAlreadyCompleteException
+./gradlew :apps:commerce-batch:bootRun \
+ --args='--spring.batch.job.name=weeklyRankingJob --snapshotDate=2026-04-16'
+
+# 3. 가중치 변경 재집계
+./gradlew :apps:commerce-batch:bootRun \
+ --args='--spring.batch.job.name=weeklyRankingJob \
+ --snapshotDate=2026-04-16 \
+ --trigger=WEIGHT_CHANGE \
+ --run.id=2026-04-16T14:30:22'
+
+# 4. API 호출
+curl -s 'http://localhost:8080/api/v1/rankings/weekly' | jq
+curl -s 'http://localhost:8080/api/v1/rankings/weekly?date=2026-04-16&page=0&size=20' | jq
+curl -s 'http://localhost:8080/api/v1/rankings/monthly' | jq
+
+# 5. Redis 상태 확인
+redis-cli GET 'rankings:weekly:latest_date'
+redis-cli TTL 'rankings:weekly:latest_date' # 25h 근사
+redis-cli KEYS 'rankings:weekly:*'
+redis-cli TTL 'rankings:weekly:2026-04-16:0:20' # 24h 근사
+
+# 6. DB 상태 확인
+mysql> SELECT snapshot_date, COUNT(*) FROM mv_product_rank_weekly GROUP BY snapshot_date;
+mysql> SELECT * FROM BATCH_JOB_EXECUTION_PARAMS WHERE parameter_name='trigger';
+```
+
+### 7-3. 데몬 모드 스케줄러 검증
+
+```bash
+# commerce-batch 를 데몬 모드로 띄움 (spring.batch.job.enabled=false)
+./gradlew :apps:commerce-batch:bootRun
+
+# 로그에 @Scheduled 등록 메시지 확인
+# 01:00 / 01:30 KST 에 weekly / monthly 자동 트리거 확인 (시간대 주의)
+```
+
+---
+
+## 최종 검증 체크리스트 (Go/No-Go)
+
+- [ ] **DDL**: 테이블 2개, UNIQUE + INDEX 생성됨
+- [ ] **Batch Domain**: Repository UPSERT 테스트 3종 통과 (INSERT / UPDATE / created_at 보존)
+- [ ] **Batch Job**: Rolling Window 경계 / TOP 100 제한 / JobInstance 멱등성 / trigger 재실행 테스트 통과
+- [ ] **Scheduler**: cron KST 반영, 부팅 시 Job 자동 실행 안 됨 (`enabled=false`)
+- [ ] **Listener**: Job 성공 시 Redis put / 실패 시 유지 / 키 네임스페이스 분리
+- [ ] **API Domain**: `findLatestSnapshotDate` / 페이지네이션 테스트 통과
+- [ ] **API Facade**: date 미지정 → 캐시 → DB 폴백, 메인 캐시 hit, 빈 snapshot 200 응답
+- [ ] **E2E**: 4종 API 호출 + Redis/DB 상태 일치
+- [ ] **기술부채 문서화**: Slack 알림 / cleanup 배치 / 2단계 daily SOT — 별도 이슈/TODO 로 등록
+
+---
+
+## 참고할 기존 코드 (패턴 복제용)
+
+- `apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingMetricsJpaRepository.java` — 네이티브 UPSERT 패턴
+- `apps/commerce-batch/src/main/java/com/loopers/batch/demo/DemoJobConfig.java` — Job 설정 뼈대
+- `apps/commerce-batch/src/main/java/com/loopers/batch/common/JobListener.java`, `StepMonitorListener.java` — 기존 Listener 부착 지점
+- `apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java` — 기존 daily/hourly cache-aside 패턴
+- `modules/redis/src/main/java/com/loopers/config/redis/RankingKeys.java` — 캐시 키 네이밍
+- `supports/logging/**/logback-spring.xml` + Slack Appender — Slack 알림 기술부채 후속 작업 진입점
+
+---
+
+## 다음 단계 (본 Step 완료 후 기술부채)
+
+- Slack 실패 알림 구성 (logback-spring.xml + ERROR 레벨)
+- 오래된 snapshot cleanup 배치 (Step 추가 또는 별도 Job)
+- 2단계 daily 중간 테이블 도입 성능 측정
+- 다중 인스턴스 스케줄러 ShedLock 또는 외부 크론 전환
+- 메인 캐시 cold-start pre-warming
+- `RankingManualRunner` 에 `run.id` 추가 — 동일 trigger + snapshotDate 조합 재실행 지원 (현재는 `JobInstanceAlreadyCompleteException` 발생)
+
+---
+
+## 실제 구현과 계획의 차이점 (구현 완료 후 기록)
+
+> 계획 수립 이후 실제 구현 과정에서 변경·추가된 사항을 기록. 각 항목에 "왜 바뀌었는지" 포함.
+
+### Step 3 변경사항
+
+#### 가중치 하드코딩 → DB 동적 조회 (`BatchRankingWeightRepository`)
+
+**계획서 원안**:
+```java
+// Reader SQL 내 하드코딩
+SUM(view_count) * 0.1 + SUM(like_count) * 0.2 + SUM(order_revenue) * 0.00001
+```
+
+**실제 구현**:
+```java
+// WeeklyRankReaderConfig — 실행 시점에 DB 조회
+BigDecimal viewWeight = weightRepository.findWeightByEventType("VIEW");
+BigDecimal likeWeight = weightRepository.findWeightByEventType("LIKE");
+BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER");
+String sql = buildSql(viewWeight, likeWeight, orderWeight);
+```
+
+**변경 이유**: 가중치는 비즈니스 요인(마케팅 전략, 데이터 분포 변화)에 따라 변경될 수 있는 값이다. SOT(Source of Truth)는 DB의 `ranking_weight` 테이블로 두고, 배치 실행 시점에 조회한다. Redis 에 캐시하지 않은 이유는 배치가 매일 1회만 실행되므로 **신속성보다 정확성이 우선**이기 때문 — Redis 캐시 TTL 불일치로 오래된 가중치가 사용될 위험보다 DB 직접 조회 1회 비용이 훨씬 작다.
+
+---
+
+#### 점수 공식에 LOG 변환 추가
+
+**계획서 원안**: 선형 합산 (`view * w1 + like * w2 + revenue * w3`)
+
+**실제 구현**:
+```java
+LOG(1 + SUM(view_count)) * viewWeight
++ LOG(1 + SUM(like_count)) * likeWeight
++ LOG(1 + SUM(order_revenue)) * orderWeight
+```
+
+**변경 이유**: 선형 합산은 특정 지표(예: 주문 매출)가 극단적으로 큰 상품이 랭킹을 독점하는 문제가 있다. `LOG(1 + x)` 변환으로 롱테일을 압축해 다양한 상품이 경쟁 가능한 랭킹을 만든다. `+1`은 0값일 때 `LOG(0) = -∞` 가 되는 것을 방지.
+
+---
+
+#### `RankingAggregateRow`에 `score` 필드 추가
+
+**계획서 원안**: score는 Processor에서 계산
+```java
+record RankingAggregateRow(long productId, long viewCount, long likeCount, BigDecimal orderRevenue)
+```
+
+**실제 구현**: Reader SQL에서 score 미리 계산해 DTO에 포함
+```java
+record RankingAggregateRow(long productId, long viewCount, long likeCount, BigDecimal orderRevenue, double score)
+```
+
+**변경 이유**: Reader SQL이 `ORDER BY score DESC`로 정렬해야 하므로 어차피 DB에서 score를 계산한다. 이를 Processor에서 다시 계산하면 중복이다. score를 DTO에 포함시켜 Processor는 rank 부여만 담당 — 단일 책임이 명확해진다.
+
+---
+
+### Step 4 변경사항
+
+#### `RankingLatestDateCacheListener`에 count 검증 추가
+
+**계획서 원안**: Job `COMPLETED` 이면 즉시 캐시 put
+
+**실제 구현**:
+```java
+long count = countByJobName(jobName, snapshotDate);
+if (count == 0) {
+ log.warn("... 적재 데이터 없음 — latest_date 캐시 갱신 스킵");
+ return;
+}
+stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL);
+```
+
+**변경 이유**: 집계 대상 상품이 없으면(신규 서비스 초기 등) Job은 `COMPLETED`이지만 실제 적재 데이터가 없다. 이 상태에서 `latest_date`를 갱신하면 API가 빈 응답을 반환하는 snapshot을 가리키게 된다. 이전 성공 snapshot을 유지하는 것이 더 안전하므로 count=0 시 갱신 스킵.
+
+**추가된 의존성**: Listener가 `MvProductRankWeeklyRepository`, `MvProductRankMonthlyRepository`를 주입받아 count 조회. 계획서의 Listener는 이 의존성이 없었음.
+
+---
+
+### Step 3 추가사항
+
+#### `RankingJobTrigger.SCHEDULED` 추가 및 `RankingManualRunner` 신설
+
+**계획서 설계 노트 원안**: "SCHEDULED는 미정의 — 파라미터 비어있음 = 자동 실행"
+
+**실제 구현**:
+- `RankingScheduler`: `trigger=SCHEDULED` 명시적 추가
+- `RankingManualRunner`: CLI 진입점 신설 — `--trigger=WEIGHT_CHANGE` 등 옵션으로 수동 실행
+
+```bash
+# 수동 재실행 예시
+./gradlew :apps:commerce-batch:bootRun \
+ --args='--spring.batch.job.name=weeklyRankingJob --trigger=WEIGHT_CHANGE --snapshotDate=2026-04-16'
+```
+
+**변경 이유**: trigger를 명시해 `BATCH_JOB_EXECUTION_PARAMS` 이력에서 "자동 실행 vs 수동 실행"을 구분 가능하게 함. `RankingManualRunner`는 `ApplicationRunner` 구현으로 부팅 직후 실행되며, `--trigger` 옵션이 없으면 아무것도 하지 않아 스케줄러 데몬 모드와 충돌하지 않음.
+
+**현재 제약**: `RankingManualRunner`에 `run.id`가 없어 동일 `trigger + snapshotDate` 조합은 한 번만 실행 가능. 같은 trigger로 재실행이 필요하면 다른 trigger 값을 사용해야 함 (기술부채).
+
+---
+
+### Step 6 추가사항
+
+#### Facade 캐시 처리에 `RankingCacheRepository` 추상화 적용
+
+**계획서 원안**: `StringRedisTemplate` 직접 사용 + `cacheAside()` private 메서드
+
+**실제 구현**: `RankingCacheRepository` 인터페이스 도입
+
+```java
+// application 레이어
+Optional cached = rankingCacheRepository.get(cacheKey);
+if (cached.isPresent()) return cached.get();
+// ...
+rankingCacheRepository.save(cacheKey, result);
+```
+
+**변경 이유**: DIP 원칙 준수 — application 레이어(Facade)가 Redis 구현 세부(`StringRedisTemplate`)를 직접 알지 않도록 인터페이스로 추상화. 테스트에서 `RankingCacheRepository` Mock 교체가 쉬워지는 부수효과.
+
+#### `toRankingResult()` 제네릭 공통화
+
+`WeeklyRank`, `MonthlyRank` 두 타입의 응답 조립 로직을 제네릭 private 메서드로 공통화. 계획서의 `toResponse(Page)` 패턴보다 중복이 줄었다.
+
+```java
+private RankingResult toRankingResult(
+ List rows,
+ Function rankExtractor,
+ Function productIdExtractor,
+ Function scoreExtractor, ...)
+```
+
+---
+
+### Step 3 추가사항 — `@ConditionalOnProperty`
+
+**계획서에 없던 설정**. 각 Job Config 클래스에 `@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "weeklyRankingJob", matchIfMissing = true)` 적용.
+
+**이유**: `spring.batch.job.name` 으로 특정 Job만 로드해 수동 실행 시 불필요한 Bean 초기화를 줄임. `matchIfMissing = true` 로 Job 이름 미지정(데몬 모드)에서는 모든 Job이 로드되어 스케줄러가 둘 다 트리거 가능.
\ No newline at end of file
diff --git a/.docs/week10/learning-point.md b/.docs/week10/learning-point.md
new file mode 100644
index 0000000000..04a7e30dc5
--- /dev/null
+++ b/.docs/week10/learning-point.md
@@ -0,0 +1,237 @@
+## 🧭 루프팩 BE L2 - Round 10
+
+> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다.
+
+그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다.
+>
+
+
+
+지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다.
+
+이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. **Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다.
+
+
+
+- Spring Batch (Job / Step / Chunk / Tasklet)
+- ItemReader / ItemProcessor / ItemWriter
+- Materialized View (사전 집계)
+- 실시간 처리 vs 배치 처리
+
+
+
+## 🧮 Bacth System
+
+
+
+### 🎞️ 실무에서 자주 보는 배치 시나리오
+
+- **주문 정산**
+ - 주문/결제/환불 데이터를 모아 매일 새벽 3시 정산 테이블 생성.
+ - PG사 매출/정산 금액 검증도 함께.
+- **랭킹/통계 적재**
+ - 일간/주간/월간 인기 상품 집계
+ - 카테고리별 판매량 통계
+- **데이터 정리/청소**
+ - 만료된 쿠폰 삭제, 오래된 로그 제거, 캐시 초기화
+- **데이터 웨어하우스(DW) 적재**
+ - 서비스 DB → DW(BigQuery, Redshift 등) 로 적재 후 분석
+
+### ⚖️ 실시간 vs 배치 트레이드오프
+
+| 항목 | 실시간 처리 | 배치 처리 |
+| --- | --- | --- |
+| 장점 | 즉각 반영 → UX 좋음 | 대규모 집계, 비용 효율적 |
+| 단점 | 인프라 복잡, 멱등성 관리 필요 | 지연 발생, 실시간성 부족 |
+| 적합 | 좋아요 수, 실시간 랭킹 | 월간 리포트, 대시보드, BI |
+| 초점 | **신속성** | **정확성 & 효율성** |
+
+---
+
+## 🏗️ Spring Batch
+
+### 💧 **기본 구성 요소**
+
+- **Job** : 배치 실행 단위 (예: “일간 주문 통계 Job”)
+- **Step** : Job 을 구성하는 세부 단계
+
+### 📌 배치 처리 모델
+
+**Chunk-Oriented Processing**
+
+- 데이터 읽기 (Reader) → 가공 (Processor) → 저장 (Writer)
+- 청크 단위로 트랜잭션이 관리됨 → 안정적 대량 처리
+
+```java
+@Bean
+public Step orderStatsStep(
+ JobRepository jobRepository,
+ PlatformTransactionManager txManager,
+ ItemReader reader,
+ ItemProcessor processor,
+ ItemWriter writer
+) {
+ return new StepBuilder("orderStatsStep", jobRepository)
+ .chunk(1000, txManager)
+ .reader(reader)
+ .processor(processor)
+ .writer(writer)
+ .build();
+}
+```
+
+**장점**
+
+- 대규모 집계/정산/데이터 변환에 적합
+- 트랜잭션 단위 조절 가능
+
+---
+
+**Tasklet**
+
+- Step = 하나의 작업(Task) 실행
+- 반복 구조 없음, 단발성 작업에 적합
+
+```java
+@Bean
+public Step cleanupStep(
+ JobRepository jobRepository,
+ PlatformTransactionManager txManager
+) {
+ return new StepBuilder("cleanupStep", jobRepository)
+ .tasklet((contribution, chunkContext) -> {
+ orderRepository.deleteOldOrders(); // 만료 주문 삭제
+ return RepeatStatus.FINISHED;
+ }, txManager)
+ .build();
+}
+```
+
+**장점**
+
+- 간단한 SQL 실행, 파일 이동, 캐시 초기화 등에 적합
+- Reader/Processor/Writer 필요 없는 작업에 깔끔
+
+> *일반적으로 **구현의 용이성** 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing 을 구현하기도 합니다.*
+>
+
+---
+
+### 🗼 Materialized View
+
+
+
+- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조**
+- MySQL 은 MV 기능이 별도로 없으므로 보통 **별도 테이블 + 배치 적재** 방식 사용
+- 주기적으로 대규모 데이터 (각 상품의 일별 일간 집계) 를 주기적으로 집계해 활용
+
+```sql
+CREATE TABLE product_metrics_weekly ( // 주간 상품 이벤트 집계
+ product_id BIGINT PRIMARY KEY,
+ like_count INT,
+ order_count INT,
+ view_count INT,
+ yearMonthWeek VARCHAR, // 예시입니다.
+ updated_at DATETIME
+);
+
+CREATE TABLE product_metrics_monthly ( // 주간 상품 이벤트 집계
+ product_id BIGINT PRIMARY KEY,
+ like_count INT,
+ order_count INT,
+ view_count INT,
+ yearMonth VARCHAR, // 예시입니다.
+ updated_at DATETIME
+);
+```
+
+---
+
+### 🎯 운영 관점에서의 배치 전략
+
+- **스케줄링** : Spring Scheduler, Quartz 혹은 인프라 (Cron + K8s)
+- **재실행 전략** : 실패 시 부분 롤백 vs 전체 재실행
+- **병렬 Step** : 여러 Step 을 동시에 실행해 성능 향상
+- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적
+
+---
+
+
+
+| 구분 | 링크 |
+| --- | --- |
+| 🔍 Spring Batch | [Spring Docs - Spring Batch](https://docs.spring.io/spring-batch/reference/) |
+| ⚙ Spring Boot with Spring Batch | [Baeldung - Spring Boot with Spring Batch](https://www.baeldung.com/spring-boot-spring-batch) |
+| 📖 Materialized View | [AWS - What is Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) |
+
+
+
+이번 10주 동안 우리는 **단순한 CRUD를 넘어서, 실제 서비스에서 마주치는 문제들을 단계적으로 풀어왔습니다**. 현업에서 여러분들이 활약하기 위해 어떤 것들을 알면 좋을지, 문제를 접근하고 해석하는 방법, 문제에 맞는 적절한 해답을 도출하는 방법 등을 전달하려고 노력했어요.
+
+- **1~3주차** : 도메인 모델링, 계층 분리, 객체 협력 설계
+- **4~6주차** : 트랜잭션과 동시성, 읽기 최적화, 외부 시스템(결제 PG) 연동과 회복 탄력성
+- **7주차** : 이벤트 와 Kafka, 유량제어
+- **8주차** : 대기열 큐
+- **9주차** : 실시간 집계, 랭킹 시스템 구축
+- **10주차** : 배치와 Materialized View를 통한 대규모 집계와 조회 최적화
+
+즉, **이커머스라는 시나리오를 통해 → 설계 → 동시성 → 성능 → 회복력 → 이벤트 → 확장성 → 데이터 파이프라인 → 집계** 까지, 실무에서 다루는 거의 모든 챕터를 작은 스케일로 경험해 본 셈입니다.
+
+하지만 여기서 끝이 아닙니다.
+
+- 실제 서비스는 **더 많은 데이터와 트래픽, 더 복잡한 요구사항** 속에서 움직입니다.
+- 새로운 기능을 추가할 때마다, 이번 과정에서 배운 **Trade-off와 선택의 기준**이 반복해서 필요합니다.
+- 이직, 프로젝트, 사이드 개발 등 어떤 길을 가더라도, 지금 경험한 **문제 정의 → 분석 → 해결** 과정은 계속해서 쓰이게 될 것이고 힘이 되어줄 겁니다.
+
+
+
+이제는 여러분이 스스로 문제를 정의하고, 배운 도구와 방법을 적용하며, 더 깊은 학습으로 나아갈 차례입니다.
+
+루프팩 BE L2는 끝났지만, **여러분의 성장 여정은 여기서부터가 시작**입니다.
\ No newline at end of file
diff --git a/.docs/week10/quests.md b/.docs/week10/quests.md
new file mode 100644
index 0000000000..5485caa7b1
--- /dev/null
+++ b/.docs/week10/quests.md
@@ -0,0 +1,120 @@
+# 📝 Round 10 Quests
+
+---
+
+## 💻 Implementation Quest
+
+> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요.
+이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다.
+>
+
+
+
+### 📋 과제 정보
+
+이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다.
+
+### (1) Spring Batch Job 구현
+
+- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다.
+ - 대상 테이블 : `product_metrics`
+ - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요.
+
+### (2) Materialized View 설계
+
+- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다.
+ - `mv_product_rank_weekly` : 주간 TOP 100 랭킹
+ - `mv_product_rank_monthly` : 월간 TOP 100 랭킹
+
+### (3) Ranking API 확장
+
+- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다.
+
+---
+
+## ✅ Checklist
+
+### 🧱 Spring Batch
+
+- [ ] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
+- [ ] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.
+- [ ] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다.
+
+### 🧩 Ranking API
+
+- [ ] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.
+
+---
+
+## ✍️ Technical Writing Quest
+
+> 이번 주에 학습한 내용, 과제 진행을 되돌아보며
+**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다.
+>
+>
+> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.**
+>
+> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요.
+>
+
+### 📚 Technical Writing Guide
+
+### ✅ 작성 기준
+
+| 항목 | 설명 |
+| --- | --- |
+| **형식** | 블로그 |
+| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 |
+| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 |
+| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 |
+| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” |
+
+---
+
+### ✨ 좋은 톤은 이런 느낌이에요
+
+> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자
+>
+
+| 특징 | 예시 |
+| --- | --- |
+| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 |
+| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 |
+| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… |
+
+### ❌ 피해야 할 스타일
+
+| 예시 | 이유 |
+| --- | --- |
+| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 |
+| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 |
+| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 |
+
+### 🎯 Retrospective
+
+- 단순히 “무엇을 했다”가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다.
+- “기능 구현” 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다.
+- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다.
+
+### 담으면 좋은 내용
+
+1. **전체 여정 요약**
+ - 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기
+ - 단순 나열이 아니라, **흐름이 어떻게 연결되었는지** 를 강조
+2. **가장 큰 전환점**
+ - **내 기존의 사고방식이 바뀌었다** 싶은 순간
+ - *예: 4주차 트랜잭션/락을 통해 단순 @Transactional 이상의 고민을 알게 된 점, 7주차 이벤트 분리를 통해 ‘확장성’에 눈을 뜬 경험*
+3. **나의 Trade-off 판단**
+ - 실습 중 내가 내린 중요한 선택 1~2개
+ - 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지
+4. **실전과의 연결**
+ - “이건 실제 회사/서비스에서 써먹을 수 있겠다” 싶은 포인트
+ - *예: 캐시 무효화 전략, Kafka 기반 집계, Resilience4j 설정 등*
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
index 0ee91a3915..7d6455163c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
@@ -4,18 +4,27 @@
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
+import com.loopers.domain.ranking.MonthlyRank;
+import com.loopers.domain.ranking.MonthlyRankRepository;
+import com.loopers.domain.ranking.RankingEntry;
import com.loopers.domain.ranking.RankingService;
+import com.loopers.domain.ranking.WeeklyRank;
+import com.loopers.domain.ranking.WeeklyRankRepository;
import lombok.RequiredArgsConstructor;
-import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@@ -29,6 +38,9 @@ public class RankingFacade {
private final ProductService productService;
private final BrandService brandService;
private final RankingCacheRepository rankingCacheRepository;
+ private final WeeklyRankRepository weeklyRankRepository;
+ private final MonthlyRankRepository monthlyRankRepository;
+ private final StringRedisTemplate stringRedisTemplate;
/**
* 일간 랭킹 페이지 조회 (Cache-Aside).
@@ -53,16 +65,13 @@ private RankingResult loadDailyRankingFromSource(LocalDate date, int page, int s
long offset = (long) page * size;
long totalElements = rankingService.countDailyRanking(date);
- List> tuples =
- rankingService.findDailyRanking(date, offset, size);
+ List entries = rankingService.findDailyRanking(date, offset, size);
- if (tuples.isEmpty()) {
+ if (entries.isEmpty()) {
return new RankingResult(List.of(), page, size, totalElements);
}
- List productIds = tuples.stream()
- .map(t -> Long.parseLong(t.getValue()))
- .toList();
+ List productIds = entries.stream().map(RankingEntry::productId).toList();
Map productMap = productService.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
@@ -73,15 +82,14 @@ private RankingResult loadDailyRankingFromSource(LocalDate date, int page, int s
List items = new ArrayList<>();
int rank = (int) offset + 1;
- for (ZSetOperations.TypedTuple tuple : tuples) {
- Long productId = Long.parseLong(tuple.getValue());
- Product product = productMap.get(productId);
+ for (RankingEntry entry : entries) {
+ Product product = productMap.get(entry.productId());
if (product == null) {
rank++;
- continue; // 삭제된 상품은 건너뜀
+ continue;
}
String brandName = brandNameMap.getOrDefault(product.getBrandId(), "");
- items.add(new RankingItem(rank, ProductInfo.from(product, brandName), tuple.getScore()));
+ items.add(new RankingItem(rank, ProductInfo.from(product, brandName), entry.score()));
rank++;
}
@@ -110,16 +118,13 @@ private RankingResult loadHourlyRankingFromSource(LocalDate date, int hour, int
long offset = (long) page * size;
long totalElements = rankingService.countHourlyRanking(date, hour);
- List> tuples =
- rankingService.findHourlyRanking(date, hour, offset, size);
+ List entries = rankingService.findHourlyRanking(date, hour, offset, size);
- if (tuples.isEmpty()) {
+ if (entries.isEmpty()) {
return new RankingResult(List.of(), page, size, totalElements);
}
- List productIds = tuples.stream()
- .map(t -> Long.parseLong(t.getValue()))
- .toList();
+ List productIds = entries.stream().map(RankingEntry::productId).toList();
Map productMap = productService.findAllByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
@@ -130,18 +135,111 @@ private RankingResult loadHourlyRankingFromSource(LocalDate date, int hour, int
List items = new ArrayList<>();
int rank = (int) offset + 1;
- for (ZSetOperations.TypedTuple tuple : tuples) {
- Long productId = Long.parseLong(tuple.getValue());
- Product product = productMap.get(productId);
+ for (RankingEntry entry : entries) {
+ Product product = productMap.get(entry.productId());
if (product == null) {
rank++;
continue;
}
String brandName = brandNameMap.getOrDefault(product.getBrandId(), "");
- items.add(new RankingItem(rank, ProductInfo.from(product, brandName), tuple.getScore()));
+ items.add(new RankingItem(rank, ProductInfo.from(product, brandName), entry.score()));
rank++;
}
return new RankingResult(items, page, size, totalElements);
}
+
+ /**
+ * 주간 랭킹 페이지 조회 (Cache-Aside).
+ * date 미지정 시 Redis latest_date → DB MAX() 순으로 폴백.
+ */
+ @Transactional(readOnly = true)
+ public RankingResult findWeeklyRanking(LocalDate date, int page, int size) {
+ LocalDate snapshot = resolveSnapshotDate(date, "rankings:weekly:latest_date",
+ weeklyRankRepository::findLatestSnapshotDate);
+ if (snapshot == null) {
+ return new RankingResult(List.of(), page, size, 0L);
+ }
+
+ String cacheKey = "rankings:weekly:%s:%d:%d".formatted(snapshot, page, size);
+ Optional cached = rankingCacheRepository.get(cacheKey);
+ if (cached.isPresent()) {
+ return cached.get();
+ }
+
+ Page ranks = weeklyRankRepository.findBySnapshotDateOrderByRankAsc(
+ snapshot, PageRequest.of(page, size));
+ RankingResult result = toRankingResult(ranks.getContent(), r -> r.getRank(),
+ r -> r.getProductId(), r -> r.getScore(), page, size, ranks.getTotalElements());
+ rankingCacheRepository.save(cacheKey, result);
+ return result;
+ }
+
+ /**
+ * 월간 랭킹 페이지 조회 (Cache-Aside).
+ * date 미지정 시 Redis latest_date → DB MAX() 순으로 폴백.
+ */
+ @Transactional(readOnly = true)
+ public RankingResult findMonthlyRanking(LocalDate date, int page, int size) {
+ LocalDate snapshot = resolveSnapshotDate(date, "rankings:monthly:latest_date",
+ monthlyRankRepository::findLatestSnapshotDate);
+ if (snapshot == null) {
+ return new RankingResult(List.of(), page, size, 0L);
+ }
+
+ String cacheKey = "rankings:monthly:%s:%d:%d".formatted(snapshot, page, size);
+ Optional cached = rankingCacheRepository.get(cacheKey);
+ if (cached.isPresent()) {
+ return cached.get();
+ }
+
+ Page ranks = monthlyRankRepository.findBySnapshotDateOrderByRankAsc(
+ snapshot, PageRequest.of(page, size));
+ RankingResult result = toRankingResult(ranks.getContent(), r -> r.getRank(),
+ r -> r.getProductId(), r -> r.getScore(), page, size, ranks.getTotalElements());
+ rankingCacheRepository.save(cacheKey, result);
+ return result;
+ }
+
+ private RankingResult toRankingResult(
+ List rows,
+ Function rankExtractor,
+ Function productIdExtractor,
+ Function scoreExtractor,
+ int page, int size, long totalElements) {
+
+ List productIds = rows.stream().map(productIdExtractor).toList();
+ Map productMap = productService.findAllByIds(productIds).stream()
+ .collect(Collectors.toMap(Product::getId, Function.identity()));
+
+ List brandIds = productMap.values().stream()
+ .map(Product::getBrandId).distinct().toList();
+ Map brandNameMap = brandService.findNamesByIds(brandIds);
+
+ List items = rows.stream()
+ .map(row -> {
+ Long productId = productIdExtractor.apply(row);
+ Product product = productMap.get(productId);
+ if (product == null) return null;
+ String brandName = brandNameMap.getOrDefault(product.getBrandId(), "");
+ return new RankingItem(rankExtractor.apply(row), ProductInfo.from(product, brandName),
+ scoreExtractor.apply(row));
+ })
+ .filter(item -> item != null)
+ .toList();
+
+ return new RankingResult(items, page, size, totalElements);
+ }
+
+ private LocalDate resolveSnapshotDate(LocalDate given, String latestKey,
+ Supplier> dbFallback) {
+ if (given != null) return given;
+
+ String cached = stringRedisTemplate.opsForValue().get(latestKey);
+ if (cached != null) return LocalDate.parse(cached);
+
+ Optional fromDb = dbFallback.get();
+ fromDb.ifPresent(d -> stringRedisTemplate.opsForValue().set(latestKey, d.toString(), Duration.ofHours(25)));
+ return fromDb.orElse(null);
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRank.java
new file mode 100644
index 0000000000..a5de819a59
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRank.java
@@ -0,0 +1,50 @@
+package com.loopers.domain.ranking;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "mv_product_rank_monthly")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class MonthlyRank {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "snapshot_date", nullable = false)
+ private LocalDate snapshotDate;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rank;
+
+ @Column(nullable = false)
+ private double score;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "order_revenue", nullable = false)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java
new file mode 100644
index 0000000000..dd54986ef7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+public interface MonthlyRankRepository {
+ Optional findLatestSnapshotDate();
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java
new file mode 100644
index 0000000000..885b02abf2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingEntry.java
@@ -0,0 +1,3 @@
+package com.loopers.domain.ranking;
+
+public record RankingEntry(Long productId, double score) {}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
index af200b1ca3..8f4888a5b0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
@@ -1,72 +1,34 @@
package com.loopers.domain.ranking;
-import com.loopers.config.redis.RankingKeys;
import lombok.RequiredArgsConstructor;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
-import java.util.Collections;
import java.util.List;
-import java.util.Set;
-/**
- * Redis ZSET 기반 랭킹 조회 서비스.
- *
- * 읽기는 defaultRedisTemplate(REPLICA_PREFERRED)을 사용해 Replica 분산 읽기.
- * 쓰기는 commerce-streamer의 SyncScheduler가 담당하므로 이 서비스는 조회만 수행.
- */
@RequiredArgsConstructor
@Service
public class RankingService {
- private final RedisTemplate defaultRedisTemplate;
+ private final RealtimeRankingRepository realtimeRankingRepository;
- /**
- * 일간 랭킹 상위 N개 조회.
- *
- * @param date 조회 날짜
- * @param offset 시작 위치 (0-based)
- * @param size 조회 건수
- * @return (productId, score) 쌍 목록, 높은 점수 순
- */
- public List> findDailyRanking(LocalDate date, long offset, long size) {
- String key = RankingKeys.dailyKey(date);
- Set> result =
- defaultRedisTemplate.opsForZSet().reverseRangeWithScores(key, offset, offset + size - 1);
- if (result == null) return Collections.emptyList();
- return List.copyOf(result);
+ public List findDailyRanking(LocalDate date, long offset, long size) {
+ return realtimeRankingRepository.findDailyRanking(date, offset, size);
}
- /**
- * 일간 랭킹 전체 상품 수 조회 (페이지네이션 totalElements용).
- */
public long countDailyRanking(LocalDate date) {
- Long count = defaultRedisTemplate.opsForZSet().size(RankingKeys.dailyKey(date));
- return count != null ? count : 0L;
+ return realtimeRankingRepository.countDailyRanking(date);
}
- /**
- * 특정 상품의 일간 랭킹 순위 조회 (1-based).
- * 랭킹에 없으면 null 반환.
- */
public Long findProductRank(LocalDate date, Long productId) {
- Long rank = defaultRedisTemplate.opsForZSet()
- .reverseRank(RankingKeys.dailyKey(date), String.valueOf(productId));
- return rank != null ? rank + 1 : null; // 0-based → 1-based
+ return realtimeRankingRepository.findProductDailyRank(date, productId);
}
- public List> findHourlyRanking(LocalDate date, int hour, long offset, long size) {
- String key = RankingKeys.hourlyKey(date, hour);
- Set> result =
- defaultRedisTemplate.opsForZSet().reverseRangeWithScores(key, offset, offset + size - 1);
- if (result == null) return Collections.emptyList();
- return List.copyOf(result);
+ public List findHourlyRanking(LocalDate date, int hour, long offset, long size) {
+ return realtimeRankingRepository.findHourlyRanking(date, hour, offset, size);
}
public long countHourlyRanking(LocalDate date, int hour) {
- Long count = defaultRedisTemplate.opsForZSet().size(RankingKeys.hourlyKey(date, hour));
- return count != null ? count : 0L;
+ return realtimeRankingRepository.countHourlyRanking(date, hour);
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RealtimeRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RealtimeRankingRepository.java
new file mode 100644
index 0000000000..bf9baf233b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RealtimeRankingRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public interface RealtimeRankingRepository {
+ List findDailyRanking(LocalDate date, long offset, long size);
+ long countDailyRanking(LocalDate date);
+ Long findProductDailyRank(LocalDate date, Long productId);
+ List findHourlyRanking(LocalDate date, int hour, long offset, long size);
+ long countHourlyRanking(LocalDate date, int hour);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRank.java
new file mode 100644
index 0000000000..4cf8fc15bb
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRank.java
@@ -0,0 +1,50 @@
+package com.loopers.domain.ranking;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "mv_product_rank_weekly")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class WeeklyRank {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "snapshot_date", nullable = false)
+ private LocalDate snapshotDate;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rank;
+
+ @Column(nullable = false)
+ private double score;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "order_revenue", nullable = false)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java
new file mode 100644
index 0000000000..5e08a500b6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+public interface WeeklyRankRepository {
+ Optional findLatestSnapshotDate();
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java
new file mode 100644
index 0000000000..7e95ed6752
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankJpaRepository.java
@@ -0,0 +1,18 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.MonthlyRank;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+public interface MonthlyRankJpaRepository extends JpaRepository {
+
+ @Query("SELECT MAX(m.snapshotDate) FROM MonthlyRank m")
+ Optional findLatestSnapshotDate();
+
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java
new file mode 100644
index 0000000000..37dbd5c337
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankRepositoryImpl.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.MonthlyRank;
+import com.loopers.domain.ranking.MonthlyRankRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class MonthlyRankRepositoryImpl implements MonthlyRankRepository {
+
+ private final MonthlyRankJpaRepository jpa;
+
+ @Override
+ public Optional findLatestSnapshotDate() {
+ return jpa.findLatestSnapshotDate();
+ }
+
+ @Override
+ public Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable) {
+ return jpa.findBySnapshotDateOrderByRankAsc(snapshotDate, pageable);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.java
new file mode 100644
index 0000000000..a6bd59f2d2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RealtimeRankingRepositoryImpl.java
@@ -0,0 +1,60 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.config.redis.RankingKeys;
+import com.loopers.domain.ranking.RankingEntry;
+import com.loopers.domain.ranking.RealtimeRankingRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+@Repository
+@RequiredArgsConstructor
+public class RealtimeRankingRepositoryImpl implements RealtimeRankingRepository {
+
+ private final RedisTemplate defaultRedisTemplate;
+
+ @Override
+ public List findDailyRanking(LocalDate date, long offset, long size) {
+ Set> result =
+ defaultRedisTemplate.opsForZSet().reverseRangeWithScores(RankingKeys.dailyKey(date), offset, offset + size - 1);
+ if (result == null) return Collections.emptyList();
+ return result.stream()
+ .map(t -> new RankingEntry(Long.parseLong(t.getValue()), t.getScore()))
+ .toList();
+ }
+
+ @Override
+ public long countDailyRanking(LocalDate date) {
+ Long count = defaultRedisTemplate.opsForZSet().size(RankingKeys.dailyKey(date));
+ return count != null ? count : 0L;
+ }
+
+ @Override
+ public Long findProductDailyRank(LocalDate date, Long productId) {
+ Long rank = defaultRedisTemplate.opsForZSet()
+ .reverseRank(RankingKeys.dailyKey(date), String.valueOf(productId));
+ return rank != null ? rank + 1 : null;
+ }
+
+ @Override
+ public List findHourlyRanking(LocalDate date, int hour, long offset, long size) {
+ Set> result =
+ defaultRedisTemplate.opsForZSet().reverseRangeWithScores(RankingKeys.hourlyKey(date, hour), offset, offset + size - 1);
+ if (result == null) return Collections.emptyList();
+ return result.stream()
+ .map(t -> new RankingEntry(Long.parseLong(t.getValue()), t.getScore()))
+ .toList();
+ }
+
+ @Override
+ public long countHourlyRanking(LocalDate date, int hour) {
+ Long count = defaultRedisTemplate.opsForZSet().size(RankingKeys.hourlyKey(date, hour));
+ return count != null ? count : 0L;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java
new file mode 100644
index 0000000000..f9acca0b70
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankJpaRepository.java
@@ -0,0 +1,18 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.WeeklyRank;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+public interface WeeklyRankJpaRepository extends JpaRepository {
+
+ @Query("SELECT MAX(w.snapshotDate) FROM WeeklyRank w")
+ Optional findLatestSnapshotDate();
+
+ Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java
new file mode 100644
index 0000000000..881519174d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankRepositoryImpl.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.WeeklyRank;
+import com.loopers.domain.ranking.WeeklyRankRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class WeeklyRankRepositoryImpl implements WeeklyRankRepository {
+
+ private final WeeklyRankJpaRepository jpa;
+
+ @Override
+ public Optional findLatestSnapshotDate() {
+ return jpa.findLatestSnapshotDate();
+ }
+
+ @Override
+ public Page findBySnapshotDateOrderByRankAsc(LocalDate snapshotDate, Pageable pageable) {
+ return jpa.findBySnapshotDateOrderByRankAsc(snapshotDate, pageable);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
index 56ecb30cf8..df36544330 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
@@ -5,6 +5,8 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
+import java.time.LocalDate;
+
@Tag(name = "Ranking V1 API", description = "랭킹 조회 API 입니다.")
public interface RankingV1ApiSpec {
@@ -28,4 +30,24 @@ ApiResponse getHourlyRanking(
@Parameter(description = "페이지 번호 (0부터 시작, 기본값: 0)") int page,
@Parameter(description = "페이지 크기 (기본값: 20)") int size
);
+
+ @Operation(
+ summary = "주간 랭킹 조회",
+ description = "배치 집계된 주간 인기 상품 랭킹을 조회합니다. date 미입력 시 최신 스냅샷 기준."
+ )
+ ApiResponse getWeeklyRanking(
+ @Parameter(description = "스냅샷 날짜 (yyyy-MM-dd, 기본값: 최신)") LocalDate date,
+ @Parameter(description = "페이지 번호 (0부터 시작, 기본값: 0)") int page,
+ @Parameter(description = "페이지 크기 (기본값: 20)") int size
+ );
+
+ @Operation(
+ summary = "월간 랭킹 조회",
+ description = "배치 집계된 월간 인기 상품 랭킹을 조회합니다. date 미입력 시 최신 스냅샷 기준."
+ )
+ ApiResponse getMonthlyRanking(
+ @Parameter(description = "스냅샷 날짜 (yyyy-MM-dd, 기본값: 최신)") LocalDate date,
+ @Parameter(description = "페이지 번호 (0부터 시작, 기본값: 0)") int page,
+ @Parameter(description = "페이지 크기 (기본값: 20)") int size
+ );
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
index dfa1051dc8..d363035327 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
@@ -46,4 +46,26 @@ public ApiResponse getHourlyRanking(
var result = rankingFacade.findHourlyRanking(targetDate, targetHour, page, size);
return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
}
+
+ @GetMapping("/weekly")
+ @Override
+ public ApiResponse getWeeklyRanking(
+ @RequestParam(required = false) LocalDate date,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size)
+ {
+ var result = rankingFacade.findWeeklyRanking(date, page, size);
+ return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
+ }
+
+ @GetMapping("/monthly")
+ @Override
+ public ApiResponse getMonthlyRanking(
+ @RequestParam(required = false) LocalDate date,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size)
+ {
+ var result = rankingFacade.findMonthlyRanking(date, page, size);
+ return ApiResponse.success(RankingV1Dto.RankingListResponse.from(result));
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java
new file mode 100644
index 0000000000..4792b6a972
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeWeeklyIntegrationTest.java
@@ -0,0 +1,113 @@
+package com.loopers.application.ranking;
+
+import com.loopers.fixture.WeeklyRankTestFixture;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+class RankingFacadeWeeklyIntegrationTest {
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+
+ @Autowired
+ private RankingFacade rankingFacade;
+
+ @Autowired
+ private WeeklyRankTestFixture fixture;
+
+ @Autowired
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ stringRedisTemplate.delete("rankings:weekly:latest_date");
+ stringRedisTemplate.delete("rankings:weekly:%s:0:20".formatted(SNAPSHOT));
+ }
+
+ @Nested
+ @DisplayName("findWeeklyRanking()")
+ class FindWeeklyRanking {
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 hit → 해당 snapshot 반환")
+ void dateOmitted_cacheHit() {
+ // arrange
+ stringRedisTemplate.opsForValue().set("rankings:weekly:latest_date", SNAPSHOT.toString());
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ RankingResult response = rankingFacade.findWeeklyRanking(null, 0, 20);
+
+ // assert
+ assertThat(response.items()).hasSize(1);
+ assertThat(response.items().get(0).rank()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 miss → DB MAX 쿼리 폴백 + 캐시 put")
+ void dateOmitted_cacheMiss_dbFallback() {
+ // arrange
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ RankingResult response = rankingFacade.findWeeklyRanking(null, 0, 20);
+
+ // assert
+ assertThat(response.items()).hasSize(1);
+ String cached = stringRedisTemplate.opsForValue().get("rankings:weekly:latest_date");
+ assertThat(cached).isEqualTo(SNAPSHOT.toString());
+ }
+
+ @Test
+ @DisplayName("date 명시 → 해당 snapshot 반환")
+ void dateSpecified() {
+ // arrange
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ RankingResult response = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+
+ // assert
+ assertThat(response.items()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("snapshot 없음 → 빈 리스트 + totalElements=0")
+ void emptySnapshot() {
+ // act
+ RankingResult response = rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+
+ // assert
+ assertThat(response.items()).isEmpty();
+ assertThat(response.totalElements()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("동일 쿼리 두 번 호출 → 두 번째는 메인 캐시 hit")
+ void mainCacheHit() {
+ // arrange
+ fixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ rankingFacade.findWeeklyRanking(SNAPSHOT, 0, 20);
+ String cacheKey = "rankings:weekly:%s:0:20".formatted(SNAPSHOT);
+
+ // assert
+ assertThat(stringRedisTemplate.hasKey(cacheKey)).isTrue();
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java
new file mode 100644
index 0000000000..545da8e75f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/fixture/MonthlyRankTestFixture.java
@@ -0,0 +1,54 @@
+package com.loopers.fixture;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.Stock;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Component
+public class MonthlyRankTestFixture {
+
+ private final NamedParameterJdbcTemplate jdbc;
+ private final BrandJpaRepository brandJpaRepository;
+ private final ProductJpaRepository productJpaRepository;
+
+ public MonthlyRankTestFixture(NamedParameterJdbcTemplate jdbc,
+ BrandJpaRepository brandJpaRepository,
+ ProductJpaRepository productJpaRepository) {
+ this.jdbc = jdbc;
+ this.brandJpaRepository = brandJpaRepository;
+ this.productJpaRepository = productJpaRepository;
+ }
+
+ public void insert(LocalDate snapshotDate, long productId, int rank) {
+ jdbc.update("""
+ INSERT INTO mv_product_rank_monthly
+ (snapshot_date, product_id, rank_position, score, view_count, like_count, order_revenue, created_at)
+ VALUES
+ (:snapshotDate, :productId, :rank, :score, :viewCount, :likeCount, :orderRevenue, NOW(6))
+ """, new MapSqlParameterSource()
+ .addValue("snapshotDate", snapshotDate)
+ .addValue("productId", productId)
+ .addValue("rank", rank)
+ .addValue("score", 100.0 / rank)
+ .addValue("viewCount", 100L)
+ .addValue("likeCount", 10L)
+ .addValue("orderRevenue", new BigDecimal("1000.00")));
+ }
+
+ public long insertWithProduct(LocalDate snapshotDate, long ignoredProductId, int rank) {
+ Brand brand = brandJpaRepository.save(new Brand("테스트브랜드"));
+ Product product = productJpaRepository.save(
+ new Product(brand.getId(), "테스트상품" + rank, new Money(10000), new Stock(100)));
+ insert(snapshotDate, product.getId(), rank);
+ return product.getId();
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/fixture/WeeklyRankTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/fixture/WeeklyRankTestFixture.java
new file mode 100644
index 0000000000..d50adf5d48
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/fixture/WeeklyRankTestFixture.java
@@ -0,0 +1,58 @@
+package com.loopers.fixture;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.Stock;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Component
+public class WeeklyRankTestFixture {
+
+ private final NamedParameterJdbcTemplate jdbc;
+ private final BrandJpaRepository brandJpaRepository;
+ private final ProductJpaRepository productJpaRepository;
+
+ public WeeklyRankTestFixture(NamedParameterJdbcTemplate jdbc,
+ BrandJpaRepository brandJpaRepository,
+ ProductJpaRepository productJpaRepository) {
+ this.jdbc = jdbc;
+ this.brandJpaRepository = brandJpaRepository;
+ this.productJpaRepository = productJpaRepository;
+ }
+
+ public void insert(LocalDate snapshotDate, long productId, int rank) {
+ jdbc.update("""
+ INSERT INTO mv_product_rank_weekly
+ (snapshot_date, product_id, rank_position, score, view_count, like_count, order_revenue, created_at)
+ VALUES
+ (:snapshotDate, :productId, :rank, :score, :viewCount, :likeCount, :orderRevenue, NOW(6))
+ """, new MapSqlParameterSource()
+ .addValue("snapshotDate", snapshotDate)
+ .addValue("productId", productId)
+ .addValue("rank", rank)
+ .addValue("score", 100.0 / rank)
+ .addValue("viewCount", 100L)
+ .addValue("likeCount", 10L)
+ .addValue("orderRevenue", new BigDecimal("1000.00")));
+ }
+
+ /**
+ * 랭킹 행 + 연관 Brand/Product 함께 삽입 (Facade 통합 테스트용).
+ * productId를 직접 지정하지 않고 DB auto_increment를 사용하므로, 삽입된 Product의 id로 rank 행을 삽입한다.
+ */
+ public long insertWithProduct(LocalDate snapshotDate, long ignoredProductId, int rank) {
+ Brand brand = brandJpaRepository.save(new Brand("테스트브랜드"));
+ Product product = productJpaRepository.save(
+ new Product(brand.getId(), "테스트상품" + rank, new Money(10000), new Stock(100)));
+ insert(snapshotDate, product.getId(), rank);
+ return product.getId();
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java
new file mode 100644
index 0000000000..c0bc3a224d
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiWeeklyMonthlyE2ETest.java
@@ -0,0 +1,198 @@
+package com.loopers.interfaces.api.ranking;
+
+import com.loopers.fixture.MonthlyRankTestFixture;
+import com.loopers.fixture.WeeklyRankTestFixture;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class RankingV1ApiWeeklyMonthlyE2ETest {
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+ private static final String WEEKLY_LATEST_KEY = "rankings:weekly:latest_date";
+ private static final String MONTHLY_LATEST_KEY = "rankings:monthly:latest_date";
+
+ @Autowired private TestRestTemplate restTemplate;
+ @Autowired private WeeklyRankTestFixture weeklyFixture;
+ @Autowired private MonthlyRankTestFixture monthlyFixture;
+ @Autowired private StringRedisTemplate stringRedisTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ stringRedisTemplate.delete(WEEKLY_LATEST_KEY);
+ stringRedisTemplate.delete(MONTHLY_LATEST_KEY);
+ stringRedisTemplate.delete("rankings:weekly:%s:0:20".formatted(SNAPSHOT));
+ stringRedisTemplate.delete("rankings:monthly:%s:0:20".formatted(SNAPSHOT));
+ }
+
+ @Nested
+ @DisplayName("GET /api/v1/rankings/weekly")
+ class WeeklyRanking {
+
+ @Test
+ @DisplayName("date 명시 → 200 + 해당 스냅샷 랭킹 반환")
+ void withExplicitDate() {
+ // arrange
+ weeklyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?date=%s&page=0&size=20".formatted(SNAPSHOT),
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).hasSize(1);
+ assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1);
+ assertThat(response.getBody().data().totalElements()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 hit → 200 + 해당 스냅샷 반환")
+ void withoutDate_cacheHit() {
+ // arrange
+ weeklyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+ stringRedisTemplate.opsForValue().set(WEEKLY_LATEST_KEY, SNAPSHOT.toString());
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?page=0&size=20",
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 miss → DB MAX 폴백 후 200 반환")
+ void withoutDate_cacheMiss_dbFallback() {
+ // arrange
+ weeklyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?page=0&size=20",
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).hasSize(1);
+ assertThat(stringRedisTemplate.opsForValue().get(WEEKLY_LATEST_KEY)).isEqualTo(SNAPSHOT.toString());
+ }
+
+ @Test
+ @DisplayName("스냅샷 데이터 없음 → 200 + 빈 리스트")
+ void emptySnapshot() {
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?date=%s&page=0&size=20".formatted(SNAPSHOT),
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).isEmpty();
+ assertThat(response.getBody().data().totalElements()).isEqualTo(0);
+ }
+
+ @Test
+ @DisplayName("응답 스키마 필드 일치 검증")
+ void responseSchemaValidation() {
+ // arrange
+ weeklyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/weekly?date=%s&page=0&size=20".formatted(SNAPSHOT),
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ RankingV1Dto.RankingListResponse body = response.getBody().data();
+ assertThat(body.page()).isEqualTo(0);
+ assertThat(body.size()).isEqualTo(20);
+ RankingV1Dto.RankingItemResponse item = body.rankings().get(0);
+ assertThat(item.rank()).isEqualTo(1);
+ assertThat(item.productName()).isNotBlank();
+ assertThat(item.brandName()).isNotBlank();
+ assertThat(item.price()).isGreaterThan(0);
+ }
+ }
+
+ @Nested
+ @DisplayName("GET /api/v1/rankings/monthly")
+ class MonthlyRanking {
+
+ @Test
+ @DisplayName("date 명시 → 200 + 해당 스냅샷 랭킹 반환")
+ void withExplicitDate() {
+ // arrange
+ monthlyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/monthly?date=%s&page=0&size=20".formatted(SNAPSHOT),
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).hasSize(1);
+ assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("date 미지정 + latest_date 캐시 miss → DB MAX 폴백 후 200 반환")
+ void withoutDate_cacheMiss_dbFallback() {
+ // arrange
+ monthlyFixture.insertWithProduct(SNAPSHOT, 1L, 1);
+
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/monthly?page=0&size=20",
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).hasSize(1);
+ assertThat(stringRedisTemplate.opsForValue().get(MONTHLY_LATEST_KEY)).isEqualTo(SNAPSHOT.toString());
+ }
+
+ @Test
+ @DisplayName("스냅샷 데이터 없음 → 200 + 빈 리스트")
+ void emptySnapshot() {
+ // act
+ ResponseEntity> response = restTemplate.exchange(
+ "/api/v1/rankings/monthly?date=%s&page=0&size=20".formatted(SNAPSHOT),
+ HttpMethod.GET, null,
+ new ParameterizedTypeReference<>() {});
+
+ // assert
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody().data().rankings()).isEmpty();
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
index e5005c373c..0d9a9ad681 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
@@ -4,11 +4,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
import java.util.TimeZone;
@ConfigurationPropertiesScan
@SpringBootApplication
+@EnableScheduling
public class CommerceBatchApplication {
@PostConstruct
@@ -18,7 +20,6 @@ public void started() {
}
public static void main(String[] args) {
- int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args));
- System.exit(exitCode);
+ SpringApplication.run(CommerceBatchApplication.class, args);
}
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java
new file mode 100644
index 0000000000..ec74aac03f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingAggregateRow.java
@@ -0,0 +1,11 @@
+package com.loopers.batch.ranking;
+
+import java.math.BigDecimal;
+
+public record RankingAggregateRow(
+ long productId,
+ long viewCount,
+ long likeCount,
+ BigDecimal orderRevenue,
+ double score
+) {}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.java
new file mode 100644
index 0000000000..d4b8147f1e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingJobTrigger.java
@@ -0,0 +1,8 @@
+package com.loopers.batch.ranking;
+
+public enum RankingJobTrigger {
+ SCHEDULED,
+ WEIGHT_CHANGE,
+ DATA_FIX,
+ MANUAL_RERUN
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java
new file mode 100644
index 0000000000..26c46a33fe
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingLatestDateCacheListener.java
@@ -0,0 +1,65 @@
+package com.loopers.batch.ranking;
+
+import com.loopers.domain.rank.MvProductRankMonthlyRepository;
+import com.loopers.domain.rank.MvProductRankWeeklyRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.time.LocalDate;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RankingLatestDateCacheListener implements JobExecutionListener {
+
+ private final StringRedisTemplate stringRedisTemplate;
+ private final MvProductRankWeeklyRepository weeklyRepository;
+ private final MvProductRankMonthlyRepository monthlyRepository;
+
+ private static final Duration TTL = Duration.ofHours(25);
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ if (jobExecution.getStatus() != BatchStatus.COMPLETED) {
+ return;
+ }
+ String jobName = jobExecution.getJobInstance().getJobName();
+ String snapshotDateStr = jobExecution.getJobParameters().getString("snapshotDate");
+ if (snapshotDateStr == null) return;
+
+ String cacheKey = resolveCacheKey(jobName);
+ if (cacheKey == null) return;
+
+ LocalDate snapshotDate = LocalDate.parse(snapshotDateStr);
+ long count = countByJobName(jobName, snapshotDate);
+ if (count == 0) {
+ log.warn("[{}] snapshotDate={} 적재 데이터 없음 — latest_date 캐시 갱신 스킵", jobName, snapshotDate);
+ return;
+ }
+
+ stringRedisTemplate.opsForValue().set(cacheKey, snapshotDateStr, TTL);
+ log.info("[{}] latest_date 캐시 put: {} -> {}", jobName, cacheKey, snapshotDateStr);
+ }
+
+ private long countByJobName(String jobName, LocalDate snapshotDate) {
+ return switch (jobName) {
+ case "weeklyRankingJob" -> weeklyRepository.countBySnapshotDate(snapshotDate);
+ case "monthlyRankingJob" -> monthlyRepository.countBySnapshotDate(snapshotDate);
+ default -> 0L;
+ };
+ }
+
+ private String resolveCacheKey(String jobName) {
+ return switch (jobName) {
+ case "weeklyRankingJob" -> "rankings:weekly:latest_date";
+ case "monthlyRankingJob" -> "rankings:monthly:latest_date";
+ default -> null;
+ };
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java
new file mode 100644
index 0000000000..0b994099cd
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingManualRunner.java
@@ -0,0 +1,62 @@
+package com.loopers.batch.ranking;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobLauncher;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * CLI 수동 재실행 진입점.
+ *
+ * --trigger 옵션이 없으면 아무 동작도 하지 않으며 스케줄러 모드로 동작한다.
+ * 사용 예시:
+ * --spring.batch.job.name=weeklyRankingJob --trigger=WEIGHT_CHANGE
+ * --spring.batch.job.name=weeklyRankingJob --trigger=WEIGHT_CHANGE --snapshotDate=2026-04-10
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class RankingManualRunner implements ApplicationRunner {
+
+ private final JobLauncher jobLauncher;
+ private final ApplicationContext applicationContext;
+
+ @Value("${spring.batch.job.name:}")
+ private String jobName;
+
+ @Override
+ public void run(ApplicationArguments args) throws Exception {
+ if (!args.containsOption("trigger")) {
+ return;
+ }
+
+ String triggerStr = args.getOptionValues("trigger").get(0);
+ RankingJobTrigger trigger = RankingJobTrigger.valueOf(triggerStr);
+
+ if (jobName.isBlank()) {
+ log.warn("[RankingManualRunner] spring.batch.job.name 미지정 — 수동 실행 불가");
+ return;
+ }
+
+ LocalDate snapshotDate = args.containsOption("snapshotDate")
+ ? LocalDate.parse(args.getOptionValues("snapshotDate").get(0))
+ : LocalDate.now(ZoneId.of("Asia/Seoul"));
+
+ Job job = applicationContext.getBean(jobName, Job.class);
+ log.info("[RankingManualRunner] 수동 실행: job={}, snapshotDate={}, trigger={}", jobName, snapshotDate, trigger);
+
+ jobLauncher.run(job, new JobParametersBuilder()
+ .addString("snapshotDate", snapshotDate.toString())
+ .addString("trigger", trigger.name())
+ .toJobParameters());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java
new file mode 100644
index 0000000000..aaa408f1dc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingScheduler.java
@@ -0,0 +1,61 @@
+package com.loopers.batch.ranking;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobLauncher;
+import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+
+/**
+ * 매일 새벽 주간/월간 랭킹 배치를 자동으로 실행하는 스케줄러.
+ * 두 Job 빈이 모두 로드된 경우(데몬 모드)에만 활성화된다.
+ */
+@Slf4j
+@Component
+@ConditionalOnBean(name = {"weeklyRankingJob", "monthlyRankingJob"})
+public class RankingScheduler {
+
+ private final JobLauncher jobLauncher;
+ private final Job weeklyRankingJob;
+ private final Job monthlyRankingJob;
+
+ public RankingScheduler(
+ JobLauncher jobLauncher,
+ @Qualifier("weeklyRankingJob") Job weeklyRankingJob,
+ @Qualifier("monthlyRankingJob") Job monthlyRankingJob) {
+ this.jobLauncher = jobLauncher;
+ this.weeklyRankingJob = weeklyRankingJob;
+ this.monthlyRankingJob = monthlyRankingJob;
+ }
+
+ @Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul")
+ public void runWeekly() {
+ run(weeklyRankingJob, "weeklyRankingJob");
+ }
+
+ @Scheduled(cron = "0 30 1 * * *", zone = "Asia/Seoul")
+ public void runMonthly() {
+ run(monthlyRankingJob, "monthlyRankingJob");
+ }
+
+ private void run(Job job, String jobName) {
+ LocalDate snapshotDate = LocalDate.now(ZoneId.of("Asia/Seoul"));
+ try {
+ jobLauncher.run(job, new JobParametersBuilder()
+ .addString("snapshotDate", snapshotDate.toString())
+ .addString("trigger", RankingJobTrigger.SCHEDULED.name())
+ .toJobParameters());
+ } catch (JobInstanceAlreadyCompleteException e) {
+ log.warn("[{}] snapshotDate={} 이미 완료된 JobInstance — 재실행 스킵", jobName, snapshotDate);
+ } catch (Exception e) {
+ log.error("[{}] 실행 실패 snapshotDate={}", jobName, snapshotDate, e);
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java
new file mode 100644
index 0000000000..6e25ed09f9
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankProcessor.java
@@ -0,0 +1,31 @@
+package com.loopers.batch.ranking.monthly;
+
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.domain.rank.MvProductRankMonthly;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@StepScope
+@Component
+public class MonthlyRankProcessor implements ItemProcessor {
+
+ private final LocalDate snapshotDate;
+ private final AtomicInteger rankCounter = new AtomicInteger(0);
+
+ public MonthlyRankProcessor(@Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+ this.snapshotDate = LocalDate.parse(snapshotDateStr);
+ }
+
+ @Override
+ public MvProductRankMonthly process(RankingAggregateRow item) {
+ int rank = rankCounter.incrementAndGet();
+ return new MvProductRankMonthly(
+ snapshotDate, item.productId(), rank, item.score(),
+ item.viewCount(), item.likeCount(), item.orderRevenue());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java
new file mode 100644
index 0000000000..25db33f7b8
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankReaderConfig.java
@@ -0,0 +1,73 @@
+package com.loopers.batch.ranking.monthly;
+
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.domain.rank.BatchRankingWeightRepository;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.database.JdbcCursorItemReader;
+import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sql.DataSource;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.time.LocalDate;
+
+@Configuration
+public class MonthlyRankReaderConfig {
+
+ @Bean
+ @StepScope
+ public JdbcCursorItemReader monthlyRankReader(
+ DataSource dataSource,
+ BatchRankingWeightRepository weightRepository,
+ @Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+
+ BigDecimal viewWeight = weightRepository.findWeightByEventType("VIEW");
+ BigDecimal likeWeight = weightRepository.findWeightByEventType("LIKE");
+ BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER");
+
+ String sql = buildSql(viewWeight, likeWeight, orderWeight);
+
+ LocalDate snapshot = LocalDate.parse(snapshotDateStr);
+ LocalDate windowStart = snapshot.minusDays(30);
+ LocalDate windowEnd = snapshot.minusDays(1);
+
+ return new JdbcCursorItemReaderBuilder()
+ .name("monthlyRankReader")
+ .dataSource(dataSource)
+ .sql(sql)
+ .preparedStatementSetter(ps -> {
+ ps.setDate(1, Date.valueOf(windowStart));
+ ps.setDate(2, Date.valueOf(windowEnd));
+ })
+ .rowMapper((rs, rowNum) -> new RankingAggregateRow(
+ rs.getLong("product_id"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue"),
+ rs.getDouble("score")))
+ .build();
+ }
+
+ private String buildSql(BigDecimal viewWeight, BigDecimal likeWeight, BigDecimal orderWeight) {
+ return """
+ SELECT product_id,
+ SUM(view_count) AS view_count,
+ SUM(like_count) AS like_count,
+ SUM(order_revenue) AS order_revenue,
+ LOG(1 + SUM(view_count)) * %s
+ + LOG(1 + SUM(like_count)) * %s
+ + LOG(1 + SUM(order_revenue)) * %s AS score
+ FROM ranking_metrics
+ WHERE metrics_date BETWEEN ? AND ?
+ GROUP BY product_id
+ ORDER BY score DESC
+ LIMIT 100
+ """.formatted(
+ viewWeight.toPlainString(),
+ likeWeight.toPlainString(),
+ orderWeight.toPlainString());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankWriter.java
new file mode 100644
index 0000000000..0349384eed
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankWriter.java
@@ -0,0 +1,20 @@
+package com.loopers.batch.ranking.monthly;
+
+import com.loopers.domain.rank.MvProductRankMonthly;
+import com.loopers.domain.rank.MvProductRankMonthlyRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class MonthlyRankWriter implements ItemWriter {
+
+ private final MvProductRankMonthlyRepository repository;
+
+ @Override
+ public void write(Chunk extends MvProductRankMonthly> chunk) {
+ repository.upsertAll(chunk.getItems());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobConfig.java
new file mode 100644
index 0000000000..de20137b7b
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobConfig.java
@@ -0,0 +1,51 @@
+package com.loopers.batch.ranking.monthly;
+
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.batch.ranking.RankingLatestDateCacheListener;
+import com.loopers.domain.rank.MvProductRankMonthly;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.database.JdbcCursorItemReader;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "monthlyRankingJob", matchIfMissing = true)
+@RequiredArgsConstructor
+@Configuration
+public class MonthlyRankingJobConfig {
+
+ public static final String JOB_NAME = "monthlyRankingJob";
+ private static final String STEP_NAME = "monthlyRankingStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final JobListener jobListener;
+
+ @Bean(JOB_NAME)
+ public Job monthlyRankingJob(Step monthlyRankingStep, RankingLatestDateCacheListener cacheListener) {
+ return new JobBuilder(JOB_NAME, jobRepository)
+ .start(monthlyRankingStep)
+ .listener(jobListener)
+ .listener(cacheListener)
+ .build();
+ }
+
+ @Bean(STEP_NAME)
+ public Step monthlyRankingStep(JdbcCursorItemReader monthlyRankReader,
+ MonthlyRankProcessor monthlyRankProcessor,
+ MonthlyRankWriter monthlyRankWriter) {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .chunk(50, transactionManager)
+ .reader(monthlyRankReader)
+ .processor(monthlyRankProcessor)
+ .writer(monthlyRankWriter)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.java
new file mode 100644
index 0000000000..cdc4a91c9e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankProcessor.java
@@ -0,0 +1,32 @@
+package com.loopers.batch.ranking.weekly;
+
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.domain.rank.MvProductRankWeekly;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.util.concurrent.atomic.AtomicInteger;
+
+// @StepScope 필수 — 매 Job 실행마다 rankCounter 가 초기화되어야 함
+@StepScope
+@Component
+public class WeeklyRankProcessor implements ItemProcessor {
+
+ private final LocalDate snapshotDate;
+ private final AtomicInteger rankCounter = new AtomicInteger(0);
+
+ public WeeklyRankProcessor(@Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+ this.snapshotDate = LocalDate.parse(snapshotDateStr);
+ }
+
+ @Override
+ public MvProductRankWeekly process(RankingAggregateRow item) {
+ int rank = rankCounter.incrementAndGet();
+ return new MvProductRankWeekly(
+ snapshotDate, item.productId(), rank, item.score(),
+ item.viewCount(), item.likeCount(), item.orderRevenue());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java
new file mode 100644
index 0000000000..ebdb4116e9
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankReaderConfig.java
@@ -0,0 +1,73 @@
+package com.loopers.batch.ranking.weekly;
+
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.domain.rank.BatchRankingWeightRepository;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.database.JdbcCursorItemReader;
+import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sql.DataSource;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.time.LocalDate;
+
+@Configuration
+public class WeeklyRankReaderConfig {
+
+ @Bean
+ @StepScope
+ public JdbcCursorItemReader weeklyRankReader(
+ DataSource dataSource,
+ BatchRankingWeightRepository weightRepository,
+ @Value("#{jobParameters['snapshotDate']}") String snapshotDateStr) {
+
+ BigDecimal viewWeight = weightRepository.findWeightByEventType("VIEW");
+ BigDecimal likeWeight = weightRepository.findWeightByEventType("LIKE");
+ BigDecimal orderWeight = weightRepository.findWeightByEventType("ORDER");
+
+ String sql = buildSql(viewWeight, likeWeight, orderWeight);
+
+ LocalDate snapshot = LocalDate.parse(snapshotDateStr);
+ LocalDate windowStart = snapshot.minusDays(7);
+ LocalDate windowEnd = snapshot.minusDays(1);
+
+ return new JdbcCursorItemReaderBuilder()
+ .name("weeklyRankReader")
+ .dataSource(dataSource)
+ .sql(sql)
+ .preparedStatementSetter(ps -> {
+ ps.setDate(1, Date.valueOf(windowStart));
+ ps.setDate(2, Date.valueOf(windowEnd));
+ })
+ .rowMapper((rs, rowNum) -> new RankingAggregateRow(
+ rs.getLong("product_id"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue"),
+ rs.getDouble("score")))
+ .build();
+ }
+
+ private String buildSql(BigDecimal viewWeight, BigDecimal likeWeight, BigDecimal orderWeight) {
+ return """
+ SELECT product_id,
+ SUM(view_count) AS view_count,
+ SUM(like_count) AS like_count,
+ SUM(order_revenue) AS order_revenue,
+ LOG(1 + SUM(view_count)) * %s
+ + LOG(1 + SUM(like_count)) * %s
+ + LOG(1 + SUM(order_revenue)) * %s AS score
+ FROM ranking_metrics
+ WHERE metrics_date BETWEEN ? AND ?
+ GROUP BY product_id
+ ORDER BY score DESC
+ LIMIT 100
+ """.formatted(
+ viewWeight.toPlainString(),
+ likeWeight.toPlainString(),
+ orderWeight.toPlainString());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.java
new file mode 100644
index 0000000000..5d51fdd24a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankWriter.java
@@ -0,0 +1,20 @@
+package com.loopers.batch.ranking.weekly;
+
+import com.loopers.domain.rank.MvProductRankWeekly;
+import com.loopers.domain.rank.MvProductRankWeeklyRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class WeeklyRankWriter implements ItemWriter {
+
+ private final MvProductRankWeeklyRepository repository;
+
+ @Override
+ public void write(Chunk extends MvProductRankWeekly> chunk) {
+ repository.upsertAll(chunk.getItems());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.java
new file mode 100644
index 0000000000..3916ec54fa
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobConfig.java
@@ -0,0 +1,51 @@
+package com.loopers.batch.ranking.weekly;
+
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.ranking.RankingAggregateRow;
+import com.loopers.batch.ranking.RankingLatestDateCacheListener;
+import com.loopers.domain.rank.MvProductRankWeekly;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.database.JdbcCursorItemReader;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = "weeklyRankingJob", matchIfMissing = true)
+@RequiredArgsConstructor
+@Configuration
+public class WeeklyRankingJobConfig {
+
+ public static final String JOB_NAME = "weeklyRankingJob";
+ private static final String STEP_NAME = "weeklyRankingStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final JobListener jobListener;
+
+ @Bean(JOB_NAME)
+ public Job weeklyRankingJob(Step weeklyRankingStep, RankingLatestDateCacheListener cacheListener) {
+ return new JobBuilder(JOB_NAME, jobRepository)
+ .start(weeklyRankingStep)
+ .listener(jobListener)
+ .listener(cacheListener)
+ .build();
+ }
+
+ @Bean(STEP_NAME)
+ public Step weeklyRankingStep(JdbcCursorItemReader weeklyRankReader,
+ WeeklyRankProcessor weeklyRankProcessor,
+ WeeklyRankWriter weeklyRankWriter) {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .chunk(50, transactionManager)
+ .reader(weeklyRankReader)
+ .processor(weeklyRankProcessor)
+ .writer(weeklyRankWriter)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/BatchRankingWeightRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/BatchRankingWeightRepository.java
new file mode 100644
index 0000000000..663da6c387
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/BatchRankingWeightRepository.java
@@ -0,0 +1,8 @@
+package com.loopers.domain.rank;
+
+import java.math.BigDecimal;
+
+public interface BatchRankingWeightRepository {
+
+ BigDecimal findWeightByEventType(String eventType);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.java
new file mode 100644
index 0000000000..512118dce5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthly.java
@@ -0,0 +1,83 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "mv_product_rank_monthly",
+ uniqueConstraints = @UniqueConstraint(name = "uk_snapshot_product", columnNames = {"snapshot_date", "product_id"}),
+ indexes = @Index(name = "idx_snapshot_rank", columnList = "snapshot_date, rank_position")
+)
+public class MvProductRankMonthly {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "snapshot_date", nullable = false)
+ private LocalDate snapshotDate;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rank;
+
+ @Column(nullable = false)
+ private double score;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "order_revenue", nullable = false, precision = 18, scale = 2)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ public MvProductRankMonthly(LocalDate snapshotDate, Long productId, int rank, double score,
+ long viewCount, long likeCount, BigDecimal orderRevenue) {
+ this.snapshotDate = snapshotDate;
+ this.productId = productId;
+ this.rank = rank;
+ this.score = score;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.orderRevenue = orderRevenue;
+ }
+
+ @PrePersist
+ protected void onCreate() {
+ this.createdAt = LocalDateTime.now();
+ }
+
+ // JDBC RowMapper 전용 복원 팩토리
+ public static MvProductRankMonthly reconstruct(Long id, LocalDate snapshotDate, Long productId,
+ int rank, double score, long viewCount, long likeCount,
+ BigDecimal orderRevenue, LocalDateTime createdAt) {
+ MvProductRankMonthly row = new MvProductRankMonthly(snapshotDate, productId, rank, score, viewCount, likeCount, orderRevenue);
+ row.id = id;
+ row.createdAt = createdAt;
+ return row;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyRepository.java
new file mode 100644
index 0000000000..d54c631c48
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.rank;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+public interface MvProductRankMonthlyRepository {
+
+ void upsertAll(List extends MvProductRankMonthly> rows);
+
+ long countBySnapshotDate(LocalDate snapshotDate);
+
+ Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java
new file mode 100644
index 0000000000..d1ec8bf693
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeekly.java
@@ -0,0 +1,83 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "mv_product_rank_weekly",
+ uniqueConstraints = @UniqueConstraint(name = "uk_snapshot_product", columnNames = {"snapshot_date", "product_id"}),
+ indexes = @Index(name = "idx_snapshot_rank", columnList = "snapshot_date, rank_position")
+)
+public class MvProductRankWeekly {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "snapshot_date", nullable = false)
+ private LocalDate snapshotDate;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rank;
+
+ @Column(nullable = false)
+ private double score;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "order_revenue", nullable = false, precision = 18, scale = 2)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ public MvProductRankWeekly(LocalDate snapshotDate, Long productId, int rank, double score,
+ long viewCount, long likeCount, BigDecimal orderRevenue) {
+ this.snapshotDate = snapshotDate;
+ this.productId = productId;
+ this.rank = rank;
+ this.score = score;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.orderRevenue = orderRevenue;
+ }
+
+ @PrePersist
+ protected void onCreate() {
+ this.createdAt = LocalDateTime.now();
+ }
+
+ // JDBC RowMapper 전용 복원 팩토리 — JPA 외부에서 id/createdAt 을 포함한 완전한 객체를 만들 때 사용
+ public static MvProductRankWeekly reconstruct(Long id, LocalDate snapshotDate, Long productId,
+ int rank, double score, long viewCount, long likeCount,
+ BigDecimal orderRevenue, LocalDateTime createdAt) {
+ MvProductRankWeekly row = new MvProductRankWeekly(snapshotDate, productId, rank, score, viewCount, likeCount, orderRevenue);
+ row.id = id;
+ row.createdAt = createdAt;
+ return row;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.java
new file mode 100644
index 0000000000..c3096ee322
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.rank;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+public interface MvProductRankWeeklyRepository {
+
+ void upsertAll(List extends MvProductRankWeekly> rows);
+
+ long countBySnapshotDate(LocalDate snapshotDate);
+
+ Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java
new file mode 100644
index 0000000000..2a1906bcd5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankingMetrics.java
@@ -0,0 +1,84 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+
+/**
+ * commerce-streamer 의 ranking_metrics 테이블 미러 엔티티.
+ * commerce-batch 테스트 환경(ddl-auto=create)에서 테이블을 생성하기 위해서만 사용.
+ * 실제 데이터 조작은 RankReader 의 네이티브 SQL 이 직접 수행.
+ */
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+@Table(
+ name = "ranking_metrics",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_ranking_metrics_product_date_hour",
+ columnNames = {"product_id", "metrics_date", "metrics_hour"}
+ ),
+ indexes = @Index(
+ name = "idx_ranking_metrics_dirty_date",
+ columnList = "dirty, metrics_date"
+ )
+)
+public class RankingMetrics {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "metrics_date", nullable = false)
+ private LocalDate metricsDate;
+
+ @Column(name = "metrics_hour", nullable = false)
+ private int metricsHour;
+
+ @Column(name = "view_count", nullable = false)
+ private int viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private int likeCount;
+
+ @Column(name = "order_revenue", nullable = false, precision = 15, scale = 2)
+ private BigDecimal orderRevenue;
+
+ @Column(name = "dirty", nullable = false)
+ private boolean dirty;
+
+ @Column(name = "created_at", nullable = false)
+ private ZonedDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private ZonedDateTime updatedAt;
+
+ @PrePersist
+ private void prePersist() {
+ ZonedDateTime now = ZonedDateTime.now();
+ this.createdAt = now;
+ this.updatedAt = now;
+ }
+
+ @PreUpdate
+ private void preUpdate() {
+ this.updatedAt = ZonedDateTime.now();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/BatchRankingWeightRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/BatchRankingWeightRepositoryImpl.java
new file mode 100644
index 0000000000..b505d0a888
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/BatchRankingWeightRepositoryImpl.java
@@ -0,0 +1,30 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.BatchRankingWeightRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+@Repository
+@RequiredArgsConstructor
+public class BatchRankingWeightRepositoryImpl implements BatchRankingWeightRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ @Override
+ public BigDecimal findWeightByEventType(String eventType) {
+ List results = jdbc.query(
+ "SELECT weight FROM ranking_weight WHERE event_type = :eventType",
+ Map.of("eventType", eventType),
+ (rs, rowNum) -> rs.getBigDecimal("weight"));
+
+ if (results.isEmpty()) {
+ throw new IllegalStateException("ranking_weight 테이블에 eventType이 없음: " + eventType);
+ }
+ return results.get(0);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImpl.java
new file mode 100644
index 0000000000..07ae3ed763
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImpl.java
@@ -0,0 +1,78 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.MvProductRankMonthly;
+import com.loopers.domain.rank.MvProductRankMonthlyRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class MvProductRankMonthlyRepositoryImpl implements MvProductRankMonthlyRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ // created_at 은 ON DUPLICATE KEY UPDATE 절에 포함하지 않아 최초 INSERT 시각 보존
+ private static final String UPSERT_SQL = """
+ INSERT INTO mv_product_rank_monthly
+ (snapshot_date, product_id, rank_position, score, view_count, like_count, order_revenue, created_at)
+ VALUES
+ (:snapshotDate, :productId, :rank, :score, :viewCount, :likeCount, :orderRevenue, NOW(6))
+ ON DUPLICATE KEY UPDATE
+ rank_position = VALUES(rank_position),
+ score = VALUES(score),
+ view_count = VALUES(view_count),
+ like_count = VALUES(like_count),
+ order_revenue = VALUES(order_revenue)
+ """;
+
+ @Override
+ public void upsertAll(List extends MvProductRankMonthly> rows) {
+ SqlParameterSource[] params = rows.stream()
+ .map(r -> new MapSqlParameterSource()
+ .addValue("snapshotDate", r.getSnapshotDate())
+ .addValue("productId", r.getProductId())
+ .addValue("rank", r.getRank())
+ .addValue("score", r.getScore())
+ .addValue("viewCount", r.getViewCount())
+ .addValue("likeCount", r.getLikeCount())
+ .addValue("orderRevenue", r.getOrderRevenue()))
+ .toArray(SqlParameterSource[]::new);
+ jdbc.batchUpdate(UPSERT_SQL, params);
+ }
+
+ @Override
+ public long countBySnapshotDate(LocalDate snapshotDate) {
+ Long count = jdbc.queryForObject(
+ "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE snapshot_date = :snapshotDate",
+ Map.of("snapshotDate", snapshotDate),
+ Long.class);
+ return count != null ? count : 0L;
+ }
+
+ @Override
+ public Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId) {
+ List results = jdbc.query(
+ "SELECT * FROM mv_product_rank_monthly WHERE snapshot_date = :snapshotDate AND product_id = :productId",
+ Map.of("snapshotDate", snapshotDate, "productId", productId),
+ (rs, rowNum) -> MvProductRankMonthly.reconstruct(
+ rs.getLong("id"),
+ rs.getDate("snapshot_date").toLocalDate(),
+ rs.getLong("product_id"),
+ rs.getInt("rank_position"),
+ rs.getDouble("score"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue"),
+ rs.getTimestamp("created_at").toLocalDateTime()
+ ));
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java
new file mode 100644
index 0000000000..b077a9f531
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImpl.java
@@ -0,0 +1,78 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.MvProductRankWeekly;
+import com.loopers.domain.rank.MvProductRankWeeklyRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class MvProductRankWeeklyRepositoryImpl implements MvProductRankWeeklyRepository {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ // created_at 은 ON DUPLICATE KEY UPDATE 절에 포함하지 않아 최초 INSERT 시각 보존
+ private static final String UPSERT_SQL = """
+ INSERT INTO mv_product_rank_weekly
+ (snapshot_date, product_id, rank_position, score, view_count, like_count, order_revenue, created_at)
+ VALUES
+ (:snapshotDate, :productId, :rank, :score, :viewCount, :likeCount, :orderRevenue, NOW(6))
+ ON DUPLICATE KEY UPDATE
+ rank_position = VALUES(rank_position),
+ score = VALUES(score),
+ view_count = VALUES(view_count),
+ like_count = VALUES(like_count),
+ order_revenue = VALUES(order_revenue)
+ """;
+
+ @Override
+ public void upsertAll(List extends MvProductRankWeekly> rows) {
+ SqlParameterSource[] params = rows.stream()
+ .map(r -> new MapSqlParameterSource()
+ .addValue("snapshotDate", r.getSnapshotDate())
+ .addValue("productId", r.getProductId())
+ .addValue("rank", r.getRank())
+ .addValue("score", r.getScore())
+ .addValue("viewCount", r.getViewCount())
+ .addValue("likeCount", r.getLikeCount())
+ .addValue("orderRevenue", r.getOrderRevenue()))
+ .toArray(SqlParameterSource[]::new);
+ jdbc.batchUpdate(UPSERT_SQL, params);
+ }
+
+ @Override
+ public long countBySnapshotDate(LocalDate snapshotDate) {
+ Long count = jdbc.queryForObject(
+ "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE snapshot_date = :snapshotDate",
+ Map.of("snapshotDate", snapshotDate),
+ Long.class);
+ return count != null ? count : 0L;
+ }
+
+ @Override
+ public Optional findBySnapshotDateAndProductId(LocalDate snapshotDate, Long productId) {
+ List results = jdbc.query(
+ "SELECT * FROM mv_product_rank_weekly WHERE snapshot_date = :snapshotDate AND product_id = :productId",
+ Map.of("snapshotDate", snapshotDate, "productId", productId),
+ (rs, rowNum) -> MvProductRankWeekly.reconstruct(
+ rs.getLong("id"),
+ rs.getDate("snapshot_date").toLocalDate(),
+ rs.getLong("product_id"),
+ rs.getInt("rank_position"),
+ rs.getDouble("score"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getBigDecimal("order_revenue"),
+ rs.getTimestamp("created_at").toLocalDateTime()
+ ));
+ return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
+ }
+}
diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml
index 9aa0d760a6..da67b3026d 100644
--- a/apps/commerce-batch/src/main/resources/application.yml
+++ b/apps/commerce-batch/src/main/resources/application.yml
@@ -13,7 +13,7 @@ spring:
- monitoring.yml
batch:
job:
- name: ${job.name:NONE}
+ enabled: false
jdbc:
initialize-schema: never
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobIntegrationTest.java
new file mode 100644
index 0000000000..a312db8439
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/monthly/MonthlyRankingJobIntegrationTest.java
@@ -0,0 +1,145 @@
+package com.loopers.batch.ranking.monthly;
+
+import com.loopers.domain.rank.MvProductRankMonthly;
+import com.loopers.domain.rank.MvProductRankMonthlyRepository;
+import com.loopers.fixture.RankingMetricsTestFixture;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.JobRepositoryTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=monthlyRankingJob")
+class MonthlyRankingJobIntegrationTest {
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ private JobRepositoryTestUtils jobRepositoryTestUtils;
+
+ @Autowired
+ @Qualifier(MonthlyRankingJobConfig.JOB_NAME)
+ private Job monthlyRankingJob;
+
+ @Autowired
+ private MvProductRankMonthlyRepository repository;
+
+ @Autowired
+ private RankingMetricsTestFixture metricsFixture;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @BeforeEach
+ void setUp() {
+ jobLauncherTestUtils.setJob(monthlyRankingJob);
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ jobRepositoryTestUtils.removeJobExecutions();
+ }
+
+ @Test
+ @DisplayName("Rolling Window [today-30, today-1] 범위만 집계에 포함된다")
+ void rollingWindowBoundary() throws Exception {
+ // arrange: today-31 과 today 데이터 삽입 (제외 대상)
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(31), 1L, 100, 10, 1000);
+ metricsFixture.insertMetrics(SNAPSHOT, 1L, 100, 10, 1000);
+ // 포함 대상: today-30 ~ today-1
+ for (int d = 1; d <= 30; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), 1L, 10, 1, 100);
+ }
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ MvProductRankMonthly row = repository.findBySnapshotDateAndProductId(SNAPSHOT, 1L).orElseThrow();
+ assertThat(row.getViewCount()).isEqualTo(300L);
+ assertThat(row.getLikeCount()).isEqualTo(30L);
+ assertThat(row.getOrderRevenue()).isEqualByComparingTo("3000.00");
+ }
+
+ @Test
+ @DisplayName("TOP 100 만 적재한다 (101위 이하는 제외)")
+ void top100Only() throws Exception {
+ // arrange: 150 개 상품의 30일치 메트릭 삽입
+ for (long pid = 1; pid <= 150; pid++) {
+ for (int d = 1; d <= 30; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), pid, pid, 0, 0);
+ }
+ }
+
+ // act
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("같은 snapshotDate 로 재실행하면 JobInstanceAlreadyCompleteException")
+ void idempotentReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // act & assert
+ assertThatThrownBy(() -> jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT)))
+ .isInstanceOf(JobInstanceAlreadyCompleteException.class);
+ }
+
+ @Test
+ @DisplayName("trigger + run.id 조합으로 재실행 가능하다 (새 JobInstance)")
+ void manualReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ JobParameters manual = new JobParametersBuilder()
+ .addString("snapshotDate", SNAPSHOT.toString())
+ .addString("trigger", "WEIGHT_CHANGE")
+ .addString("run.id", LocalDateTime.now().toString())
+ .toJobParameters();
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(manual);
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(1);
+ }
+
+ private JobParameters jobParams(LocalDate date) {
+ return new JobParametersBuilder()
+ .addString("snapshotDate", date.toString())
+ .toJobParameters();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java
new file mode 100644
index 0000000000..0c5acc2d5c
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/weekly/WeeklyRankingJobIntegrationTest.java
@@ -0,0 +1,145 @@
+package com.loopers.batch.ranking.weekly;
+
+import com.loopers.domain.rank.MvProductRankWeekly;
+import com.loopers.domain.rank.MvProductRankWeeklyRepository;
+import com.loopers.fixture.RankingMetricsTestFixture;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.JobRepositoryTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=weeklyRankingJob")
+class WeeklyRankingJobIntegrationTest {
+
+ private static final LocalDate SNAPSHOT = LocalDate.of(2026, 4, 16);
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ private JobRepositoryTestUtils jobRepositoryTestUtils;
+
+ @Autowired
+ @Qualifier(WeeklyRankingJobConfig.JOB_NAME)
+ private Job weeklyRankingJob;
+
+ @Autowired
+ private MvProductRankWeeklyRepository repository;
+
+ @Autowired
+ private RankingMetricsTestFixture metricsFixture;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @BeforeEach
+ void setUp() {
+ jobLauncherTestUtils.setJob(weeklyRankingJob);
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ jobRepositoryTestUtils.removeJobExecutions();
+ }
+
+ @Test
+ @DisplayName("Rolling Window [today-7, today-1] 범위만 집계에 포함된다")
+ void rollingWindowBoundary() throws Exception {
+ // arrange: today-8 과 today 데이터 삽입 (제외 대상)
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(8), 1L, 100, 10, 1000);
+ metricsFixture.insertMetrics(SNAPSHOT, 1L, 100, 10, 1000);
+ // 포함 대상: today-7 ~ today-1
+ for (int d = 1; d <= 7; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), 1L, 10, 1, 100);
+ }
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ MvProductRankWeekly row = repository.findBySnapshotDateAndProductId(SNAPSHOT, 1L).orElseThrow();
+ assertThat(row.getViewCount()).isEqualTo(70L);
+ assertThat(row.getLikeCount()).isEqualTo(7L);
+ assertThat(row.getOrderRevenue()).isEqualByComparingTo("700.00");
+ }
+
+ @Test
+ @DisplayName("TOP 100 만 적재한다 (101위 이하는 제외)")
+ void top100Only() throws Exception {
+ // arrange: 150 개 상품의 7일치 메트릭 삽입
+ for (long pid = 1; pid <= 150; pid++) {
+ for (int d = 1; d <= 7; d++) {
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(d), pid, pid, 0, 0);
+ }
+ }
+
+ // act
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // assert
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(100);
+ }
+
+ @Test
+ @DisplayName("같은 snapshotDate 로 재실행하면 JobInstanceAlreadyCompleteException")
+ void idempotentReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ // act & assert
+ assertThatThrownBy(() -> jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT)))
+ .isInstanceOf(JobInstanceAlreadyCompleteException.class);
+ }
+
+ @Test
+ @DisplayName("trigger + run.id 조합으로 재실행 가능하다 (새 JobInstance)")
+ void manualReRun() throws Exception {
+ // arrange
+ metricsFixture.insertMetrics(SNAPSHOT.minusDays(1), 1L, 10, 0, 0);
+ jobLauncherTestUtils.launchJob(jobParams(SNAPSHOT));
+
+ JobParameters manual = new JobParametersBuilder()
+ .addString("snapshotDate", SNAPSHOT.toString())
+ .addString("trigger", "WEIGHT_CHANGE")
+ .addString("run.id", LocalDateTime.now().toString())
+ .toJobParameters();
+
+ // act
+ JobExecution exec = jobLauncherTestUtils.launchJob(manual);
+
+ // assert
+ assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ assertThat(repository.countBySnapshotDate(SNAPSHOT)).isEqualTo(1);
+ }
+
+ private JobParameters jobParams(LocalDate date) {
+ return new JobParametersBuilder()
+ .addString("snapshotDate", date.toString())
+ .toJobParameters();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java b/apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java
new file mode 100644
index 0000000000..514020f0d0
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/fixture/RankingMetricsTestFixture.java
@@ -0,0 +1,33 @@
+package com.loopers.fixture;
+
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.util.Map;
+
+@Component
+public class RankingMetricsTestFixture {
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public RankingMetricsTestFixture(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ public void insertMetrics(LocalDate date, long productId, long viewCount, long likeCount, long orderRevenue) {
+ jdbc.update("""
+ INSERT INTO ranking_metrics
+ (product_id, metrics_date, metrics_hour, view_count, like_count, order_revenue, dirty, created_at, updated_at)
+ VALUES
+ (:productId, :date, 0, :viewCount, :likeCount, :orderRevenue, false, NOW(), NOW())
+ """,
+ Map.of(
+ "productId", productId,
+ "date", date,
+ "viewCount", viewCount,
+ "likeCount", likeCount,
+ "orderRevenue", orderRevenue
+ ));
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java
new file mode 100644
index 0000000000..c00d3788e8
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankMonthlyRepositoryImplIntegrationTest.java
@@ -0,0 +1,94 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.MvProductRankMonthly;
+import com.loopers.domain.rank.MvProductRankMonthlyRepository;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+class MvProductRankMonthlyRepositoryImplIntegrationTest {
+
+ @Autowired
+ private MvProductRankMonthlyRepository repository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Nested
+ @DisplayName("upsertAll()")
+ class UpsertAll {
+
+ @Test
+ @DisplayName("최초 호출 시 행이 INSERT 된다")
+ void insertOnFirstCall() {
+ // arrange
+ List rows = List.of(
+ new MvProductRankMonthly(LocalDate.of(2026, 4, 16), 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00")),
+ new MvProductRankMonthly(LocalDate.of(2026, 4, 16), 2L, 2, 90.0, 9L, 4L, new BigDecimal("900.00"))
+ );
+
+ // act
+ repository.upsertAll(rows);
+
+ // assert
+ assertThat(repository.countBySnapshotDate(LocalDate.of(2026, 4, 16))).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("같은 (snapshot_date, product_id) 로 재호출 시 값이 UPDATE 된다 (멱등성)")
+ void updateOnDuplicateKey() {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(
+ new MvProductRankMonthly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))
+ ));
+ MvProductRankMonthly updated = new MvProductRankMonthly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"));
+
+ // act
+ repository.upsertAll(List.of(updated));
+
+ // assert
+ MvProductRankMonthly row = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow();
+ assertThat(row.getScore()).isEqualTo(200.0);
+ assertThat(row.getViewCount()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("created_at 은 최초 INSERT 시각을 유지하고 UPDATE 시 갱신되지 않는다")
+ void createdAtIsPreservedOnUpdate() throws InterruptedException {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(
+ new MvProductRankMonthly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))
+ ));
+ LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ Thread.sleep(10);
+
+ // act
+ repository.upsertAll(List.of(
+ new MvProductRankMonthly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"))
+ ));
+
+ // assert
+ LocalDateTime afterUpdate = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ assertThat(afterUpdate).isEqualTo(firstCreatedAt);
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java
new file mode 100644
index 0000000000..257abab86a
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/rank/MvProductRankWeeklyRepositoryImplIntegrationTest.java
@@ -0,0 +1,94 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.MvProductRankWeekly;
+import com.loopers.domain.rank.MvProductRankWeeklyRepository;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+class MvProductRankWeeklyRepositoryImplIntegrationTest {
+
+ @Autowired
+ private MvProductRankWeeklyRepository repository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Nested
+ @DisplayName("upsertAll()")
+ class UpsertAll {
+
+ @Test
+ @DisplayName("최초 호출 시 행이 INSERT 된다")
+ void insertOnFirstCall() {
+ // arrange
+ List rows = List.of(
+ new MvProductRankWeekly(LocalDate.of(2026, 4, 16), 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00")),
+ new MvProductRankWeekly(LocalDate.of(2026, 4, 16), 2L, 2, 90.0, 9L, 4L, new BigDecimal("900.00"))
+ );
+
+ // act
+ repository.upsertAll(rows);
+
+ // assert
+ assertThat(repository.countBySnapshotDate(LocalDate.of(2026, 4, 16))).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("같은 (snapshot_date, product_id) 로 재호출 시 값이 UPDATE 된다 (멱등성)")
+ void updateOnDuplicateKey() {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(
+ new MvProductRankWeekly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))
+ ));
+ MvProductRankWeekly updated = new MvProductRankWeekly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"));
+
+ // act
+ repository.upsertAll(List.of(updated));
+
+ // assert
+ MvProductRankWeekly row = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow();
+ assertThat(row.getScore()).isEqualTo(200.0);
+ assertThat(row.getViewCount()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("created_at 은 최초 INSERT 시각을 유지하고 UPDATE 시 갱신되지 않는다")
+ void createdAtIsPreservedOnUpdate() throws InterruptedException {
+ // arrange
+ LocalDate date = LocalDate.of(2026, 4, 16);
+ repository.upsertAll(List.of(
+ new MvProductRankWeekly(date, 1L, 1, 100.0, 10L, 5L, new BigDecimal("1000.00"))
+ ));
+ LocalDateTime firstCreatedAt = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ Thread.sleep(10);
+
+ // act
+ repository.upsertAll(List.of(
+ new MvProductRankWeekly(date, 1L, 1, 200.0, 20L, 10L, new BigDecimal("2000.00"))
+ ));
+
+ // assert
+ LocalDateTime afterUpdate = repository.findBySnapshotDateAndProductId(date, 1L).orElseThrow().getCreatedAt();
+ assertThat(afterUpdate).isEqualTo(firstCreatedAt);
+ }
+ }
+}