8 min read
AI assisted

PostgreSQL Lakehouse (1/2) — DuckLake가 막힌 자리에 pg_lake가 있었다

DuckLake의 libpq 충돌을 거쳐 pg_lake로 이어진 8개 Phase Lakehouse 실험

Series — PostgreSQL Lakehouse Experiments
  1. 1. PostgreSQL Lakehouse (1/2) — DuckLake가 막힌 자리에 pg_lake가 있었다
  2. 2. PostgreSQL Lakehouse (2/2) — 분산 처리와 Citus 통합

아이디어는 단순했습니다. PostgreSQL을 Lakehouse 카탈로그로 쓰는 것. DuckDB, Spark, Trino 중 어떤 엔진에서 쿼리하든 같은 테이블에 접근하고, 스냅샷·파티션·파일 위치 같은 메타데이터는 PostgreSQL이 관리합니다. Databricks Unity Catalog나 AWS Glue 같은 외부 카탈로그 없이, 이미 운영 중인 PostgreSQL 하나로 해결하겠다는 발상이었습니다.

8개 Phase에 걸쳐 실험했고, 결론은 시작점으로 삼았던 DuckLake가 아닌 2025년 11월 공개된 신규 프로젝트에서 나왔습니다.


왜 이걸 만들려 했나

전통적인 데이터 아키텍처에서 OLTP(트랜잭션 처리)와 OLAP(분석)은 분리됩니다. 전자는 PostgreSQL, 후자는 Redshift나 BigQuery 같은 별도 시스템을 씁니다. Lakehouse는 이 경계를 허무는 아키텍처입니다. 오브젝트 스토리지(S3, GCS)에 오픈 포맷(Parquet, Iceberg)으로 데이터를 두고, 여러 엔진이 같은 데이터를 읽고 쓰는 구조입니다.

[DuckDB standalone]  [pg_duckdb]  [PySpark]  [Trino]
         ↓                ↓           ↓          ↓
    PostgreSQL (카탈로그 — 테이블 메타데이터, 스냅샷, 파일 경로)
         ↓
    MinIO / GCS (Parquet 파일 저장)

이 구조의 핵심 가정은 PostgreSQL이 "메타데이터 저장소 + 트랜잭션 조율자" 역할을 할 수 있다는 것입니다. 각 엔진이 PostgreSQL에 연결해 "어느 파일을 읽어야 하는지" 물어보고, 쓰기 시에는 스냅샷을 PostgreSQL에 기록합니다.

이 설계를 실현하는 첫 번째 후보가 DuckLake였습니다.


Phase 1–2: DuckLake — 카탈로그가 연결되지 않는다

DuckLake는 DuckDB 팀이 2024년에 발표한 Lakehouse 테이블 포맷입니다. 공식 문서에 PostgreSQL 카탈로그 지원이 명시돼 있었습니다. MinIO를 스토리지로 쓰는 로컬 환경에서 기본 기능을 먼저 확인했습니다.

DuckDB standalone에서 DuckLake 테이블을 만들고 INSERT/SELECT/UPDATE/DELETE를 실행하면 정상 동작합니다.

# DuckDB standalone — 정상
import duckdb
conn = duckdb.connect()
conn.execute("INSTALL ducklake; LOAD ducklake;")
conn.execute("""
    ATTACH 'ducklake:postgres:host=localhost dbname=catalog' AS lake
""")
conn.execute("CREATE TABLE lake.orders (id INT, amount FLOAT, ts TIMESTAMP)")
conn.execute("INSERT INTO lake.orders VALUES (1, 99.9, NOW())")

이건 됩니다. 문제는 PostgreSQL 안에서 실행되는 pg_duckdb에서 같은 ATTACH를 시도할 때 발생했습니다.

-- pg_duckdb 안에서 실행 — 실패
SELECT duckdb.raw_query($$ ATTACH 'ducklake:postgres:...' AS lake $$);
ERROR: symbol lookup error: /usr/lib/postgresql/17/lib/pg_duckdb.so:
undefined symbol: PQconnectdb

