Direct Lake and VertiPaq

연결 문자열의 “Direct Lake”가 실제로 Direct Lake에 있다는 뜻은 아니다


핵심 개념

Direct Lake는 Power BI/Fabric이 OneLake의 Delta Parquet 파일을 직접 읽는 모드다. 데이터 임포트도, 예약 새로고침도, 데이터 중복도 없이 Import Mode 성능과 준실시간 데이터를 동시에 얻는다는 약속이다.

대체로 잘 동작한다. 문제는 Direct Lake가 문서 각주에 묻혀 있는 구체적인 운영 요구사항을 가지고 있고, 그 각주를 먼저 찾지 못하면 런치를 망친다는 것이다.

그 요구사항의 이름은 framing이다.

Frame — 상상하는 방식으로 동작하지 않는다

Direct Lake는 Delta 테이블을 직접 쿼리하지 않는다. 특정 버전 시점의 스냅샷인 frame을 Power BI 엔진 메모리로 읽는다. 그 프레임이 Fabric capacity SKU의 메모리 한계 안에 들어가야 한다.

프레임을 못 올릴 때 — 테이블이 너무 크거나, row group 크기가 잘못됐거나, 컬럼 수 대비 SKU 메모리가 부족하거나 — Power BI는 에러를 던지지 않는다. 아무것도 알려주지 않는다.

조용히 DirectQuery로 폴백한다.

Fallback trap이 위험한 이유

리포트를 망가뜨려서가 아니라 망가뜨리지 않기 때문이다.

리포트는 계속 동작한다. 성능만 저하되고, 그것을 모드 변경과 즉시 연결짓지 못할 수 있다. 스테이징에서 작은 데이터셋으로 테스트하면 다 빠르고, 프로덕션에 대용량을 올리는 순간 모든 시각적 요소 상호작용마다 라이브 SQL이 Delta 테이블에 나간다. 폴백 중인지 아닌지를 능동적으로 확인하지 않으면 며칠을 엉뚱한 곳 최적화에 쓰게 된다.

이것은 Silent Failures and Data Integrity의 성능 버전이다 — 실패가 에러가 아니라 성능 저하로만 나타난다.

폴백을 유발하는 세 가지

1. Row group 사이징

Spark 처리에 최적화된 Delta 테이블은 row group이 크다(그룹당 100만 row 이상, 흔히 300–400만). Direct Lake의 기대는 다르다 — Spark 스캔 처리량이 아니라 columnar in-memory 접근에 맞춘 크기다. 목표는 약 100만 row 수준의 row group과 통제된 파일 크기(예: 128MB).

제약 없는 OPTIMIZE는 PySpark 잡에는 맞고 Direct Lake framing에는 틀린 결과를 만든다. → Partition Sizing and Management

2. 컬럼 수와 cardinality

Direct Lake는 테이블 로드 시 전체 컬럼 세트를 프레임한다. 15억 row × 47컬럼은 큰 메모리이고, 상품 설명이나 자유 텍스트 노트 같은 high-cardinality 문자열 필드가 프레임을 더 부풀린다.

해법은 기술적이지 않다. 시맨틱 모델의 모든 컬럼에 비즈니스 정당화를 요구하는 것 — 파이프라인 추적 목적으로 Delta 테이블에 존재하는 컬럼이 시맨틱 모델에 있을 이유는 없다. 이건 리포트가 실제로 무엇을 필요로 하는지에 대한 이해관계자와의 대화이고, 그래서 더 어렵고 더 중요하다.

3. Capacity SKU

SKU를 올린다고 폴백이 사라지지 않는다. 폴백이 발동하는 임계값만 바뀐다. F32에서 맞던 규모를 F64로 옮기면 더 나은 성능을 기대하지만, row group 구성에 따라 F64의 framing capacity를 초과하는 테이블 크기에 걸릴 수 있다.

교훈: 편의상 잡은 부분집합을 공유 dev capacity에서 테스트하지 말고, 프로덕션 데이터 볼륨을 프로덕션 SKU에서 테스트하라.

파티셔닝이 프레임 크기를 줄인다

Direct Lake는 테이블 전체를 한 번에 프레임하지 않는다 — 파티션 필터가 걸린 테이블을 쿼리하면 파티션 단위로 프레임한다.

따라서 파티셔닝 전략을 리포트에서 가장 흔한 필터 패턴에 맞추면 프레임 크기가 극적으로 줄어든다. 월별 sale date로 파티셔닝하면 현재 월 필터 리포트는 15억 row가 아니라 1.2억 row 파티션만 프레임한다.

검증을 릴리스 게이트로

