PostgreSQL 분석 집계 병목, ClickHouse + Node.js 파이프라인으로 최대 100배 빠르게 해소하기
페이지뷰·버튼 클릭·구매 퍼널을 PostgreSQL RDS에 쌓다 보면, 일 단위 집계 쿼리가 30초를 넘기는 시점이 반드시 옵니다. 인덱스를 추가하고 파티셔닝을 적용해도, 행이 수억 건을 넘어서면 GROUP BY 쿼리의 응답 시간은 단순 최적화로는 더 이상 눌러지지 않습니다. PostgreSQL은 OLTP에서 여전히 훌륭하지만, 분석 집계에서만큼은 저장 구조 자체가 다른 방향을 가리킵니다.
이 글에서는 ClickHouse를 Node.js 백엔드와 연결해 클릭스트림 이벤트를 실시간으로 수집·집계하는 파이프라인을 다룹니다. Materialized View로 삽입 시점에 집계를 완성하고, TTL로 데이터 수명을 자동 관리하고, Distributed 테이블로 샤딩해 수십억 행을 수평 확장하는 방식을 구체적인 SQL·TypeScript 코드와 함께 살펴봅니다.
수십억 행 규모의 대형 GROUP BY 집계에서 PostgreSQL 대비 최대 100배 이상 빠른 응답이 보고됩니다. 정확한 배수는 집계 대상 행 수와 쿼리 복잡도에 따라 달라지며, 일반적인 범위는 10~100배입니다. HighLevel은 이 방식으로 스토리지를 88% 줄이고 쿼리 응답을 200ms 이하로 당겼고, Wingify(VWO)는 비용을 80% 절감했습니다.
PostgreSQL을 버리라는 이야기가 아닙니다. PostgreSQL은 OLTP 주 저장소로 그대로 두고, 분석 집계만 ClickHouse로 오프로드하는 하이브리드 구조가 성숙한 팀의 표준 접근입니다. 그 조합을 Node.js로 연결하는 방법을 살펴보겠습니다.
핵심 개념
ClickHouse가 빠른 이유 — 컬럼형 저장과 벡터화 실행
PostgreSQL은 행(row) 단위로 데이터를 저장합니다. SELECT event_type, COUNT(*) FROM events GROUP BY event_type처럼 두 컬럼만 필요한 쿼리도 디스크에서 모든 컬럼을 읽어옵니다. ClickHouse는 컬럼(column) 단위로 저장하기 때문에 쿼리에 필요한 컬럼만 스캔합니다.
여기에 더해 벡터화 실행 엔진이 SIMD(Single Instruction Multiple Data) 명령어를 활용해 코어당 수억수십억 행/초를 처리합니다. LZ4/ZSTD 압축이 컬럼 단위로 적용되니 비슷한 값들이 모여 압축률도 훨씬 좋습니다. PostgreSQL 대비 510배 저장 공간이 줄어드는 건 자연스러운 결과입니다.
MergeTree 패밀리 — 스토리지 엔진 선택이 성능을 결정합니다
ClickHouse의 핵심 스토리지 엔진이 MergeTree입니다. 삽입된 데이터는 "파트(part)"로 디스크에 기록되고, 백그라운드에서 병합(merge)이 일어나며 정렬과 집계가 완성됩니다. 패밀리 안에서 용도에 따라 엔진을 골라 쓰는 것이 포인트입니다.
| 엔진 | 주요 용도 |
|---|---|
MergeTree |
원시 이벤트 저장 기본 엔진 |
ReplicatedMergeTree |
고가용성 복제가 필요할 때 |
SummingMergeTree |
SUM 집계 특화, Materialized View 목적지로 자주 사용 |
AggregatingMergeTree |
COUNT DISTINCT·AVG 등 복합 집계, MV 목적지 |
Null |
데이터를 저장하지 않는 패스스루 테이블 — MV 트리거 전용 |
Distributed |
다중 샤드 라우팅, 데이터를 직접 보유하지 않는 프록시 |
Materialized View — 쿼리 시점이 아닌 삽입 시점에 집계합니다
ClickHouse의 Materialized View는 PostgreSQL 방식과 완전히 다릅니다. PostgreSQL MV는 REFRESH 명령을 실행할 때 전체 데이터를 다시 집계하지만, ClickHouse MV는 원본 테이블에 행이 삽입되는 순간 트리거로 동작합니다. 들어오는 데이터를 즉시 집계해 목적지 테이블에 씁니다.
수십억 건의 원시 이벤트가 수천 건의 집계 행으로 압축되어 저장되는 구조입니다. 집계 쿼리를 실행할 때는 이미 집계된 결과만 읽으면 됩니다.
한 가지 주의할 점은, ClickHouse MV는 INSERT 시에만 동작하고 기존 데이터를 소급 처리하지 않습니다. 새 MV를 만들면 과거 데이터는 별도로 INSERT INTO ... SELECT ...로 백필해야 합니다. 처음 설계할 때 이걸 감안해두면 나중에 덜 당황스럽습니다.
TTL — 데이터 수명 주기를 DB 레벨에서 선언적으로 관리합니다
클릭스트림 원시 이벤트를 영원히 보관할 필요는 없습니다. TTL을 쓰면 오래된 데이터를 자동으로 삭제하거나, 저비용 스토리지(HDD, S3)로 이동할 수 있습니다.
-- 90일 후 삭제
TTL event_time + INTERVAL 90 DAY DELETE
-- Hot → Warm → Cold 계층형 이동
TTL event_time + INTERVAL 7 DAY TO DISK 'warm',
event_time + INTERVAL 30 DAY TO VOLUME 'cold'Hot(SSD) → Warm(HDD) → Cold(S3) 3계층 TTL 정책은 프로덕션에서 검증된 표준 패턴입니다. ClickHouse Cloud에서도 동일한 정책을 완전 관리형으로 제공합니다.
샤딩과 분산 테이블 — 단일 노드 한계를 넘어섭니다
데이터가 단일 노드를 넘어서면 ReplicatedMergeTree 로컬 테이블과 Distributed 테이블을 조합합니다. Distributed 테이블은 데이터를 직접 보유하지 않고, 여러 샤드에 쓰기·읽기를 라우팅하는 프록시 역할만 합니다. ClickHouse Keeper(또는 ZooKeeper)가 레플리카 간 동기화를 조율합니다.
Async Insert — 소량 고빈도 삽입의 파트 폭발을 막는 방법
ClickHouse는 소량 삽입을 고빈도로 수행하면 과도한 파트가 생성되어 성능이 떨어집니다. async_insert 설정을 활성화하면 서버가 내부적으로 배치를 모아 처리하기 때문에, Node.js에서 개별 이벤트를 건당 INSERT할 때 발생하는 파트 폭발 문제가 크게 완화됩니다. 최신 ClickHouse 버전에서 이 기능의 기본값 활성화가 진행 중이지만, 버전과 무관하게 명시적으로 설정해두는 것이 안전합니다.
실전 적용
시나리오: 전자상거래 클릭스트림 파이프라인
초당 1만~10만 건의 page_view, add_to_cart, purchase 이벤트가 들어오는 서비스를 가정합니다. 목표는 ① 원시 이벤트 저장 ② 실시간 집계(페이지별 뷰 수, 퍼널별 전환율) ③ 90일 TTL ④ 클러스터 확장입니다.
1단계: 원본 테이블과 Materialized View 정의
테이블 정의 전에 PARTITION BY와 ORDER BY의 역할 차이를 짚겠습니다. PARTITION BY toYYYYMM(event_time)은 데이터를 월별 파일(파트)로 물리적으로 분리해 오래된 데이터 삭제 비용을 낮춥니다. ORDER BY는 파트 내부 정렬 순서를 결정하고 sparse index를 생성합니다. 쿼리 속도에 직접 영향을 미치는 건 ORDER BY입니다. 카디널리티가 낮은 컬럼(event_type)을 앞에 두어야 필터 쿼리에서 sparse index를 효과적으로 활용할 수 있습니다.
-- 원시 이벤트 테이블
CREATE TABLE clickstream_events
(
event_id UUID DEFAULT generateUUIDv4(),
user_id UInt64,
session_id String,
event_type LowCardinality(String), -- page_view / add_to_cart / purchase
page_path String,
revenue Decimal(10, 2) DEFAULT 0,
event_time DateTime64(3) DEFAULT now64()
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, page_path, event_time)
TTL event_time + INTERVAL 90 DAY DELETE;
LowCardinality(String)은 카디널리티가 낮은 컬럼(event_type처럼 값이 수십 개 수준인 경우)에 딕셔너리 인코딩을 적용해 저장 공간과 쿼리 속도를 함께 개선합니다.
다음으로 집계 결과를 저장할 목적지 테이블과 MV를 만들기 전에, AggregateFunction 타입에 대해 한 가지 짚겠습니다. countState()·uniqState() 같은 *State() 함수는 집계를 완료하지 않고 직렬화된 중간 상태를 반환합니다. 일반 UInt64 대신 AggregateFunction(...) 타입이 필요한 이유가 바로 이 중간 상태를 저장하기 위해서입니다. 나중에 *Merge() 함수가 여러 파트의 중간 상태를 최종 결과로 합칩니다.
-- MV 집계 결과를 받을 목적지 테이블
CREATE TABLE clickstream_hourly
(
event_hour DateTime,
event_type LowCardinality(String),
page_path String,
event_count AggregateFunction(count),
unique_users AggregateFunction(uniq, UInt64),
total_revenue AggregateFunction(sum, Decimal(10, 2))
)
ENGINE = AggregatingMergeTree
ORDER BY (event_hour, event_type, page_path);
-- Materialized View — 삽입 시점 트리거로 집계
CREATE MATERIALIZED VIEW clickstream_mv
TO clickstream_hourly
AS
SELECT
toStartOfHour(event_time) AS event_hour,
event_type,
page_path,
countState() AS event_count,
uniqState(user_id) AS unique_users,
sumState(revenue) AS total_revenue
FROM clickstream_events
GROUP BY event_hour, event_type, page_path;집계 결과를 조회할 때는 *Merge() 접미사 함수를 씁니다.
SELECT
event_hour,
event_type,
page_path,
countMerge(event_count) AS views,
uniqMerge(unique_users) AS unique_users,
sumMerge(total_revenue) AS revenue
FROM clickstream_hourly
WHERE event_hour >= now() - INTERVAL 24 HOUR
GROUP BY event_hour, event_type, page_path
ORDER BY event_hour DESC;2단계: 샤딩 클러스터 구성
클러스터 구성에서 가장 흔히 놓치는 부분이 MV 부착 위치입니다. Distributed 테이블은 데이터를 직접 보유하지 않기 때문에 여기에 MV를 붙이면 트리거가 동작하지 않습니다. MV는 반드시 각 샤드의 로컬 테이블에 붙어야 합니다.
올바른 패턴은 원시 이벤트와 집계 결과 레이어 모두 로컬+Distributed 쌍으로 구성하고, MV는 로컬끼리 연결하는 것입니다.
<!-- /etc/clickhouse-server/config.d/cluster.xml -->
<clickhouse>
<remote_servers>
<events_cluster>
<shard>
<replica><host>ch-node-1</host><port>9000</port></replica>
<replica><host>ch-node-2</host><port>9000</port></replica>
</shard>
<shard>
<replica><host>ch-node-3</host><port>9000</port></replica>
<replica><host>ch-node-4</host><port>9000</port></replica>
</shard>
</events_cluster>
</remote_servers>
<macros>
<cluster>events_cluster</cluster>
<shard>01</shard> <!-- 노드별로 다르게 설정 -->
<replica>ch-node-1</replica>
</macros>
</clickhouse>-- 원시 이벤트 로컬 테이블 (각 샤드)
CREATE TABLE clickstream_events_local ON CLUSTER events_cluster
(
event_id UUID DEFAULT generateUUIDv4(),
user_id UInt64,
session_id String,
event_type LowCardinality(String),
page_path String,
revenue Decimal(10, 2) DEFAULT 0,
event_time DateTime64(3) DEFAULT now64()
)
ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/clickstream_events',
'{replica}'
)
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, page_path, event_time)
TTL event_time + INTERVAL 90 DAY DELETE;
-- 집계 결과 로컬 테이블 (각 샤드)
CREATE TABLE clickstream_hourly_local ON CLUSTER events_cluster
(
event_hour DateTime,
event_type LowCardinality(String),
page_path String,
event_count AggregateFunction(count),
unique_users AggregateFunction(uniq, UInt64),
total_revenue AggregateFunction(sum, Decimal(10, 2))
)
ENGINE = ReplicatedAggregatingMergeTree(
'/clickhouse/tables/{shard}/clickstream_hourly',
'{replica}'
)
ORDER BY (event_hour, event_type, page_path);
-- MV: 로컬 원시 → 로컬 집계 (각 샤드에서 독립적으로 발화)
CREATE MATERIALIZED VIEW clickstream_mv ON CLUSTER events_cluster
TO clickstream_hourly_local
AS
SELECT
toStartOfHour(event_time) AS event_hour,
event_type,
page_path,
countState() AS event_count,
uniqState(user_id) AS unique_users,
sumState(revenue) AS total_revenue
FROM clickstream_events_local
GROUP BY event_hour, event_type, page_path;
-- 원시 이벤트 Distributed 테이블 (쓰기 진입점)
CREATE TABLE clickstream_events ON CLUSTER events_cluster
AS clickstream_events_local
ENGINE = Distributed(
'events_cluster',
currentDatabase(),
'clickstream_events_local',
cityHash64(user_id)
);
-- 집계 결과 Distributed 테이블 (읽기 진입점)
CREATE TABLE clickstream_hourly ON CLUSTER events_cluster
AS clickstream_hourly_local
ENGINE = Distributed(
'events_cluster',
currentDatabase(),
'clickstream_hourly_local',
rand()
);Distributed clickstream_events에 INSERT하면 cityHash64(user_id) 기준으로 샤드가 선택됩니다. 같은 user_id의 데이터는 항상 같은 샤드에 모여 사용자 단위 집계가 효율적입니다. MV는 데이터가 도착한 각 샤드의 로컬 테이블에서 발화하므로 집계 연산이 분산 처리됩니다. 읽기는 Distributed clickstream_hourly를 통해 모든 샤드의 결과를 자동으로 병합합니다.
3단계: Node.js에서 배치 삽입 구현
npm install @clickhouse/client kafkajs// clickhouse-client.ts
import { createClient, ClickHouseClient } from '@clickhouse/client';
export const chClient: ClickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123',
username: process.env.CLICKHOUSE_USER ?? 'default',
password: process.env.CLICKHOUSE_PASSWORD ?? '',
database: 'analytics',
clickhouse_settings: {
async_insert: 1,
// 0: 서버가 메모리에 수신하면 즉시 응답 — 처리량 최대화
// 서버 크래시 시 디스크에 기록되지 않은 배치가 유실될 수 있음
// 데이터 유실이 허용되지 않는 경우 1로 설정
wait_for_async_insert: 0,
},
});// event-ingestion.ts
import { chClient } from './clickhouse-client';
export interface ClickstreamEvent {
user_id: number;
session_id: string;
event_type: 'page_view' | 'add_to_cart' | 'purchase';
page_path: string;
revenue?: number;
event_time?: string;
}
// 배치 삽입 — 권장: 1,000~100,000건 단위
export async function insertEvents(events: ClickstreamEvent[]): Promise<void> {
await chClient.insert({
table: 'clickstream_events',
values: events.map(e => ({
...e,
revenue: e.revenue ?? 0,
event_time: e.event_time ?? new Date().toISOString(),
})),
format: 'JSONEachRow',
});
}
// 타입 안전 집계 쿼리 — 명명된 파라미터 사용
export async function getHourlyStats(pagePathPrefix: string, hours: number) {
const result = await chClient.query({
query: `
SELECT
event_hour,
event_type,
page_path,
countMerge(event_count) AS views,
uniqMerge(unique_users) AS unique_users,
sumMerge(total_revenue) AS revenue
FROM clickstream_hourly
WHERE event_hour >= now() - INTERVAL {hours:UInt32} HOUR
AND page_path LIKE {path_prefix:String}
GROUP BY event_hour, event_type, page_path
ORDER BY event_hour DESC
`,
query_params: {
hours,
path_prefix: `${pagePathPrefix}%`,
},
format: 'JSONEachRow',
});
return result.json<{
event_hour: string;
event_type: string;
page_path: string;
views: string;
unique_users: string;
revenue: string;
}>();
}// kafka-consumer.ts
import { Kafka } from 'kafkajs';
import { insertEvents, ClickstreamEvent } from './event-ingestion';
const kafka = new Kafka({ brokers: ['kafka:9092'] });
const consumer = kafka.consumer({ groupId: 'clickhouse-ingester' });
const BATCH_SIZE = 5000;
const FLUSH_INTERVAL_MS = 1000;
let buffer: ClickstreamEvent[] = [];
async function flushBuffer(): Promise<void> {
if (buffer.length === 0) return;
const batch = buffer.splice(0, buffer.length);
try {
await insertEvents(batch);
} catch (err) {
console.error('ClickHouse insert failed, re-queuing batch:', err);
// 실패한 배치를 버퍼 앞쪽으로 되돌려 다음 플러시에서 재시도
buffer.unshift(...batch);
}
}
// setInterval의 async 콜백에서 발생하는 rejection은 자동으로 전파되지 않으므로 명시적 처리
setInterval(() => {
flushBuffer().catch(err => console.error('Flush interval error:', err));
}, FLUSH_INTERVAL_MS);
await consumer.subscribe({ topic: 'clickstream-events' });
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value!.toString()) as ClickstreamEvent;
buffer.push(event);
if (buffer.length >= BATCH_SIZE) {
await flushBuffer();
}
},
});장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 집계 쿼리 속도 | 수억 행 이상 대형 GROUP BY에서 PostgreSQL 대비 최대 100배 이상, 일반적으로 10~100배 빠름 |
| 삽입 처리량 | 수백만 건/초 지속 삽입 가능, 밀리초 단위 집계 대시보드 제공 |
| 압축률 | LZ4/ZSTD 컬럼형 압축으로 PostgreSQL 대비 5~10배 저장 공간 절감 |
| 비용 효율 | Wingify 80% 비용 절감, HighLevel 88% 스토리지 절감 실제 사례 |
| 수평 확장 | 샤딩 + 레플리케이션으로 선형 처리량 및 스토리지 확장 |
| 데이터 수명 주기 | TTL 삭제·이동 정책을 DB 레벨에서 선언적으로 관리 |
| 운영 성숙도 | Cloudflare, Uber 등 극한 규모에서 검증 완료 |
단점 및 고려사항
| 항목 | 내용 |
|---|---|
| OLTP 부적합 | 잦은 UPDATE/DELETE 트랜잭션에 적합하지 않음 — PostgreSQL과 역할 분리 필수 |
| JOIN 한계 | 대형 테이블 간 JOIN은 메모리 소비가 큼 — 비정규화와 Dictionary 활용 권장 |
| 스키마 변경 비용 | MV 스키마 변경 시 새 MV 생성 + 과거 데이터 수동 백필 필요 |
| 운영 복잡도 | 샤딩 클러스터는 ClickHouse Keeper 운영 오버헤드 수반 |
| 규모 임계점 | 수백만 행 수준에서는 PostgreSQL 대비 이점이 크지 않음, 수억 건부터 효과 명확 |
| 파트 관리 | 소량 고빈도 삽입 시 파트 폭발 위험 — 배치 삽입 또는 Async Insert 필수 |
실무에서 흔히 범하는 실수
ORDER BY를 너무 느슨하게 잡는 경우. ORDER BY (event_time)처럼 잡으면 WHERE event_type = 'purchase'로 필터링할 때 전체 데이터를 스캔합니다. 쿼리 패턴을 먼저 파악하고 ORDER BY (event_type, page_path, event_time)처럼 카디널리티가 낮은 컬럼을 앞에 두는 것이 기본입니다.
MV 목적지 테이블을 일반 MergeTree로 잡는 경우. AggregatingMergeTree 대신 일반 MergeTree를 목적지로 쓰면 countState() 같은 집계 상태 함수가 제대로 병합되지 않습니다. 집계 MV의 목적지는 AggregatingMergeTree 또는 SummingMergeTree를 써야 합니다.
클러스터 환경에서 MV를 Distributed 테이블에 붙이는 경우. Distributed 테이블은 데이터를 직접 보유하지 않기 때문에 MV 트리거가 발화하지 않습니다. MV는 반드시 각 샤드의 로컬 테이블(clickstream_events_local)에 부착해야 합니다. 집계 결과 테이블도 로컬+Distributed 쌍으로 구성해야 합니다.
단건 INSERT를 루프로 호출하는 경우. 1건씩 넣으면 파트가 폭발적으로 늘어나 머지 작업이 밀리면서 전체 성능이 망가집니다. 1,000~100,000건 단위 배치 삽입이 기본입니다.
마치며
ClickHouse의 핵심 가치는 삽입 시점 집계(Materialized View)와 컬럼형 저장이 맞물려 만들어내는 구조적 이점에 있습니다. 쿼리를 아무리 최적화해도 행 단위 저장에서 억 건 집계를 빠르게 만드는 데는 한계가 있지만, ClickHouse는 그 문제를 아키텍처 레벨에서 다르게 접근합니다.
지금 시작해볼 수 있는 3단계:
-
로컬 ClickHouse 띄우기. Docker로 바로 올릴 수 있습니다:
docker run -p 8123:8123 clickhouse/clickhouse-server.@clickhouse/client를 설치하고 기존 PostgreSQL 분석 쿼리 하나를 ClickHouse로 옮겨서 속도를 직접 비교해볼 수 있습니다. -
원본 테이블과 MV 한 쌍 정의하기. 가장 자주 쓰는 집계 쿼리를 Materialized View로 옮겨두면, 다음 번 그 쿼리를 실행할 때 이미 결과가 준비되어 있습니다.
-
PostgreSQL과 공존 구조 잡기. 바로 마이그레이션하지 않아도 됩니다. ClickPipes의 Postgres 커넥터나 PeerDB를 활용하면 PostgreSQL 데이터를 CDC 방식으로 ClickHouse에 동기화해, 분석 쿼리만 먼저 오프로드하는 방식으로 점진적으로 전환할 수 있습니다.
구조가 다르면 결과가 다릅니다. 쿼리 튜닝이 아니라 아키텍처를 바꾸는 것이 포인트입니다.
참고 자료
- ClickHouse 공식 JavaScript 통합 문서
- 공식 clickhouse-js GitHub 저장소
- Materialized View 롤업 타임시리즈 가이드
- 클러스터 배포 및 복제 공식 문서
- ClickHouse vs PostgreSQL 공식 비교
- HighLevel: ClickHouse Cloud로 데이터 플랫폼 재구축 사례
- Wingify: Postgres에서 ClickHouse로 전환, 비용 80% 절감
- Mux: ClickHouse를 실시간 스트림 처리 엔진으로 사용하는 방법
- Inigo: Materialized View와 ClickHouse로 고성능 분석 구현
- ClickHouse 수십억 행 스케일링 방법
- Hot-Warm-Cold TTL 스토리지 계층화
- Tinybird: ClickHouse Materialized View 실전 예제
- GlassFlow: ClickHouse Async Insert 동작 원리
- Altinity 샤딩·레플리케이션 딥다이브
- ClickHouse 2025 연간 업데이트 정리