Incremental Materialization

Fact는 테이블이 아니라 그래프다


핵심 개념

Wide denormalized 테이블(Fact, materialized view, OBT)은 소비자가 실시간 조인 없이 단일 테이블에서 읽게 해준다. 문제는 유지다.

  • Full refresh — 매번 전체를 다시 만든다. 단순하지만 데이터가 커지면 무너진다
  • Streaming — 변경을 실시간으로 반영한다. 상태 무거운 조인이 높은 이벤트 볼륨을 만나면 비용이 금지적이 된다
  • Incremental materialization — 변경 이벤트만 처리하되, 무엇을 함께 가져와야 하는지와 어디서 찾을지를 알아야 한다

세 번째 길의 핵심 통찰은 fact를 증분 유지 가능한 그래프로 취급하는 것이다.

Full refresh가 무너지는 지점

Razorpay 사례가 전형적이다.

운영 — 파이프라인이 spot 노드에서 수 시간 돌면 spot 손실은 필연이다. 그런데 비즈니스 로직이 전체 데이터셋을 한 번에 요구하므로 안전한 체크포인트가 없다. 한 번 실패하면 처음부터.

스케일 — back-dated reference가 결정타다. 오늘의 payment가 5년 전 order를, refund가 1년 전 payment를 참조한다. 이것이 secondary 테이블마다 무한정 조인과 full table scan을 강제하고, 적용할 안전한 시간 윈도우 자체가 없게 만든다. 시간 파티션 프루닝이라는 가장 강력한 무기가 봉인된다.

비용 — 이 지점에서 흔한 미봉책은 보존 기간 축소인데, 이는 오래된 데이터를 warm store(TiDB, ClickHouse 등)로 폴백하게 만들어 warm store 의존을 오히려 깊게 하고 그쪽 보존 정책까지 막는다.

스트리밍이 항상 답은 아닌 이유

CDC 토픽을 구독해 실시간 비정규화 row를 만드는 접근은 매력적이지만 세 가지에서 걸린다.

  1. 고변동 데이터 — 30분 윈도우에 1천만 이벤트 규모로 10~20개 change stream을 조인하면 비용이 지속 불가능해진다
  2. 모든 히스토리가 warm store에 — back-dated reference 때문에 조회용 전체 히스토리를 warm store에 유지해야 한다
  3. write amplification — high-cardinality 조인이 upsert 볼륨을 증폭시킨다. lake에서 조인이 느리면 모든 조인 테이블을 사전 계산 fact에 강제로 넣을 수밖에 없어 증폭이 더 심해진다

세 가지 구성 요소

1. Silver 레이어 — “무엇이 바뀌었나”

updated_date로 파티션된 평탄화·중복제거 테이블. PK별 일자별 최신 변경만 보존한다. 이것이 “T-1일에 이 엔티티에 일어난 모든 업데이트”를 값싸게 답한다.

2. 의존성 그래프 — “무엇을 함께 가져와야 하나”

Fact config를 조인 조건에서 엣지를 유도한 순회 가능한 인메모리 그래프로 모델링한다. 각 엣지가 조인 술어를 메타데이터로 지닌다.

payments ──order_id──> orders
    │
    └──card_id──> cards
    ▲
    │payment_id
discounts ──offer_id──> offers

그래프가 변경 이벤트마다 정확히 무엇을 가져올지 말해준다.

  • Primary(payments) 업데이트 → 엣지를 바깥으로 따라가 orders와 cards를 조회하고 완전한 비정규화 row 구성
  • Secondary(orders) 업데이트 → 루트로 back-traverse해 payments.order_id = O1.id인 payments를 찾아 영향받은 PK 파악. cards는 orders→루트 경로에 없으므로 건너뛴다 — 이 프루닝이 그래프 모델링의 실질적 이득이다

3. Secondary index — “lake의 어디서 찾을까”

엔티티별로 조인 컬럼 + 파티션 값만 담은 작은 테이블. payment index라면 id, card_id, order_id, created_date.