1일차부터 있었어야 할 체크다.

  • Delta 테이블 업데이트마다 → 프레임 크기가 capacity 한도 안인지 확인
  • Power BI 배포마다 → 모니터링 로그에서 쿼리 모드가 Direct Lake인지 DirectQuery인지 확인
  • Fabric 포털의 Monitor → Refresh History가 어느 쪽이 쓰였는지 보여준다
  • XMLA 엔드포인트에서 연결 문자열을 뽑아 확인하는 방법도 있다

뭔가 느리게 느껴질 때 확인하는 것이 아니라, 릴리스 전에 통과해야 하는 테스트로 취급하라.

요약 — 기본기의 문제

대용량을 Direct Lake에서 다루는 엔지니어링 자체는 문제를 이해하고 나면 특별히 복잡하지 않다. row group 사이징, 컬럼 규율, 파티션 전략 — 어떤 대규모 분석 테이블에도 적용되는 기본기다.

어려운 건 폴백이 조용하다는 사실을 배우는 것이다.


VertiPaq이 좋아하는 Parquet 쓰기

폴백을 피했다고 끝이 아니다. 폴백하지 않는 Direct Lake 안에서도 Parquet을 어떻게 썼느냐가 쿼리 레이턴시와 capacity 소비를 결정한다.

transcoding — 왜 쓰기 시점이 쿼리 성능을 지배하는가

Direct Lake의 로딩은 컬럼 단위 on-demand transcoding이다.

  1. 아직 메모리에 없는 컬럼을 건드리는 첫 DAX 쿼리가 그 컬럼만 VertiPaq in-memory 포맷으로 변환한다
  2. 그 컬럼의 row group별 Parquet dictionary들이 하나의 global VertiPaq dictionary로 병합된다
  3. 컬럼의 각 row group이 하나의 resident column segment로 로드되며 Parquet data ID → VertiPaq ID로 remap된다
  4. 이후 모든 쿼리는 그 transcode가 만든 segment를 스캔한다

따라서 쿼리 레이턴시와 capacity 소비는 Parquet이 어떻게 쓰였는지의 속성이다. 그리고 transcoding은 행 순서를 바꾸지 않으므로, 쓰기 시점의 정렬이 메모리에 상주하는 segment까지 그대로 살아남는다 — 정렬이 압축뿐 아니라 hot 실행에도 영향을 준다는 뜻이다.

규칙 1 — dictionary 인코딩, 그리고 footer가 그렇다고 말할 것

VertiPaq 자체가 dictionary 기반 엔진이다. chunk가 dictionary 인코딩된 채 도착하면 transcode는 dictionary를 병합하고 data ID를 remap할 뿐, 값을 절대 디코딩하지 않는다. 그 외에는 로드 시점에 값 하나하나를 디코딩하고 재해시해야 한다.

144M행 DECIMAL(18,4) 컬럼 하나에서 PLAIN 618.6 MB vs dictionary 423.1 MB — 약 200MB 추가에 재인코딩 비용까지.

결정적 함정: 인코딩만으로는 부족하다. 선언이 인코딩의 일부다.

