pg_clickhouse — PostgreSQL에서 ClickHouse 사용하기
pg_clickhouse — PostgreSQL에서 ClickHouse 사용하기

pg_clickhouse — PostgreSQL에서 ClickHouse 사용하기

ClickHouse 분류
Feature
Type
Introduction
작성자

Ken

"데이터를 옮기는 건 ClickPipes가 다 했다. 진짜 문제는 지난 몇 년간 쌓인 PostgreSQL 분석 SQL을 다시 쓰는 일이다." — David Wheeler가 pg_clickhouse를 출시하며 던진 한 문장에 이 익스텐션의 존재 이유가 다 들어 있다.

2025년 12월 ClickHouse가 공개한 pg_clickhouse(Apache 2.0)는 PostgreSQL의 FDW(Foreign Data Wrapper) 메커니즘 위에 적극적 푸시다운을 얹어, PG 쪽 쿼리를 고치지 않고 ClickHouse에서 실행시키는 익스텐션이다. 2026년 1월 ClickBench에서 모든 PG 분석 익스텐션 중 가장 빠른 1위를 차지하며 네이티브 ClickHouse 성능에 근접한다.

이 글은 PG 분석 익스텐션 지형 안에서 pg_clickhouse의 위치를 짚고, 푸시다운이 실제로 어디까지 동작하는지, 언제 쓰고 언제 쓰지 말아야 하는지를 use case 중심으로 정리한다. 실습은 ClickHouse가 배포한 도커 이미지(ghcr.io/clickhouse/pg_clickhouse:18) 위에서 진행했다.

1. 시작하며 — 데이터보다 쿼리가 더 무거운 이유

전형적인 시나리오. Postgres 위에 빌드한 SaaS 앱이 잘 커서 분석 쿼리가 OLTP의 발목을 잡기 시작한다. Read replica → CDC → 별도 분석 DB로의 길은 잘 알려져 있고, ClickPipes로 데이터 이전은 분 단위로 끝난다. 그런데 그 다음 단계가 길다.

옮기는 대상
대표 도구
통상 소요 시간
데이터 (스키마 + 행)
ClickPipes, Debezium, 자체 ETL
시간 ~ 일
쿼리 (대시보드/ORM/크론)
(사람이 손으로)
주 ~ 분기

대시보드 200개, ORM 코드 수천 곳, 크론잡 수십 개에 박힌 분석 SQL을 ClickHouse 방언(quantile(), argMax(), uniq(), -If combinator 등)으로 다시 쓰는 데 진짜 시간이 든다. pg_clickhouse는 이 단계를 통째로 건너뛰게 해주는 것을 목표로 만들어졌다.

핵심 아이디어 한 줄: ClickHouse 테이블이 PG의 일반 테이블처럼 보이고, PG 분석 SQL이 들어오면 자동 번역돼 ClickHouse가 실행하고, 결과 행만 PG로 돌아온다.

2. PG 분석 익스텐션 지형 안에서의 pg_clickhouse

PG로 분석을 빠르게 하려는 시도는 2024–2026년 사이 폭발적으로 늘었다. 큰 줄기는 두 카테고리:

차이가 갖는 운영상 의미:

A. 내장형 (pg_duckdb / pg_mooncake)
B. FDW형 (pg_clickhouse)
데이터 위치
PG 인스턴스 또는 PG가 관리하는 object store
별도 ClickHouse 클러스터
컴퓨트 스케일링
PG 노드와 묶임
ClickHouse와 독립
OLTP에 대한 영향
같은 프로세스/메모리 풀 — 격리 약함
격리 강함 (다른 호스트)
기존 ClickHouse 자산 활용
불가
그대로 활용
라이선스
DuckDB(MIT) / 기타
Apache 2.0

pg_clickhouse를 선택해야 하는 결정 근거는 단순하다 — "이미 ClickHouse(또는 ClickHouse Cloud)에 데이터가 있고, PG OLTP는 OLTP대로 두고 싶다." 내장형이 PG와 분석 엔진을 한 박스에 묶는 반면, FDW는 두 시스템을 명확히 분리한 채 쿼리만 다리를 놓는다.

기존 clickhouse_fdw(2019–2020 Ildus Kurbangaliev / Adjust 기반)와의 차이도 짚어둘 만하다. pg_clickhouse는 그 코드를 import해 시작했지만, push-down 적극화에 집중해 대대적으로 재작성했다 — SEMI JOIN 지원, ordered-set aggregate 변환, FILTER (WHERE) → -If combinator 번역 등이 핵심 차별점이다.