libpq 심볼 충돌. 이 에러가 왜 나는지 이해하려면 pg_duckdb의 동작 방식을 알아야 합니다.

pg_duckdb는 DuckDB 엔진을 PostgreSQL 프로세스 안에 동적 라이브러리로 임베드합니다. PostgreSQL 프로세스(postgres)가 실행되면 pg_duckdb 라이브러리가 로드되고, 그 안에 DuckDB가 들어있습니다.

DuckLake의 PostgreSQL 카탈로그 연결은 DuckDB 내부에서 libpq(PostgreSQL 클라이언트 라이브러리)를 통해 PostgreSQL에 재연결하는 방식으로 구현됩니다. 그런데 이미 PostgreSQL 프로세스 자체가 libpq를 로드하고 있습니다. 같은 프로세스 공간에 libpq가 두 번 로드되면서 심볼이 충돌합니다.

postgres 프로세스 메모리:
  [PostgreSQL 런타임]  ← libpq 이미 로드됨
  [pg_duckdb.so]
    [DuckDB 엔진]
      [DuckLake 카탈로그 커넥터]  ← PQconnectdb() 호출 시도
        → symbol lookup: PQconnectdb
        → 충돌! 어느 libpq를 써야 하는지 모름

이것은 런타임 설정이나 환경 변수로 우회할 수 없는 구조적 문제입니다. DuckLake GitHub 이슈를 확인해봐도 수정 계획이 없었습니다.

파악한 DuckLake의 실제 지원 범위:

환경 PostgreSQL 카탈로그 Trino Spark
DuckDB standalone PASS FAIL 제한적
pg_duckdb FAIL (libpq 충돌)

DuckLake는 DuckDB standalone에서만 완전히 동작하는 포맷입니다. 멀티엔진 Lakehouse 목표에는 맞지 않습니다.

Phase 2에서 PySpark는 PostgreSQL에 직접 JDBC로 연결해 분산 쿼리가 성공했습니다. PGSpider는 여러 노드의 Parquet를 "분산 처리"한다고 홍보하지만 실제로는 각 노드 결과를 UNION ALL로 합치는 래퍼에 불과했습니다. 분산 처리가 아닙니다.


Phase 3: GCS에서 TPC-H SF=100 — 스토리지 성능의 현실

DuckLake의 한계를 확인한 뒤 테이블 포맷을 Apache Iceberg로 바꿨습니다. Iceberg는 Netflix가 처음 만든 오픈 테이블 포맷으로 현재 업계 표준입니다. Parquet 파일에 스냅샷 메타데이터를 더해 시간 여행, 스키마 진화, 멀티엔진 읽기/쓰기를 지원합니다.

스토리지도 로컬 MinIO에서 GCS(Google Cloud Storage)로 전환하고 TPC-H SF=100 전체를 실행했습니다.

측정 결과
실행 쿼리 수 22개 전체
성공률 100%
데이터 규모 ~100GB / ~866M 행
GCS cold query 속도 NVMe 대비 약 650배 느림
GCS + 세션 캐시 (재쿼리) NVMe 대비 약 2.6배 느림

22개 쿼리가 전부 성공했습니다. 하지만 첫 번째 GCS cold query가 NVMe 대비 650배 느리다는 수치가 의미심장합니다.

GCS에서 Parquet 파일을 읽는다는 건 HTTP over TLS로 오브젝트 스토리지에 요청을 보내는 것입니다. 100GB 파일을 읽으면 네트워크 전송이 발생합니다. NVMe는 수 GB/s, GCS 네트워크는 수백 MB/s 수준이고 레이턴시 특성도 전혀 다릅니다.

세션 캐시가 활성화되면 두 번째 실행에서 2.6배 수준으로 회복됩니다. 하지만 이건 같은 쿼리를 반복할 때만 해당합니다. 새로운 필터 조건이나 다른 테이블은 다시 cold입니다.