엔진은 footer의 encoding_stats가 chunk 전체가 dictionary 인코딩임을 페이지를 디코딩하지 않고 증명할 때만 저렴한 remap 경로를 탄다. DuckDB의 writer는 encoding_stats를 아예 내보내지 않았고(duckdb#24957, 2026-08-24 머지), 그 필드 하나 때문에 142M행 dictionary string 컬럼의 cold first-touch가 10,857.5 ms → 689.3 ms, 약 15배 차이가 났다. 페이지 내용은 동일한 채로. 그 footer 필드만 합성해 넣어도 speedup이 재현된다.

이것은 Silent Failures and Data Integrity의 또 다른 얼굴이다 — 파일은 완벽히 유효하고, 데이터도 맞고, 단지 메타데이터가 없어서 15배 느릴 뿐이다.

규칙 2 — row group 2~6M행 (16M은 절대 넘기지 말 것)

앞서 “약 100만 row”라고 적은 목표는 더 정밀하게 다듬을 수 있다. 양 끝이 측정 가능하게 실패한다.

모든 row group은 컬럼당 dictionary 병합 한 번과 segment 셋업 한 번을 추가한다.

잘못결과
너무 작음 (1,172 그룹 × ~123k행)cold 96,503 ms vs 27,785 ms — 3.5배 느림
너무 큼 (16M행 = VertiPaq segment 상한)segment가 9개뿐이라 scan pool이 굶는다 — 측정된 정렬 geometry 중 최악

Power BI가 코어를 몇 개 쓰는지 공개하지 않으므로 실용 규칙은 “코어를 바쁘게 유지할 만큼 충분한 row group”. 144M행 테이블에서 warm 쿼리 시간이 19~24 그룹 사이에서 5,700 ms → 3,221 ms로 계단식 하락했고 72 그룹은 24 대비 아무것도 사지 못했다. 결론: 6M을 기본값으로.

cold는 hot보다 약간 큰 그룹을 선호하고, 아주 큰 그룹은 둘 다에 나쁘다.

규칙 3 — 하나의 global ORDER BY, 저카디널리티 먼저

clustering도 Z-ordering도 아니다. 저카디널리티 컬럼을 앞에(그리고 날짜를 선호) 두는 단일 global ORDER BY 가 긴 RLE run을 만든다. V-Order는 아니지만 어떤 경우엔 충분히 좋다.

⚠️ 디스크 크기가 아니라 쿼리가 필터하는 컬럼으로 정렬하라. 실험에서 대안 정렬 키가 파일 크기를 추가로 30% 줄였지만 쿼리 시간은 통계적으로 0을 샀다.

규칙 4 — ragged row group을 만들지 말 것

delta-rs는 크기 상한에 닿는 순간 파일을 닫아 진행 중이던 row group을 잘라낸다 — 측정된 바로 선언된 행 수의 0.43배. 잘린 그룹은 그냥 작은 게 아니라 segment 크기를 고르지 않게 만든다 — 정확히 row group 크기를 조정해서 피하려던 비균일 scan 부하다. (delta-rs#4677이 row group 경계에서만 파일을 roll하도록 제안, opt-in)

규칙 5 — geometry를 추정에서 유도하지 말 것

“쓰기 전에 행 수를 추정해 완벽한 row group 크기를 계산한다”는 영리한 최적화는 완전히 실패한다. 쿼리 플래너가 출력 크기 추정에 형편없기 때문이다 — DuckDB가 실제 143,980,961행 테이블을 ~14.9M행으로, 9.7배 과소 추정했다. 게다가 초기 구현에서는 추정 비용이 테이블 쓰기 비용과 거의 같았다.

정확한 count에서 유도하라 — 재작성 중인 테이블, Delta log에서.

V-Order가 실제로 무엇인가

흔히 “행 재정렬”로 이해되고 재정렬은 실재하지만, 어려운 부분은 재정렬 자체가 아니라 그것을 빠르게 하는 것이다. 그리고 V-Order는 local sort이지 global sort가 아니다.

더 중요한 것: V-Order 쓰기는 재정렬만 하는 게 아니라 row group geometry를 설정하고 인코딩 패스를 돌려 모든 컬럼에 선언된 dictionary를 유지한다. 위의 규칙 1~4를 한 번에 처리한다는 뜻이다.

손으로 쓴 ORDER BY는 지목한 컬럼만 무너뜨리고 나머지는 파편화된 채 남긴다. V-Order는 여러 컬럼을 한 번에, 가장 반복적인 것 먼저 정렬한다. 그 가치는 행 수가 아니라 surface — 컬럼 수 × 범주형 skew — 에 달렸다:

  • skew된 17컬럼 taxi 테이블: 가장 반복적인 컬럼의 run이 3,371배 감소
  • 거의 unique한 5컬럼 테이블: 행 순서는 그대로인데도 파일 16% 축소

비용/편익: 빌드 컴퓨트의 약 8%를 더 써서 쿼리 capacity를 최대 2.8배 절감 (동일 데이터에서 1,332 CU vs 3,769 CU). 쓰기 프리미엄은 한 번 내고 쿼리는 매일 낸다 → Fabric 엔진으로 쓴다면 V-Order를 켜는 것은 no-brainer다.

⚠️ 함정: 새 Fabric 워크스페이스는 Spark를 writeHeavy 프로파일로 기본 설정하는데 그것은 V-Order를 쓰지 않는다. 그리고 write 프로파일 변경이 명확하지 않아 많은 사용자가 그 존재조차 모른다.

Delta냐 Iceberg냐는 중요하지 않다

테이블 안의 Parquet만 중요하다. Snowflake·Databricks·BigQuery가 쓴 테이블을 Direct Lake로 읽는 것이 이미 프로덕션 패턴이고, 그 경우 V-Order는 애초에 선택지가 아니다 — 위의 규칙 1~4를 직접 지켜야 한다. → Catalog-Managed Tables

체크리스트

항목목표
row group2~6M행 (기본 6M), 16M 절대 초과 금지
dictionary 인코딩모든 컬럼 + footer encoding_stats 존재 확인
정렬단일 global ORDER BY, 저카디널리티·날짜 먼저, 쿼리 필터 컬럼 기준
row group 균일성ragged/truncated 그룹 없음
geometry 유도추정이 아니라 정확한 count
Fabric writerV-Order 켜기 (writeHeavy 기본값 주의)
프레임 크기capacity SKU 한도 내 (위 폴백 섹션)

연관 개념


Source: Direct Lake is Magic Until It Isnt, Writing Parquet That VertiPaq Likes