3. 아키텍처 — Foreign Data Wrapper + binary 프로토콜

pg_clickhouse는 PostgreSQL의 SQL/MED(SQL Management of External Data, 2003년 표준) 위에 구축된다. PG가 9.3(2011) 이후 지원해온 FDW 메커니즘이라 PG 측 코드 변경 0에 익스텐션 한 줄로 들어간다.

운영자가 기억해야 할 두 가지 전송 옵션:

driver
포트
특성
binary
9000
네이티브 TCP. 더 빠르고 스트리밍 가능. 권장
http
8123
HTTP. 방화벽/프록시 친화적, https로 TLS 가능

ClickHouse Cloud로 붙으려면 TLS 필수 — pg_clickhouse는 두 프로토콜 모두에 TLS를 지원한다 (기존 clickhouse_fdw에는 없던 부분).

4. 5분 셋업 — 도커 한 줄

ClickHouse가 PG 13~18용 사전 빌드 이미지(ghcr.io/clickhouse/pg_clickhouse:{13..18})를 제공해 설치 단계가 자체로 사라진다. 도커로 둘을 비공개 네트워크에 띄우고 익스텐션을 켠다:

# docker-compose.yml (핵심만)
services:
  postgres:
    image: ghcr.io/clickhouse/pg_clickhouse:18    # PG 18.4 + pg_clickhouse 0.3
    networks: [pgch_net]
    ports: ["5432:5432"]
  clickhouse:
    image: clickhouse/clickhouse-server:26.5
    networks: [pgch_net]
    ports: ["8123:8123", "9000:9000"]
networks: { pgch_net: { driver: bridge } }

PG에 접속해서:

CREATE EXTENSION pg_clickhouse;

CREATE SERVER ch_srv
    FOREIGN DATA WRAPPER clickhouse_fdw
    OPTIONS (driver 'binary', host 'clickhouse', port '9000', dbname 'default');

CREATE USER MAPPING FOR CURRENT_USER
    SERVER ch_srv
    OPTIONS (user 'default', password '');

세 줄로 연결까지 끝. clickhouse_fdw라는 이름은 익스텐션이 등록하는 FDW 핸들러의 이름이지 별도 익스텐션을 깔라는 뜻이 아니다 (혼동 포인트 1번).

연결 검증은 raw-query escape hatch로:

SELECT clickhouse_raw_query('SELECT version()', 'host=clickhouse port=8123');
-- 26.5.1.882
보안 노트: clickhouse_raw_query는 기본적으로 EXECUTE 권한이 슈퍼유저에만 부여돼 있다. 일반 분석 사용자에게는 GRANT EXECUTE명시적으로 골라서 줘야 한다. 다음 섹션 9에서 자세히.

5. IMPORT FOREIGN SCHEMA — 100개 테이블을 한 줄에

기존 ClickHouse 측 스키마가 있다고 가정. lab.events(100K) + lab.users(1K)가 있다고 하면:

-- PG 쪽: 받을 schema 만들고 한 번에 import
CREATE SCHEMA imported_lab;
IMPORT FOREIGN SCHEMA lab FROM SERVER ch_srv INTO imported_lab;

끝. ClickHouse 측 모든 테이블이 PG의 imported_lab.* 아래 foreign table로 자동 생성된다. 컬럼 타입은 ClickHouse → PG 매핑 표로 자동 변환된다.

5.1 타입 매핑 — 알아야 할 함정 몇 개

ClickHouse
PostgreSQL
주의
UInt8, UInt16
smallint, integer
UInt32, UInt64
bigint
UInt64 오버플로 시 wrap 대신 ERROR
Int*
동급 PG int
Float32, Float64
real, double precision
Decimal(p,s)
numeric(p,s)
Date, Date32
date
DateTime, DateTime64
timestamptz
http 드라이버 + 파라미터화 쿼리는 CH < 25.8에서 TZ 이슈
String
text
바이너리는 NUL 바이트에서 잘림 → bytea 권장
LowCardinality(T)
T와 동일
UUID
uuid
IPv4, IPv6
inet
Bool
boolean
JSON
jsonb / json

부분 임포트 변형:

IMPORT FOREIGN SCHEMA lab LIMIT TO (events) FROM SERVER ch_srv INTO imported_lab;
IMPORT FOREIGN SCHEMA lab EXCEPT   (users)  FROM SERVER ch_srv INTO imported_lab;
운영 함정 #2 — 대소문자 식별자. ClickHouse 측 컬럼명이 대소문자 혼합이면 PG에서는 큰따옴표로 감싸야 한다("CamelCol"). 임포트는 잘 되지만 쿼리 시점에 깨진다.