GCS 기반 Lakehouse에서 "빠른 ad-hoc 분석"을 기대한다면 캐싱 전략이 선제 조건입니다. 캐시 미스 하나가 수백 배 레이턴시를 유발합니다.


Phase 4: Iceberg + Nessie 멀티엔진 검증

Iceberg가 포맷으로 정착됐지만 문제가 남았습니다. Iceberg 테이블의 메타데이터를 누가 관리하는가. 엔진들이 "최신 스냅샷이 어디 있는지" 알려면 카탈로그 서비스가 필요합니다.

가장 많이 쓰이는 오픈소스 카탈로그가 Nessie입니다. Netflix, Dremio 등이 기여하는 프로젝트로 REST API로 Iceberg 카탈로그를 제공합니다. git처럼 branch와 commit 개념을 지원합니다.

Nessie 카탈로그를 쓰는 세 엔진에서 같은 Iceberg 테이블에 접근해 TPC-H Q1(SF=1, 600만 행)을 측정했습니다.

엔진 TPC-H Q1 실행시간 비고
pg_duckdb 0.37초 iceberg_scan() 직접 호출
Trino 2.36초 Nessie REST Catalog 경유
Spark 10.8초 JDBC Catalog + S3FileIO

pg_duckdb가 가장 빠릅니다. 이유는 iceberg_scan() 함수가 DuckDB의 컬럼형 실행 엔진으로 Iceberg 파일을 직접 읽기 때문입니다. Trino는 분산 처리에 최적화돼 단일 노드 소규모 쿼리에서는 오버헤드가 있습니다. Spark는 JVM 기동 비용이 반영됩니다.

세 엔진이 동일한 Iceberg 스냅샷을 읽고 같은 결과를 반환했습니다. 상호운용성 목표는 달성됐습니다.

그런데 Nessie는 별도 서비스입니다. Docker 컨테이너를 하나 더 관리해야 합니다. 원래 목표였던 "PostgreSQL이 카탈로그"와 다릅니다. Nessie가 카탈로그고, PostgreSQL은 그냥 애플리케이션 DB입니다.


Phase 4.5: pg_lake — 목표에 가장 가까운 프로젝트 발견

Snowflake Labs가 2025년 11월 오픈소스로 공개한 pg_lake는 Apache Iceberg를 PostgreSQL에 직접 통합합니다.

공식 설명을 그대로 인용하면: "pg_lake is a PostgreSQL extension that natively implements Apache Iceberg, enabling PostgreSQL to act as a full Lakehouse catalog." — 별도 카탈로그 서비스 없이 PostgreSQL 자체가 Iceberg DDL/DML을 처리합니다.

아키텍처

클라이언트 (psql / JDBC / 애플리케이션)
              ↓ Port 5439
PostgreSQL 18.1 + pg_lake 3.3
  (pg_lake_iceberg: Iceberg v2 구현)
  (pg_lake_table: 파일 FDW)
  (pg_lake_engine: 쿼리 라우팅)
              ↓ Unix Socket
pgduck_server (DuckDB 컬럼형 실행 엔진)
              ↓
MinIO S3 (Iceberg Parquet 파일)

핵심은 pgduck_server입니다. PostgreSQL에서 집계·스캔 쿼리가 발생하면 pg_lake가 이를 Unix socket을 통해 DuckDB 실행 엔진으로 라우팅합니다. DuckDB가 Parquet 파일을 컬럼형으로 읽고 결과를 반환합니다. 사용자는 표준 PostgreSQL SQL을 쓰지만 실제 실행은 DuckDB가 합니다.

사용 방법

-- 일반 CREATE TABLE에 USING iceberg만 추가
CREATE TABLE orders (
    order_id    INTEGER,
    customer_id INTEGER,
    amount      DECIMAL(10, 2),
    order_date  TIMESTAMP DEFAULT NOW(),
    metadata    JSONB
) USING iceberg;

