PostgreSQL을 건드리지 않고 수억 건 집계를 처리하는 법 — ClickHouse 사이드카와 CDC 실시간 동기화
새벽 배치 리포트가 실행되는 동안 결제 API 응답시간이 2~3초씩 튀는 현상. 원인은 단순합니다. orders나 payments 테이블이 주문 트랜잭션과 대시보드 집계를 동시에 처리하고 있었고, OLTP와 OLAP 워크로드가 같은 PostgreSQL 인스턴스 안에서 CPU와 I/O를 두고 경합하고 있었던 것입니다.
HTAP 사이드카 패턴은 이 경합을 구조적으로 차단합니다. PostgreSQL은 트랜잭션 전담으로 두고, ClickHouse를 분석 전담 사이드카로 옆에 붙입니다. 두 DB 사이는 CDC(Change Data Capture)로 실시간 연결되어 PostgreSQL에서 일어나는 모든 변경이 수 초 내에 ClickHouse에 반영됩니다. 대시보드와 리포팅 쿼리는 모두 ClickHouse를 바라보고, PostgreSQL은 트랜잭션에만 집중합니다.
2025~2026년 기준으로 이 패턴은 이미 사실상의 표준이 됐습니다. ClickHouse가 PeerDB를 인수(2024년 7월)한 이후 ClickPipes를 통한 Postgres CDC 사용량은 ClickHouse 블로그 기준으로 급격히 증가했으며, 수백 개 기업이 매월 대규모 Postgres 데이터를 ClickHouse로 복제 중이라고 보고됩니다. Dataddo가 분석한 것처럼 "HTAP은 제품이 아니라 파이프라인으로 실현됐다"는 시각이 설득력을 얻는 이유도 여기에 있습니다. 단일 엔진에 두 워크로드를 욱여넣는 방식은 리소스 경합을 구조적으로 피할 수 없습니다. 이 글에서는 그 파이프라인을 직접 구성하는 과정과 실무에서 자주 마주치는 함정들을 다룹니다.
핵심 개념
전체 아키텍처 한눈에 보기
PostgreSQL은 오른쪽에서 일어나는 일을 전혀 모릅니다. WAL 로그를 내보낼 뿐이고, 그 이후는 CDC 파이프라인이 알아서 처리합니다. 이 격리가 핵심입니다.
CDC가 WAL을 어떻게 읽는가
PostgreSQL의 WAL(Write-Ahead Log)은 데이터 변경의 완전한 기록입니다. 기본 설정(wal_level = replica)은 물리 복제용이지만, wal_level = logical로 바꾸면 논리 복제 슬롯을 통해 행 단위 변경 이벤트를 외부 구독자에게 스트리밍할 수 있습니다.
# postgresql.conf — 논리 복제 활성화
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10주의:
wal_level변경은 PostgreSQL 재시작이 필요합니다. 운영 DB라면 점검 시간과 롤링 재시작 계획을 미리 잡아두어야 합니다.
변경 이벤트는 크게 세 가지입니다. INSERT는 새 행, UPDATE는 기존 행의 새 버전, DELETE는 삭제 마커. ClickHouse ReplacingMergeTree는 이 세 가지를 모두 "같은 Primary Key에 대한 버전 추가"로 처리합니다.
삭제를 삽입으로 처리하는 이유가 처음엔 낯설게 느껴질 수 있습니다. ClickHouse는 불변 컬럼 스토어라서 행을 즉시 지울 수 없습니다. 대신 is_deleted = 1 마커를 가진 행을 추가하고, 백그라운드 머지나 FINAL 키워드 사용 시점에 최신 버전만 남기고 나머지를 물리 삭제합니다.
ClickHouse 테이블 엔진 선택
CDC 용도별로 엔진을 골라야 합니다.
| 엔진 | 언제 쓰나 | 핵심 특징 |
|---|---|---|
ReplacingMergeTree |
UPDATE/DELETE가 있는 모든 OLTP 테이블 | 같은 PK의 최신 버전 유지, soft-delete 지원 |
MergeTree |
순수 이벤트 로그, 삭제 없는 append-only | 단순하고 빠름 |
AggregatingMergeTree |
메트릭 사전 집계, 롤업 | 중간 집계 상태 저장 |
CollapsingMergeTree |
수동 부호 기반 삭제 처리 | 구현 복잡, 특수 케이스에만 사용 |
대부분의 OLTP 미러링에는 ReplacingMergeTree가 답입니다.
쿼리 라우팅 결정하기
ClickHouse에 직접 쿼리를 날리는 방식과, PostgreSQL에서 pg_clickhouse 확장을 통해 투명하게 오프로드하는 방식 중 어느 쪽이 적합한지는 팀의 SQL 레이어 선호도에 따라 달라집니다.
pg_clickhouse는 2025년 출시된 PostgreSQL 확장으로, 주요 분석 쿼리를 ClickHouse로 푸시다운하여 처리합니다. ClickBench 기준 성능 벤치마크 결과는 공식 블로그에서 확인할 수 있습니다. 기존 ORM 코드나 PostgreSQL 연결 풀을 그대로 쓰면서 분석 쿼리만 자동으로 ClickHouse로 넘기고 싶을 때 유용한 선택입니다.
실전 적용
1단계: PostgreSQL 논리 복제 준비
-- 복제할 테이블에 대한 publication 생성
CREATE PUBLICATION ch_replication FOR TABLE orders, payments, users;
-- 복제 슬롯 생성 (PeerDB가 자동으로 만들기도 하지만 수동 확인 시)
SELECT pg_create_logical_replication_slot('peerdb_slot', 'pgoutput');슬롯이 생성되면 pg_replication_slots에서 상태를 확인할 수 있습니다.
-- 슬롯 상태 모니터링 (WAL 누적 크기 주의)
SELECT slot_name, active,
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots;lag_bytes가 계속 커지고 있다면 CDC 파이프라인이 소비하지 못하고 있다는 신호입니다. max_slot_wal_keep_size를 설정해두지 않으면 WAL이 무한 증가해서 디스크를 가득 채울 수 있습니다.
# postgresql.conf — WAL 누적 방지 안전장치
# 쓰기 볼륨에 따라 반드시 조정 필요 — 피크 시간 WAL 생성 속도를 측정한 뒤 결정하세요
max_slot_wal_keep_size = 10GB고쓰기 환경에서는 10GB가 수 분 치 WAL에 불과할 수 있습니다. pg_stat_replication의 write_lag 등을 참고해 운영 환경의 피크 WAL 생성 속도를 먼저 파악한 다음 적절한 값을 결정하는 것이 안전합니다.
2단계: ClickHouse 수신 테이블 구성
orders 테이블을 예시로 들면, PostgreSQL 원본과 컬럼 구조는 같지만 _version과 is_deleted 컬럼이 추가됩니다.
-- ClickHouse
CREATE TABLE orders (
id UInt64,
user_id UInt64,
status String,
total_amount Decimal(18, 2),
created_at DateTime,
updated_at DateTime,
_version UInt64, -- CDC 버전 (타임스탬프 또는 LSN)
is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(_version)
PARTITION BY toYYYYMM(created_at)
ORDER BY (user_id, id);ReplacingMergeTree(_version) 선언으로 같은 PK(ORDER BY 키)에 대해 _version이 가장 큰 행이 살아남습니다.
최신 ClickHouse 버전에서는 ReplacingMergeTree(_version, is_deleted) 형태의 이중 인자도 지원합니다. 이 경우 머지 시 is_deleted = 1 행이 자동으로 물리 삭제되어 쿼리마다 is_deleted = 0 필터를 반복해서 쓰는 부담이 줄어듭니다. 사용 중인 ClickHouse 버전의 릴리즈 노트에서 지원 여부를 확인한 뒤 적용하세요.
3단계: 초기 스냅샷 — 운영 중인 테이블 데이터 이관
CDC는 파이프라인을 연결한 시점 이후의 변경만 포착합니다. 이미 수억 건이 쌓인 운영 테이블이라면, 파이프라인 시작 전 초기 스냅샷(Initial Snapshot / Backfill) 을 먼저 ClickHouse에 적재해야 합니다.
PeerDB는 미러를 생성할 때 초기 스냅샷을 자동으로 처리합니다. 소스 테이블 전체를 청크 단위로 읽어 ClickHouse에 적재한 다음, 그 시점부터 누적된 WAL 변경을 순서대로 적용하는 방식입니다. ClickPipes도 동일한 흐름을 UI에서 설정할 수 있습니다.
주의: 수억 건 테이블의 초기 적재는 수 시간이 걸릴 수 있습니다. 이 기간 동안 소스 PostgreSQL의 복제 슬롯이 활성 상태를 유지해야 하므로 그만큼 WAL이 누적됩니다. 초기 적재 완료 전에 파이프라인이 중단되면 처음부터 다시 시작해야 하는 경우도 있으니, 1단계에서 설정한
max_slot_wal_keep_size와 디스크 여유 공간을 사전에 넉넉히 확보하세요.
4단계: 올바른 SELECT 작성
이 부분에서 실수가 제일 많이 나옵니다. FINAL 없이 쿼리하면 머지 전 중복 행이 섞여 집계 결과가 틀릴 수 있습니다.
-- 방법 1: SELECT FINAL (정확하지만 느림, 소규모 테이블에 적합)
SELECT status, COUNT(*) AS cnt, SUM(total_amount) AS revenue
FROM orders FINAL
WHERE is_deleted = 0
GROUP BY status;
-- 방법 2: argMax로 최신 버전만 선택 (대규모 집계에 권장)
-- orders.id는 전역 유니크 PK이므로 단독 GROUP BY로 충분합니다
SELECT status,
COUNT(*) AS cnt,
SUM(total_amount) AS revenue
FROM (
SELECT id,
argMax(status, _version) AS status,
argMax(total_amount, _version) AS total_amount,
argMax(is_deleted, _version) AS is_deleted
FROM orders
GROUP BY id
)
WHERE is_deleted = 0
GROUP BY status;방법 2가 복잡해 보이지만, 수억 건 규모에서는 FINAL보다 훨씬 빠릅니다. 머티리얼라이즈드 뷰(Materialized View)로 미리 argMax 레이어를 만들어두면 더 깔끔하게 사용할 수 있습니다.
5단계: PeerDB로 파이프라인 연결 (셀프 호스티드)
# peerdb-config.yaml
# 간략화된 예시입니다 — 초기 스냅샷, 배치 크기, idle_timeout 등의 설정은 PeerDB 공식 문서를 참조하세요
source:
type: POSTGRES
host: pg-host
port: 5432
database: mydb
user: replicator
password: ${PG_PASSWORD}
replication_slot: peerdb_slot
publication: ch_replication
destination:
type: CLICKHOUSE
host: ch-host
port: 9000
database: analytics
user: default
password: ${CH_PASSWORD}
mirrors:
- name: orders_mirror
source_table: public.orders
destination_table: orders
columns_to_sync: "*"
soft_delete: true
soft_delete_col_name: is_deleted
version_col_name: _versionClickHouse Cloud를 쓴다면 ClickPipes UI에서 동일한 설정을 클릭 몇 번으로 완료할 수 있습니다. 2025년 GA 이후 Postgres 17의 failover replication slot도 지원하여, HA 환경에서 Primary 장애 시에도 CDC가 끊기지 않습니다.
실제 도메인별 활용 패턴
| 도메인 | PostgreSQL 소스 | ClickHouse 쿼리 패턴 |
|---|---|---|
| 이커머스 | orders, inventory | 시간별 매출 집계, 재고 소진 예측 |
| 핀테크 | transactions | 이상 탐지, 일별/월별 정산 |
| IoT/모니터링 | events, metrics | 시계열 롤업, 임계치 집계 |
| SaaS 어드민 | usage_logs | 테넌트별 사용량, 과금 계산 |
장단점 분석
장점과 단점 요약
| 항목 | 내용 |
|---|---|
| OLTP 격리 | 집계 쿼리가 PostgreSQL CPU·I/O를 전혀 소비하지 않음 → 트랜잭션 응답시간 안정화 |
| 집계 성능 | 컬럼 스토리지 + 벡터화 실행. Aiven 실측 기준 특정 쿼리에서 수 배~수십 배 향상 |
| 낮은 복제 지연 | PeerDB/ClickPipes 기준 일반 워크로드에서 2~5초, 일부 환경에서 sub-second |
| 스택 단순화 | ClickHouse 단일 목적지라면 Debezium + Kafka + Schema Registry 없이 동등한 기능 확보 |
| 이벤추얼 컨시스턴시 | 실시간 트랜잭션 의사결정에는 부적합. 재고 차감 같은 강한 일관성 로직은 PostgreSQL에서 처리 |
| WAL 슬롯 누적 위험 | 파이프라인이 중단된 채 방치되면 PostgreSQL 디스크 고갈 및 크래시 가능 |
| 스키마 변경 | 컬럼 추가·삭제 시 파이프라인 장애 가능. DROP COLUMN은 별도 처리 필요 |
| 운영 복잡도 | 두 시스템 운영 → 장애 포인트 증가, 모니터링 범위 확대 |
Debezium + Kafka와의 트레이드오프
"Debezium + Kafka + Schema Registry 없이 동등한 기능 확보"를 순수 장점으로만 보기는 어렵습니다. 이미 Kafka 기반 데이터 파이프라인을 운영 중인 팀이라면 오히려 반대로 봐야 합니다.
Debezium + Kafka의 강점은 다운스트림 팬아웃에 있습니다. ClickHouse 외에도 S3, Elasticsearch, 다른 DB 등 여러 소비자가 동일한 변경 스트림을 구독할 수 있고, 메시지 보존 기간을 통한 이벤트 리플레이도 가능합니다. PeerDB와 ClickPipes는 ClickHouse 단일 목적지에 특화된 솔루션입니다. 분석 목적지가 ClickHouse 하나라면 PeerDB·ClickPipes가 명확히 단순하지만, 향후 다운스트림이 늘어날 가능성이 있다면 Kafka 레이어를 유지하는 것이 나을 수 있습니다.
실무에서 자주 하는 실수들
실수 1: SELECT FINAL을 남발하기
FINAL은 정확한 결과를 보장하지만, 머지되지 않은 파트를 런타임에 합치는 비용이 큽니다. 수억 건 테이블에서 습관적으로 쓰다가 "ClickHouse가 느리다"는 결론을 내리는 경우가 있습니다. argMax 기반 서브쿼리나 머티리얼라이즈드 뷰로 최신 상태를 미리 계산해두는 것이 낫습니다.
실수 2: WAL 슬롯 모니터링 누락
CDC 파이프라인이 잠깐 내려갔다가 다시 올라올 때, 그동안 누적된 WAL 크기를 아무도 확인하지 않아서 PostgreSQL 디스크가 꽉 차는 사고가 생각보다 많습니다. Prometheus + Grafana 기준으로 pg_replication_slots의 lag_bytes를 핵심 알럿으로 설정해두는 것이 안전합니다.
실수 3: 스키마 변경 전 스테이징 검증 생략
PostgreSQL에서 컬럼을 추가하면 PeerDB는 자동으로 감지해서 ClickHouse 쪽에도 반영합니다. 그러나 DROP COLUMN이나 타입 변경은 파이프라인이 멈추거나 데이터가 불일치할 수 있습니다. 운영 배포 전에 스테이징 파이프라인에서 반드시 검증하세요.
마치며
PostgreSQL은 트랜잭션의 진실 소스로, ClickHouse는 분석 전담 엔진으로, CDC가 그 사이를 실시간으로 연결한다. 이것이 이 패턴의 핵심입니다.
단일 HTAP 엔진이 현실에서 계속 어려움을 겪는 이유는, 쓰기 트랜잭션과 대규모 읽기 집계가 근본적으로 서로 다른 리소스 특성을 요구하기 때문입니다. 사이드카 패턴은 그 현실을 솔직하게 인정하는 아키텍처입니다.
지금 시작할 수 있는 흐름을 세 단계로 나누면 이렇습니다.
1단계 — PostgreSQL 논리 복제 준비: postgresql.conf에서 wal_level = logical로 변경하고, 복제할 테이블에 대한 PUBLICATION을 생성합니다. 재시작이 필요하므로 점검 시간을 미리 잡아두는 것이 좋습니다.
2단계 — ClickHouse 수신 테이블 설계: ReplacingMergeTree로 테이블을 만들 때 _version과 is_deleted 컬럼을 함께 정의합니다. ORDER BY 키는 주요 조회 패턴을 고려해서 신중하게 고릅니다. 나중에 바꾸기 어렵습니다.
3단계 — 파이프라인 연결 및 모니터링: PeerDB(셀프 호스티드) 또는 ClickPipes(클라우드)로 파이프라인을 붙이고, WAL 슬롯 lag_bytes 알럿을 첫날부터 설정합니다. 파이프라인이 살아있는 동안은 잘 작동합니다. 문제는 늘 파이프라인이 멈췄을 때 발생합니다.
참고 자료
- Change Data Capture with PostgreSQL and ClickHouse - Part 1 — ClickHouse Blog
- Postgres CDC in ClickHouse, A year in review — ClickHouse Blog
- Postgres CDC connector for ClickPipes is now Generally Available — ClickHouse Blog
- CDC Setup from Postgres to ClickHouse — PeerDB Docs
- Postgres to ClickHouse Real time Replication using PeerDB — PeerDB Blog
- Introducing pg_clickhouse: A Postgres extension for querying ClickHouse — ClickHouse Blog
- pg_clickhouse is the fastest Postgres extension on ClickBench — ClickHouse Blog
- Unifying OLTP and OLAP: HTAP databases, zero-ETL, and best-of-breed architectures — ClickHouse
- PostgreSQL + ClickHouse as the Open Source unified data stack — ClickHouse Blog
- Offload PostgreSQL Analytics to ClickHouse for Better Results — Aiven Blog
- Why Engine Choice Matters for CDC in ClickHouse: MergeTree vs Replacing vs Collapsing Explained — FiveOneFour
- Deduplication strategies — ClickHouse Docs
- PostgreSQL Logical Replication Configuration — PostgreSQL Official Docs
- HTAP in 2026: Why the Hybrid Database Dream Came True as a Pipeline, Not a Product — Dataddo Blog
- Real-Time Postgres to ClickHouse CDC: Supercharge Analytics with PeerDB — CloudRaft