6. 푸시다운 — EXPLAIN (VERBOSE)의 Remote SQL이 진실을 말한다

여기가 pg_clickhouse의 핵심이고, 글의 절반을 차지해야 할 부분이다. 익스텐션이 진짜 일하는지 보는 단 하나의 방법은 EXPLAIN (VERBOSE)Remote SQL: 줄을 읽는 것.

6.1 단순 집계 푸시다운

EXPLAIN (VERBOSE)
SELECT category, count(*), avg(amount)
FROM imported_lab.events
WHERE created_at >= now() - INTERVAL '7 days'
GROUP BY category;
 Foreign Scan
   Relations: Aggregate on (events)
   Remote SQL: SELECT category, count(*), avg(amount) FROM lab.events
               WHERE ((created_at >= now() - INTERVAL '7 days'))
               GROUP BY category

Aggregate on (events)PG가 행을 가져와서 집계하는 게 아니라, ClickHouse가 집계까지 끝내고 결과만 보낸다는 뜻. 이게 안 보이면 pushdown이 깨진 것이다.

6.2 JOIN 푸시다운 — 두 foreign table을 ClickHouse가 직접 조인

EXPLAIN (VERBOSE)
SELECT u.country, count(DISTINCT e.user_id) AS uniq_users
FROM imported_lab.events e
JOIN imported_lab.users  u USING (user_id)
WHERE e.event_type = 'purchase'
GROUP BY u.country;
 Foreign Scan
   Relations: Aggregate on ((events) INNER JOIN (users))
   Remote SQL: SELECT r2.country, count(DISTINCT r1.user_id)
               FROM lab.events r1
               INNER JOIN lab.users r2 ON (r1.user_id = r2.user_id)
               WHERE r1.event_type = 'purchase'
               GROUP BY r2.country

PG 측 옵티마이저가 두 foreign table을 한 묶음으로 인식하고 조인을 ClickHouse 내부로 밀어 넣었다. 일반 FDW가 가장 자주 실패하는 케이스 — 보통은 양쪽에서 행을 다 가져와 PG 메모리에서 hash join하다가 OOM. 적극적 푸시다운의 의의가 여기서 가장 크다.

6.3 PG → ClickHouse 함수 자동 번역

pg_clickhouse가 흥미로워지는 지점은 함수 이름이 다른 경우를 알아서 번역한다는 것.

PostgreSQL
→ 변환
ClickHouse
percentile_cont(0.5) WITHIN GROUP (ORDER BY price)
quantile(0.5)(price)
min(price) FILTER (WHERE town='X')
minIf(price, town='X')
date_trunc('hour', ts)
toStartOfHour(ts)
regexp_replace(s, p, r)
replaceRegexpAll(s, p, r)
count(DISTINCT x)
uniqExact(x)
row_number() OVER (...)
row_number() OVER (...)

