레이크하우스 표 형식 — Apache Iceberg 를 metadata 로 이해한다
Spark 가 만들고 파이썬이 쓰고 DuckDB 가 읽는다 — 한 표를 세 엔진이
목표
Spark 가 만든 표에 pyiceberg 가 하루치를 더 쓰고, DuckDB 와 pyiceberg 가 그 표를 읽고, 다시 Spark 가 모든 엔진이 쓴 행을 모아 집계한다. 그 사이에 옛 metadata 경로를 붙들고 읽는 '낡은 포인터' 함정과, 한 엔진의 스키마 변경이 다른 엔진에 어떻게 보이는지를 확인한다.
왜 중요한가
표 형식의 가치는 엔진을 고르지 않는다는 데 있다. 배치는 Spark 로, 작은 적재는 파이썬으로, 즉석 분석은 DuckDB 로 해도 모두 같은 metadata 와 같은 Parquet 파일을 본다. 그런데 이 약속에는 조건이 붙는다. 모든 엔진이 같은 카탈로그를 거쳐 '지금' metadata 를 찾아야 하고, 같은 스펙 기능(형식 판·삭제 파일·타입)을 지원해야 한다. metadata.json 경로를 직접 넘겨 읽는 도구는 편하지만 그 경로가 가리키는 순간에 고정된다. 누군가 그 뒤에 커밋하면 여러분은 옛 표를 읽고 있는데 아무 경고도 없다. 타입도 조심해야 한다 — Spark 의 TIMESTAMP 는 Iceberg 의 timestamptz 라서, 시간대 없는 시각을 주는 파이썬 코드는 스키마 검사에 걸린다. 반대로 열 이름 바꾸기처럼 필드 ID 로 풀리는 변경은 모든 엔진에 그대로 보인다.
단계
- /root/ice/eng/spark.py(앱
ice-eng-spark)로lake.eng.orders를days(order_ts)로 나누어 만들고 2026-03-01 부터 03-07 까지 일곱 파일을 한 번에 커밋하세요. - /root/ice/eng/py_append.py(pyiceberg)로 2026-03-08 을 같은 표에 추가하세요.
- /root/ice/eng/duck.py(DuckDB)로 지금 metadata 를 읽어 지역별
amount합계를 /root/ice/eng/out/duck_region.json 에 쓰세요. - /root/ice/eng/py_read.py(pyiceberg)로
region = 'seoul'이고order_ts >= 2026-03-05인 행 수를 /root/ice/eng/out/py_count.json 에 쓰세요. - 지금 metadata 경로를 적어 둔 뒤 /root/ice/eng/append.py(앱
ice-eng-append)로 2026-03-09 를 커밋하고, /root/ice/eng/stale.py 로 옛 경로와 새 경로를 각각 DuckDB 로 읽어 /root/ice/eng/out/stale.json 에 쓰세요. - /root/ice/eng/rename.py(앱
ice-eng-rename)로region을area로 바꾸고, /root/ice/eng/columns.py 로 pyiceberg 와 DuckDB 가 보는 열 이름을 /root/ice/eng/out/rename.json 에 쓰세요. - /root/ice/eng/daily.py(앱
ice-eng-daily)로 날마다 주문 수·금액 합계 표lake.eng.daily(d, orders, amount)를 만드세요. - /root/ice/eng/report.md 에
## 한 표, 세 엔진## 낡은 포인터## 이름 바꾸기세 절을 쓰세요.
참고
- DuckDB 는
con.execute("LOAD iceberg")뒤iceberg_scan('<metadata.json 경로>')로 읽습니다. 경로는ice-loc eng.orders가 찍어 줍니다. 확장은 이미지에 미리 넣어 두었습니다(인터넷이 없습니다). - 스냅샷을 누가 썼는지는 요약에서 보입니다 — Spark 는
engine-name: spark와app-id를 남기고 pyiceberg 는 남기지 않습니다(SELECT summary FROM lake.eng.orders.snapshots). - 흔한 실수: 2단계에서 시간대 없는
timestamp로 추가하려다 스키마 불일치로 막히는 것, 5단계에서 옛 경로를 커밋 뒤에 읽어 두 경로가 같아지는 것. - 공식 문서: Multi-Engine Support · pyiceberg — API · DuckDB — Iceberg extension · Spec — Primitive Types
Spark 가 표를 만든다
/root/ice/eng/spark.py 를 앱 이름 ice-eng-spark 로 만들어 lake.eng.orders(열 여섯 개, PARTITIONED BY (days(order_ts)), 'format-version' = '2')를 만들고 2026-03-01 부터 03-07 까지 일곱 파일을 한 번의 append() 로 넣으세요.
이 스냅샷의 요약에는 engine-name 이 spark 로 남습니다. 채점기는 첫 커밋의 행 수와 작성 엔진을 봅니다.
파이썬이 같은 표에 쓴다
/root/ice/eng/py_append.py 로 2026-03-08 파일을 pyarrow 로 읽고(amount int32, order_ts 는 UTC 시간대의 timestamp), load_catalog("lake").load_table("eng.orders").append(…) 로 추가하세요.
pyiceberg 는 쓰기 전에 pyarrow 스키마를 표 스키마와 견줍니다. order_ts 가 시간대 없는 timestamp 면 timestamptz 와 맞지 않는다고 거절합니다. 파티션(days) 값은 pyiceberg 가 직접 계산해 매니페스트에 적습니다. 채점기는 두 번째 커밋이 3월 8일 행 수를 더했는지, Spark 가 아닌 엔진이 썼는지 봅니다.
DuckDB 가 읽는다
/root/ice/eng/duck.py 로 ice-loc eng.orders 가 찍는 경로를 iceberg_scan() 에 넣어 지역별 sum(amount) 를 구하고 /root/ice/eng/out/duck_region.json 에 {"지역": 합계, …} 로 쓰세요.
DuckDB 는 카탈로그를 거치지 않고 metadata.json 한 파일에서 시작해 매니페스트를 따라 내려갑니다. Spark 가 쓴 파일과 pyiceberg 가 쓴 파일이 같은 목록에 있으니 둘 다 읽힙니다. 채점기는 3월 1–8일 원본으로 계산한 합계와 견줍니다.
pyiceberg 가 조건으로 읽는다
/root/ice/eng/py_read.py 로 region == 'seoul' 그리고 order_ts >= 2026-03-05T00:00:00+00:00 인 행 수를 세어 /root/ice/eng/out/py_count.json 에 {"rows": 정수} 로 쓰세요.
pyiceberg 의 조건은 먼저 매니페스트의 파티션 값(날짜)과 열 통계로 파일을 고르고, 고른 파일 안에서 행을 거릅니다. 채점기는 3월 5–8일 원본으로 계산한 값과 견줍니다.
낡은 포인터 — 옛 경로는 옛 표다
OLD=$(ice-loc eng.orders) 로 지금 경로를 적어 두고, /root/ice/eng/append.py(앱 ice-eng-append, 날짜 인자)로 2026-03-09 를 커밋한 뒤 NEW=$(ice-loc eng.orders) 를 읽으세요. /root/ice/eng/stale.py 가 두 경로를 인자로 받아 각각 DuckDB 로 행 수를 세어 /root/ice/eng/out/stale.json 에 {"old_path", "old_rows", "new_path", "new_rows"} 로 쓰게 하세요.
metadata 파일은 한 번 쓰이면 바뀌지 않습니다. 옛 경로로 읽으면 언제 읽든 그 순간의 표가 나옵니다 — 타임트래블에는 쓸모 있지만, '지금' 을 읽으려던 대시보드라면 조용히 낡은 숫자를 보여 줍니다. 채점기는 두 경로가 metadata 이력에 있는지, 행 수가 그때의 total-records 와 같은지 봅니다.
한 엔진이 바꾼 이름을 다른 엔진이 본다
/root/ice/eng/rename.py(앱 ice-eng-rename)로 ALTER TABLE lake.eng.orders RENAME COLUMN region TO area 를 돌리고, /root/ice/eng/columns.py 로 pyiceberg 의 tbl.schema() 열 이름과 DuckDB 의 iceberg_scan() 결과 열 이름을 /root/ice/eng/out/rename.json 에 {"pyiceberg": [...], "duckdb": [...]} 로 쓰세요.
이름 바꾸기는 metadata 의 스키마만 바꾸고, 파일은 옛 이름(region)을 그대로 들고 있습니다. 필드 ID 로 짝을 맞추는 엔진은 새 이름으로 옛 파일의 값을 읽습니다. 채점기는 두 목록에 area 가 있고 region 이 없는지 봅니다.
Spark 가 모든 엔진의 행을 모은다
/root/ice/eng/daily.py 를 앱 이름 ice-eng-daily 로 만들어 lake.eng.orders 를 날짜(to_date(order_ts))로 묶어 주문 수와 amount 합계를 lake.eng.daily(d, orders, amount) 로 만드세요(CREATE TABLE … AS SELECT).
3월 8일 행은 pyiceberg 가, 나머지는 Spark 가 썼습니다. 읽는 엔진에게는 차이가 없습니다. 날짜 경계는 세션 시간대(UTC)로 잘립니다. 채점기는 3월 1–9일 원본으로 계산한 날마다의 값과 견줍니다.
여러 엔진을 한 표에 붙일 때의 규칙
/root/ice/eng/report.md 에 ## 한 표, 세 엔진 ## 낡은 포인터 ## 이름 바꾸기 세 절을 쓰세요. 둘째 절에는 5단계의 old_rows 와 new_rows 를 숫자로 넣으세요.
여러분 팀에 새 엔진(예: 사내 BI 도구)을 붙인다면 무엇을 먼저 확인하겠습니까 — 카탈로그를 거치는가, 어떤 형식 판·삭제 파일을 읽는가, timestamptz 를 어떻게 다루는가.