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 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 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 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 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 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 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 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); + } + } +}