Skip to content

[Volume-10] 주간/월간 랭킹 배치 - #456

Open
kimsungho93 wants to merge 18 commits into
loopers-labs:kimsungho93from
kimsungho93:volume-10
Open

kimsungho93 wants to merge 18 commits into
loopers-labs:kimsungho93from
kimsungho93:volume-10

Conversation

@kimsungho93

Copy link
Copy Markdown
Contributor

아래 내용을 그대로 PR에 붙여 넣으면 됩니다. 구현 완료와 향후 과제를 명확히 구분했습니다.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약:
    • 기존 product_metrics는 상품별 누적값만 저장해 특정 주간·월간의 지표를 다시 계산할 수 없었다.
    • 기존 Ranking API는 Redis 기반 DAILY 랭킹만 제공했다.
    • 대용량 집계를 Chunk로 처리하면서 계산 중인 결과가 사용자에게 노출되지 않아야 했다.
  • 문제(또는 리스크):
    • 상품 수가 증가하면 기간 집계와 페이징 쿼리가 반복되어 실행시간이 급격히 늘어날 수 있다.
    • Batch가 대부분 처리된 뒤 실패했을 때 처음부터 다시 실행하면 복구 비용이 커진다.
    • 계산 후보와 API 공개 결과를 같은 테이블에서 관리하면 저장소의 책임이 섞이고 MV가 전체 상품 수만큼 커질 수 있다.
    • 늦게 도착한 이벤트, 실패 재시작, 완료 결과 재집계를 구분할 기준이 필요하다.
  • 성공 기준(완료 정의):
    • product_metrics를 상품별 일간 집계 SOT로 전환한다.
    • WEEKLY·MONTHLY 기간을 처리하는 Chunk 기반 Ranking Job을 구현한다.
    • 모든 계산과 검증이 끝난 Top 100만 원자적으로 공개한다.
    • 실패 시 마지막 성공 Chunk 이후부터 안전하게 재시작한다.
    • 기존 DAILY 동작을 유지하면서 Ranking API를 DAILY·WEEKLY·MONTHLY로 확장한다.
    • 운영 규모를 가정한 Dataset으로 실행시간과 결과 정합성을 검증한다.

선택지와 결정

  • 고려한 대안:
    • 원천 데이터
      • A: 기존 상품별 누적 지표를 그대로 사용한다.
      • B: (metric_date, product_id) 단위의 일간 지표를 저장하고 기간별로 다시 집계한다.
    • 결과 공개
      • A: Chunk마다 주간·월간 MV를 직접 갱신한다.
      • B: 실행별 Snapshot과 후보를 만든 뒤 검증된 Top 100만 공개한다.
    • Reader
      • A: 기본 Paging 또는 OFFSET 방식으로 기간 데이터를 읽는다.
      • B: product_id Keyset과 (product_id, metric_date) 인덱스를 함께 사용한다.
    • 실행 상태
      • A: ResourcelessJobRepository를 사용하고 실패 시 전체를 다시 집계한다.
      • B: JDBC JobRepository에 실행 이력과 Chunk 체크포인트를 저장한다.
    • Cleanup
      • A: Ranking Snapshot Job의 마지막 Step으로 실행한다.
      • B: 공개 성공 여부와 독립적으로 재실행할 수 있는 별도 Job으로 분리한다.
  • 최종 결정:
    • product_metrics를 이벤트 발생일 기준의 일간 집계 SOT로 변경했다.
    • period, aggregationEndDate, revision을 식별 파라미터로 사용하는 productRankingSnapshotJob을 구성했다.
    • Job을 다음 세 단계로 나눴다.
      • Prepare: 미완성 Snapshot과 실행에 적용할 점수 정책을 준비한다.
      • Calculate: 상품별 기간 합계를 Keyset으로 읽고 점수를 Chunk 단위로 저장한다.
      • Publish: 원천·후보 건수를 검증하고 Top 100 저장과 completed_at 갱신을 하나의 트랜잭션으로 처리한다.
    • 전체 점수 후보는 product_rank_candidates, 공개 Top 100은 주간·월간 MV에 분리했다.
    • API는 period가 없으면 기존 DAILY를 사용하고, WEEKLY·MONTHLY는 요청 날짜가 속한 기간의 최신 완료 Snapshot을 조회한다.
    • 실패 재시작은 동일한 JobParameters를 사용하고, 완료 결과를 다시 집계할 때만 revision을 증가시킨다.
    • Cleanup은 별도의 productRankingCleanupJob으로 구현했다.
    • Jenkins에는 Ranking Batch CI와 매일 02:00 KST에 WEEKLY·MONTHLY를 순차 실행하는 운영 Pipeline을 추가했다.
  • 트레이드오프:
    • Snapshot Header와 후보 테이블이 추가되어 스키마와 Cleanup 운영 복잡도가 증가했다.
    • 후보와 공개 MV를 분리한 구조는 이전 구조보다 약 3.6초 느렸지만, MV가 Top 100이라는 계약을 스스로 보장하고 계산·공개 책임이 명확해졌다.
    • Chunk Size 5,000이 1,000보다 약 3초 빨랐지만, 장애 시 최대 재처리 범위가 5배 커지므로 기본값은 1,000으로 결정했다.
    • Keyset은 임의 페이지 이동에는 적합하지 않지만, 고유 정렬 키를 따라 전체 데이터를 순차 처리하고 재시작하는 Batch Reader에는 적합했다.
    • 늦은 이벤트까지 동일한 DB 시점으로 고정하지 않는다. 상품 랭킹은 최종 일관성을 허용하고 필요한 경우 새 revision으로 수동 재집계한다.
    • JDBC JobRepository와 FK가 추가 비용을 만들 수 있지만 현재는 정합성과 재시작 안정성을 우선했다.
  • 추후 개선 여지(있다면):
    • Cleanup 대상 Snapshot 자동 탐색과 독립 실행 스케줄을 추가한다.
    • 운영 배포 전 JPA 자동 DDL 대신 버전 관리되는 DDL 적용 절차를 마련한다.
    • 실제 운영 DB의 p95·p99 실행시간과 부하를 기준으로 Job 간 실행 간격을 조정한다.
    • Writer UPSERT가 SLA 병목으로 확인되면 Writer 개선, Chunk Size 5,000 재측정, Local Partitioning 순서로 검토한다.
    • 늦은 이벤트의 수동 재집계가 반복될 때만 High-watermark, 입력 Snapshot 또는 CDC를 검토한다.
    • 강제 종료로 STARTED 상태가 남는 상황의 자동 복구는 운영 환경이 정해진 뒤 별도 Runbook으로 검증한다.

🤔 고민한 점 / 막혔던 부분

  • 미완성 결과를 숨기기 위해 범용 Staging 테이블이 필요한지 고민했다. 계산 상태를 표현하는 product_rank_candidates와 Snapshot의 completed_at을 사용해 계산 결과와 공개 결과의 책임을 분리했다.
  • Spring Batch 기본 MySQL Paging Provider에 GROUP BY가 포함되면서 다음 페이지마다 남은 상품 전체를 반복 집계해 약 17분이 걸렸다.
  • Keyset 조건만 추가하거나 인덱스만 추가해서는 성능이 개선되지 않았다. 집계 전에 Keyset 조건을 적용하는 전용 Query Provider와 (product_id, metric_date) 인덱스를 함께 적용해야 효과가 있었다.
  • 모든 후보를 MV에 저장할지 Top 100만 저장할지 고민했다. 재시작과 검증에 필요한 후보는 별도 테이블에 저장하고, API가 읽는 MV에는 Top 100만 저장했다.
  • Chunk Size 1,000 / 5,000 / 10,000을 반복 측정했다. 처리시간 차이가 크지 않아 처리량보다 재시작 안정성을 우선하여 1,000을 선택했다.
  • ResourcelessJobRepository의 단순함과 JDBC JobRepository의 재시작 안정성을 비교했다. 향후 데이터 증가와 실행 후반 장애를 고려해 JDBC 방식을 유지했다.
  • 늦은 이벤트 처리를 위해 CDC나 입력 Snapshot 도입도 검토했지만, 현재 랭킹 데이터의 성격과 과제 범위에서는 복잡도가 더 크다고 판단했다.

🙋 기타

  • product_metrics 2,260,000행, 활성 상품 400,000개, 월간 31일 Dataset으로 검증했다.
  • Keyset 최적화 결과:
    • 전체 Job: 1,023.0712초 → 72.7506초
    • Reader SQL 누적 시간: 약 960.99초 → 약 11.81초
    • 누락 상품·추가 상품·점수 차이·순위 차이: 모두 0건
  • 후보와 공개 MV를 분리한 최종 구조는 Chunk Size 1,000 기준 중앙값 61.2212초, 공개 MV 100행, 롤백 0건을 확인했다.
  • 관련 Ranking·Streamer·Batch·API 테스트와 패키징 빌드를 통과했다.
  • Cleanup Job은 구현했지만 삭제 가능한 Snapshot 자동 탐색과 정기 스케줄 연결은 이번 PR 범위에서 제외했다.
  • Jenkins Pipeline 구현과 로컬 검증은 완료했으며, 실제 Jenkins Agent와 Credentials 연결은 배포 환경에서 설정해야 한다.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 121 files, which is 21 over the limit of 100.

To get a review, narrow the scope:
• coderabbit review --committed # exclude uncommitted changes
• coderabbit review --dir # limit to a subdirectory
• coderabbit review --base # compare against a closer base

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b64e203f-1308-4889-a72b-eea97c3661b9

📥 Commits

Reviewing files that changed from the base of the PR and between 1b881af and b0fea55.

📒 Files selected for processing (124)
  • .gitattributes
  • Jenkinsfile
  • apps/commerce-api/src/main/java/com/loopers/product/application/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/product/infrastructure/ProductListQueryDsl.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/DailyRankingEntries.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/DailyRankingQuery.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/PublishedRanking.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/PublishedRankingQuery.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingLookupStage.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingMetrics.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingReadService.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/application/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/JdbcPublishedRankingQuery.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/infrastructure/RedisDailyRankingQuery.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/interfaces/api/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/ranking/interfaces/api/RankingV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/product/application/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/application/RankingFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/application/RankingMetricsTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/application/RankingReadServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/application/RankingServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/infrastructure/JdbcPublishedRankingQueryIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/infrastructure/RedisDailyRankingQueryIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/interfaces/api/RankingV1ApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/ranking/interfaces/api/RankingV1ApiFailureE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricAggregateKeysetQueryProvider.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingCleanupJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingCleanupJobParameterValidator.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingCleanupJobParameters.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingSnapshotJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingSnapshotJobParameterValidator.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductRankingSnapshotJobParameters.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/DeleteObsoleteProductRankingCandidatesTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/PrepareProductRankingTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductRankingCandidateWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/ProductRankingScoreProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/PublishProductRankingTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/NewProductRankingSnapshot.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductMetricAggregate.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingAssignment.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingCandidateRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingCleanupService.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingPreparationService.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingPublicationService.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingResultRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingSnapshotHeader.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingSnapshotKey.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingSnapshotRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/ProductRankingSourceQuery.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/application/RankingCandidate.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/JdbcProductRankingCandidateRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/JdbcProductRankingResultRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/JdbcProductRankingSnapshotRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/infrastructure/JdbcProductRankingSourceQuery.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/perf/ProductMetricPerfDataGenerator.java
  • apps/commerce-batch/src/main/java/com/loopers/ranking/perf/ProductMetricPerfDataSeeder.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/ProductMetricAggregateKeysetQueryProviderTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/ProductRankingCleanupJobParametersTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/ProductRankingSnapshotJobParametersTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/DeleteObsoleteProductRankingCandidatesTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/PrepareProductRankingTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/ProductRankingCandidateWriterTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/ProductRankingScoreProcessorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/PublishProductRankingTaskletTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/CalculateProductRankingScoresRestartIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/CalculateProductRankingScoresRetryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/CalculateProductRankingScoresStepIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/PrepareProductRankingStepIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricAggregateReaderIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricAggregateReaderRetryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingCleanupJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingCleanupJobRestartIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingSnapshotJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingSnapshotJobRestartIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/PublishProductRankingAtomicityIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/PublishProductRankingStepIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/application/ProductRankingCleanupServiceTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/application/ProductRankingPreparationServiceTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/application/ProductRankingPublicationServiceTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/infrastructure/JdbcProductRankingCandidateRepositoryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/infrastructure/JdbcProductRankingResultRepositoryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/infrastructure/JdbcProductRankingSnapshotRepositoryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/infrastructure/JdbcProductRankingSourceQueryIntegrationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/perf/ProductMetricPerfDataGeneratorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/ranking/perf/ProductMetricPerfDataSeederIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/application/CatalogMetricsMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/application/ProductMetricDelta.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/application/ProductMetricEventHandler.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/application/ProductMetricHourlyDelta.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/application/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/infrastructure/JdbcProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/metrics/infrastructure/ProductMetrics.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/application/ProductMetricDeltaTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/application/ProductMetricEventHandlerIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/application/ProductMetricEventHandlerRollbackIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/application/ProductMetricEventHandlerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/infrastructure/MetricsJdbcRepositoryIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/metrics/interfaces/consumer/CatalogMetricsKafkaE2ETest.java
  • jenkins/Jenkinsfile.product-ranking
  • jenkins/README.md
  • jenkins/resolve-ranking-parameters.sh
  • modules/jpa/src/main/resources/jpa.yml
  • modules/ranking/build.gradle.kts
  • modules/ranking/src/main/java/com/loopers/ranking/RankingPeriod.java
  • modules/ranking/src/main/java/com/loopers/ranking/RankingScorePolicy.java
  • modules/ranking/src/main/java/com/loopers/ranking/config/RankingScoreConfig.java
  • modules/ranking/src/main/java/com/loopers/ranking/config/RankingScoreProperties.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/MonthlyProductRank.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/ProductMetric.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/ProductMetricId.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/ProductRankCandidate.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/ProductRankId.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/ProductRankSnapshot.java
  • modules/ranking/src/main/java/com/loopers/ranking/persistence/WeeklyProductRank.java
  • modules/ranking/src/main/resources/ranking.yml
  • modules/ranking/src/test/java/com/loopers/RankingPersistenceTestApplication.java
  • modules/ranking/src/test/java/com/loopers/ranking/RankingPeriodTest.java
  • modules/ranking/src/test/java/com/loopers/ranking/RankingScorePolicyTest.java
  • modules/ranking/src/test/java/com/loopers/ranking/persistence/ProductMetricSchemaIntegrationTest.java
  • modules/ranking/src/test/java/com/loopers/ranking/persistence/RankingPersistenceSchemaIntegrationTest.java
  • scripts/catalog-ranking-e2e.ps1

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


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.

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.

1 participant