[Databricks] Delta Lake Z-Order 다차원 정렬과 파일 건너뛰기
1. 배경
Delta Lake 테이블은 여러 개의 Parquet 파일로 저장된다. 쿼리가 몇 개의 파일을 여는지는 스캔 바이트와 네트워크 I/O, 실행 시간에 직접 영향을 준다.
Delta Lake는 파일을 쓸 때 컬럼별 min, max, nullCount 통계를 _delta_log 트랜잭션 로그에 기록한다. 조건 값이 어떤 파일의 min~max 범위 밖에 있으면 그 파일은 열지 않는다. 이 동작을 Data Skipping이라 한다.
통계가 있어도 값이 파일마다 무작위로 흩어져 있으면 모든 파일의 범위가 min=1, max=1000000처럼 넓어져 건너뛸 파일이 없다. 비슷한 값이 같은 파일에 모여 있어야 건너뛸 파일이 생긴다. Z-Order는 여러 컬럼을 동시에 기준으로 삼아 비슷한 값을 같은 파일에 모으는 배치 방식이다.
본문의 테이블·컬럼 이름(events, user_id 등)은 예시 값이다. 동작은 Databricks Runtime의 Delta Lake를 기준으로 설명하며 세부 기본값은 런타임 버전마다 다를 수 있다.
2. 동작 원리
2.1 단일 컬럼 정렬의 한계
컬럼이 하나라면 그 컬럼으로 정렬해서 파일을 쓰면 된다. 컬럼이 둘 이상일 때 ORDER BY a, b로 정렬하면 첫 번째 컬럼 a만 파일 단위로 모인다. b는 같은 a 값 안에서만 정렬되므로 b로만 필터하면 거의 모든 파일의 b 범위가 전체 값 범위에 가깝고 건너뛸 파일이 없다.
2.2 Z-order curve(Morton 코드)
Z-Order는 여러 컬럼 값의 비트를 번갈아 끼워 넣어 정수 하나를 만든다. 이 값을 z-value라 하고 비트를 섞는 과정을 bit interleaving이라 한다. 정렬은 z-value 기준으로 한다. 좌표 (x, y) = (2, 3)으로 계산하면 아래와 같다.
x = 2 → 0 1 0 (x2 x1 x0)
y = 3 → 0 1 1 (y2 y1 y0)
z = y2 x2 y1 x1 y0 x0
= 0 0 1 1 1 0 → 001110(2) = 14
z-value 순서로 점을 이으면 평면을 Z자로 반복해 훑는 곡선이 된다. 이 순서대로 파일을 채우면 파일 하나가 긴 띠 대신 정사각형에 가까운 블록을 맡는다. x로 필터하든 y로 필터하든 읽어야 할 블록 수가 비슷하게 유지된다.
OPTIMIZE ... ZORDER BY (x, y)
└─ 대상 파일 읽기
├─ 각 행의 x, y → range ID로 변환 (값 분포 편향 완화)
├─ range ID 비트 교차 → z-value 계산
├─ z-value 기준 정렬·셔플
└─ 목표 크기 단위로 새 파일 기록 → 파일별 min/max 통계 갱신
위 계산은 값을 그대로 좌표로 쓴 예시이고 실제 구현은 샘플링으로 구한 범위 구간 번호를 쓴다. 이 번호를 range ID라 하며 비트 교차는 range ID에 적용한다. 값이 한쪽에 몰린 컬럼이나 문자열 컬럼에서도 블록이 고르게 나뉘는 이유다.
3. 정렬 방식별 읽는 파일 수
x와 y가 각각 0~7인 64개 행을 파일 16개에 4행씩 나눠 저장한 상태를 격자로 그렸다. 칸 안의 숫자는 그 행이 들어간 파일 번호다. 정렬 방식이나 쿼리 조건을 바꾸면 읽어야 하는 파일이 표시되고 파일 수가 다시 계산된다. 정렬하지 않은 배치는 무작위 순서 하나를 고정해 사용했다.
3.1 결과 비교
| 정렬 방식 | 한 파일이 차지하는 모양 | WHERE x = k |
WHERE y = k |
|---|---|---|---|
| 정렬 안 함 | 격자 전체에 흩어짐 | 평균 6.4 / 16 | 평균 6.5 / 16 |
ORDER BY x, y |
세로 1×4 띠 | 2 / 16 | 8 / 16 |
ZORDER BY (x, y) |
2×2 정사각형 블록 | 4 / 16 | 4 / 16 |
표의 값은 k를 0부터 7까지 바꿔 읽은 파일 수의 평균이고, 정렬한 두 방식은 k와 무관하게 값이 같다.
정렬하지 않으면 조건에 맞는 8개 행이 여러 파일에 흩어져 평균 6개 넘는 파일을 읽는다. 행이 훨씬 많은 실제 테이블에서는 모든 파일의 범위가 넓어져 건너뛸 파일이 거의 없다.
ORDER BY x, y는 첫 번째 컬럼 x로 필터할 때만 읽는 파일이 줄고 y로 필터하면 8개를 읽는다. Z-Order는 x 필터에서 ORDER BY보다 2개를 더 읽는 대신 y 필터에서 4개를 덜 읽는다. 곡선 표시를 켜면 저장 순서가 Z자를 반복하며 격자를 채운다. Z-Order라는 이름은 이 모양에서 왔다.
컬럼을 3개, 4개로 늘리면 블록은 3차원 이상의 입체가 된다. 파일 하나가 맡는 행 수는 그대로라서 컬럼 하나로 본 값 범위가 넓어지고 건너뛰는 파일이 준다. Z-Order 컬럼은 보통 1~3개로 둔다.
4. 적용 방법
4.1 SQL
OPTIMIZE는 작은 파일을 합치는 bin-packing과 Z-Order 정렬을 한 번에 처리한다. 파티션 테이블이면 WHERE로 대상 파티션을 좁혀 다시 쓰는 양을 줄인다.
-- 테이블 전체
OPTIMIZE events ZORDER BY (user_id, event_type);
-- 최근 파티션만 (파티션 컬럼 조건만 허용)
OPTIMIZE events
WHERE event_date >= '2026-09-01'
ZORDER BY (user_id);
4.2 Python (Delta Lake API)
from delta.tables import DeltaTable
(DeltaTable.forName(spark, "events")
.optimize()
.where("event_date >= '2026-09-01'")
.executeZOrderBy("user_id"))
4.3 효과 확인
OPTIMIZE 결과는 테이블 이력의 operationMetrics에 남는다. 조회 쪽 효과는 같은 쿼리를 OPTIMIZE 전후로 실행해 스캔 바이트와 files pruned / files read 비율을 비교한다.
DESCRIBE HISTORY events; -- numFilesAdded, numFilesRemoved, zOrderStats 확인
재작성 전 파일은 VACUUM을 실행하기 전까지 지워지지 않는다. 그동안은 Time Travel로 이전 버전을 조회할 수 있다.
5. 컬럼 선택 기준과 주의점
5.1 컬럼 선택 기준
| 기준 | 판정 | 설명 |
|---|---|---|
| 필터·조인에 자주 쓰는 컬럼 | 적합 | WHERE·JOIN 조건에 반복해서 나오는 컬럼 |
| 카디널리티가 높은 컬럼 | 적합 | user_id, device_id처럼 값 종류가 많은 컬럼 |
| 카디널리티가 낮은 컬럼 | 부적합 | 국가나 상태 코드처럼 값 종류가 적은 컬럼은 파티셔닝이 맞다 |
| 통계 미수집 컬럼 | 부적합 | Unity Catalog 외부 테이블은 기본적으로 앞쪽 32개 컬럼의 통계를 수집한다. 관리형 테이블은 Predictive Optimization이 필터에 자주 쓰는 컬럼을 선택한다 |
| 파티션 컬럼 | 불가 | 이미 디렉터리로 나뉘어 있어 ZORDER BY에 넣으면 오류가 난다 |
| 4개 이상 컬럼 | 비권장 | 컬럼이 늘수록 컬럼 하나로 본 클러스터링 효과가 떨어진다 |
5.2 파티셔닝과의 조합
자주 쓰는 구성은 날짜처럼 카디널리티가 낮은 컬럼으로 파티셔닝하고 파티션 안을 카디널리티가 높은 컬럼으로 Z-Order하는 방식이다. PARTITIONED BY (event_date)와 ZORDER BY (user_id)를 함께 쓰는 식이다. OPTIMIZE는 데이터가 새로 들어오는 최근 파티션에만 주기적으로 실행한다.
5.3 운영 시 주의점
| 항목 | 내용 |
|---|---|
| 증분 처리 한계 | ZORDER BY는 멱등하지 않다. 새 데이터가 들어오면 다시 실행해야 하고 기존 파일까지 다시 쓰일 수 있다 |
| 키 변경 비용 | Z-Order 컬럼을 바꾸면 해당 범위 전체를 다시 정렬해야 한다 |
| 동시 쓰기 충돌 | INSERT와는 충돌하지 않지만 UPDATE·DELETE·MERGE와 겹치면 ConcurrentDeleteReadException, ConcurrentDeleteDeleteException 같은 충돌 예외로 한쪽이 실패할 수 있다 |
| 연산 비용 | 셔플과 정렬이 들어가므로 조회가 잦은 테이블에만 적용한다 |
6. Liquid Clustering과의 비교
Databricks는 새로 만드는 테이블에 파티셔닝과 Z-Order 대신 Liquid Clustering을 권장한다. 클러스터링 키는 테이블을 만들 때 선언하고 이후 OPTIMIZE는 아직 정렬되지 않은 데이터를 중심으로 증분 처리한다.
CREATE TABLE events (...) CLUSTER BY (user_id, event_type);
ALTER TABLE events CLUSTER BY (device_id); -- 키 변경: 기존 데이터 즉시 재작성 없음
OPTIMIZE events; -- 증분 클러스터링
| 항목 | Z-Order | Liquid Clustering |
|---|---|---|
| 증분 처리 | 제한적 | 지원 |
| 키 변경 | 대상 범위 재작성 | 메타데이터를 변경하고 이후 OPTIMIZE에서 점진적으로 재배치 |
| 파티셔닝 병행 | 가능 | 불가 (파티셔닝을 대체) |
| 키 자동 선택 | 없음 | CLUSTER BY AUTO 지원 |
| 선언 위치 | OPTIMIZE 실행 시마다 지정 | 테이블 속성으로 1회 선언 |
파티셔닝과 Z-Order로 이미 운영 중이고 쿼리 패턴이 바뀌지 않는 테이블은 그대로 둬도 된다. 신규 테이블이나 쿼리 패턴이 자주 바뀌는 테이블, 파티션 크기가 들쭉날쭉한 테이블에는 Liquid Clustering을 먼저 검토한다. 두 방식은 함께 쓰지 못하므로 전환할 때 기존 ZORDER BY 작업을 제거한다.
7. 정리
- Delta Lake는 파일별 min/max 통계로 조건에 맞지 않는 파일을 건너뛰며, 비슷한 값이 같은 파일에 모여 있어야 효과가 난다.
ORDER BY a, b로 정렬하면 첫 번째 컬럼만 모이고 두 번째 컬럼은 여러 파일에 흩어진다.- Z-Order는 비트를 교차한 z-value로 정렬한다. 파일 하나가 정사각형에 가까운 블록을 맡으므로 어느 컬럼으로 필터해도 읽는 파일이 고르게 준다.
- 8×8 예시에서 Z-Order는 x 필터와 y 필터 모두 16개 중 4개를 읽었고
ORDER BY x, y는 각각 2개와 8개를 읽었다. - 컬럼은 필터에 자주 쓰는 고카디널리티 컬럼 1~3개로 고르고, 파티션 컬럼과 통계를 수집하지 않는 컬럼은 제외한다.
- OPTIMIZE는 파일을 다시 쓰는 비용이 커서 최근 파티션 위주로 실행하고 효과는
DESCRIBE HISTORY와 스캔 지표로 확인한다. 신규 테이블에서는 증분 처리와 키 변경을 지원하는 Liquid Clustering을 먼저 검토한다.
Leave a comment