컬럼 부분집합이 작고 자주 안 변하므로 원본 소스 테이블보다 훨씬 작고 유지하기 쉽다. 인덱스가 읽기를 소수의 파티션으로 좁혀 full table scan을 제거한다.

lake는 아카이브가 아니라 쿼리 가능해야 한다. Lake 위의 secondary index가 히스토리 조회에 대한 warm store 의존을 없앤다. (Iceberg도 향후 릴리스에 네이티브 secondary index를 추가 중이다.)

처리 흐름

Primary flow

  1. 마지막 체크포인트 이후 primary 테이블 업데이트 fetch
  2. 각 secondary index와 조인해 파티션 값 획득
  3. 그 값으로 각 소스 테이블의 관련 파티션만 읽기
  4. 비정규화 row로 stitch → 영향받은 파티션으로 스코프를 좁힌 merge로 upsert

Secondary flow (그래프를 level-order 순회하며 순차 처리)

  1. 체크포인트 이후 secondary 테이블 업데이트 fetch
  2. back-traverse해 루트까지 모든 조상 노드의 인덱스와 조인, 각 row를 타겟 merge에 필요한 PK·파티션 값으로 enrich. 필요한 조상 데이터가 이미 인덱스에 있으므로 소스 테이블 전체 읽기가 필요 없다
  3. 부분 비정규화 row를 영향받은 파티션 스코프로 upsert

모든 차원이 fact에 속하지는 않는다

high-cardinality 차원은 write amplification 때문에 비정규화 비용이 크다. “Payment Links” 하나가 수천 건의 결제를 받는다면, 그 링크의 컬럼 하나를 바꿀 때 fact의 수천 row를 다시 써야 한다.

대안은 런타임 조인인데, lake의 조인도 비싸다(시간 제한 조인이 불가능하면 secondary 테이블 full scan). 해법은 공통 술어를 push down하는 것이다.

리포트가 보통 merchant_id로 스코프되고 대부분 테이블에 이 컬럼이 있다면 이를 공통 술어로 쓴다. high cardinality라 파티션할 수는 없으므로 고정 버킷 수의 bucketing(샤딩과 유사, Iceberg V2가 네이티브 지원)을 쓰고, 조인 조건 양쪽에 merchant_id를 추가한다.

-- 이렇게 하면 Trino가 orders에 full scan
LEFT JOIN orders ON orders.id = payment_links.order_id
 
-- 양쪽에 push down되어 매칭 버킷만 스캔
LEFT JOIN orders ON orders.merchant_id = payment_links.merchant_id
                AND orders.id = payment_links.order_id

그러면 쿼리 라우터가 일부 조인은 사전 계산 fact에서, 일부는 fact와 Iceberg 테이블 사이 쿼리 시점에 처리하도록 지능적으로 나눌 수 있다.

롤아웃 — 부정확한 데이터가 나가면 안 될 때

  1. 데이터 정합성 확인 — 증분과 full-refresh 결과를 레코드 단위 비교. 체크포인팅, 순서 뒤바뀜, 인덱스 지연 이슈를 100% 정확도까지 해소
  2. shadow testing — 라이브와 병렬로 증분 결과에서 shadow 산출물을 만들고 자동 야간 비교가 불일치를 플래그 → Replay Testing
  3. 점진적 라이브 롤아웃 — 레거시 폴백을 둔 채 트래픽 점진 이동

Self-healing

파이프라인을 하나에서 셋으로 쪼개면 사고 확률이 자연히 올라간다. 그래서 각 파이프라인이 자기 체크포인트를 발행해 다운스트림이 업스트림의 진행 위치를 보고 지연 시 얼마나 처리할지 스스로 결정하게 한다. 지연이 생기면 warm store로 자동 폴백해 신선도를 유지하고, 데이터 품질 체크가 불일치를 알림 채널에 플래그해 디버그 범위를 좁힌다. → Self-Healing Pipelines

연관 개념


Source: How We Refresh Razorpays Data Warehouse 10x Faster with Graphs and Indexes