[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현#403
[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현#403YoHanKi wants to merge 6 commits intoLoopers-dev-lab:YoHanKifrom
Conversation
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/ 로컬 성능 테스트 산출물 제외
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (108)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
📌 Summary
이번 주차 발제와 출발점
Round 10 발제는 Spring Batch + Materialized View로 주/월 집계 랭킹을 추가하고,
?period=파라미터로 일/주/월을 단일 API에서 제공하는 것입니다. 출발점은 week9까지 만들어둔 Redis ZSET 기반 daily 실시간 랭킹 하나이며, 주/월이라는 시간 윈도우를 배치로 생성해 이 위에 얹는 것이 이번 스코프입니다.구현 방식으로는 크게 3가지 경로를 검토했습니다:
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 덕분에 실제로는 원자적으로 동작하는 것으로 측정되었습니다(MvProductRankAtomicSwapTest2건 PASS로 실증). 다만 이 원자성은@Transactionalpropagation /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 — 왜 "일부러 느린" 2단계 구조를 택했는가 ⭐
측정 결과 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_signalsRAW 신호에서 주/월/분기 각각SUM(0.1*view + 0.2*like + 0.7*order) GROUP BY product_db_id ORDER BY score DESC LIMIT 100으로 집계DELETE FROM mv WHERE period_key=? → INSERT TOP 100단일 txweeklyRankJob,monthlyRankJob2개. Step은 Cleanup Tasklet + Chunk(R/W) 1개씩Plan-A — Shadow Redis ZSET + MV 아카이브
RedisZSetItemWriter(pipelined ZADD)로 shadow key에 적재RENAME shadow → final로 원자 교체 (week9 daily Redis 패턴의 주/월 확장)ZREVRANGE단일 경로로 통일key한 개로 분기RedisZSetItemWriter필요 · Redis 소실 시 MV에서 복구 스크립트 별도 필요Plan-B — Two-Phase ETL (선택)
dailyScoreJob: RAW 신호 → 일별 score 물리화 (mv_product_score_daily, UPSERT)weekly/monthly/quarterlyRankJob: 일별 score를SUM(score) GROUP BY product_db_id윈도우로 집계ScoreCalculator.calculate()1개 메서드에만 존재. Phase 2 SQL은SUM(score)로 고정 — 공식이 뭘로 바뀌든 Phase 2는 무변경Base 플랜을 기각한 이유 — week9 설계 의도의 단절과 SQL 표현 한계
week9에서
ScoreAggregator를 별도 도메인 서비스로 분리한 것은 "score 공식이0.1v+0.2l+0.7o에서 끝나지 않는다"는 전제였습니다. 운영 중 발생할 것으로 예상되는 공식 변경 시나리오를 놓고 Base 플랜이 어떻게 깨지는지 정리했습니다.0.1 → 0.15)log(1 + viewCount))SUM의 집계 순서 재구성 필요 (LOG(1 + SUM(view))가SUM(LOG(1 + view))과 다름 — 산식 의도대로 쓰려면 서브쿼리/HAVING)orderAmount > 10000 → +500)CASE WHEN SUM(order_amount) > 10000 THEN 500 ELSE 0 END추가score * exp(-λ * (today - date)))isPromoted ? ×1.2 : ×1.0)세 번째 문제도 있습니다. 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로 바꿨을 때 얻을 수 있는 이득의 상한으로 해석됩니다).
측정 결과 avg 차이 2ms, p95 차이 21ms로 나타났습니다. 이 차이가 작은 이유를 구조적으로 분해하면:
서빙 경로를 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로 다시 그리면 다음과 같습니다:
application.yml1곳 (streamer/batch/api가supports/ranking으로 공유)ScoreCalculator.calculate()1개 Java 메서드ScoreCalculator.calculate()1개 Java 메서드ScoreCalculator.calculate()1개 Java 메서드 + Processor에 날짜 주입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일 전체를 재계산해야 할 것으로 예상됩니다.
3. 감사/디버깅: "2026-04-10에 상품 42의 score가 몇이었는가?"를
mv_product_score_daily에서 즉시 조회할 수 있습니다. Base는 RAW counts를 읽어 공식을 수동 재적용해야 하는데, 공식이 도중에 바뀌었다면 당시 공식을 기억해서 재현해야 하므로 실무에서 실패하기 쉬운 작업으로 예상됩니다."느려 보이는데 왜?" — 500ms 수치의 정직한 분해
1,000 상품 × 7일 시드에서 측정한 결과입니다.
숫자만 보면 약 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 재사용·감사 기능을 얻습니다. "느림"이 결정 축이 될 수 없는 비용이라는 것이 선택의 전제였습니다.
인정한 트레이드오프
mv_product_score_daily1개 추가 — 디스크 비용은 10만 상품 × 365일 기준 수 GB 수준으로 예상됩니다. 스케일업 시 날짜 파티셔닝 + 보존 기간 정책이 필요할 것으로 보입니다product_daily_signals)가 살아있으면 언제든 재생성 가능합니다supports/ranking모듈로 추출해 단일 소스로 통합했습니다 (보조 결정 섹션 참고)보조 결정: Job 분리 vs 단일 Job Flow
Phase 1 → Phase 2 실행 순서 의존성을 어떻게 관리할지도 선택이 필요했습니다.
&&체이닝@ConditionalOnProperty로 Job 격리, 실무 표준rankingBatchJob+ Flow(dailyScore → weekly → monthly → quarterly)Phase 1 실패 시
&&exit code로 Phase 2가 미실행되므로, 부분 성공 편향(id 작은 상품만 score 보유)을 cron 레이어가 1차 차단하는 구조로 예상됩니다.추후 개선 여지
&&직렬화)mv_product_score_daily날짜 파티셔닝 + 보존 기간 정책2. MV 원자 갱신 전략
🔎 클릭하여 확인 — 5가지 갱신 방식 검토 → 단일 트랜잭션 Writer 채택 → 3가지 스왑 전략(기존 방식 / Publication + version / Stage table RENAME) 실측 → Publication + version 방식 프로덕션 전환
"reader가 반쪽짜리 랭킹(빈 페이지 / OLD·NEW 혼재 / 일부 누락)을 절대 보지 않는다"가 이번 주/월 MV의 최상위 요구사항입니다. 이 요구를 충족하는 과정에서 3단계의 선택이 있었습니다.
단계 1 — Writer 수준: 5가지 갱신 방식 중 AtomicMvRankWriter 채택
처음 구현한 Phase 2는
CleanupMvTasklet(DELETE) →buildRankStep(INSERT)으로 Step/트랜잭션이 분리되어 있어, DELETE 커밋 후 INSERT 시작 전까지 MV가 빈 상태로 API에 노출되는 문제가 관측되었습니다. 대안을 5가지 놓고 비교했습니다.UPSERT가 불가능한 이유를 짚어두면 다음과 같습니다.
이 구조에서 안전한 갱신은 전체 교체뿐이며, 이를 원자적으로 수행하는 것이 AtomicMvRankWriter의 역할입니다.
단계 2 — Stateful Processor 제거, Writer에서 rank 부여
AtomicMvRankWriter 채택 직후
RankAssignProcessor의AtomicInteger rankCounter가 Stateful이라 Step 재시작 시 rank가 1부터 다시 시작되는 문제가 발생할 것으로 예상되었습니다. 재시작 시나리오는 다음과 같이 예상됩니다.4가지 대안 검토:
allowStartIfComplete(true)→ 항상 전체 재실행ROW_NUMBER()변경 후:
단계 3 — 기존 방식(S1) vs Publication + version(S2) vs Stage table RENAME(S3) 실측 비교
AtomicMvRankWriter가 실제로 원자적인지 계약 테스트(
MvProductRankAtomicSwapTest)로 고정했습니다:즉 S1은 측정상 실제로는 원자적으로 동작했습니다. 그럼에도 3단계 하드닝을 검토한 이유는, 이 원자성이 3개의 암묵적 컨벤션에 의존하는 구조이기 때문입니다:
AtomicMvRankWriter가 모든 갱신을TransactionTemplate으로 감싸야 함deleteByPeriodKey의@Transactionalpropagation이 기본값(REQUIRED)이어야 함하나만 깨져도 조용히 반쪽 랭킹이 노출될 수 있다고 판단되어, 3단계 대안을 모두 프로토타입으로 구현해 실측했습니다.
실측 벤치마크 (
MvSwapStrategyBenchmarkTest, 10K rows, local MySQL 8)Stage table RENAME 방식은 구조적으로 기각되었습니다 —
mv_product_rank_weekly가 여러 periodKey를 단일 테이블에서 공유하는 구조라, RENAME 시 다른 periodKey 데이터까지 파괴될 것으로 예상되었습니다. 실측 전 설계 부적합으로 판정했습니다.동시 writer 락 경합 실측 (
MvProductRankConcurrentWriterTest)CannotAcquireLockException+ deadlock 탐지가 관측되었고, 한 쪽이 victim rollback 되었습니다시맨틱 재검증 (
MvAtomicSwapSemanticsTest, 6 tests PASS)최종 선택: Publication + version 방식 프로덕션 전환
선택 이유:
@Transactionalpropagation을 바꾸거나TransactionTemplate을 해체하면 즉시 회귀할 수 있다고 판단됩니다INNER JOIN publication ON published_version구조로 reader가 절대 중간 version을 보지 못하도록 스키마 차원에서 차단하는 형태입니다기존 방식 vs Publication 방식 트레이드오프:
published_version불변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 중간상태 관측 불가Writer 계약 복원 (Part 10 A1)
초기 AtomicMvRankWriter는
write()에서 메모리에만 쌓고afterStep()에서 일괄 INSERT하는 구조였고,writeCount메타데이터 거짓 문제(ItemWriter 계약 위반)가 있었습니다. RL-1 실측에서 afterStep 예외가 Spring Batch 5.x에서 조용히 삼켜져 Status COMPLETED lie까지 발생하는 것이 관측되었습니다(Prometheus 녹색 · 알람 0 · 사용자는 빈 랭킹을 보게 될 수 있는 상황). 두 단계로 해결했습니다:573361a):try/catch+setStatus(FAILED)+addFailureException+ExitStatus.FAILED3중 처리로 Status 거짓말을 차단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 흐름:인정한 트레이드오프
mv_product_rank_publication테이블 신설 + 모든 reader 쿼리에JOIN publication추가 — PK 인덱스 JOIN으로 1ms 미만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로 부하를 올려 측정한 결과:
enrich()루프 내productCache.findById(id)per-item측정 결과 진짜 병목은 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 →findAllByIds1회RankingProductCache.findAllByIds신규multiGet(keys)1회 호출 — pipelining으로 내부 처리ProductRepository.findAllByIdIncludingDeleted(missIds)IN-query 1회RankingAppenrich 4개 메서드 (enrich,enrichByOffset,enrichByCursor,enrichHourlyCursor)를 batch 호출로 변경했습니다. 공통 로직 중복 제거를 위해BiFunction<Integer, RankingEntry, Long>기반enrichWith로 통합했습니다.실측 결과 (NEW-T5)
측정 결과는 다음과 같이 나타났습니다.
장애 격리 레이어 추가 (
85958ee)MGET 배치 경로가 핵심 의존 지점이 되므로, 각 단계에 try/catch + 로그를 적용해 blast radius를 축소했습니다.
기각된 대안:
multiGet이 내부적으로 pipelining을 사용하므로 추가 복잡도 대비 이득이 미미할 것으로 예상예방된 장애
Ranking API 사용자 노출 timeout (Sev-1 직전)으로 이어질 수 있었던 시나리오:
4. DailyScoreJob chunk size — 3차례 실측 반복
🔎 클릭하여 확인 — 500 → 1000 → 2000 → 5000, 두 번이나 이전 결론을 뒤집은 실측
chunk size는 배치의 가장 기본적인 튜닝 포인트입니다. 이 선택이 왜 한 번에 끝나지 않고 3번의 실측 반복이 필요했는지, 각 단계에서 이전 결론이 어떻게 뒤집혔는지 기록했습니다.
1차 (E1) — 500 → 1000
환경: 10K signals × 1day, Hibernate
show-sql=true측정 결과 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로 수행했습니다.
측정 결과 1차 결론이 반박되었습니다:
결정 변경: 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 값 변경이전 판단 반박:
→ 실측 결과 slow query 징후 없음(697ms). 보수적 선택이 과도했다. 실측 기반으로 과감히 상향.
최종 결정: chunk 5000 +
batch.daily-score.chunk-sizeproperty로 외부 주입 (운영 중 이슈 시 yml만 수정).총 가속도 (500 → 5000)
예방된 장애
DailyScoreJob batch 윈도우 SLA 위반 위험:
기각된 대안
max_allowed_packet헤드룸 소진 위험5. Quarterly 3개월 롤링 — Full Recalc vs Delta Accumulator
🔎 클릭하여 확인 — 두 방식을 모두 구현해서 비교한 뒤 A(Full Recalc) 선택
발제는 주/월만 요구했지만, "분기별 트렌드" 조회를 추가로 제공하기로 결정했습니다. 90일 윈도우를 매일 어떻게 갱신할지에서 2가지 선택지가 있었습니다.
고려한 2가지 Approach
Approach A — Full Recalculation (선택)
mv_product_score_daily에서 90일 전체 SUM →mv_product_rank_quarterly에 적재RankJobFactory.buildStep()100% 재사용)Approach B — Delta Accumulator
mv_product_score_quarterly_acc어큐뮬레이터 테이블 유지둘 다 실제로 구현해서 비교
"어느 쪽이 나을까" 추정이 아니라, 두 구현을 모두 코딩해서 비교 실험했습니다.
QuarterlyRankJobIntegrationTest(4건)QuarterlyDeltaRankJobIntegrationTest(4건)측정 결과 두 방식 모두 정합성은 동일하게 나타났습니다. 차이는 운영 특성에서 드러났습니다.
RankJobFactory재사용 (신규 파일 1개)왜 A를 선택했는가
기각된 B 코드 정리
B 방식 채택 기각 후, 비교 실험에 썼던 B 관련 코드는 전부 삭제했습니다 (
QuarterlyDeltaRankJobConfig.java,DeltaAccumulatorTasklet.java,AccumulatorAggregationSql.java,MvProductScoreQuarterlyAccModel.java,QuarterlyDeltaRankJobIntegrationTest.java,RankJobFactory.buildDeltaStep/buildAccumulatorStep/buildAccumulatorReader메서드,mv_product_score_quarterly_accDDL).검증:
./gradlew :apps:commerce-batch:compileJava :apps:commerce-batch:compileTestJava성공.QuarterlyRankJobIntegrationTest4/4 PASS 유지되었습니다.K6 부하 테스트 — period 폭은 응답 성능과 무관
측정 결과는 다음과 같이 나타났습니다.
Total: 155,152 req · 0% error · 1,148 req/s. period 폭(7/30/90일)과 응답 성능은 무관한 것으로 관측되었습니다 — MV publish된 결과 테이블이 이미 집계된 상태라 윈도우 크기가 응답에 영향을 주지 않을 것으로 예상됩니다.
재검토 트리거
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
측정 결과 성능 단면만 보면 A가 유리한 것으로 나타났습니다. 그럼에도 B를 선택한 근거는 다음 5가지입니다.
1. 도메인 경계 — ranking이 products 테이블 구조를 직접 알지 않음
A는
mv_product_rank_quarterly.ref_product_id = products.id조건을 랭킹 SQL 안에 직접 박는 구조입니다.products테이블 스키마가 바뀌면(예:products.status→products.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을 짜는 구조이므로 다음과 같은 문제가 예상됩니다:
@Converter로 처리하던 VO가 native SQL에서는 수동 매핑이 필요합니다@Where(clause = "deleted_at IS NULL")같은 soft delete 규칙이 적용되지 않습니다3. 캐시 레이어 삽입 용이성
현재 B는
RankingProductCache가 Redis 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로 표시합니다.if (product.isDeleted()) STATUS_DISCONTINUED else product.status()분기 — Java 조건문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%
재검토 트리거
7. Spring Batch 구조 결정들
🔎 클릭하여 확인 — Reader 선택 / BaseEntity·복합 PK / Job 분리 / 재검증으로 뒤집힌 결정들
Phase 1 Reader — JpaPagingItemReader 선택
실측 (결정 C 검증): TestContainers MySQL, 1,000 상품 × 1일
측정 결과 차이가 59배로 보이지만 이 비교는 공정하지 않은 것으로 판단됩니다. 공정하게 분해하면 다음과 같습니다:
결정: Phase 1은 JpaPagingItemReader를 유지합니다 — Entity 재사용 + 기본/-a 플랜과 차별화 + Reader 비용 차이 6ms로 미미하다고 판단됩니다.
Phase 2 Reader — JdbcCursorItemReader 필수
Phase 2의 Reader SQL은 GROUP BY + SUM + ORDER BY + LIMIT 조합입니다. JpaPagingItemReader를 사용할 경우:
... LIMIT 0, 20→ 전체 집계(GROUP BY) 수행 후 결과에서 0~19행 반환... LIMIT 20, 20→ 전체 집계를 다시 수행 후 20~39행 반환... 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는 SQLNOW()로 처리했습니다.복합 PK — @EmbeddedId (B-2)
JPA 표준 권장 방식입니다. 복합 키를 하나의 객체로 캡슐화하여 equals/hashCode/Serializable을 자연스럽게 보장하는 구조입니다.
@IdClass대안 대비 객체 지향 접근으로 판단됩니다.EXPLAIN 예측 vs 실측
Phase 2 Reader SQL EXPLAIN을 사전에 예측한 뒤 실측과 비교했습니다.
실측 실행 시간: 8ms (1000 상품 × 7일 = 7000행)
예측과 다른 점:
idx_score_date대신 PK를 선택했습니다 — PK(product_db_id, score_date)의 score_date가 두 번째 컬럼이라 range 스캔이 가능했던 것으로 판단됩니다핵심 확인:
Using temporary; Using filesort가 예측대로 발생했습니다. GROUP BY + ORDER BY(계산 컬럼)에서는 불가피할 것으로 예상됩니다.스케일업 예측 (E6 실측 기반):
측정 결과 ~선형 증가(~0.9 µs/row)로 나타났습니다. 50K 상품에서 330ms로 새벽 배치에는 충분한 수준으로 판단됩니다. 50K+ 도달 시 커버링 인덱스 또는 incremental weekly MV upsert를 검토할 필요가 있을 것으로 예상됩니다.
K6 테스트 환경 오인 — HikariCP pool 한계를 DB 한계로 오인
이전 결론 (K2): "300rps collapse, 운영 DB 한계"
재검증 결과 (NEW-T3): test 프로파일 HikariCP=10 설정의 영향으로 추정됩니다
측정 결과 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 완결성 검증 —
ScoreCompletenessTaskletPhase 2 시작 전, 해당 기간의 모든 날짜에 대해
mv_product_score_daily행 수가 일정 수준 이상인지 확인합니다. 미달 시 WARN 로그 + 선택적 실패(batch.rank.validation.fail-on-incomplete토글)를 적용합니다.부분 성공 시나리오 방어:
product_daily_signals를 productDbId 순으로 읽으므로 id가 작은 5만 상품만 해당일 score가 존재할 것으로 예상됩니다&&체이닝이 1차 방어선, Validation Tasklet이 2차 방어선 역할을 담당합니다테스트:
allDatesPresent_returnsFinished,partialDatesMissing_failModeOn_throws,partialDatesMissing_failModeOff_returnsFinished,noDataAtAll_failModeOn_throws,nullCount_treatedAsZero— 5/5 PASS로 검증되었습니다.② MV 출력 검증 —
MvOutputHealthCheckTaskletPhase 2 빌드 이후 결과 MV의 이상을 탐지하는 Tasklet입니다:
min-rows: 1미달 시 FAILED (empty 방지)max-variance-pct: 0.5초과 시 FAILED (50% 이상 급증/급감 감지)fail-on-anomaly: false: 운영 초기엔 경고 로그만, 안정화 후 true로 전환할 것으로 예상됩니다mode=backfill파라미터 시 variance 검증 건너뜀 (이전 period 비교가 부적절하기 때문)테스트 4종 PASS로 검증되었습니다.
③ Orphan 정리 —
MvRankCleanupTasklet+ 독립 JobPublication 방식(S2) 구조상
published_version미만의 old version 행은 cleanup 대상이 됩니다.batchLimit단위 반복 DELETE로 락 시간을 축소했습니다. publication row 부재 시에는 no-op으로 처리됩니다.Weekly/Monthly Job 끝단이 아니라 **독립
mvRankCleanupJob**을 둔 이유는 다음과 같습니다:테스트:
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는 다음과 같이 동작합니다:
응답 DTO:
isFallback: true플래그periodKey노출 (실제 사용된 키)HTTP 응답 헤더:
X-Ranking-Period-KeyX-Ranking-Is-FallbackX-Ranking-VersionCDN 캐시 태깅 및 디버그에 용이할 것으로 예상됩니다.
테스트:
RankingAppColdStartFallbackTest(flag on/off × empty/populated × primary/fallback)로 검증되었습니다.⑤ Admin 가시성 + Prometheus 메트릭
Admin Endpoint:
GET /api-admin/v1/rankings/mv/{periodType}X-Loopers-Ldap: loopers.admin인증 (기존 CouponAdmin 패턴)Micrometer 메트릭:
모든 메트릭에
period_type=WEEKLY|MONTHLY|QUARTERLY태그를 붙였고, Prometheus 자동 노출되도록 구성했습니다.관찰 포인트 3종 확립:
/actuator/prometheus): Timer p50/p95/p99 + Counter/api-admin/v1/rankings/mv/{WEEKLY|MONTHLY}): 페이징 MV publication 상태X-Ranking-*): 사용자 응답에 정확한 version/fallback 정보9. 기타 보조 결정
🔎 클릭하여 확인 — score 공식 복제 철회 / 진행 중인 월 표시 / 삭제 상품 처리 / Step 재시작 영속성 등
score 공식 복제 철회 →
supports/ranking모듈 추출초기 판단: "메서드 1개를 위한 모듈 추출은 과잉"으로 판단하여, streamer/batch 2곳에 복제를 허용하는 방향으로 시작했습니다.
번복 근거: 실제 공유 대상이 3개 클래스 × 3개 앱 모듈로 확인되었습니다.
ScoreAggregator(실시간 Redis ZSET score)RankScoreCalculator(Phase 1 일간 score)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에 영속화했습니다:beforeStep()에서 복원 — Spring Batch restart 시 rank_no 중복을 방지하는 구조입니다.TOP_N 외부화 (D2)
RankJobFactory.topN을@Value("${batch.rank.top-n:100}")로 분리했습니다. 재컴파일 없이 yml 조정이 가능해졌습니다.Transaction isolation 명시 (C1)
PublishingRankWriter의insertTx/publishTx를READ_COMMITTED로 명시했습니다.ON DUPLICATE KEY UPDATE ... LAST_INSERT_ID패턴이 RR 대신 RC에서도 정상 동작할 것으로 예상됩니다. bump 순서 직렬화는 PK lock으로 충분하다고 판단하여 gap lock 비용을 회피했습니다.Backfill 운영 패턴 — 셸 루프
Job 내부 range 루프 대신 셸 루프를 채택했습니다:
근거: Job 파라미터 확장은 invasive하다고 판단되었고, OS-level은 이미 검증된 방식이며, 실행 격리 + Job별 로그·메트릭 분리 이점이 있습니다.
비용 예측: 주간 1
3s × 52 ≈ 13분 / 월간 310s × 12 ≈ 13분 / 분기 수 초 × N (새벽 배치 창 내)으로 예상됩니다.코드 리뷰 대응 (최종 커밋 5건)
fix(api): invalidperiod500 → 400 매핑 (CoreException(BAD_REQUEST))fix(api): invaliddate500 → 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):RankingKeyGeneratorquarterly 4개 신규 메서드 단위 테스트 추가 (포맷·90일 윈도우 포함·윤년 경계·연 경계)🏗️ Design Overview
변경 범위
supports/ranking— week9ScoreCalculator/RankingKeyGenerator/RankingWeightProperties를 batch/streamer/api 3곳이 공유하도록 통합mv_product_score_daily,mv_product_rank_{weekly,monthly,quarterly},mv_product_rank_publicationdailyScoreJob,weeklyRankJob,monthlyRankJob,quarterlyRankJob,mvRankCleanupJobGET /api/v1/rankings?period=, Admin/api-admin/v1/rankings/mv/{periodType}, 응답 헤더X-Ranking-{Period-Key, Is-Fallback, Version}주요 컴포넌트 책임
DailyScoreJobConfigRankJobFactoryPublishingRankWriterScoreCompletenessTasklet/MvOutputHealthCheckTasklet/MvRankCleanupTaskletRankingPeriodRankingAppMvProductRankRepository/MvProductRankPublicationRepositoryRankingV1ControllerBatch Step 구성
설정 외부화 (
application.yml)구조 선택 근거
product_daily_signals(week9 생성): 일별 granularity를 유지하여 주/월/분기 파생이 모두 재생성 가능합니다. MV는 SOT가 아닙니다.JdbcCursorItemReader를 사용합니다 — offset 밀림 이슈가 구조적으로 없고, GROUP BY 결과 TOP 100만 읽으므로 커서로 충분하다고 판단했습니다.RankingApp에period.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] enddailyScoreJob): RAW signals → 일별 score MV (가중치 적용,ON DUPLICATE KEY UPDATE)weekly/monthly/quarterlyRankJob): 일별 score MV → period별 rank MV (SUM윈도우 집계)published_version만 노출하므로, 측정 결과 reader가 DELETE/INSERT 중간상태를 관측할 가능성은 0으로 나타났습니다실험 결과
K6 부하 테스트 (quarterly + 비교)
Total: 155,152 req · 0% error · 1,148 req/s — 측정 결과 period 폭(7/30/90일)은 publish된 결과 테이블에서 응답 성능과 무관한 것으로 나타났습니다.
JOIN vs App-level Aggregation (구조 선택 검증)
STATUS_DISCONTINUED혼입명확성 우선 — 현재 규모(10ms대)에서 4.94× 느림은 UX에 영향을 주지 않을 것으로 예상됩니다. 수백만 상품·캐시 히트율 하락 시 재검토가 필요할 것으로 보입니다.
Test Plan
RankingKeyGeneratorTest(quarterly 4 methods),RankingV1ControllerPeriodTest(period/date 400 매핑),RankingAppPeriodTestQuarterlyRankJobIntegrationTest,WeeklyRankJobIntegrationTest,MonthlyRankJobIntegrationTest,FullPipelineE2ETestMvAtomicSwapSemanticsTest(6 scenarios),MvSwapStrategyBenchmarkTest,MvPublishingConcurrentWriterTest,MvRankCleanupPublishRaceTest,MvPublicationAtomicityTestk6/ranking-quarterly-load.js(155K req, 0% error),k6/s2-reader-load.jsQuarterlyJoinVsAppAggregationTest(correctness + perf)✅ 발제 체크리스트
🧱 Spring Batch
🧩 Ranking API