PostgreSQL Lakehouse (2/2) — 분산 처리와 Citus 통합
- 1. PostgreSQL Lakehouse (1/2) — DuckLake가 막힌 자리에 pg_lake가 있었다
- 2. PostgreSQL Lakehouse (2/2) — 분산 처리와 Citus 통합
1편에서 DuckLake의 libpq 심볼 충돌과 pg_lake를 발견한 과정을 다뤘습니다. 이번 글은 그 이후의 실험들입니다. DuckDB 단일 노드의 처리 한계를 넘으려 할 때 어떤 분산 처리 옵션이 있는지, PostgreSQL DW(Citus)와 Lakehouse(pg_lake)를 통합하려면 어떻게 해야 하는지, 그리고 새로운 파일 포맷인 Vortex는 아직 시기상조인지를 다룹니다.
Phase 5: 분산 처리 — Ray vs Daft vs Smallpond
DuckDB는 단일 노드에서 수십 GB~수백 GB 분석을 빠르게 처리합니다. TB 스케일이 되거나 여러 노드로 부하를 분산해야 할 때는 별도 레이어가 필요합니다. 세 가지 옵션을 MinIO와 DuckLake 양쪽에서 테스트했습니다.
Ray + DuckDB
Ray는 Python 분산 컴퓨팅 프레임워크입니다. 원래 강화학습 연구에서 시작했지만 지금은 데이터 처리, ML 학습, 서빙까지 쓰입니다. DuckDB와 조합하면 각 Ray 워커 노드에서 DuckDB 인스턴스가 독립적으로 실행됩니다.
import ray
import duckdb
@ray.remote
def process_partition(s3_path: str) -> pd.DataFrame:
conn = duckdb.connect()
conn.execute("INSTALL httpfs; LOAD httpfs;")
conn.execute(f"SET s3_endpoint='minio:9000'; SET s3_use_ssl=false;")
return conn.execute(f"""
SELECT region, SUM(amount) as total
FROM read_parquet('{s3_path}')
GROUP BY region
""").df()
# 파티션별 병렬 처리
futures = [process_partition.remote(path) for path in partition_paths]
results = ray.get(futures)MinIO S3 호환 스토리지와 DuckLake 모두 정상 동작했습니다. Ray가 워커 수명주기, 실패 복구, 결과 수집을 관리하고 DuckDB가 각 파티션의 데이터를 처리합니다. PySpark를 운영해본 팀이라면 친숙한 패턴입니다.
점수: 9/10. 단점은 Ray 클러스터 운영 오버헤드입니다.
Daft
Daft는 Eventual과 Cohere가 개발한 분산 DataFrame 프레임워크입니다. Rust로 작성됐고, Python DataFrame API(pandas/Spark 유사)를 제공합니다. Iceberg를 네이티브로 지원합니다.
import daft
# Iceberg 테이블 직접 읽기
df = daft.read_iceberg(table)
# Lazy evaluation — 실행 계획만 구성
result = (df
.where(df["year"] == 2025)
.groupby("region")
.agg(daft.col("amount").sum())
)
# collect() 시점에 실제 실행
result.collect()Daft의 Lazy evaluation은 Spark와 비슷합니다. collect() 전까지는 실행 계획만 만들고, 실행 시점에 최적화합니다. Rust 코어 덕분에 Python GIL 제약이 없어 Ray+DuckDB보다 설정이 단순합니다.
MinIO와 DuckLake 모두 PASS. 점수: 9/10.
Smallpond
MotherDuck(DuckDB 클라우드 서비스 회사)이 만든 분산 DuckDB 레이어입니다. DuckDB 위에서 투명하게 분산 처리를 추가해줄 것으로 기대했습니다.
실제로는 MinIO S3 호환 스토리지 연결에서 바로 막혔습니다.
import smallpond
sp = smallpond.init()
# MinIO S3 호환 URL 사용 시 — 실패
df = sp.read_parquet("s3://bucket/data/*.parquet",
endpoint_url="http://minio:9000")
# Error: unsupported S3 endpoint configurationDuckLake도 실패했습니다. 에러 메시지가 불명확해 디버깅하기 어려웠습니다. MinIO와 DuckLake 모두 FAIL. 점수: 3/10.
Smallpond의 실패는 S3 호환 스토리지 지원이 문서와 달리 제한적이라는 것을 보여줬습니다. 상용 MotherDuck 클라우드 환경에서는 다를 수 있지만 자체 인프라에서는 아직 쓰기 어렵습니다.
| 프레임워크 | MinIO | DuckLake | 점수 | 특징 |
|---|---|---|---|---|
| Ray + DuckDB | PASS | PASS | 9/10 | 성숙한 생태계, PySpark 유사 운영 |
| Daft | PASS | PASS | 9/10 | Rust 코어, Iceberg 네이티브, 더 단순 |
| Smallpond | FAIL | FAIL | 3/10 | S3 호환 제한, 불명확한 에러 |
Phase 6: Vortex 파일 포맷 — 아직은 이르다
Vortex는 Spiraldb가 Rust로 개발한 차세대 컬럼형 파일 포맷입니다. Parquet의 후계자를 자처하며 다음을 주장합니다.
- Random access: Parquet 대비 100배 빠름
- Full scan: Parquet 대비 10~20배 빠름
- SIMD 친화적 인코딩, 더 나은 압축율
DuckDB 1.3+는 Vortex를 네이티브로 지원합니다.
import duckdb
conn = duckdb.connect()
# Parquet → Vortex 변환
conn.execute("""
COPY (SELECT * FROM read_parquet('data.parquet'))
TO 'data.vortex'
""")
# Vortex 직접 읽기
result = conn.execute("SELECT * FROM 'data.vortex' WHERE id = 12345").df()
# Random access가 Parquet 대비 100x 빠름DuckDB와 Polars에서는 Vortex가 잘 동작했습니다. 문제는 Trino가 Vortex를 지원하지 않는다는 것입니다. Spark도 아직 지원하지 않습니다.
멀티엔진 Lakehouse 목표를 갖고 있다면 Vortex는 현재 시점에서 채택 불가입니다. 데이터를 Vortex로 저장하면 DuckDB에서만 읽을 수 있게 됩니다. DuckDB 단독 환경이라면 의미있는 개선이지만, Trino/Spark와 공유해야 한다면 아직 이릅니다.
Vortex를 "보류 중인 흥미로운 기술"로 분류하고 Phase 7로 넘어갔습니다.
Phase 7: Citus + pg_duckdb — 같은 프로세스에서 충돌
Citus는 PostgreSQL을 수평 분산하는 extension입니다. 여러 노드에 테이블을 샤딩해 수평 확장을 제공합니다. 기존에 Citus 기반 DW가 있는 상황에서 pg_lake Lakehouse와 통합하려면 두 가지 접근이 있습니다.
- 같은 PostgreSQL 인스턴스에서 Citus + pg_duckdb를 함께 로드
- 별도 인스턴스로 분리하고 postgres_fdw로 연결
Phase 7에서 먼저 1번을 시도했습니다.
-- PostgreSQL에 두 extension 모두 설치 후 로드
CREATE EXTENSION citus;
CREATE EXTENSION pg_duckdb;
-- → CRASHPostgreSQL 서버 크래시. 로그를 분석하면 Citus와 pg_duckdb가 PostgreSQL 내부 훅(hook)에서 충돌합니다.
충돌의 기술적 원인:
Citus 는 PostgreSQL 쿼리 실행 경로를 깊이 수정합니다. planner_hook, executor_hook, ProcessUtility_hook 등을 등록해 분산 쿼리 계획 수립과 실행을 가로챕니다. 내부 메모리 관리도 커스텀합니다.
pg_duckdb 는 DuckDB 엔진을 PostgreSQL 프로세스 내부에 임베드합니다. DuckDB는 자체 메모리 할당자(jemalloc), 스레드 풀, 파일 I/O 시스템을 가집니다. pg_duckdb도 PostgreSQL 내부 훅들을 등록합니다.
두 extension이 같은 PostgreSQL 프로세스에 로드되면 훅 체인에서 충돌이 발생합니다. DuckDB의 jemalloc과 PostgreSQL의 palloc 메모리 관리자가 서로를 인식하지 못하고 충돌합니다. 이것은 GitHub Issue #444로 공개 등록돼 있으며, Citus와 pg_duckdb 양쪽 모두 수정 계획이 없다고 명시했습니다.
같은 인스턴스에서 Citus + pg_duckdb(따라서 pg_lake)를 함께 쓰는 것은 현재 불가능합니다.
Phase 8: Citus + pg_lake — 별도 인스턴스 + postgres_fdw
Phase 7 실패 후 접근 방식을 바꿨습니다. Citus DW와 pg_lake Lakehouse를 완전히 별도 PostgreSQL 인스턴스로 분리하고 postgres_fdw로 연결합니다.
Citus 12.1 (PostgreSQL 16, DW) ←——postgres_fdw——→ pg_lake 3.3 (PostgreSQL 18, Lakehouse)
[분산 쿼리, OLTP] [Iceberg 테이블, 분석]
↑ ↓
기존 애플리케이션 MinIO (Parquet)
↑
DuckDB standalonepostgres_fdw는 PostgreSQL에 내장된 표준 FDW(Foreign Data Wrapper)입니다. 원격 PostgreSQL 서버에 연결해 테이블을 마치 로컬 테이블처럼 읽고 쓸 수 있게 합니다. 크로스 버전(PG16 → PG18)도 PostgreSQL 와이어 프로토콜이 하위 호환이므로 동작합니다.
-- Citus(PG16)에서 pg_lake(PG18) 연결 설정
CREATE SERVER pglake_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'pglake-host', port '5439', dbname 'warehouse');
CREATE USER MAPPING FOR current_user
SERVER pglake_server
OPTIONS (user 'pg_lake_user', password '...');
IMPORT FOREIGN SCHEMA public
FROM SERVER pglake_server
INTO lake_schema;
-- pg_lake Iceberg 테이블을 Citus에서 직접 조회
SELECT region, SUM(amount)
FROM lake_schema.orders -- 실제로는 pg_lake Iceberg 테이블
WHERE order_date >= '2025-01-01'
GROUP BY region;Citus에서 이 쿼리를 실행하면 postgres_fdw가 pg_lake 서버로 쿼리를 전달합니다. pg_lake는 Iceberg Parquet 파일에서 데이터를 읽어 결과를 반환합니다.
측정 결과:
| 연산 | 응답시간 | 비고 |
|---|---|---|
| SELECT (cold, 집계 포함) | 102ms | aggregation pushdown 동작 |
| INSERT via FDW → Iceberg | 205ms | Iceberg 스냅샷 자동 생성 |
| UPDATE via FDW → Iceberg | 99ms | 행 단위 갱신 |
| DELETE via FDW → Iceberg | 70ms | 행 단위 삭제 |
aggregation pushdown이 동작한다는 점이 중요합니다. GROUP BY와 SUM() 같은 집계를 Citus가 pg_lake 서버로 밀어내(push down) pg_lake에서 처리합니다. 모든 행을 Citus로 가져온 뒤 집계하는 게 아닙니다.
3-way 일관성 검증
Citus FDW에서 INSERT한 데이터를 세 경로에서 읽었습니다.
-- 1. pg_lake 직접 조회
SELECT * FROM orders WHERE order_id = 999;
-- → 반환
-- 2. Citus FDW 경유 조회 (lake_schema.orders)
SELECT * FROM lake_schema.orders WHERE order_id = 999;
-- → 동일 결과
-- 3. DuckDB standalone에서 iceberg_scan()
-- (pg_lake MinIO의 동일 Parquet 파일 직접 읽기)
SELECT * FROM iceberg_scan('s3://pg-lake-warehouse/iceberg/public/orders/metadata/...');
-- → 동일 결과세 경로 모두 동일한 데이터를 반환합니다. Iceberg 스냅샷 기반이므로 가능한 일관성입니다. Citus FDW를 통해 INSERT하면 pg_lake가 새 Iceberg 스냅샷을 생성하고, DuckDB가 그 스냅샷의 metadata.json을 참조하면 즉시 반영됩니다.
최종 아키텍처 권장
8개 Phase 결과를 종합한 프로덕션 권장 구성입니다.
[ad-hoc 분석 / OLTP-like 쿼리] [배치 분산 처리] [외부 엔진]
psql, JDBC 애플리케이션 Ray + DuckDB Spark
↓ Daft Trino
PostgreSQL 18 (pg_lake) ←———————FDW——————→ Citus DW (PG16)
↓
MinIO / GCS (Iceberg v2 Parquet)
↑
DuckDB standalone (iceberg_scan)| 시나리오 | 권장 |
|---|---|
| PostgreSQL 중심 Lakehouse (소규모~중규모) | pg_lake + Iceberg |
| 대규모 멀티엔진 (Spark+Trino 헤비) | Iceberg + Nessie + Spark/Trino |
| 단순 분석 (1TB 이하, 단일 엔진) | DuckDB + PostgreSQL 카탈로그 |
| DuckDB 분산 처리 | Ray + DuckDB 또는 Daft |
| 기존 Citus DW + Lakehouse 연결 | Citus + pg_lake + postgres_fdw |
| DuckDB 단독 환경 | DuckLake (standalone) |
이 실험에서 얻은 것들
DuckLake는 DuckDB 전용 도구입니다. PostgreSQL 카탈로그를 지원한다는 문서가 있지만, pg_duckdb 환경에서는 libpq 심볼 충돌로 동작하지 않습니다. DuckDB standalone만 쓰는 환경에서는 간단하고 좋은 선택입니다.
GCS cold read는 NVMe 대비 650배 느립니다. 클라우드 Lakehouse 아키텍처를 설계할 때 첫 번째 쿼리의 응답 시간을 반드시 고려해야 합니다. 캐싱 전략 없이 GCS에서 실시간 분석을 기대하는 건 현실적이지 않습니다.
Citus + pg_duckdb는 현재 같은 인스턴스에서 불가능합니다. 별도 인스턴스 + postgres_fdw가 유일한 현실적 경로입니다. INSERT/UPDATE/DELETE via FDW가 모두 동작하고 3-way 일관성이 확인됐습니다.
pg_lake는 2025년 11월 기준 가장 완성도 높은 PostgreSQL Lakehouse 솔루션입니다. GA 이전이므로 프로덕션 채택 전에 안정성 검증이 필요하지만, 기능적으로는 원래 목표를 가장 잘 충족했습니다.
Ray+DuckDB와 Daft는 둘 다 실용적입니다. Smallpond는 아직 자체 인프라에서 쓰기 어렵습니다. Vortex는 DuckDB 전용 환경에서만 의미있고 멀티엔진 Lakehouse에서는 시기상조입니다.