특히 **FILTER (WHERE) → -If 콤비네이터** 변환이 인상적이다. PG의 ordered-set aggregate를 *ClickHouse의 parametric aggregate*로 매핑하면서, FILTER절을 ClickHouse 함수 접미사-If`로 변형한다. 두 엔진의 의미를 가장 효율적인 형태로 정렬하는 작업.

6.4 SEMI JOIN — 출시 첫 분기의 최대 진전

TPC-H 쿼리 4번 같은 EXISTS 서브쿼리가 가장 잘 보여주는 사례. 원래 FDW들은 EXISTS를 분리된 foreign scan + nested loop로 풀어서 수십 초~분 단위 소요. pg_clickhouse는 0.1.0에서 PG SEMI JOIN을 그대로 ClickHouse의 LEFT SEMI JOIN으로 푸시다운하도록 만들었다.

-- TPC-H Q4
SELECT o_orderpriority, count(*)
FROM orders
WHERE o_orderdate >= date '1993-07-01' AND o_orderdate < date '1993-10-01'
  AND EXISTS (SELECT * FROM lineitem
              WHERE l_orderkey = o_orderkey AND l_commitdate < l_receiptdate)
GROUP BY o_orderpriority;

번역 결과:

Remote SQL: SELECT r1.o_orderpriority, count(*)
            FROM tpch.orders r1
            LEFT SEMI JOIN tpch.lineitem r3
              ON ((r3.l_commitdate < r3.l_receiptdate))
                 AND ((r1.o_orderkey = r3.l_orderkey))
            WHERE ((r1.o_orderdate >= '1993-07-01'))
              AND ((r1.o_orderdate < '1993-10-01'))
            GROUP BY r1.o_orderpriority

성능 차이는 극적이다 — 0.1.0 출시 블로그에서 공개한 TPC-H SF1 측정:

Query
Native PG
pg_clickhouse (SEMI 전)
pg_clickhouse (SEMI 후)
Q1
4,478 ms
82 ms
73 ms
Q3
1,454 ms
74 ms
74 ms
Q4
650 ms
timeout
67 ms
Q5
452 ms
timeout
104 ms
Q9
3,028 ms
timeout
136 ms
Q17
1,802 ms
timeout
9 ms
Q18
6,185 ms
timeout
10 ms

요약: SEMI JOIN 지원만으로 timeout 났던 8개 쿼리가 살아났고, 22개 중 21개가 1초 이내로 떨어졌으며, 12개가 완전 푸시다운됐다.

ClickBench 측에서도 2026년 1월 결과 기준 pg_clickhouse가 모든 PG 분석 익스텐션 중 가장 빠르며, 네이티브 ClickHouse와의 격차도 작다. 오버헤드는 쿼리 재작성 + 네트워크 왕복 + PG 타입 변환에서만 발생.

6.5 푸시다운 함수 레퍼런스

자주 찾게 되는 것만 모은 미니맵:

전체 목록은 공식 레퍼런스 참고. 이 표에 없는 함수는 PG 측에서 실행되므로 — 행이 PG로 끌려와 PG가 계산한다 — 푸시다운이 깨지는 일등 원인이다.

7. 세션 단위 ClickHouse 설정 전달

ClickHouse 측 설정을 쿼리별로 조절해야 할 때:

SET pg_clickhouse.session_settings = 'connect_timeout 5, max_block_size 8192';

기본값은 'join_use_nulls 1, group_by_use_nulls 1, final 1' — 즉 NULL semantics를 PG에 맞추고 FINAL을 기본으로 켠다. ReplacingMergeTree/CollapsingMergeTree를 쓰는 ClickHouse 테이블이라면 이 기본값이 정확한 dedup된 결과를 보장한다.

성능을 위해 final 0로 끄거나 max_threads, max_memory_usage 같은 자원 한도를 워크로드별로 다르게 줄 수 있다.

8. clickhouse_raw_query — 탈출구와 그 비용

pg_clickhouse가 모든 SQL을 푸시다운할 수는 없다. ClickHouse 전용 기능(예: dictGet()을 SELECT 리스트에 직접 사용, ANN 인덱스, arrayJoin 등)을 써야 한다면 raw escape가 답이다.

SELECT clickhouse_raw_query(
    'SELECT dictGet(country_dict, ''name'', toUInt32(123))',
    'host=clickhouse port=8123'
) AS country_name;

특히 딕셔너리 활용이 흥미로운 패턴 — ClickHouse에 DICTIONARY를 만들어 두면 WHERE dictGet(...) = 'X'는 푸시다운되고, SELECT 리스트의 dictGet은 raw query로 우회한다. 코드 패턴:

8.1 보안 — clickhouse_raw_query는 잠금 상태로 출고된다

기본값으로 EXECUTE 권한이 슈퍼유저에게만 있다. 일반 분석 사용자가 호출하면 즉시 거절. 임의 ClickHouse SQL을 실행할 수 있다는 건 SQL injection 벡터가 곧장 열린다는 뜻이라 의도된 설계.

선택적 부여가 운영의 정답:

SECURITY DEFINER + 입력 검증 래퍼를 통해 어떤 dictionary에 대해 어떤 key 타입만 허용할지 좁히는 패턴이 표준.

9. 언제 pg_clickhouse를 써야 하나 — 의사결정 가이드

핵심 use case 3가지로 정리하면:

Use case 1 — Lift-and-shift 마이그레이션

PG 위에 빌드한 SaaS의 OLAP 워크로드를 분리하려는데, 대시보드/ORM 코드의 분석 SQL이 광범위하게 박혀 있다. ClickPipes로 데이터를 옮긴 뒤 search_pathimported_lab,public처럼 바꾸면 기존 쿼리가 그대로 ClickHouse에서 돈다. 마이그레이션의 위험을 수개월 단위 SQL 재작성에서 몇 시간 단위 search_path 변경으로 줄인다.

Use case 2 — OLTP와 OLAP의 명시적 컴퓨트 격리

ClickHouse는 별도 호스트(또는 별도 Cloud 인스턴스). 분석 워크로드가 늘어도 PG OLTP CPU/메모리에 전혀 영향 없음. pg_duckdb처럼 PG 프로세스 내부로 분석 엔진을 박는 모델은 격리가 약하다는 점에서 정반대 결정.

Use case 3 — 동일 SQL 자산을 PG OLTP + CH OLAP 양쪽에서 운영

레거시 PG 데이터 + 신규 CH 데이터를 같은 분석 SQL로 다루고 싶을 때. 같은 BI 도구, 같은 ORM에서 FQN을 schema로 구분해 PG와 CH 테이블을 섞어 쓴다. pg_clickhouse가 차이를 숨겨준다.

반대로 pg_clickhouse가 맞지 않는 경우

  • PG 자체로 데이터를 들고 있어야 하는 분석 (예: 단일 노드에서 끝나는 보고서, 외부 분석 DB 운영을 도입 못하는 환경) → pg_duckdb/pg_mooncake가 더 적합.
  • 무거운 DML 워크로드를 푸시다운하고 싶을 때 → 0.x 단계에서는 INSERT 위주만 지원, COPY도 내부적으로 INSERT 사용. 분석 read에 특화된 익스텐션이라 DML은 후순위 로드맵이다.
  • PG 13 이전 환경 → 미지원.

10. 운영상 함정 — 출시 1년 시점의 caveats

업스트림 문서가 솔직하게 공개한 항목 + 실습 중 부딪힐 만한 것들:

카테고리
함정
우회
쓰기
COPY가 내부적으로 INSERT 사용
대량 적재는 ClickPipes / 직접 CH INSERT
타입
UInt64 오버플로 시 wrap 대신 ERROR
캐스팅 명시 (AS bigint 등으로 검사)
타입
TEXT 컬럼에 바이너리 — NUL 바이트에서 잘림
bytea 사용
식별자
대소문자 혼합 컬럼명은 큰따옴표 강제
snake_case로 정렬 또는 view 한 겹
윈도우
랭킹 윈도우 함수의 frame spec은 푸시다운 시 무시
결과 동일 확인 후 frame 제거
HTTP
http 드라이버 + parameterized query + DateTime: CH < 25.8 TZ 이슈
binary 드라이버 또는 CH 25.8+
푸시다운
모든 PG 함수가 번역되지는 않음
EXPLAIN VERBOSE로 Remote SQL 항상 확인
푸시다운
TPC-H 22개 중 10개가 완전 푸시다운 안 됨 (0.1.0 기준)
로드맵 진행 중 (0.1.4에서 ClickBench 커버리지 추가)

가장 빈번한 실수: foreign table에 LIMIT 없이 SELECT * → 수백만 행이 PG로 끌려옴. 항상 LIMIT 또는 집계로 시작한다. 그리고 항상 EXPLAIN VERBOSE로 Remote SQL을 확인.

11. 마무리

pg_clickhouse를 한 줄로 요약하면 — "PG의 FDW 메커니즘을, 분석 워크로드에 정말로 쓸 수 있는 수준까지 푸시다운으로 끌어올린 익스텐션". 기존 clickhouse_fdw가 멈춰 있던 곳에서 SEMI JOIN, ordered-set aggregate 변환, -If combinator 번역까지 더해 TPC-H 22 중 21개를 sub-1s로 만들고, ClickBench 1위에 올랐다.

기억할 세 가지:

  1. 그것은 새로운 분석 엔진이 아니다. ClickHouse를 그대로 쓴다 — PG 분석 SQL을 ClickHouse에 자동 전달하는 다리일 뿐. 따라서 ClickHouse의 모든 성능과 모든 운영 의무가 그대로 적용된다.
  2. EXPLAIN (VERBOSE)의 Remote SQL이 유일한 진실의 원천이다. Aggregate가 Aggregate on (table)로 표시되는지 항상 확인. 그게 안 보이면 PG가 행을 끌어오고 있는 것이다.
  3. clickhouse_raw_query는 마지막 카드. 푸시다운이 닿지 않는 ClickHouse 전용 기능에만 쓰고, EXECUTE 권한을 함부로 풀지 말 것.

PG → CH 마이그레이션의 진짜 비용은 데이터 이전이 아니라 쿼리 자산 이전이라는 인식에서 출발한 이 익스텐션은, 2026년 한국 SaaS의 흔한 패턴인 "PG OLTP + CH OLAP + 그 둘을 같은 BI에서 보기" 를 별도 어댑터 레이어 없이 가능하게 한다. 도커 한 줄로 시도해 보고, 자신의 워크로드에서 어느 쿼리가 푸시다운되고 어느 게 깨지는지를 EXPLAIN VERBOSE로 직접 보는 것 — 그게 도입의 정답에 가장 빠르게 다가가는 길이다.

참고 자료

소스 코드