-- 표준 SQL INSERT — 완전 동작
INSERT INTO orders VALUES (1, 100, 299.99, NOW(), '{"channel": "web"}');
INSERT INTO orders VALUES (2, 101, 149.50, NOW(), '{"channel": "app"}');

-- UPDATE와 DELETE도 지원
UPDATE orders SET amount = 349.99 WHERE order_id = 1;
DELETE FROM orders WHERE customer_id = 101;

-- 트랜잭션 완전 지원
BEGIN;
INSERT INTO orders VALUES (3, 102, 599.00, NOW(), NULL);
ROLLBACK; -- 완전한 롤백

-- 스키마 진화
ALTER TABLE orders ADD COLUMN region TEXT;

-- JSONB 조건
SELECT * FROM orders WHERE metadata->>'channel' = 'web';

이 모든 것이 백엔드에서는 Iceberg v2 Parquet 파일로 저장됩니다. MinIO 버킷을 열어보면 Iceberg 표준 형식의 메타데이터 JSON과 Parquet 데이터 파일이 있습니다.

성능

TPC-H Q1(60K 행 기준): 10~12ms 수행. DuckDB 컬럼형 엔진 덕분입니다.

상호운용성 — 네 방향 전부 검증

pg_lake가 만든 Iceberg 테이블을 다른 엔진들이 읽고 쓸 수 있는지 확인했습니다.

방향 방법 결과
Spark → pg_lake 읽기 FDW + metadata.json 경로 PASS
pg_lake → Spark 읽기 JDBC Catalog + S3FileIO PASS
pg_lake → DuckDB 읽기 iceberg_scan(metadata_path) PASS
pg_lake → Trino 읽기 REST Catalog Bridge PASS

pg_lake가 만든 Parquet 파일은 표준 Iceberg v2 형식이므로 Spark, DuckDB, Trino가 모두 직접 읽을 수 있습니다. 반대로 Spark가 쓴 Iceberg 테이블을 pg_lake FDW로 읽는 것도 가능합니다.

이것이 DuckLake와의 결정적 차이입니다. DuckLake는 pg_duckdb에서 PostgreSQL 카탈로그를 쓸 수 없어 멀티엔진 시나리오가 막혔습니다. pg_lake는 PostgreSQL이 Iceberg 표준 카탈로그 역할을 하면서 모든 주요 엔진과 통신합니다.


DuckLake vs Iceberg(Nessie) vs pg_lake 비교

항목 DuckLake Iceberg + Nessie pg_lake
PostgreSQL 카탈로그 FAIL (libpq 충돌) Nessie 별도 서비스 필요 네이티브 통합
pg_duckdb 지원 FAIL PASS PASS
Trino 지원 FAIL PASS PASS
Spark 지원 제한적 PASS PASS
DuckDB standalone PASS PASS PASS
완전 ACID 제한적 제한적 완전 지원
스키마 진화 PASS PASS PASS
인메모리 개발 가능 불가 (Nessie 필요) 불가 (pgduck_server 필요)
외부 서비스 수 0–1개 2개 (Nessie + 스토리지) 1개 (스토리지만)
생태계 표준 DuckDB 전용 업계 표준 Iceberg 표준

pg_lake의 단점을 솔직히 말하면: 아직 GA 이전(v3.3 기준)이고, PostgreSQL 18.1이 필요하며, pgduck_server 데몬을 같이 띄워야 합니다. 운영 복잡도가 DuckDB standalone보다 높습니다.

하지만 "PostgreSQL 카탈로그 + 완전 ACID + Iceberg 표준 + 멀티엔진 상호운용"이라는 네 조건을 동시에 만족하는 오픈소스 프로젝트는 2025년 11월 기준으로 pg_lake가 유일했습니다.

2편에서는 분산 처리 옵션 비교(Ray+DuckDB vs Daft vs Smallpond), Citus와의 통합 실험(실패와 성공), 그리고 최종 아키텍처 권장을 다룹니다.