Skip to content

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

Open
YoHanKi wants to merge 6 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:week10-batch-core
Open

[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현#403
YoHanKi wants to merge 6 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:week10-batch-core

Conversation

@YoHanKi
Copy link
Copy Markdown

@YoHanKi YoHanKi commented Apr 16, 2026

📌 Summary

이번 주차 발제와 출발점

Round 10 발제는 Spring Batch + Materialized View로 주/월 집계 랭킹을 추가하고, ?period= 파라미터로 일/주/월을 단일 API에서 제공하는 것입니다. 출발점은 week9까지 만들어둔 Redis ZSET 기반 daily 실시간 랭킹 하나이며, 주/월이라는 시간 윈도우를 배치로 생성해 이 위에 얹는 것이 이번 스코프입니다.

구현 방식으로는 크게 3가지 경로를 검토했습니다:

  1. Base 플랜 — RAW 신호에서 바로 주/월 집계를 SQL로 해서 MV에 넣는 가장 단순한 방식
  2. Plan-A — GROUP BY 결과를 Redis ZSET에 쌓고 RENAME으로 원자 교체, MV는 아카이브로만 쓰는 방식 (week9 daily 패턴의 확장)
  3. Plan-B — 일별 score를 먼저 물리화(Phase 1)하고, 주/월/분기는 그 위에서 SUM(score)만 하는 Two-Phase ETL

셋을 비교한 결과 Plan-B를 선택했고, 그 선택의 결을 따라 "원자 발행 · 실시간 N+1 제거 · chunk size 실측 · 운영 안전장치"까지 결정이 이어졌습니다. 이 PR은 그 결정들의 모음입니다.

기술적 우위보다 어떤 관점을 우선했는가

여러 선택 지점에서 "더 빠른 쪽"이 명확히 존재했습니다. 아래 세 번은 모두 다른 축을 우선했고, 각각 실측으로 그 트레이드오프가 감당 가능한지 검증했습니다.

① 계산식 변경 안전성 > 배치 실행 시간
week9에서 ScoreAggregator별도 도메인 서비스로 떼어낸 것은 "공식이 0.1v + 0.2l + 0.7o에서 끝나지 않는다"는 전제였습니다. 이 전제를 이어받아 공식을 Java 1개 메서드에만 두기로 결정했고, 그 결과 배치에 Phase 1(일별 score 물리화) 단계가 추가되어 측정 결과 ~500ms의 오버헤드가 관측되었습니다. 그 대가로 log·조건부·시간 감쇠 같은 복잡 공식이 들어와도 SQL을 고치지 않는 구조를 얻을 수 있었습니다. 500ms는 새벽 배치 창 기준으로는 체감되지 않을 것으로 예상됩니다.

② 읽기 일관성의 구조적 보장 > 단순한 코드
기존 DELETE+INSERT를 단일 트랜잭션으로 묶는 방식은 MySQL MVCC 덕분에 실제로는 원자적으로 동작하는 것으로 측정되었습니다(MvProductRankAtomicSwapTest 2건 PASS로 실증). 다만 이 원자성은 @Transactional propagation / TransactionTemplate 유지 / isolation 레벨이라는 암묵적 컨벤션 3개에 의존하는 구조입니다. version 컬럼 + 별도 publication 테이블(노출 중인 version 포인터) + CAS flip 방식으로 바꿔, 벤치마크상 CAS 1번 = 1ms가 노출 전환 순간이 되도록 원자성을 스키마로 보장하는 형태로 전환했습니다. 추가 비용은 mv_product_rank_publication 테이블 1개 + reader JOIN 1개입니다.

③ 도메인 경계 > 벤치마크 절대값
랭킹 + 상품 정보를 단일 native JOIN으로 가져오는 방식이 App-level 2-step보다 4.94배 빠른 것으로 측정되었습니다(2.45ms vs 12.11ms, QuarterlyJoinVsAppAggregationTest). 다만 12ms는 p95 threshold(100ms)의 12% 수준이어서 여유가 충분하다고 판단됩니다. 이 여유를 ranking 도메인이 products 스키마를 직접 알지 않게 하는 것, Redis MGET 캐시 레이어를 투명하게 끼울 수 있게 하는 것에 투자했습니다.

발제 범위와 자발 확장 분리

발제 4개 체크리스트(Batch Job 파라미터화 / Chunk 패턴 / MV 적재 / period API)는 전부 충족했습니다. 그 위에 발제 외 자발 확장으로 분기(3개월 롤링) · Publication + version 기반 원자 발행 · Redis MGET 배치화 · 콜드스타트 Fallback · HealthCheck / Cleanup 독립 Job을 추가했습니다. 자발 확장이 발제 핵심을 가리지 않도록 Design Overview 섹션에 ¹ 표기로 명시 분리했습니다.


🧭 Context & Decision

결정이 많아 가장 중요한 1번(Two-Phase ETL 채택)만 펼쳐두고, 나머지는 디테일 태그로 감쌌습니다. 각 제목을 클릭하여 확인할 수 있습니다. 순서는 스토리 흐름(집계 구조 → 원자 발행 → 서빙 성능 → 배치 튜닝 → Spring Batch 구조 → 운영 안전장치 → 보조 결정) 기준입니다.


1. Two-Phase ETL — 왜 "일부러 느린" 2단계 구조를 택했는가 ⭐

  • 결론 : score 공식 변경이 발생할 때의 수정 범위를 "Java 한 곳"으로 고정하고, 주/월/분기에서 동일 일간 score를 재사용하기 위해 Phase 1(일별 score 물리화) → Phase 2(SUM 윈도우 집계) 구조를 채택했습니다.

측정 결과 base 플랜이 50배 빠른 것으로 나타났지만, 그 "50배"는 측정 단면이 달라서 생긴 착시로 판단됩니다(아래에서 분해). 새벽 배치 창에서는 체감되지 않을 것으로 예상됩니다.

고려한 3가지 플랜

처음부터 "Plan-A vs Plan-B" 둘만 비교한 것은 아닙니다. 발제 "Spring Batch + MV로 주/월 집계"를 충족하는 가장 단순한 Base 플랜부터 시작해서, 이를 Redis로 확장한 Plan-A, 계산식 분리를 구조화한 Plan-B까지 3가지를 놓고 비교했습니다.

Base 플랜 — SQL 인라인 score, MV 직접 쿼리

  • product_daily_signals RAW 신호에서 주/월/분기 각각 SUM(0.1*view + 0.2*like + 0.7*order) GROUP BY product_db_id ORDER BY score DESC LIMIT 100 으로 집계
  • 중간 테이블 없음. Writer는 DELETE FROM mv WHERE period_key=? → INSERT TOP 100 단일 tx
  • Job은 weeklyRankJob, monthlyRankJob 2개. Step은 Cleanup Tasklet + Chunk(R/W) 1개씩
  • 장점: 구현 최소, 가장 빠름(SQL 1회), 새 테이블 0개
  • 단점: score 산식이 SQL 문자열 안에 인라인 — 공식 변경 시 SQL 2~3 파일 동시 수정

Plan-A — Shadow Redis ZSET + MV 아카이브

  • RAW 신호에서 GROUP BY → RedisZSetItemWriter(pipelined ZADD)로 shadow key에 적재
  • 끝나면 RENAME shadow → final 로 원자 교체 (week9 daily Redis 패턴의 주/월 확장)
  • MV는 아카이브 용도로만 적재 (ZREVRANGE top 100 → MV INSERT, 이중 chunk step)
  • 서빙은 모든 period가 ZREVRANGE 단일 경로로 통일
  • 장점: 배치 중 조회 블로킹 0 (Shadow RENAME), 서빙 경로 단순 — API 코드가 key 한 개로 분기
  • 단점: Step 4개(Clear / Build / Rename / Archive) · 커스텀 RedisZSetItemWriter 필요 · Redis 소실 시 MV에서 복구 스크립트 별도 필요

Plan-B — Two-Phase ETL (선택)

  • Phase 1 dailyScoreJob: RAW 신호 → 일별 score 물리화 (mv_product_score_daily, UPSERT)
  • Phase 2 weekly/monthly/quarterlyRankJob: 일별 score를 SUM(score) GROUP BY product_db_id 윈도우로 집계
  • score 산식은 Java ScoreCalculator.calculate() 1개 메서드에만 존재. Phase 2 SQL은 SUM(score) 로 고정 — 공식이 뭘로 바뀌든 Phase 2는 무변경
  • 장점: 공식 변경 수정 범위 = Java 1파일, cross-period 재사용, 증분 처리, 감사 가능
  • 단점: Phase 1이라는 1단계 추가 (중간 MV 1개 · Job 수 증가 · backfill 시 Phase 1부터 재실행)

Base 플랜을 기각한 이유 — week9 설계 의도의 단절과 SQL 표현 한계

week9에서 ScoreAggregator별도 도메인 서비스로 분리한 것은 "score 공식이 0.1v+0.2l+0.7o에서 끝나지 않는다"는 전제였습니다. 운영 중 발생할 것으로 예상되는 공식 변경 시나리오를 놓고 Base 플랜이 어떻게 깨지는지 정리했습니다.

변경 시나리오 Base 플랜 수정 범위 SQL 표현 가능성
가중치 수치 조정 (0.1 → 0.15) 3 SQL 파일 (weekly/monthly/quarterly) 바인딩 수정 가능
log 정규화 (log(1 + viewCount)) 3 SQL + SUM집계 순서 재구성 필요 (LOG(1 + SUM(view))SUM(LOG(1 + view)) 과 다름 — 산식 의도대로 쓰려면 서브쿼리/HAVING) 가능하나 가독성 ↓
조건부 보너스 (orderAmount > 10000 → +500) 3 SQL 모두에 CASE WHEN SUM(order_amount) > 10000 THEN 500 ELSE 0 END 추가 가능하나 SQL 복잡도 ↑
시간 감쇠 (score * exp(-λ * (today - date))) SQL에서 날짜별 가중치 계산 어려움 — Reader SQL 구조 자체를 바꿔야 함 표현 한계
외부 API 기반 가산점 (isPromoted ? ×1.2 : ×1.0) SQL로 외부 호출 불가 — Java 레이어 필요 표현 불가

세 번째 문제도 있습니다. Base는 ORDER BY score DESC LIMIT 100이 SQL 안에 있어서, score를 SQL에서 빼면 정렬/LIMIT 구조 자체가 무너지는 형태입니다. 단순 가중치 변경은 바인딩 파라미터로 막을 수 있지만, 공식 형태 변경이 들어오면 Reader를 통째로 다시 써야 할 것으로 예상됩니다.

정리하면 Base는 "단순한 요구사항에는 완벽하지만, week9가 열어놓은 설계 여지를 다시 닫는" 선택으로 판단되었습니다. week9의 ScoreAggregator 분리가 선취한 자유도를 week10에서 버리는 것은 일관성 위반이라고 판단했습니다.

Plan-A를 기각한 이유 — "서빙 통일"이라는 수사의 실측 검증

Plan-A는 "모든 period가 Redis ZREVRANGE로 단일 경로"라는 점에서 매력적으로 보였습니다. 다만 실제로 그 장점이 유효한지 k6로 실측했습니다(참고: 이 실측은 Plan-B 구현 후 주/월 MV 쿼리 vs daily Redis ZREVRANGE를 비교한 수치로, Plan-A로 바꿨을 때 얻을 수 있는 이득의 상한으로 해석됩니다).

period 데이터 소스 avg p95 error
daily Redis ZREVRANGE 187ms 227ms 0%
weekly MV PK scan 189ms 248ms 0%
monthly MV PK scan 182ms 212ms 0%

측정 결과 avg 차이 2ms, p95 차이 21ms로 나타났습니다. 이 차이가 작은 이유를 구조적으로 분해하면:

[응답 시간 내부 구성 ~187ms]
  ├─ 랭킹 조회 (Redis ZREVRANGE 또는 MV PK scan): ~1ms
  ├─ 상품 정보 enrich (Redis MGET × 20건 + DB fallback): ~150ms  ← 진짜 병목
  ├─ JSON 직렬화: ~5ms
  └─ 서블릿 오버헤드: ~30ms

서빙 경로를 MV→Redis로 바꾸는 Plan-A는 1ms 미만을 절약하는 최적화로 해석됩니다. 그 1ms를 얻기 위해 지불해야 하는 비용은 Step 4개짜리 Job 구조, 커스텀 RedisZSetItemWriter + Tasklet 2개, Redis 소실 시 MV→Redis 복구 스크립트입니다. SOT가 DB인 이상 재실행은 어차피 DB에서 재읽기가 필요하므로, Redis 레이어는 추가 복원 경로만 늘리는 것으로 판단됩니다.

추가로 주/월 랭킹은 트래픽 특성이 일간과 다를 것으로 예상됩니다. 일간은 실시간 갱신 + 높은 조회 빈도라 Redis가 적합하지만, 주/월은 새벽 배치 후 간헐적 조회 패턴이라 Redis 캐싱의 실익이 구조적으로 약할 것으로 보입니다. Plan-A는 기술적 완성도는 있으나 이 요구사항에서는 가치를 내기 어려운 복잡도로 판단되었습니다.

Plan-B를 선택한 이유 — 공식 변경 안전성과 cross-period 재사용

같은 공식 변경 시나리오 표를 Plan-B로 다시 그리면 다음과 같습니다:

변경 시나리오 Plan-B 수정 범위
가중치 수치 조정 application.yml 1곳 (streamer/batch/api가 supports/ranking으로 공유)
log 정규화 ScoreCalculator.calculate() 1개 Java 메서드
조건부 보너스 ScoreCalculator.calculate() 1개 Java 메서드
시간 감쇠 ScoreCalculator.calculate() 1개 Java 메서드 + Processor에 날짜 주입
외부 API 가산점 ScoreCalculator가 Spring Bean이라 의존성 주입으로 해결

Phase 2의 Reader SQL은 SUM(score) FROM mv_product_score_daily WHERE score_date BETWEEN ? AND ? GROUP BY product_db_id영구 고정됩니다. 단위 테스트도 ScoreCalculator에 대해서만 작성하면 공식 검증이 완료되는 구조입니다.

추가로 얻을 수 있는 이점은 다음과 같습니다.

1. Cross-period 재사용: Phase 1이 1일분 score를 1회 계산하면 Phase 2(weekly/monthly/quarterly)가 모두 SUM으로 재사용할 수 있습니다. Base 플랜은 주·월·분기 각각이 RAW 신호를 재집계해 동일 공식 계산을 3번 수행하게 됩니다.

2. 일상 배치 증분화: Phase 1은 매일 1일치만 처리(증분)하면 됩니다. Base는 매일 주 7일 + 월 30일 + 분기 90일 전체를 재계산해야 할 것으로 예상됩니다.

Plan-B 일상 배치 Base 플랜 일상 배치
Phase 1 10만행 읽기 + score 계산 (없음)
주간 SUM(score) 7일 × 10만 GROUP BY 7일 × 10만 + score 계산
월간 SUM(score) 30일 × 10만 GROUP BY 30일 × 10만 + score 계산
분기 SUM(score) 90일 × 10만 GROUP BY 90일 × 10만 + score 계산
score 계산 횟수 1회 (Phase 1) 3회 (주+월+분기 각각)

3. 감사/디버깅: "2026-04-10에 상품 42의 score가 몇이었는가?"를 mv_product_score_daily에서 즉시 조회할 수 있습니다. Base는 RAW counts를 읽어 공식을 수동 재적용해야 하는데, 공식이 도중에 바뀌었다면 당시 공식을 기억해서 재현해야 하므로 실무에서 실패하기 쉬운 작업으로 예상됩니다.

"느려 보이는데 왜?" — 500ms 수치의 정직한 분해

1,000 상품 × 7일 시드에서 측정한 결과입니다.

경로 측정값
Base 플랜 시뮬레이션 (SQL 인라인 GROUP BY 1회) 11ms
Plan-B Phase 2 단독 (SUM score, Spring Batch Job) 501ms

숫자만 보면 약 50배 느린 것으로 나타납니다. 이 비교가 공정하지 않은 이유는 다음과 같습니다.

① 측정 대상이 다릅니다 — Base는 순수 SQL 1회 실행 시간이고, Plan-B는 Spring Batch Job 전체(JobLauncher 기동 + JobRepository 메타데이터 INSERT + StepExecution 기록 + chunk별 트랜잭션 커밋 6회 + ExecutionContext 직렬화 + JobListener 실행)를 측정한 값입니다. 순수 SQL만 비교하면 Plan-B Phase 2의 SUM(score)도 수 ms 수준일 것으로 예상됩니다.

② Base도 Job으로 감싸면 동일한 오버헤드가 붙을 것으로 예상됩니다 — 실제 운영에서 Base도 Spring Batch Job으로 포장되므로 Job 기동·메타데이터·트랜잭션 비용이 동일하게 붙습니다. 위 11ms는 이 오버헤드를 제외한 순수 SQL 측정값입니다.

③ 절대값이 작습니다 — 500ms는 새벽 배치 창(보통 수십 분~수 시간) 기준으로는 0에 수렴할 것으로 보입니다. 실시간 서빙 경로가 아닌 점도 고려했습니다.

④ Phase 1은 어차피 실행되는 단계입니다 — Phase 1(dailyScoreJob)은 매일 1회 실행되어야 하는 고정 단계입니다. Phase 2의 "500ms"는 이 위에 추가되는 비용이지 매 period마다 전체를 새로 도는 비용은 아닙니다. Cross-period 재사용을 고려하면 Plan-B가 오히려 CPU 총비용이 더 낮을 것으로 예상됩니다(score 계산 1회 vs 3회).

정리하면, Plan-B의 500ms는 Spring Batch 오버헤드로 설명되는 배치 단발성 비용이고, 그 대가로 공식 변경 안전성·cross-period 재사용·감사 기능을 얻습니다. "느림"이 결정 축이 될 수 없는 비용이라는 것이 선택의 전제였습니다.

인정한 트레이드오프

  • Step/Job 수 증가 — Phase 1 + Phase 2 × 3 period로 Job 4개. 단일 Job Flow로 묶지 않은 이유는 "weekly만 재실행" 같은 부분 실행 유연성을 유지하기 위함입니다
  • 중간 MV mv_product_score_daily 1개 추가 — 디스크 비용은 10만 상품 × 365일 기준 수 GB 수준으로 예상됩니다. 스케일업 시 날짜 파티셔닝 + 보존 기간 정책이 필요할 것으로 보입니다
  • backfill 시 Phase 1부터 재실행이 필요하나, SOT(product_daily_signals)가 살아있으면 언제든 재생성 가능합니다
  • score 계산 로직이 streamer(실시간) / batch(배치) 2곳에 복제되어야 했으나, 이 PR에서 supports/ranking 모듈로 추출해 단일 소스로 통합했습니다 (보조 결정 섹션 참고)

보조 결정: Job 분리 vs 단일 Job Flow

Phase 1 → Phase 2 실행 순서 의존성을 어떻게 관리할지도 선택이 필요했습니다.

방식 장점 단점
선택 4개 별도 Job + 외부 cron && 체이닝 Job별 독립 실행/재실행, @ConditionalOnProperty로 Job 격리, 실무 표준 실행 순서가 외부(cron)에 의존
기각 단일 rankingBatchJob + Flow(dailyScore → weekly → monthly → quarterly) 순서 보장을 Spring Batch가 관리 "weekly만 재실행" 같은 부분 실행 불가, 실패 시 전체 Job 재시작

Phase 1 실패 시 && exit code로 Phase 2가 미실행되므로, 부분 성공 편향(id 작은 상품만 score 보유)을 cron 레이어가 1차 차단하는 구조로 예상됩니다.

추후 개선 여지

  • Phase 1 완료를 trigger로 Phase 2 자동 체이닝 (현재는 cron && 직렬화)
  • 상품 수 수백만 도달 시 mv_product_score_daily 날짜 파티셔닝 + 보존 기간 정책

2. MV 원자 갱신 전략

🔎 클릭하여 확인 — 5가지 갱신 방식 검토 → 단일 트랜잭션 Writer 채택 → 3가지 스왑 전략(기존 방식 / Publication + version / Stage table RENAME) 실측 → Publication + version 방식 프로덕션 전환

이 섹션에서 반복해서 나오는 약어 정리:

  • 기존 방식(S1)DELETE FROM mv WHERE period_key=? → INSERT TOP 100을 단일 트랜잭션으로 묶는 방식. AtomicMvRankWriter가 이 전략을 수행합니다.
  • Publication + version(S2) — MV 테이블에 version 컬럼을 추가하고, 별도 publication 테이블에 "현재 노출 중인 version"을 포인터로 둔 뒤 CAS로 flip하는 방식. 이번 PR에서 최종 선택한 방식입니다.
  • Stage table RENAME(S3) — staging 테이블에 INSERT 후 RENAME TABLE로 원자 교체하는 방식.

"reader가 반쪽짜리 랭킹(빈 페이지 / OLD·NEW 혼재 / 일부 누락)을 절대 보지 않는다"가 이번 주/월 MV의 최상위 요구사항입니다. 이 요구를 충족하는 과정에서 3단계의 선택이 있었습니다.

단계 1 — Writer 수준: 5가지 갱신 방식 중 AtomicMvRankWriter 채택

처음 구현한 Phase 2는 CleanupMvTasklet(DELETE) → buildRankStep(INSERT)으로 Step/트랜잭션이 분리되어 있어, DELETE 커밋 후 INSERT 시작 전까지 MV가 빈 상태로 API에 노출되는 문제가 관측되었습니다. 대안을 5가지 놓고 비교했습니다.

방식 장점 단점 판정
DELETE Tasklet → INSERT Chunk (기존) Step 분리 명확 DELETE~INSERT 사이 빈 구간 ❌ 실무 사용 불가
PK(period_key, rank_no) 기반 UPSERT 트랜잭션 1회 순위 변경 시 PK가 바뀌어 UPSERT 불가. UK(period_key, ref_product_id)와 충돌 ❌ 스키마 구조상 불가능
REPLACE INTO MySQL 네이티브 두 Unique 제약(PK + UK) 충돌로 예측 불가능한 DELETE 발생 ❌ 위험
Shadow table + RENAME 원자적 교체 테이블 2개 관리, Hibernate DDL 충돌, 레플리카 이슈 ❌ 과잉
AtomicMvRankWriter (누적 → afterStep 원자 적재) Chunk 패턴 유지 + 단일 트랜잭션 메모리에 100행 누적 (부담 없음) ✅ 채택

UPSERT가 불가능한 이유를 짚어두면 다음과 같습니다.

PK: (period_key, rank_no)
UK: (period_key, ref_product_id)

상품 42가 rank 5 → rank 10으로 변경될 때:
- INSERT ON DUPLICATE KEY UPDATE on UK(2026W15, 42)
  → 기존 행 (2026W15, 5, 42, ...) 찾음 → rank_no를 5 → 10으로 UPDATE
  → 그런데 PK (2026W15, 10)에 이미 다른 상품이 있을 수 있어 → PK 충돌 가능

이 구조에서 안전한 갱신은 전체 교체뿐이며, 이를 원자적으로 수행하는 것이 AtomicMvRankWriter의 역할입니다.

단계 2 — Stateful Processor 제거, Writer에서 rank 부여

AtomicMvRankWriter 채택 직후 RankAssignProcessorAtomicInteger rankCounterStateful이라 Step 재시작 시 rank가 1부터 다시 시작되는 문제가 발생할 것으로 예상되었습니다. 재시작 시나리오는 다음과 같이 예상됩니다.

Chunk 1: read 20, process(rank 1-20), write() → 메모리 누적 (DB 쓰기 없음)
Chunk 2: read 20, process(rank 21-40), write() → 메모리 누적
Chunk 3: 실패 → rollback

재시작:
  Reader: item 41부터 (40개 skip)
  새 Processor: counter=1 → item 41에 rank 1 부여 (잘못됨)
  새 Writer: 빈 list → 이전 chunk 1-2 데이터 소실

4가지 대안 검토:

방식 판정
ExecutionContext에 counter 저장 Writer 누적 방식과 부정합 — 기각
allowStartIfComplete(true) → 항상 전체 재실행 chunk 중간 재시작 이점 포기 — 기각
SQL ROW_NUMBER() Reader SQL 복잡 — 기각
Processor 제거 → Writer에서 rank 부여 Stateless, 재시작 안전 — 채택

변경 후:

Reader(AggregatedScoreRow) → Writer(AggregatedScoreRow 누적)
                                → afterStep(): 정렬 + rank 부여 + DELETE+INSERT
                                   ↑ Stateless (rank는 최종 단계에서 1회만 부여)

단계 3 — 기존 방식(S1) vs Publication + version(S2) vs Stage table RENAME(S3) 실측 비교

AtomicMvRankWriter가 실제로 원자적인지 계약 테스트(MvProductRankAtomicSwapTest)로 고정했습니다:

  • writer DELETE 후 INSERT 대기 중 reader count = 50 (OLD) — 측정 결과 빈 상태 관측 0건
  • reader snapshot이 전부 OLD row로 관측되었고, OLD/NEW 혼재는 0건
  • MySQL InnoDB REPEATABLE_READ + MVCC가 별도 커넥션의 reader를 writer commit 전까지 OLD snapshot에 고정하는 것으로 보입니다

즉 S1은 측정상 실제로는 원자적으로 동작했습니다. 그럼에도 3단계 하드닝을 검토한 이유는, 이 원자성이 3개의 암묵적 컨벤션에 의존하는 구조이기 때문입니다:

  1. AtomicMvRankWriter가 모든 갱신을 TransactionTemplate으로 감싸야 함
  2. deleteByPeriodKey@Transactional propagation이 기본값(REQUIRED)이어야 함
  3. DB isolation이 READ_COMMITTED 이상이어야 함

하나만 깨져도 조용히 반쪽 랭킹이 노출될 수 있다고 판단되어, 3단계 대안을 모두 프로토타입으로 구현해 실측했습니다.

실측 벤치마크 (MvSwapStrategyBenchmarkTest, 10K rows, local MySQL 8)

측정 ms
기존 방식 full SWAP (DELETE+INSERT 단일 tx) 429
기존 방식 DELETE only 59~105
기존 방식 batchInsert(10K) 268
Publication 방식 full SWAP (bump+INSERT+CAS 단일 tx) 442
Publication 방식 next_version bump 7
Publication 방식 batchInsert(10K) with version 371
Publication 방식 CAS publish UPDATE 13
Publication 방식 publish-only CAS (flip 순간) 1

Stage table RENAME 방식은 구조적으로 기각되었습니다mv_product_rank_weekly가 여러 periodKey를 단일 테이블에서 공유하는 구조라, RENAME 시 다른 periodKey 데이터까지 파괴될 것으로 예상되었습니다. 실측 전 설계 부적합으로 판정했습니다.

동시 writer 락 경합 실측 (MvProductRankConcurrentWriterTest)

  • 1차 실행: 두 writer 동시 기동 → CannotAcquireLockException + deadlock 탐지가 관측되었고, 한 쪽이 victim rollback 되었습니다
  • 해석: 기존 방식은 원자성은 지키지만 운영 중 중복 Job 기동 시 한 쪽은 반드시 실패할 것으로 예상됩니다. Publication 방식은 측정 결과 각 writer가 독립 version으로 INSERT 후 CAS만 마지막에 1번 충돌 해결하는 구조로, deadlock이 발생하지 않았습니다

시맨틱 재검증 (MvAtomicSwapSemanticsTest, 6 tests PASS)

시나리오 기존 방식 Publication 방식
Mid-run crash ✅ rollback으로 OLD snapshot ✅ CAS 미실행 → published_version 불변
알고리즘 가중치 변경 (1.5→2.4) ✅ 혼재 0% (COMMIT 원자) ✅ 혼재 0% (version별 격리)
kill 후 재실행 멱등 ✅ DELETE+INSERT 덮어쓰기 ✅ next_version bump 재발행

최종 선택: Publication + version 방식 프로덕션 전환

선택 이유:

  • 기존 방식의 원자성이 암묵적 컨벤션 3개에 의존하는 구조라, 누군가 @Transactional propagation을 바꾸거나 TransactionTemplate을 해체하면 즉시 회귀할 수 있다고 판단됩니다
  • Publication 방식은 INNER JOIN publication ON published_version 구조로 reader가 절대 중간 version을 보지 못하도록 스키마 차원에서 차단하는 형태입니다
  • 측정 결과 CAS flip이 1ms로 나타났습니다 — 대용량 INSERT가 길어도 reader는 구 version을 계속 읽고 CAS 순간에만 새 version으로 전환될 것으로 예상됩니다
  • 동시 writer deadlock 문제도 자동 해소됩니다 (각자 독립 version)
  • 알고리즘 롤백이 이전 version의 CAS 한 번으로 즉시 복원 가능합니다

기존 방식 vs Publication 방식 트레이드오프:

시나리오 기존 방식 Publication 방식 ✅
Mid-run crash COMMIT rollback에 의존 CAS가 마지막 단계 → published_version 불변
동시 writer 같은 periodKey에 동시 DELETE+INSERT → deadlock 각 writer 독립 version → deadlock 없음
알고리즘 롤백 별도 백업에서 복원 이전 version CAS 한 번으로 복원
Cleanup periodKey별 DELETE (active rows 영향) orphan version 전용 Tasklet, reader 무영향
원자성 보장 방식 암묵적 컨벤션 3개 스키마 + CAS
sequenceDiagram
    autonumber
    participant W as PublishingRankWriter
    participant MV as mv_product_rank_*
    participant P as publication
    W->>P: next_version bump (≈7ms)
    W->>MV: INSERT version=N (≈371ms, tx 분리 가능)
    W->>P: CAS published=N if N>current (≈1ms flip)
    Note over MV: reader JOIN published_version<br/>→ DELETE/INSERT 중간상태 관측 불가
Loading

Writer 계약 복원 (Part 10 A1)
초기 AtomicMvRankWriter는 write()에서 메모리에만 쌓고 afterStep()에서 일괄 INSERT하는 구조였고, writeCount 메타데이터 거짓 문제(ItemWriter 계약 위반)가 있었습니다. RL-1 실측에서 afterStep 예외가 Spring Batch 5.x에서 조용히 삼켜져 Status COMPLETED lie까지 발생하는 것이 관측되었습니다(Prometheus 녹색 · 알람 0 · 사용자는 빈 랭킹을 보게 될 수 있는 상황). 두 단계로 해결했습니다:

  1. 1차 fix (573361a): try/catch + setStatus(FAILED) + addFailureException + ExitStatus.FAILED 3중 처리로 Status 거짓말을 차단
  2. 2차 fix (Part 10 A1): PublishingRankWriter에서 write()가 직접 rankRepository.batchInsert 수행 → writeCount 정확. afterStep()은 CAS publish만 담당

Cleanup Job 분리 (Part 10 A2)
Weekly/Monthly Job 끝단에 cleanup Step을 두는 방식과 독립 mvRankCleanupJob을 두는 방식 중 독립 Job을 선택했습니다. publish 성공과 cleanup 실패가 Job 전체 FAILED로 혼동되지 않도록 실패 격리 + 독립 cron을 가능하게 하기 위함입니다. 최종 Job 흐름:

weekly/monthly/quarterlyRankJob = validate → build → healthCheck
mvRankCleanupJob (독립)         = cleanup[weekly] → cleanup[monthly] → cleanup[quarterly]

인정한 트레이드오프

  • mv_product_rank_publication 테이블 신설 + 모든 reader 쿼리에 JOIN publication 추가 — PK 인덱스 JOIN으로 1ms 미만
  • 영향 파일 17개 (Writer / Repository / Reader / Admin / Cleanup / 테스트)
  • MV 테이블 크기가 일시적으로 N+1 version 공존 — cleanup 주기 내에서만 2배

3. Redis MGET 배치화

🔎 클릭하여 확인 — 200rps에서 p95 4초 병목 발견, 숨겨진 N+1, 459배 개선

문제 발견 과정 — "저부하에선 안 보이던 병목"

1차 실험(K1)에서 daily(Redis) vs weekly(MV) vs monthly(MV)를 50rps로 비교 측정한 결과 셋 다 p95 100~130ms로 사실상 동일하게 나타났습니다. 당시 이 결과를 "Redis vs MV storage 선택이 latency에 영향 없음"으로 해석하고 마무리했습니다.

이후 고부하 재검증에서 이 결론이 뒤집혔습니다. 200rps로 부하를 올려 측정한 결과:

Endpoint 부하 p95 (Before) 원인
daily (Redis) 200rps 3.4s enrich() 루프 내 productCache.findById(id) per-item
weekly (MV, 10K rows) 200rps 4.14s 동일 N+1
weekly (MV, empty) 200rps 4.7ms ✓ 빈 결과는 enrichment 우회

측정 결과 진짜 병목은 storage 선택이 아니라 enrichment의 N+1로 드러났습니다. 페이지당 20 entries × productCache.findById = 20 sequential Redis GET. 200rps × 20 = 4,000 Redis RTT/s로 추정됩니다.

1차 K1의 "Redis vs MV equivalent" 결론이 이 버그를 가린 이유는 양쪽 모두 N+1에 갇혀 있어서 storage 차이가 드러나지 않았기 때문으로 판단됩니다. 50rps에서는 동시 요청 수가 적어 N+1이 증상으로 나타나지 않았던 것으로 보입니다.

이 발견의 메타-교훈:

"낮은 부하에서 두 옵션이 동일하게 보일 때는 '차이 없다'가 아니라 '둘 다 같은 숨은 병목에 갇혀 있다'가 정답일 수 있다. 부하를 올려 분기점을 강제로 만들어야 한다."

수정 — findById × N → findAllByIds 1회

RankingProductCache.findAllByIds 신규

  1. Redis multiGet(keys) 1회 호출 — pipelining으로 내부 처리
  2. 미스된 key만 모아 ProductRepository.findAllByIdIncludingDeleted(missIds) IN-query 1회
  3. DB 결과를 Redis에 재충전 (async write)

RankingApp enrich 4개 메서드 (enrich, enrichByOffset, enrichByCursor, enrichHourlyCursor)를 batch 호출로 변경했습니다. 공통 로직 중복 제거를 위해 BiFunction<Integer, RankingEntry, Long> 기반 enrichWith로 통합했습니다.

실측 결과 (NEW-T5)

측정 결과는 다음과 같이 나타났습니다.

Endpoint 부하 p95 Before p95 After 개선
weekly (MV, 10K rows) 200rps 4140ms 9.02ms 459×
daily (Redis) 200rps 3400ms 8.93ms 381×
weekly @ 400rps (collapse) 4.25ms
weekly @ 800rps (collapse) 3.73ms
weekly @ 1200rps (collapse) 3.23ms
  • 1200rps까지 collapse 없이 유지되었습니다
  • 부하 증가에도 p95가 오히려 개선되는 경향이 관측되었습니다(JIT warmup + connection 재사용 영향으로 추정)

장애 격리 레이어 추가 (85958ee)

MGET 배치 경로가 핵심 의존 지점이 되므로, 각 단계에 try/catch + 로그를 적용해 blast radius를 축소했습니다.

장애 기존 거동 격리 후 거동
Redis 마스터 장애 API 전체 500 DB 직접 fetch로 degrade (캐싱 없음)
특정 키 value corrupt (스키마 변경 후 역직렬화 실패) 페이지 전체 500 해당 키만 미스 처리, 나머지는 정상
DB 장애 API 500 빈 Map 반환 → 해당 entry들 DISCONTINUED 표시
Redis 부분 write 실패 요청 실패 DB 결과는 반환 + WARN 로그

기각된 대안:

  • Lettuce 파이프라인 명시 사용 — multiGet이 내부적으로 pipelining을 사용하므로 추가 복잡도 대비 이득이 미미할 것으로 예상
  • Resilience4j 서킷브레이커 즉시 도입 — 이번 범위는 "변경 격리"에 한정, 서킷브레이커는 전체 아키텍처 결정이 필요한 범위로 판단

예방된 장애

Ranking API 사용자 노출 timeout (Sev-1 직전)으로 이어질 수 있었던 시나리오:

  • 위험: 인기 상품 viral 등으로 200rps 도달 시 p95 4초가 관측되었습니다. CDN/gateway 5s timeout과 거의 일치해 사용자에게 5xx/timeout이 노출될 가능성이 있을 것으로 예상됩니다
  • 이전 K1 결론("Redis vs MV 동일")이 이 버그를 가렸던 것으로 보입니다. 운영 트래픽 증가 시에 발견했을 경우 incident 후 응급 hotfix로 이어질 수 있었던 시나리오로 판단됩니다
  • 수정 효과: 측정 결과 p95 4140ms → 9ms, 캐파시티 마진은 1200rps 이상 확보되었습니다

4. DailyScoreJob chunk size — 3차례 실측 반복

🔎 클릭하여 확인 — 500 → 1000 → 2000 → 5000, 두 번이나 이전 결론을 뒤집은 실측

chunk size는 배치의 가장 기본적인 튜닝 포인트입니다. 이 선택이 왜 한 번에 끝나지 않고 3번의 실측 반복이 필요했는지, 각 단계에서 이전 결론이 어떻게 뒤집혔는지 기록했습니다.

1차 (E1) — 500 → 1000

환경: 10K signals × 1day, Hibernate show-sql=true

chunk duration_ms commitCount
50 9009 201
100 4601 101
500 1839 21
1000 1370 11
2000 1327 6

측정 결과 500 → 1000 = -25%, 1000 → 2000 = -3% (diminishing returns) 로 나타났고, 당시에는 500 → 1000 상향만 반영하고 2000은 미적용했습니다.

2차 (NEW-T1) — 1000 → 2000 (1차 결론 반박)

1차 측정 환경에 대한 재검토 결과, show-sql=true + 10K 스케일 조건에서 Hibernate 로깅 노이즈가 작은 chunk의 CPU를 지배했을 가능성이 확인되었습니다. 이로 인해 작은 chunk가 과대평가되었을 것으로 예상되어 재측정이 필요하다고 판단했습니다.

재측정을 100K signals × show-sql=false, production scale로 수행했습니다.

chunk duration_ms rows/sec heap_mb
500 27,783 3,600 94
1000 14,705 6,800 74
2000 9,805 10,200 62
5000 6,900 14,500 141

측정 결과 1차 결론이 반박되었습니다:

  • 500 → 1000 = -47% (이전: -25%)
  • 1000 → 2000 = -33% (이전: "diminishing -3%" — 측정 환경 문제로 인한 오판으로 드러났습니다)
  • 2000 → 5000 = -30% — 여전히 성장 중인 것으로 관측됨

결정 변경: chunk 1000 → 2000. 5000은 heap 2배 증가로 일단 보류했습니다.

메타-교훈: dev 스케일(10K)에서 측정한 결과를 prod 스케일(100K+) 결정 근거로 사용하면 위험할 수 있습니다. show-sql, 작은 데이터, 짧은 트랜잭션 모두 노이즈를 만드는 요인으로 예상됩니다.

3차 (DailyScoreChunkMatrixTest) — 2000 → 5000 (2차 결론도 반박)

2차에서 "5000은 heap 2배 증가로 보류"한 판단도 실측 없는 보수적 선택이었습니다. "청크 2000 실측 없음" 항목을 정면으로 실측했습니다.

환경: TestContainers MySQL 8, 10K seed, @Value("${batch.daily-score.chunk-size:5000}")로 외부 주입 + @ParameterizedTest + 리플렉션으로 chunk 값 변경

chunk elapsed commits elapsed/chunk=500
500 2610ms 21 1.00x
2000 1360ms 6 0.52x
5000 697ms 3 0.27x

이전 판단 반박:

"MySQL max_allowed_packet 기본 64MB 대비 안전 영역이지만, rewrite된 단일 INSERT가 5000 values로 확장될 때 slow query 가능성. 4배만 높이는 보수적 선택"

→ 실측 결과 slow query 징후 없음(697ms). 보수적 선택이 과도했다. 실측 기반으로 과감히 상향.

최종 결정: chunk 5000 + batch.daily-score.chunk-size property로 외부 주입 (운영 중 이슈 시 yml만 수정).

총 가속도 (500 → 5000)

scale chunk 500 chunk 5000 배속
100K signals 27.8s ~3s (추정) ~9×
10K signals 2.6s 697ms 3.7×

예방된 장애

DailyScoreJob batch 윈도우 SLA 위반 위험:

  • 상품 100K → 200K로 성장 시 chunk=500은 ~55s, chunk=1000은 ~30s. 후속 weekly/monthly job이 같은 cron 윈도우(보통 5분 이내) 내 실행되어야 하는데, signals 폭증 시 윈도우 초과 가능
  • chunk=5000으로 100K = ~3s, 200K 성장해도 윈도우 마진 충분

기각된 대안

  • chunk 10000 (전체 한 방에): Spring Batch 재시작 메타 관리 이점 소실(중간 체크포인트 없음) + 데이터 증가 시 max_allowed_packet 헤드룸 소진 위험
  • 동적 chunk: 과공학. 필요 시 Part 8-5 스케줄에 따라 10만/100만 규모에서 수동 재측정

5. Quarterly 3개월 롤링 — Full Recalc vs Delta Accumulator

🔎 클릭하여 확인 — 두 방식을 모두 구현해서 비교한 뒤 A(Full Recalc) 선택

발제는 주/월만 요구했지만, "분기별 트렌드" 조회를 추가로 제공하기로 결정했습니다. 90일 윈도우를 매일 어떻게 갱신할지에서 2가지 선택지가 있었습니다.

고려한 2가지 Approach

Approach A — Full Recalculation (선택)

  • 매일 실행 시 mv_product_score_daily에서 90일 전체 SUMmv_product_rank_quarterly 에 적재
  • 기존 weekly/monthly와 동형 구조 (RankJobFactory.buildStep() 100% 재사용)

Approach B — Delta Accumulator

  • mv_product_score_quarterly_acc 어큐뮬레이터 테이블 유지
  • Cold start: 전체 90일 SUM으로 초기화
  • Warm update: 떨어지는 날(day-90) 빼고, 새 날(day-1) 더함 — 일일 처리량 2일치로 일정

둘 다 실제로 구현해서 비교

"어느 쪽이 나을까" 추정이 아니라, 두 구현을 모두 코딩해서 비교 실험했습니다.

테스트 Approach 결과
QuarterlyRankJobIntegrationTest (4건) A ALL PASS
QuarterlyDeltaRankJobIntegrationTest (4건) B ALL PASS
A cold start: 90일 × 3상품 A score = product_id × 10.0 × 90 ✅
B cold start B A와 동일 결과 ✅
B warm update: day-90 빠지고 day+1 추가 B 정확 ✅

측정 결과 두 방식 모두 정합성은 동일하게 나타났습니다. 차이는 운영 특성에서 드러났습니다.

관점 A: Full Recalc ✅ B: Delta Accumulator
일일 비용 90일 × N상품 GROUP BY 2일치 증분 (90일이든 365일이든 일정)
멱등성 완벽 — 재실행만으로 완전 복원 상태 누적 → 드리프트 리스크
장애 복구 재실행 full rebuild 필요 (acc 테이블 재생성)
구현 기존 RankJobFactory 재사용 (신규 파일 1개) 신규 acc 테이블 + 보정 Job + Tasklet (신규 파일 4개)

왜 A를 선택했는가

  1. 기존 weekly/monthly 패턴과 100% 동형 — 유지보수 비용이 최소화될 것으로 예상됩니다
  2. 멱등성 완벽 — 장애 시 재실행만 하면 복구 가능합니다
  3. 현재 규모에서 비용 무시 가능 — 측정 결과 상품 수 10만 미만에서 90일 SUM은 수 초 내 완료되는 것으로 나타났습니다
  4. B의 드리프트 리스크 — acc 테이블에서 day-90 계산이 1번이라도 틀어지면 이후 모든 날짜가 오염될 가능성이 있습니다. 복잡도 대비 이점이 미약하다고 판단됩니다

기각된 B 코드 정리

B 방식 채택 기각 후, 비교 실험에 썼던 B 관련 코드는 전부 삭제했습니다 (QuarterlyDeltaRankJobConfig.java, DeltaAccumulatorTasklet.java, AccumulatorAggregationSql.java, MvProductScoreQuarterlyAccModel.java, QuarterlyDeltaRankJobIntegrationTest.java, RankJobFactory.buildDeltaStep/buildAccumulatorStep/buildAccumulatorReader 메서드, mv_product_score_quarterly_acc DDL).

검증: ./gradlew :apps:commerce-batch:compileJava :apps:commerce-batch:compileTestJava 성공. QuarterlyRankJobIntegrationTest 4/4 PASS 유지되었습니다.

K6 부하 테스트 — period 폭은 응답 성능과 무관

측정 결과는 다음과 같이 나타났습니다.

Scenario VU avg p95 Threshold 결과
quarterly_baseline 50 9.93ms 13.47ms p95<100 ✓ PASS
weekly_comparison 50 10.04ms 13.78ms p95<100 ✓ PASS
monthly_comparison 50 9.70ms 12.95ms p95<100 ✓ PASS
quarterly_high_load 200 23.75ms 39.41ms p95<200 ✓ PASS

Total: 155,152 req · 0% error · 1,148 req/s. period 폭(7/30/90일)과 응답 성능은 무관한 것으로 관측되었습니다 — MV publish된 결과 테이블이 이미 집계된 상태라 윈도우 크기가 응답에 영향을 주지 않을 것으로 예상됩니다.

재검토 트리거

  • 상품 수 수백만 + period 폭 365일+ 규모 도달 시 A의 일일 비용이 새벽 배치 창을 초과할 가능성이 있어 B로 재평가가 필요할 것으로 예상됩니다

6. 랭킹 조회 — SQL JOIN vs App-level 2-step Aggregation

🔎 클릭하여 확인 — 4.94배 느린 App-level을 선택한 5가지 근거

"JOIN 한 번으로 가져오는 방식과 건수가 적으니 앱에서 조합하는 방식 중 어떤 것이 나은가"에 대한 검증이 필요하다고 판단되어, 두 방식을 벤치마크(QuarterlyJoinVsAppAggregationTest)로 비교했습니다.

고려한 2가지 방식

A: SQL JOIN (native)mv_product_rank_quarterly JOIN products JOIN publication 한 번에 조인

B: App-level 2-step (선택) — 1단계: rank MV에서 (rank_no, ref_product_id, score) 조회 → 2단계: productCache.findAllByIds(ids) 로 상품 정보 배치 fetch → 앱 레이어에서 조합

벤치마크 결과

조건: 100 products × 100 rank rows, top-100 조회, 50회 iteration

방식 avg 응답시간 상대 배수
A: SQL JOIN (native) 2.451 ms 1.00×
B: App 2-step + JPA cache ✅ 12.113 ms 4.94×

측정 결과 성능 단면만 보면 A가 유리한 것으로 나타났습니다. 그럼에도 B를 선택한 근거는 다음 5가지입니다.

1. 도메인 경계 — ranking이 products 테이블 구조를 직접 알지 않음

A는 mv_product_rank_quarterly.ref_product_id = products.id 조건을 랭킹 SQL 안에 직접 박는 구조입니다. products 테이블 스키마가 바뀌면(예: products.statusproducts.lifecycle_status 리네임) 랭킹 쿼리까지 수정해야 할 것으로 예상됩니다.

B는 productCache.findAllByIds(List<Long>)라는 ProductModel 도메인 메서드를 호출합니다. ProductModel 필드 변경은 Product 도메인 내부에서 흡수되어 ranking에 영향을 주지 않을 것으로 예상됩니다.

2. JPA 엔티티 재사용 vs native SQL

B는 ProductRepository.findAllByIdIncludingDeleted()라는 기존 JPA 메서드를 그대로 재사용합니다. 엔티티 매핑, VO 변환, soft delete 처리가 모두 기존 도메인 규칙을 따릅니다.

A는 native SQL로 JOIN을 짜는 구조이므로 다음과 같은 문제가 예상됩니다:

  • Entity 매핑 우회 — @Converter로 처리하던 VO가 native SQL에서는 수동 매핑이 필요합니다
  • ProductModel의 @Where(clause = "deleted_at IS NULL") 같은 soft delete 규칙이 적용되지 않습니다

3. 캐시 레이어 삽입 용이성

현재 B는 RankingProductCacheRedis multiGet + DB 폴백을 투명하게 처리하는 구조입니다 — Redis HIT 시 DB 접근 0회, Redis MISS 시 DB로 폴백 + Redis 재충전.

A(JOIN)는 DB를 항상 접근합니다. Redis 캐시를 추가하려면 JOIN 결과 전체를 캐시하거나 JOIN을 해체해서 2-step으로 재구성해야 해서, 사실상 B로 돌아가는 설계로 예상됩니다.

4. status 분기 로직 — SQL CASE vs 앱 분기

삭제된 상품은 API 응답에서 STATUS_DISCONTINUED로 표시합니다.

  • B: 앱 레이어에서 if (product.isDeleted()) STATUS_DISCONTINUED else product.status() 분기 — Java 조건문
  • A: SQL에 CASE WHEN products.deleted_at IS NOT NULL THEN 'DISCONTINUED' ELSE products.status END 삽입 — SQL 복잡도가 증가할 것으로 예상됩니다

공식 로직이 늘어날수록 A는 SQL 안에서, B는 Java에서 늘어나게 됩니다. Java 쪽이 단위 테스트·IDE 지원·리팩터링 용이성 모두 우위에 있다고 판단됩니다.

5. 성능 여유 — 12ms는 p95 임계의 12%

  • k6 측정 결과 quarterly p95 = 13.47ms로 나타났습니다 (threshold 100ms 대비 13.5% 사용)
  • 200VU 고부하에서도 p95 = 39.41ms 수준입니다 (threshold 200ms 대비 19.7% 사용)
  • "4.94배 느림"은 상대값일 뿐, 절대 기준에서는 여유가 충분하다고 판단됩니다

재검토 트리거

  • 상품 수 수백만 규모에서 Redis 캐시 히트율이 하락해 DB 배치 조회가 p95를 잡아먹기 시작할 경우 재검토가 필요할 것으로 예상됩니다

7. Spring Batch 구조 결정들

🔎 클릭하여 확인 — Reader 선택 / BaseEntity·복합 PK / Job 분리 / 재검증으로 뒤집힌 결정들

Phase 1 Reader — JpaPagingItemReader 선택

실측 (결정 C 검증): TestContainers MySQL, 1,000 상품 × 1일

방식 측정 대상 시간
JpaPagingItemReader (현재) Spring Batch Job 전체 826ms
JdbcCursorItemReader (시뮬레이션) 순수 SQL 실행 14ms

측정 결과 차이가 59배로 보이지만 이 비교는 공정하지 않은 것으로 판단됩니다. 공정하게 분해하면 다음과 같습니다:

JPA Job 826ms:
  Job 기동 + 메타데이터: ~200ms (일회성)
  SQL 실행 (2 pages): ~20ms
  EntityManager clear × 2: ~5ms
  Score 계산 × 1000: ~5ms
  UPSERT Writer × 1000: ~500ms
  트랜잭션 커밋 × 3: ~30ms

순수 Reader 비용만 비교:
  JPA (2 pages): ~20ms
  JDBC (1 cursor): ~14ms
  차이: 6ms (1000행 기준, 무시 가능)

결정: Phase 1은 JpaPagingItemReader를 유지합니다 — Entity 재사용 + 기본/-a 플랜과 차별화 + Reader 비용 차이 6ms로 미미하다고 판단됩니다.

Phase 2 Reader — JdbcCursorItemReader 필수

Phase 2의 Reader SQL은 GROUP BY + SUM + ORDER BY + LIMIT 조합입니다. JpaPagingItemReader를 사용할 경우:

  • page 0: ... LIMIT 0, 20전체 집계(GROUP BY) 수행 후 결과에서 0~19행 반환
  • page 1: ... LIMIT 20, 20전체 집계를 다시 수행 후 20~39행 반환
  • page 2: ... LIMIT 40, 20전체 집계를 또 수행 후 40~59행 반환

page마다 GROUP BY + filesort를 반복 실행하게 되어, 5 pages × 전체 집계 = 5배 비용으로 예상됩니다.

JdbcCursorItemReader는 한 번의 SQL 실행으로 커서를 열고 chunk size만큼씩 fetch하므로, GROUP BY + filesort가 1회만 실행됩니다. 네트워크 왕복도 최소화됩니다.

결정: Phase 2는 JdbcCursorItemReader가 필수입니다. 집계 쿼리의 페이징 반복 실행이라는 구조적 문제 때문입니다.

BaseEntity 상속 여부 — 상속 안 함 (A-1)

MV는 배치가 원자적으로 교체하는 조회 전용 구조입니다. BaseEntity의 soft delete(deletedAt), AbstractAggregateRoot(도메인 이벤트), auto-increment id 모두 불필요합니다. Writer가 JdbcBatchItemWriter(JPA 우회)이므로 @PrePersist/@PreUpdate도 의미가 없습니다. timestamp는 SQL NOW()로 처리했습니다.

복합 PK — @EmbeddedId (B-2)

JPA 표준 권장 방식입니다. 복합 키를 하나의 객체로 캡슐화하여 equals/hashCode/Serializable을 자연스럽게 보장하는 구조입니다. @IdClass 대안 대비 객체 지향 접근으로 판단됩니다.

EXPLAIN 예측 vs 실측

Phase 2 Reader SQL EXPLAIN을 사전에 예측한 뒤 실측과 비교했습니다.

EXPLAIN 항목 실측 예측 일치 여부
select_type SIMPLE SIMPLE
type range range
key PRIMARY idx_score_date ❌ (PK 사용)
rows 12 ~7,000 ❌ (옵티마이저 추정치)
Extra Using where; Using temporary; Using filesort Using where; Using temporary; Using filesort

실측 실행 시간: 8ms (1000 상품 × 7일 = 7000행)

예측과 다른 점:

  • MySQL 옵티마이저가 idx_score_date 대신 PK를 선택했습니다 — PK (product_db_id, score_date)의 score_date가 두 번째 컬럼이라 range 스캔이 가능했던 것으로 판단됩니다
  • rows=12는 통계 기반 추정치로 실제 스캔 행 수와 다른 것으로 보입니다

핵심 확인: Using temporary; Using filesort가 예측대로 발생했습니다. GROUP BY + ORDER BY(계산 컬럼)에서는 불가피할 것으로 예상됩니다.

스케일업 예측 (E6 실측 기반):

products rows min_ms
1K 7K 6
10K 70K 58
50K 350K 330

측정 결과 ~선형 증가(~0.9 µs/row)로 나타났습니다. 50K 상품에서 330ms로 새벽 배치에는 충분한 수준으로 판단됩니다. 50K+ 도달 시 커버링 인덱스 또는 incremental weekly MV upsert를 검토할 필요가 있을 것으로 예상됩니다.

K6 테스트 환경 오인 — HikariCP pool 한계를 DB 한계로 오인

이전 결론 (K2): "300rps collapse, 운영 DB 한계"

재검증 결과 (NEW-T3): test 프로파일 HikariCP=10 설정의 영향으로 추정됩니다

rps p95 (ms) dropped
100 5.36 0
200 4.72 0
400 4.24 0
600 3.86 0
800 3.84 0

측정 결과 800rps까지 flat으로 나타났습니다. 이전 "300rps collapse"는 test profile pool=10 설정 한계 때문인 것으로 확인되었습니다. storage 차원에서는 knee가 관측되지 않았습니다.

예방된 장애: test/prod 프로파일 차이로 인한 잘못된 캐파시티 plan으로 이어질 가능성이 있었습니다. 운영팀이 300rps 한계를 기준으로 read replica 추가 등의 비용 의사결정을 내릴 수 있었던 시나리오로 판단됩니다.

메타-교훈: test 프로파일 한계를 prod 한계로 일반화하면 위험할 수 있습니다. HikariCP 10 vs 40 한 줄 설정 차이가 "300rps collapse" vs "1200rps flat"으로 나타난 것이 그 증거로 보입니다.


8. 운영 안전장치 — Validation / HealthCheck / Cold-Start / Admin / 메트릭

🔎 클릭하여 확인 — "조용한 성공" 차단을 위한 5단계 방어

배치에서 가장 위험한 상태는 조용한 성공입니다 — 기능이 동작하지 않는데 Status COMPLETED · Prometheus 녹색 · 알람 0인 상태로, 장애 탐지가 크게 지연될 것으로 예상됩니다. 이번 PR에서 도입한 5단계 방어를 정리했습니다.

① Phase 1 완결성 검증 — ScoreCompletenessTasklet

Phase 2 시작 전, 해당 기간의 모든 날짜에 대해 mv_product_score_daily 행 수가 일정 수준 이상인지 확인합니다. 미달 시 WARN 로그 + 선택적 실패(batch.rank.validation.fail-on-incomplete 토글)를 적용합니다.

부분 성공 시나리오 방어:

  • Phase 1이 10만 상품 중 5만만 처리한 상태에서 Phase 2가 실행되는 경우를 가정
  • product_daily_signals를 productDbId 순으로 읽으므로 id가 작은 5만 상품만 해당일 score가 존재할 것으로 예상됩니다
  • SUM(score)에서 id가 작은 상품들이 1일분 score를 더 갖게 되어 체계적 편향(systematic bias)이 발생할 가능성이 있습니다
  • cron && 체이닝이 1차 방어선, Validation Tasklet이 2차 방어선 역할을 담당합니다

테스트: allDatesPresent_returnsFinished, partialDatesMissing_failModeOn_throws, partialDatesMissing_failModeOff_returnsFinished, noDataAtAll_failModeOn_throws, nullCount_treatedAsZero — 5/5 PASS로 검증되었습니다.

② MV 출력 검증 — MvOutputHealthCheckTasklet

Phase 2 빌드 이후 결과 MV의 이상을 탐지하는 Tasklet입니다:

  • 최소 row 수: min-rows: 1 미달 시 FAILED (empty 방지)
  • 전기 대비 변동폭: max-variance-pct: 0.5 초과 시 FAILED (50% 이상 급증/급감 감지)
  • fail-on-anomaly: false: 운영 초기엔 경고 로그만, 안정화 후 true로 전환할 것으로 예상됩니다
  • backfill 모드 skip: mode=backfill 파라미터 시 variance 검증 건너뜀 (이전 period 비교가 부적절하기 때문)

테스트 4종 PASS로 검증되었습니다.

③ Orphan 정리 — MvRankCleanupTasklet + 독립 Job

Publication 방식(S2) 구조상 published_version 미만의 old version 행은 cleanup 대상이 됩니다. batchLimit 단위 반복 DELETE로 락 시간을 축소했습니다. publication row 부재 시에는 no-op으로 처리됩니다.

Weekly/Monthly Job 끝단이 아니라 **독립 mvRankCleanupJob**을 둔 이유는 다음과 같습니다:

  • publish 성공과 cleanup 실패가 Job 전체 FAILED로 혼동되지 않도록 실패 격리
  • cleanup 독립 cron 가능 (publish와 다른 schedule)
  • cleanup 장애가 publish 지연을 유발하지 않도록 분리

테스트: published 미만 행만 삭제, publication 부재 시 no-op, batchLimit보다 많은 orphan도 반복으로 전량 제거 — 3/3 PASS로 검증되었습니다.

Cleanup vs Publish race test (MvRankCleanupPublishRaceTest): 2 스레드 교차 실행으로 published version 보존 + orphan 제거를 검증했습니다 (PASS).

④ Cold-Start Fallback

빈 period(예: 새 주간 시작 직후) 조회 시 응답 UX는 다음과 같이 동작합니다:

flag off (default): 빈 배열 반환
flag on:            직전 period로 fallback + isFallback: true 명시

응답 DTO:

  • isFallback: true 플래그
  • periodKey 노출 (실제 사용된 키)

HTTP 응답 헤더:

  • X-Ranking-Period-Key
  • X-Ranking-Is-Fallback
  • X-Ranking-Version

CDN 캐시 태깅 및 디버그에 용이할 것으로 예상됩니다.

테스트: RankingAppColdStartFallbackTest (flag on/off × empty/populated × primary/fallback)로 검증되었습니다.

⑤ Admin 가시성 + Prometheus 메트릭

Admin Endpoint: GET /api-admin/v1/rankings/mv/{periodType}

  • published_version / next_version / updated_at
  • total rows / published rows / orphan rows
  • version별 행수 분포
  • 페이징 (size ≤ 500 clamp) + OpenAPI 어노테이션
  • X-Loopers-Ldap: loopers.admin 인증 (기존 CouponAdmin 패턴)

Micrometer 메트릭:

batch.rank.bump              # next_version bump (Timer p50/95/99)
batch.rank.cas               # CAS publish 호출 (Timer)
batch.rank.cleanup           # orphan DELETE duration (Timer)
batch.rank.cleanup.deleted   # 누적 삭제 행 수 (Counter)

모든 메트릭에 period_type=WEEKLY|MONTHLY|QUARTERLY 태그를 붙였고, Prometheus 자동 노출되도록 구성했습니다.

관찰 포인트 3종 확립:

  1. Prometheus 메트릭 (/actuator/prometheus): Timer p50/p95/p99 + Counter
  2. Admin endpoint (/api-admin/v1/rankings/mv/{WEEKLY|MONTHLY}): 페이징 MV publication 상태
  3. HTTP 응답 헤더 (X-Ranking-*): 사용자 응답에 정확한 version/fallback 정보

9. 기타 보조 결정

🔎 클릭하여 확인 — score 공식 복제 철회 / 진행 중인 월 표시 / 삭제 상품 처리 / Step 재시작 영속성 등

score 공식 복제 철회 → supports/ranking 모듈 추출

초기 판단: "메서드 1개를 위한 모듈 추출은 과잉"으로 판단하여, streamer/batch 2곳에 복제를 허용하는 방향으로 시작했습니다.

번복 근거: 실제 공유 대상이 3개 클래스 × 3개 앱 모듈로 확인되었습니다.

위치 공유 클래스
commerce-streamer ScoreAggregator (실시간 Redis ZSET score)
commerce-batch RankScoreCalculator (Phase 1 일간 score)
commerce-api RankingKeyGenerator (weekly/monthly 조회용)

결정: supports/ranking 모듈을 신설해 ScoreCalculator, RankingWeightProperties, RankingKeyGenerator, RankingAutoConfiguration로 통합했습니다. 3앱이 단일 소스에 의존하는 형태입니다.

"반드시 같아야 하는 코드를 복제하는 것이 더 나쁘다"가 판단 번복의 기준이 되었습니다.

월간 랭킹 "진행 중인 월" 문제 — lastUpdatedAt 노출

문제: period_key = "202604"가 4월 5일(5일치 집계)과 4월 30일(30일치 확정) 모두 같은 키로 저장되는 구조입니다. API 소비자가 부분 집계인지 확정 집계인지 구분하기 어려울 것으로 예상되었습니다.

결정: API 응답에 lastUpdatedAt 필드를 추가했습니다 (스키마 변경 없이 MAX(updated_at) 조회). 소비자는 "lastUpdatedAt": "2026-04-10T03:00:15+09:00"을 보고 "4/10 새벽 기준 데이터이므로 부분 집계"임을 유추할 수 있을 것으로 예상됩니다.

대안 기각: 집계 범위(from~to)까지 노출하는 방식은 관리자 기능에서 필요할 때 추가하기로 했습니다. 외부 소비자에게는 "언제 최신인지"만 알려주면 충분할 것으로 판단했습니다.

삭제 상품 처리 — skip → DISCONTINUED

기존: 삭제 상품을 enrich()에서 skip하는 방식이라, ?size=20 요청에 17개만 반환되는 경우가 있었습니다.

문제: 커머스에서 인기 상품이 갑자기 사라지면 UX가 부자연스러울 것으로 예상됩니다. "품절/판매중지" 뱃지 표시가 더 자연스러운 방향으로 판단했습니다.

결정: 삭제 상품을 제거하지 않고 STATUS_DISCONTINUED 상태로 포함하는 방향으로 변경했습니다. toInfo()에서 snapshot null/deleted인 경우 STATUS_DISCONTINUED를 반환합니다. ?size=20 요청은 항상 20개가 반환되도록 수정했습니다.

enrich(), enrichByCursor(), enrichHourlyCursor()를 일괄 수정했습니다.

Step 재시작 영속성 (D1)

PublishingRankWriter의 version / rankCursor / written count를 StepExecutionContext에 영속화했습니다:

publishingWriter.version
publishingWriter.rankCursor
publishingWriter.written

beforeStep()에서 복원 — Spring Batch restart 시 rank_no 중복을 방지하는 구조입니다.

TOP_N 외부화 (D2)

RankJobFactory.topN@Value("${batch.rank.top-n:100}")로 분리했습니다. 재컴파일 없이 yml 조정이 가능해졌습니다.

Transaction isolation 명시 (C1)

PublishingRankWriterinsertTx / publishTxREAD_COMMITTED로 명시했습니다. ON DUPLICATE KEY UPDATE ... LAST_INSERT_ID 패턴이 RR 대신 RC에서도 정상 동작할 것으로 예상됩니다. bump 순서 직렬화는 PK lock으로 충분하다고 판단하여 gap lock 비용을 회피했습니다.

Backfill 운영 패턴 — 셸 루프

Job 내부 range 루프 대신 셸 루프를 채택했습니다:

# 최근 4주 재집계
for date in 20260406 20260330 20260323 20260316; do
  java -jar commerce-batch.jar \
    --spring.batch.job.name=weeklyRankJob \
    --date=$date --mode=backfill
done

근거: Job 파라미터 확장은 invasive하다고 판단되었고, OS-level은 이미 검증된 방식이며, 실행 격리 + Job별 로그·메트릭 분리 이점이 있습니다.

비용 예측: 주간 13s × 52 ≈ 13분 / 월간 310s × 12 ≈ 13분 / 분기 수 초 × N (새벽 배치 창 내)으로 예상됩니다.

코드 리뷰 대응 (최종 커밋 5건)

  • fix(api): invalid period 500 → 400 매핑 (CoreException(BAD_REQUEST))
  • fix(api): invalid date 500 → 400 매핑 (parseDateOrToday에서 DateTimeParseException을 감싸기, 글로벌 핸들러 추가 없이 국소화)
  • refactor(batch): RankJobFactory.CHUNK_SIZE = 20 상수 → @Value("${batch.rank.chunk-size:20}") 외부화 (나머지 batch.rank.* 설정과 일관성 맞춤)
  • test(batch): quarterly 멱등성 약화 단정 제거 → 2회 실행 후 행수·product·score·version 단조성 4단정으로 강화
  • test(batch): RankingKeyGenerator quarterly 4개 신규 메서드 단위 테스트 추가 (포맷·90일 윈도우 포함·윤년 경계·연 경계)

🏗️ Design Overview

변경 범위

  • 신규 모듈: supports/ranking — week9 ScoreCalculator / RankingKeyGenerator / RankingWeightProperties를 batch/streamer/api 3곳이 공유하도록 통합
  • 신규 테이블: mv_product_score_daily, mv_product_rank_{weekly,monthly,quarterly}, mv_product_rank_publication
  • 신규 Job: dailyScoreJob, weeklyRankJob, monthlyRankJob, quarterlyRankJob, mvRankCleanupJob
  • API 확장: GET /api/v1/rankings?period=, Admin /api-admin/v1/rankings/mv/{periodType}, 응답 헤더 X-Ranking-{Period-Key, Is-Fallback, Version}

주요 컴포넌트 책임

레이어 클래스 책임
Batch Job DailyScoreJobConfig 일별 score 집계
Batch Job RankJobFactory 주/월/분기 랭킹 배치 공통 구성
Batch Step PublishingRankWriter 랭킹 적재 및 version publish
Batch Step ScoreCompletenessTasklet / MvOutputHealthCheckTasklet / MvRankCleanupTasklet 입력 검증, 결과 검증, 구버전 정리
Domain RankingPeriod period 해석 및 기간 계산
Application RankingApp 랭킹 조회 흐름 조합
Infrastructure MvProductRankRepository / MvProductRankPublicationRepository MV 조회 및 publish
API RankingV1Controller 랭킹 조회 API

Batch Step 구성

weekly/monthly/quarterlyRankJob = validation → build → healthCheck
mvRankCleanupJob (독립)         = cleanup[weekly] → cleanup[monthly] → cleanup[quarterly]
dailyScoreJob                   = Phase 1 단독

설정 외부화 (application.yml)

batch:
  rank:
    top-n: 100
    chunk-size: 20
    validation:   { fail-on-incomplete: false }
    health-check: { min-rows: 1, max-variance-pct: 0.5, fail-on-anomaly: false }
    cleanup:      { batch-limit: 1000 }

ranking:
  cold-start-fallback:
    enabled: false  # default — on 시 empty period → 직전 period 폴백

구조 선택 근거

  • SOT = product_daily_signals (week9 생성): 일별 granularity를 유지하여 주/월/분기 파생이 모두 재생성 가능합니다. MV는 SOT가 아닙니다.
  • Batch vs Event 경계: 원자 스왑 필수 + 종결 지점 명확 + backfill 자연스러움이 요구되어 period rank는 Batch로 결정했습니다. 실시간 daily는 Event(Redis ZSET)로 유지합니다.
  • PagingItemReader 미채택: 본 프로젝트 Phase 2는 JdbcCursorItemReader를 사용합니다 — offset 밀림 이슈가 구조적으로 없고, GROUP BY 결과 TOP 100만 읽으므로 커서로 충분하다고 판단했습니다.
  • 단일 App만 쓰는 Facade 금지 규칙을 준수했습니다: quarterly 추가 시 Facade 없이 RankingAppperiod.toMvType() / period.periodKey(date, previous)를 캡슐화해 분기를 응집시켰습니다.

🔁 Flow Diagram

Main Flow

flowchart LR
    subgraph BATCH[commerce-batch]
        direction LR
        SIG[(product_daily_signals<br/>RAW SOT)] --> R1[JpaPagingItemReader]
        R1 --> P1[DailyScoreProcessor<br/>score = 0.1v + 0.2l + 0.7o]
        P1 --> W1[JdbcBatchItemWriter<br/>UPSERT]
        W1 --> D[(mv_product_score_daily)]
        D --> R2[JdbcCursorItemReader<br/>GROUP BY window]
        R2 --> W2[PublishingRankWriter<br/>bump+INSERT+CAS]
        W2 --> MV[(mv_product_rank_*<br/>weekly/monthly/quarterly)]
        W2 <--> PUB[(mv_product_rank_publication<br/>period_type, period_key,<br/>published_version)]
    end
    subgraph API[commerce-api]
        C[RankingV1Controller<br/>?period=] -->|daily| Z[(Redis ZSET)]
        C -->|weekly/monthly/quarterly| Q[RankingApp]
        Q --> MV
        Q -. JOIN published_version .-> PUB
        Q --> RC[RankingProductCache<br/>Redis MGET + DB fallback]
    end
Loading
  • Phase 1 (dailyScoreJob): RAW signals → 일별 score MV (가중치 적용, ON DUPLICATE KEY UPDATE)
  • Phase 2 (weekly/monthly/quarterlyRankJob): 일별 score MV → period별 rank MV (SUM 윈도우 집계)
  • 서빙: publication pointer로 published_version만 노출하므로, 측정 결과 reader가 DELETE/INSERT 중간상태를 관측할 가능성은 0으로 나타났습니다

실험 결과

K6 부하 테스트 (quarterly + 비교)

시나리오 VU avg p95 threshold 결과
quarterly baseline 50 9.93ms 13.47ms <100ms
weekly comparison 50 10.04ms 13.78ms <100ms
monthly comparison 50 9.70ms 12.95ms <100ms
quarterly high-load 200 23.75ms 39.41ms <200ms

Total: 155,152 req · 0% error · 1,148 req/s — 측정 결과 period 폭(7/30/90일)은 publish된 결과 테이블에서 응답 성능과 무관한 것으로 나타났습니다.

JOIN vs App-level Aggregation (구조 선택 검증)

방식 avg 배수 결정 기준 선택
SQL JOIN (native) 2.45ms 1.00× 측정상 빠르지만 ranking ↔ product 결합도 ↑, SQL 내 CASE로 STATUS_DISCONTINUED 혼입
App 2-step + JPA cache 12.11ms 4.94× 도메인 경계 분리, Redis MGET 캐시 레이어 삽입 용이, 12ms는 p95 threshold(100ms) 대비 여유

명확성 우선 — 현재 규모(10ms대)에서 4.94× 느림은 UX에 영향을 주지 않을 것으로 예상됩니다. 수백만 상품·캐시 히트율 하락 시 재검토가 필요할 것으로 보입니다.


Test Plan

# 전체 테스트 (batch + api)
./gradlew :apps:commerce-batch:test :apps:commerce-api:test

# k6 부하 (Docker 필요 없음, 로컬에 k6 설치 시)
k6 run k6/ranking-quarterly-load.js
  • 단위: RankingKeyGeneratorTest (quarterly 4 methods), RankingV1ControllerPeriodTest (period/date 400 매핑), RankingAppPeriodTest
  • 통합: QuarterlyRankJobIntegrationTest, WeeklyRankJobIntegrationTest, MonthlyRankJobIntegrationTest, FullPipelineE2ETest
  • 원자성: MvAtomicSwapSemanticsTest (6 scenarios), MvSwapStrategyBenchmarkTest, MvPublishingConcurrentWriterTest, MvRankCleanupPublishRaceTest, MvPublicationAtomicityTest
  • 부하: k6/ranking-quarterly-load.js (155K req, 0% error), k6/s2-reader-load.js
  • 벤치: QuarterlyJoinVsAppAggregationTest (correctness + perf)

✅ 발제 체크리스트

🧱 Spring Batch

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

🧩 Ranking API

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

YoHanKi added 6 commits April 17, 2026 01:02
ScoreCalculator / RankingKeyGenerator / RankingWeightProperties 3종을
commerce-streamer·commerce-batch·commerce-api 가 공유하도록 supports/ranking
신규 모듈로 분리. RankingAutoConfiguration 은 ranking.weight 존재 시에만
Bean 노출 (@ConditionalOnProperty).

- 복제 3곳 → 단일 소스: 가중치 공식 변경 시 supports/ranking 1 파일만 수정
- commerce-streamer 도메인의 기존 RankingKeyGenerator / WeightProperties /
  ScoreAggregator 제거, RankingApp / RankingRecalculationApp / Repository
  는 supports 의존으로 전환
Phase 1/Phase 2 MV 테이블(score_daily, rank_weekly/monthly/quarterly) +
Publication pointer 도메인 모델, JPA Repository, 스키마 정의.

- mv_product_score_daily — 일별 상품 점수(Phase 1 산출물)
- mv_product_rank_{weekly,monthly,quarterly} — period별 랭킹(Phase 2 산출물)
- mv_product_rank_publication — periodKey별 published_version 포인터 (S2 원자 발행)
- batch listener(JobListener/ChunkListener) 로깅·메트릭 공통화
Phase 1 — DailyScoreJob (Reader/Processor/Writer 풀세트):
- JpaPagingItemReader(product_daily_signals) → DailyScoreProcessor(ScoreCalculator
  가중치 적용) → JdbcBatchItemWriter(mv_product_score_daily UPSERT)

Phase 2 — Weekly/Monthly/QuarterlyRankJob (R/W + Tasklet):
- validation(ScoreCompletenessTasklet) → build(JdbcCursorItemReader GROUP BY
  + PublishingRankWriter) → healthCheck(MvOutputHealthCheckTasklet)
- RankJobFactory — period-agnostic Step 조립 공통화

S2 원자 발행 (PublishingRankWriter):
- next_version bump → 새 version INSERT(별도 tx) → afterStep CAS publish
- reader가 DELETE/INSERT 중간상태를 관측할 가능성 0
- ExecutionContext 기반 재시작 영속성

독립 Cleanup Job — orphan version DELETE with LIMIT 반복 (락 분산)
파라미터: --spring.batch.job.name=... --date=yyyyMMdd --mode=backfill
Ranking API:
- GET /api/v1/rankings?period=daily|weekly|monthly|quarterly
- RankingPeriod.fromString() — invalid → BAD_REQUEST, null → DAILY
- daily → Redis ZSET / weekly·monthly·quarterly → MV 조회(S2 published_version)
- Cold-Start Fallback (feature flag off) — empty period 시 이전 period 폴백
- X-Ranking-{Period-Key, Is-Fallback, Version} 응답 헤더
- RankingProductCache soft-cache(Stale-While-Revalidate) + MGET 배치 조회

Admin API:
- GET /api-admin/v1/rankings/mv/{periodType} — published_version / totalRow /
  orphanRow 페이징 조회 (size ≤ 500 clamp)

도메인·인프라:
- MvProductRank{Weekly,Monthly,Quarterly}Model + Publication 모델 + Repository
- MvProductRankRepositoryImpl — period-agnostic 조회 (version JOIN)
- Phase 1·Phase 2·E2E: DailyScoreJobIntegrationTest, Weekly/Monthly/
  QuarterlyRankJobIntegrationTest, FullPipelineE2ETest
- Tasklet: ScoreCompletenessTaskletTest, MvOutputHealthCheckTaskletTest,
  MvRankCleanupTaskletTest
- 원자성·동시성: MvAtomicSwapSemanticsTest, MvPublicationAtomicityTest,
  MvPublishingConcurrentWriterTest, MvRankCleanupPublishRaceTest,
  MvSwapStrategyBenchmarkTest
- API: RankingAppPeriodTest, RankingAppColdStartFallbackTest,
  RankingV1ControllerPeriodTest, QuarterlyJoinVsAppAggregationTest (benchmark)
- docs/operation/s2-deploy-rollback.md — Publication 포인터 전환 · 롤백 절차
- docs/operation/s2-migration.sql — Publication 초기 적재용 마이그레이션 SQL
- .gitignore: k6/local/ 로컬 성능 테스트 산출물 제외
Copilot AI review requested due to automatic review settings April 16, 2026 16:11
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

Warning

Rate limit exceeded

@YoHanKi has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 39 minutes and 4 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 39 minutes and 4 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dfb27c80-070d-43e4-81d4-de94446d47f3

📥 Commits

Reviewing files that changed from the base of the PR and between 894be5a and ec06f8c.

⛔ Files ignored due to path filters (1)
  • docs/operation/s2-deploy-rollback.md is excluded by !**/*.md and included by **
📒 Files selected for processing (108)
  • .gitignore
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankPublicationRow.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusApp.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankVersionCount.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriodType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankPublicationJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
  • apps/commerce-api/src/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyJoinVsAppAggregationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyRankViewRow.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppColdStartFallbackTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppPeriodTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerPeriodTest.java
  • apps/commerce-batch/build.gradle.kts
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MonthlyRankJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MvRankCleanupJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/QuarterlyRankJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/WeeklyRankJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/AggregatedScoreRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvRankCleanupTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/PublishingRankWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankAggregationSql.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankJobFactory.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/ScoreCompletenessTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/score/DailyScoreJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/DailyScoreProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/MvProductScoreDailyRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankId.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationId.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankQuarterlyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRow.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankPeriodType.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyId.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyModel.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRow.java
  • apps/commerce-batch/src/main/java/com/loopers/domain/signal/ProductDailySignalModel.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankPublicationRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankQuarterlyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyRepositoryImpl.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/main/resources/schema-batch.sql
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/FullPipelineE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/MonthlyRankJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/QuarterlyRankJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/WeeklyRankJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvRankCleanupTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/ScoreCompletenessTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreJobIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvAtomicSwapSemanticsTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankAtomicSwapTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankConcurrentWriterTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankPublicationRepositoryImplTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankRepositoryImplTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublicationAtomicityTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublishingConcurrentWriterTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvRankCleanupPublishRaceTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvSwapStrategyBenchmarkTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankScoreCalculatorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankingKeyGeneratorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/domain/score/MvProductScoreDailyRepositoryImplTest.java
  • apps/commerce-streamer/build.gradle.kts
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java
  • docs/operation/s2-migration.sql
  • settings.gradle.kts
  • supports/ranking/build.gradle.kts
  • supports/ranking/src/main/java/com/loopers/ranking/RankingAutoConfiguration.java
  • supports/ranking/src/main/java/com/loopers/ranking/RankingKeyGenerator.java
  • supports/ranking/src/main/java/com/loopers/ranking/RankingWeightProperties.java
  • supports/ranking/src/main/java/com/loopers/ranking/ScoreCalculator.java
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Spring Batch 기반으로 mv_product_score_daily(Phase 1) → mv_product_rank_{weekly,monthly,quarterly}(Phase 2) 파이프라인을 구성하고, API에서 GET /api/v1/rankings?period=로 일/주/월(+분기) 랭킹을 단일 엔드포인트로 제공하도록 확장한 PR입니다. 또한 S2(Version + Publication 포인터) 방식의 “원자 발행”과 운영/검증(health-check, cleanup, admin 조회, runbook)까지 포함합니다.

Changes:

  • supports/ranking 공유 모듈 신설(ScoreCalculator/KeyGenerator/WeightProperties + AutoConfiguration) 및 streamer/batch/api에서 공용 사용
  • batch에 daily score 적재 Job + period rank 생성 Job(주/월/분기) + orphan cleanup Job 추가, MV 스키마 및 publication 테이블 도입
  • API에 period 파라미터 및 MV 기반 조회(Version-aware JOIN) + cold-start fallback + 응답 헤더/DTO 확장, admin MV 상태 조회 API 추가

Reviewed changes

Copilot reviewed 108 out of 109 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
supports/ranking/src/main/java/com/loopers/ranking/ScoreCalculator.java Score 계산 로직을 공유 모듈로 이동/정리
supports/ranking/src/main/java/com/loopers/ranking/RankingWeightProperties.java ranking.weight 설정 바인딩/검증 로직
supports/ranking/src/main/java/com/loopers/ranking/RankingKeyGenerator.java daily/weekly/monthly/quarterly 키 및 기간 계산 유틸
supports/ranking/src/main/java/com/loopers/ranking/RankingAutoConfiguration.java ScoreCalculator Bean 구성
supports/ranking/build.gradle.kts ranking support 모듈 빌드 설정 추가
settings.gradle.kts :supports:ranking 모듈 include
docs/operation/s2-migration.sql S2 스키마 마이그레이션 SQL 추가
docs/operation/s2-deploy-rollback.md S2 배포/롤백 런북 추가
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java KeyGenerator import를 supports로 전환
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingKeyGenerator.java 기존 streamer 전용 KeyGenerator 제거
apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingRecalculationApp.java ScoreAggregator → ScoreCalculator로 교체
apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingApp.java ScoreAggregator → ScoreCalculator로 교체
apps/commerce-streamer/build.gradle.kts :supports:ranking 의존 추가
apps/commerce-batch/src/main/resources/schema-batch.sql MV 테이블 + publication 테이블 스키마 추가
apps/commerce-batch/src/main/resources/application.yml ranking.weight 및 batch.rank 설정 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyRepositoryImpl.java mv_product_score_daily UPSERT 리포지토리 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/score/MvProductScoreDailyJpaRepository.java score_daily JPA repo 추가(테스트/조회용)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankWeeklyJpaRepository.java weekly MV JPA repo 추가(삭제/카운트)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankMonthlyJpaRepository.java monthly MV JPA repo 추가(삭제/카운트)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankQuarterlyJpaRepository.java quarterly MV JPA repo 추가(삭제/카운트)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankRepositoryImpl.java MV write/read/delete용 JDBC/JPA 혼합 구현 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/MvProductRankPublicationRepositoryImpl.java publication bump/find/CAS JDBC 구현 추가
apps/commerce-batch/src/main/java/com/loopers/domain/signal/ProductDailySignalModel.java batch용 product_daily_signals 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRow.java score_daily 적재 Row record 추가
apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyRepository.java score_daily 리포지토리 인터페이스 추가
apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyModel.java score_daily 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/score/MvProductScoreDailyId.java score_daily EmbeddedId 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/RankPeriodType.java period 타입 → MV 테이블명 매핑 enum 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRow.java MV rank row record(+version) 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankRepository.java MV rank 리포지토리 인터페이스 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankId.java MV rank PK( period_key, version, rank_no ) EmbeddedId 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankWeeklyModel.java weekly MV 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankMonthlyModel.java monthly MV 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankQuarterlyModel.java quarterly MV 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationRepository.java publication 리포지토리 인터페이스 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationModel.java publication 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/rank/MvProductRankPublicationId.java publication EmbeddedId 추가
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java 로그 포맷 수정(SLF4J 파라미터 방식)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java 로그 포맷 수정(SLF4J 파라미터 방식)
apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/MvProductScoreDailyRow.java batch step용 score row record 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/score/step/DailyScoreProcessor.java signal → score row 변환 processor 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/score/DailyScoreJobConfig.java dailyScoreJob 구성(R/P/W) 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/ScoreCompletenessTasklet.java score 완결성 검증 tasklet 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTasklet.java MV row/variance health-check tasklet 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/MvRankCleanupTasklet.java orphan version cleanup tasklet 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/AggregatedScoreRow.java 기간 집계 row record 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankAggregationSql.java score_daily 기간 집계 SQL 상수 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/PublishingRankWriter.java version insert + CAS publish writer 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/step/RankJobFactory.java 주/월/분기 공통 step/job 빌더 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/WeeklyRankJobConfig.java weeklyRankJob 조립 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MonthlyRankJobConfig.java monthlyRankJob 조립 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/QuarterlyRankJobConfig.java quarterlyRankJob 조립 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/rank/MvRankCleanupJobConfig.java cleanup 전용 Job 추가
apps/commerce-batch/build.gradle.kts :supports:ranking 의존 추가
apps/commerce-batch/src/test/java/com/loopers/domain/score/MvProductScoreDailyRepositoryImplTest.java score_daily UPSERT 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankingKeyGeneratorTest.java period key/기간 계산 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/RankScoreCalculatorTest.java ScoreCalculator 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvSwapStrategyBenchmarkTest.java S1/S2 swap 벤치 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvRankCleanupPublishRaceTest.java cleanup↔publish race 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublishingConcurrentWriterTest.java S2 동시 writer 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvPublicationAtomicityTest.java publication 원자성/멱등 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankRepositoryImplTest.java MV rank repo 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankPublicationRepositoryImplTest.java publication repo 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankConcurrentWriterTest.java S1 동시 writer 위험 노출 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvProductRankAtomicSwapTest.java S1 atomic swap reader 스냅샷 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/domain/rank/MvAtomicSwapSemanticsTest.java S2 CAS 시맨틱 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreProcessorTest.java DailyScoreProcessor 단위 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/score/DailyScoreJobIntegrationTest.java dailyScoreJob 통합 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/ScoreCompletenessTaskletTest.java score 완결성 tasklet 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvRankCleanupTaskletTest.java cleanup tasklet 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/step/MvOutputHealthCheckTaskletTest.java health-check tasklet 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/MonthlyRankJobIntegrationTest.java monthlyRankJob 통합 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/batch/job/rank/QuarterlyRankJobIntegrationTest.java quarterlyRankJob 통합 테스트 추가
apps/commerce-api/src/main/resources/application.yml ranking.weight + cold-start-fallback 설정 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java period 파라미터 문서화 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java period 지원 + 헤더 노출 + date 파싱 에러 처리
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java 응답에 lastUpdatedAt/periodKey/isFallback 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java period 파싱/ MV type & key 캡슐화 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriodType.java MV period 타입 enum 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java MV 조회 포트 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankRepositoryImpl.java publication JOIN 기반 MV 조회 구현
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java period/헤더 메타 포함하도록 결과 확장
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingApp.java daily vs MV period 분기 + fallback + batch 캐시 조회 적용
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingProductCache.java Redis MGET 기반 배치 조회 + DB 폴백
apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusApp.java publication/version 상태 조회 App 추가
apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankStatusInfo.java MV 상태 응답 모델 추가
apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankVersionCount.java version별 row 카운트 모델 추가
apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankPublicationRow.java publication row mapper record 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Controller.java MV 상태 admin API 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/admin/ranking/MvRankAdminV1Dto.java admin 응답 DTO 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java API 쪽 weekly MV 엔티티 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java API 쪽 monthly MV 엔티티 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationModel.java API 쪽 publication 엔티티 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankPublicationId.java API 쪽 publication EmbeddedId 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankId.java API 쪽 MV rank EmbeddedId 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java weekly MV JPA repo 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java monthly MV JPA repo 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankPublicationJpaRepository.java publication JPA repo 추가
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerPeriodTest.java period/date 400 매핑 테스트 추가
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppTest.java batch product cache 조회 및 DISCONTINUED 동작 테스트로 변경
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppPeriodTest.java period 분기 단위 테스트 추가
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppIntegrationTest.java DISCONTINUED 동작에 맞게 기대값 수정
apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAppColdStartFallbackTest.java cold-start fallback 동작 테스트 추가
apps/commerce-api/src/test/java/com/loopers/application/ranking/QuarterlyRankViewRow.java 테스트용 view row record 추가
apps/commerce-api/build.gradle.kts :supports:ranking 의존 추가
.gitignore k6 local 디렉토리 ignore 추가

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants