How We Refresh Razorpay’s Data Warehouse 10x Faster with Graphs and Indexes
Author: Amit Prabhu, Utkarsh Koppikar, Rohan (Razorpay) | Source: engineering.razorpay.com | Published: 2026-07-14
한 줄 요약
Fact는 테이블이 아니라 그래프다 — fact config를 순회 가능한 의존성 그래프로 모델링하고 lake 위에 secondary index를 두면 전체 재생성 대신 변경 이벤트만 증분 처리할 수 있고, 스트리밍이 실패한 자리를 배치+증분이 이긴다.
핵심 주장/내용
- Fact란: 마이크로서비스 테이블 10~30개를 조인해 한 번 materialize한 S3 위의 평탄한 비정규화 테이블. 정산 Fact는 payments·refunds·adjustments·card details를 하나의 wide row로 합쳐 대시보드나 리포트가 실시간 조인 없이 단일 테이블에서 읽게 한다. 고전적 웨어하우스 fact 테이블보다는 도메인 특화 materialized view에 가깝다
- Full refresh 파이프라인의 3가지 문제:
- 운영 — spot 노드에서 수 시간 돌아 spot 손실이 불가피한데, 비즈니스 로직이 전체 데이터셋을 한 번에 요구해 안전한 체크포인트가 없다. 한 번 실패하면 처음부터
- 스케일 — back-dated reference: 오늘의 payment가 5년 전 order를, refund가 1년 전 payment를 참조할 수 있다. 이것이 secondary 테이블마다 무한정 조인과 full table scan을 강제하고 적용할 안전한 시간 윈도우가 없게 만든다. Payments fact는 30개 이상 테이블을 조인하며 8~10시간 실행. 비용 관리를 위해 fact 보존을 1년으로 줄이는 미봉책을 썼지만 TiDB(warm store) 의존을 깊게 만들고 warm store 보존 정책을 막았다
- 비용 — 대형 EMR 클러스터에서 하루 70시간 이상. 대부분 fact를 격일 스케줄로 옮겨 최대 48시간 신선도 지연 발생
- 스트리밍 시도와 실패 이유 (1년간 작동 후): ① 고변동 데이터 — 30분 윈도우당 1천만+ 이벤트로 10~20개 change stream을 조인하니 비용이 지속 불가능 ② 모든 데이터가 warm store에 — back-dated reference 때문에 전체 히스토리를 TiDB에 유지해야 해서 어떤 보존 정책도 막힘 ③ write amplification — high-cardinality 조인이 upsert 볼륨을 증폭. lake에서 조인이 느려서 모든 조인 테이블을 사전 계산 fact에 강제로 넣을 수밖에 없었다
- 피벗 — 증분 Fact의 핵심 통찰: Fact를 증분 유지 가능한 그래프로 취급한다. 전체 테이블을 재생성하는 대신 엔티티별 변경 이벤트만 처리하되, 의존성 그래프로 어떤 관련 row를 가져올지 알고, secondary index로 lake의 어디에서 찾을지 안다
- 3가지 설계 질문:
- T-1일의 모든 업데이트를 어떻게 가져오나 → silver 레이어 파이프라인이
updated_date로 파티션된 평탄화·중복제거 테이블을 만들고 PK별 일자별 최신 변경만 보존 - 변경 이벤트에 대해 어떤 관련 row를 가져와야 하나 → fact config를 조인 조건에서 엣지를 유도한 인메모리 순회 가능 의존성 그래프로 모델링. 각 엣지가 조인 술어를 메타데이터로 지님
- Payments 업데이트 → 엣지를 바깥으로 따라가 Orders와 Cards 조회 후 완전한 비정규화 row 구성
- Orders 업데이트 → 루트로 back-traverse해
payments.order_id = O1.id인 Payments를 찾아 영향받은 PK 파악. Cards는 Orders→루트 경로에 없으므로 건너뜀
- lake에서 어떻게 효율적으로 조회하나 → 엔티티별 Secondary Index — 조인 컬럼과
created_date파티션 값만 담은 작은 테이블(payment index면 id, card_id, order_id, created_date). 컬럼 부분집합이 작고 자주 안 변하므로 원본 소스 테이블보다 훨씬 작고 유지하기 쉽다. 인덱스가 읽기를 소수의 파티션으로 좁혀 full table scan을 제거하고, 이것이 히스토리 조회에 대한 TiDB 의존을 없앤 핵심 컴포넌트- (배경 제약: S3의 소스 테이블은 Delta 포맷
created_date파티션, PK와 merchant_id에 Z-ordering — 둘 다 high cardinality라 조회 쿼리에는 비효율)
- (배경 제약: S3의 소스 테이블은 Delta 포맷
- T-1일의 모든 업데이트를 어떻게 가져오나 → silver 레이어 파이프라인이
- Primary flow: 마지막 체크포인트 이후 primary 테이블 업데이트 fetch → 각 secondary index와 조인해
created_date파티션 값 획득 → 그 값으로 각 소스 테이블의 관련 파티션만 읽기 → 비정규화 row로 stitch해 영향받은 파티션으로 스코프를 좁힌 merge로 upsert - Secondary flow: 그래프를 level-order로 순회하며 순차 처리. 체크포인트 이후 업데이트 fetch → back-traverse해 루트까지 모든 조상 노드의 인덱스와 조인하여 각 업데이트 row를 타겟 merge에 필요한 PK·파티션 값으로 enrich(필요한 조상 데이터가 이미 인덱스에 있으므로 소스 테이블 전체 읽기 불필요) → 부분 비정규화 row를 영향받은 파티션 스코프로 upsert
- high-cardinality 차원 — 모든 조인이 사전 계산 fact에 속하지는 않는다: “Payment Links” 테이블 하나의 링크가 수천 건의 결제를 받을 수 있다. secondary 테이블인 payment_links의 컬럼 하나를 업데이트하면 fact의 수천 개 payment row를 다시 써야 한다(write amplification)
- 그런데 lake의 조인도 비싸다 — 도메인이 시간 제한 조인을 지원하지 않아 secondary 테이블에 full scan이 필요. 공통 술어를 push down해 스캔을 줄이는 것이 목표
- 리포트 생성은 보통
merchant_id로 스코프되고 대부분 테이블에 이 컬럼이 있다. high cardinality라 파티션할 수는 없으므로 고정 버킷 수의 bucketing(샤딩과 유사)을 사용하고 네이티브 버킷팅을 지원하는 Iceberg V2를 테이블 포맷으로 선택 - 조인 조건 양쪽에
merchant_id를 추가하면 Trino가 양쪽에 술어를 푸시다운해 매칭되는 버킷만 스캔한다. 이것이 데이터 레이크에서의 조인을 가능하게 했다 - 남은 gap은 Iceberg 복제 파이프라인 구축, non-Fact 테이블의 sort order·merchant_id 버킷팅 튜닝, merge 스캔을 줄이는 hidden month/day 파티셔닝, 그리고 Reporting Service의 query builder가 조인을 지능적으로 라우팅(일부는 사전 계산 Fact에서, 일부는 Fact와 Iceberg 테이블 사이 쿼리 시점 조인)으로 해결
- 프로덕션 롤아웃 3단계 — 부정확한 데이터가 머천트에 도달하지 않게: ① 데이터 정합성 확인 — 증분과 full-refresh fact를 레코드 단위 비교하며 체크포인팅·순서 뒤바뀜·인덱스 지연 이슈를 100% 정확도까지 해소 ② shadow testing — 라이브 리포트와 병렬로 증분 fact에서 shadow 리포트를 생성하는 A/B, 자동 야간 비교가 불일치를 플래그 ③ 점진적 라이브 롤아웃 — 레거시 폴백을 둔 채 트래픽을 점진 이동
- self-healing: 파이프라인 3개 구조는 자연히 사고 확률을 높이므로, 각 파이프라인이 자기 체크포인트를 발행해 다운스트림이 업스트림의 진행 위치를 정확히 보고 지연 시 얼마나 처리할지 지능적으로 결정. 지연이 생기면 TiDB 같은 소스로 자동 폴백해 리포트 신선도 유지. 데이터 품질 체크가 불일치를 Slack에 플래그해 디버그 범위를 좁힌다
주요 수치 / 사실
- 50개 이상의 Fact 유지, 머천트 리포트의 약 40%가 Fact에서 직접 서빙. Razorpay는 월 100만 건에 가까운 리포트 생성
- 가장 큰 Fact 5개를 증분 전략으로 마이그레이션 → 컴퓨트 시간 약 90% 감소, 비례하는 비용 절감
- Fact의 전체 히스토리 커버리지 복원 → TiDB 보존을 크게 축소 가능
- 런타임 조인이 가능해져 거대 모놀리식 fact를 도메인별 작은 fact로 분해 → blast radius와 유지보수 비용 감소
- 교훈 4가지: ① 스트리밍이 항상 답은 아니다 — 상태 무거운 조인은 높은 이벤트 볼륨에서 결국 비용 금지적이 되고 배치+증분이 이길 수 있다 ② lake는 아카이브가 아니라 쿼리 가능해야 한다 — lake 위 secondary index가 히스토리 조회의 warm store 의존을 없앴다(Iceberg도 향후 릴리스에 네이티브 secondary index 추가 중) ③ 모든 차원이 materialized fact에 속하지는 않는다 — 버킷팅된 Iceberg 테이블과의 런타임 조인이 규모에서 write amplification보다 싸다 ④ Fact는 테이블이 아니라 그래프다
- 로드맵: Apache Flink로 인덱스 파이프라인을 CDC 인제스션에 병합한 실시간 secondary index, ClickHouse·TiFlash 등 다른 싱크로 증분 처리 확장
관련 위키
- Incremental Materialization
- Data Pipeline Fundamentals
- Change Data Capture
- Partition Sizing and Management
- Catalog-Managed Tables
- Replay Testing
- Self-Healing Pipelines
- Data Engineering FinOps
Source: 원문 보기