PostgreSQL 집계 쿼리가 한계에 다다랐을 때 — ClickHouse + Node.js 실전 가이드
그 쿼리 기억하시죠. 대시보드 API 응답이 30초를 넘기고, Sentry에 타임아웃 에러가 쌓이기 시작하는 순간. SELECT COUNT(DISTINCT user_id) FROM events WHERE created_at >= ...을 EXPLAIN으로 봐도 이미 최선인 인덱스를 쓰고 있는데, 테이블 크기가 1억 건을 넘자 감당이 안 되는 상황을요.
이 글의 목적은 그 문제를 ClickHouse로 해결하는 구체적인 방법입니다. 컬럼형 저장이 왜 집계에 유리한지, Node.js 공식 클라이언트로 어떻게 연결하는지, 배치 INSERT·스키마 설계·Kafka 연동 같은 실무 패턴, 그리고 PostgreSQL을 언제 유지해야 하는지까지 다룹니다.
컬럼형 저장이 분석 쿼리에 유리한 이유
행 기반 DB인 PostgreSQL은 (id, user_id, event_type, properties, created_at) 한 행이 디스크의 연속된 영역에 저장됩니다. SELECT COUNT(*) GROUP BY event_type을 실행하면 event_type 컬럼 하나만 필요한데도, 그 컬럼을 포함한 모든 페이지를 디스크에서 읽어야 합니다.
ClickHouse는 반대입니다. user_id 컬럼은 user_id 파일에, event_type 컬럼은 event_type 파일에 따로 저장됩니다. 집계에 필요한 컬럼만 골라서 읽으니 I/O 자체가 다릅니다.
여기에 세 가지 메커니즘이 더해집니다.
블록 단위 벡터화 실행: ClickHouse는 행을 하나씩 처리하지 않고, 기본 65,536행 단위의 블록(max_block_size 기본값)으로 묶어서 처리합니다. 행 단위 인터프리터 오버헤드가 대폭 줄어드는 이유입니다.
SIMD 활용: CPU 단일 명령으로 여러 값을 병렬 연산합니다. 같은 타입의 데이터가 연속으로 배열되어야 가능한데, 컬럼 저장이 이 조건을 자연스럽게 만족합니다.
높은 압축률: 동일 타입의 값이 연속 저장되므로 압축 효율이 좋습니다. 타임스탬프처럼 단조 증가하는 값에는 DoubleDelta 인코딩을 적용하면 특히 높은 압축률을 얻을 수 있습니다. 압축률이 높다는 건 디스크에서 읽어야 할 데이터가 그만큼 작다는 의미이고, 이것이 쿼리 속도로 직결됩니다.
MergeTree: 이벤트 로그의 기본 엔진
ClickHouse의 핵심 스토리지 엔진은 MergeTree입니다. 이벤트 로그처럼 append-only 특성을 가진 데이터에 최적화되어 있습니다. ORDER BY로 정렬 키를 지정하면 데이터를 물리적으로 정렬해 저장하기 때문에 범위 스캔이 빠릅니다.
인덱스 구조도 다릅니다. PostgreSQL의 B-tree 인덱스는 모든 행마다 항목이 있지만, ClickHouse의 희소 인덱스(Sparse Index)는 8,192행마다 인덱스 항목 하나입니다. 인덱스 자체가 작아서 메모리에 항상 올라가 있고, 대량 스캔 시 오히려 더 효율적입니다.
파생 엔진들도 알아두면 좋습니다.
| 엔진 | 용도 |
|---|---|
ReplacingMergeTree |
동일 정렬 키 중복 행 제거 |
SummingMergeTree |
카운터 자동 합산 (DAU, 페이지뷰 등) |
AggregatingMergeTree |
복합 지표 사전 집계 |
ReplicatedMergeTree |
고가용성 레플리케이션 |
PostgreSQL과 ClickHouse의 역할 분리
ClickHouse를 붙인다고 PostgreSQL을 버리는 게 아닙니다. 역할을 나눠야 합니다.
단건 포인트 룩업, 트랜잭션, 외래키 제약은 PostgreSQL이 훨씬 잘합니다. ClickHouse는 그 데이터에서 발생한 이벤트 로그를 집계하는 역할을 맡습니다.
이벤트를 ClickHouse로 보내는 경로는 두 가지입니다. 앱이 Kafka에 직접 발행하거나, Debezium 같은 CDC 도구가 PostgreSQL 변경을 포착해 Kafka로 흘리는 방식입니다. 두 경로를 혼용하는 경우도 많습니다. 이 이중 DB 아키텍처가 실무 표준입니다.
설치 및 기본 연결
Docker로 로컬 환경을 먼저 띄울 수 있습니다.
docker run -d \
--name clickhouse-server \
-p 8123:8123 \
-p 9000:9000 \
clickhouse/clickhouse-server:latestNode.js 프로젝트에는 공식 클라이언트를 설치합니다.
npm install @clickhouse/client기본 연결은 이렇습니다.
import { createClient } from '@clickhouse/client';
const client = createClient({
url: 'http://localhost:8123',
username: 'default',
password: '',
database: 'default',
});이벤트 로그 테이블 스키마 설계
스키마 설계가 ClickHouse 성능의 절반을 결정합니다. 특히 ORDER BY 키 순서 선택이 핵심인데, 흔히 "카디널리티가 낮은 컬럼을 앞에 두라"고만 기억하면 실수합니다. 정확한 원칙은 자주 WHERE 필터로 쓰이는 컬럼 중 카디널리티가 낮은 것을 앞에 두는 것입니다. 쿼리에서 필터로 쓰이지 않는 컬럼을 아무리 앞에 두어도 희소 인덱스 효과는 없습니다.
CREATE TABLE events
(
event_id UUID DEFAULT generateUUIDv4(),
service_name LowCardinality(String),
event_type LowCardinality(String),
user_id String,
session_id String,
properties Map(String, String),
created_at DateTime64(3) CODEC(DoubleDelta, ZSTD(1))
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (service_name, event_type, created_at)
TTL created_at + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;LowCardinality(String): 반복 문자열 컬럼에 사용하면 내부적으로 딕셔너리 인코딩이 적용되어 메모리와 스토리지를 대폭 절감합니다.
CODEC(DoubleDelta, ZSTD(1)): 단조 증가하는 타임스탬프에 DoubleDelta를 적용하면 압축률이 크게 올라갑니다.
ORDER BY (service_name, event_type, created_at): 서비스별·이벤트 타입별 쿼리가 주된 패턴이라면 이 순서가 맞습니다. ORDER BY (created_at, service_name)처럼 타임스탬프를 앞에 두면 WHERE service_name = ? 조건이 풀 스캔이 됩니다.
TTL: 90일 지난 데이터를 자동 삭제합니다. 보존 기간이 있는 로그라면 처음부터 설정해두는 게 운영에 편합니다.
배치 INSERT — 단건 INSERT는 절대 피해야 합니다
단건 INSERT를 루프로 돌리면 PostgreSQL보다도 느려집니다. ClickHouse는 INSERT마다 새로운 파트(part)를 디스크에 기록하는데, 단건 INSERT가 반복되면 파트 수가 빠르게 쌓입니다. ClickHouse의 기본 설정에서 파티션당 동시 파트 수가 150개를 넘으면 INSERT 속도를 자동으로 throttle하고, 300개를 초과하면 INSERT 자체가 Too many parts 오류를 반환하며 멈춥니다. 느려지는 게 아니라 쓰기가 차단됩니다.
Node.js에서 메모리 버퍼로 배치 처리하는 패턴입니다.
import { createClient } from '@clickhouse/client';
interface EventRow {
service_name: string;
event_type: string;
user_id: string;
session_id: string;
properties: Record<string, string>;
created_at: string; // ISO 8601
}
class EventBuffer {
private buffer: EventRow[] = [];
private flushPromise: Promise<void> = Promise.resolve();
private readonly flushSize = 5000;
private readonly flushIntervalMs = 3000;
private timer: ReturnType<typeof setInterval>;
constructor(private readonly client: ReturnType<typeof createClient>) {
this.timer = setInterval(() => this.scheduleFlush(), this.flushIntervalMs);
}
push(event: EventRow): void {
this.buffer.push(event);
if (this.buffer.length >= this.flushSize) {
this.scheduleFlush();
}
}
// Promise 체인으로 직렬화 — 타이머와 push 양쪽에서 동시 호출되어도 flush는 순차 실행됨
private scheduleFlush(): void {
this.flushPromise = this.flushPromise.then(() => this.flush());
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return;
const rows = this.buffer.splice(0);
try {
await this.client.insert({
table: 'events',
values: rows,
format: 'JSONEachRow',
});
} catch (err) {
console.error('[EventBuffer] INSERT 실패, 데이터 반환 후 재시도 대기:', err);
this.buffer = [...rows, ...this.buffer]; // 실패한 rows를 버퍼 앞에 반환
}
}
// 프로세스 종료 전 반드시 호출해 남은 데이터를 소진
async destroy(): Promise<void> {
clearInterval(this.timer);
await this.flushPromise;
await this.flush();
}
}
const buffer = new EventBuffer(client);
process.on('SIGTERM', async () => {
await buffer.destroy();
process.exit(0);
});3초마다 또는 5,000건이 쌓이면 자동으로 플러시됩니다. flushPromise 체인으로 flush가 순차 실행되도록 직렬화했고, INSERT 실패 시 꺼냈던 rows를 버퍼 앞에 되돌려 재시도 기회를 보장합니다. destroy()로 graceful shutdown도 처리합니다.
집계 쿼리 — 파라미터 바인딩으로 인젝션 방지
파라미터 바인딩은 {param:Type} 문법을 사용합니다. SQL 인젝션을 방지하면서 쿼리 캐시 효율도 높아집니다.
async function getDailyActiveUsers(
serviceName: string,
startDate: string,
endDate: string,
): Promise<{ date: string; dau: number }[]> {
const result = await client.query({
query: `
SELECT
toDate(created_at) AS date,
uniq(user_id) AS dau
FROM events
WHERE service_name = {service:String}
AND created_at BETWEEN {start:DateTime64} AND {end:DateTime64}
GROUP BY date
ORDER BY date
`,
query_params: {
service: serviceName,
start: startDate,
end: endDate,
},
format: 'JSONEachRow',
});
return result.json();
}uniq()는 HyperLogLog 기반 근사 집계 함수입니다. 정확한 COUNT(DISTINCT)보다 약 2~3% 오차가 있지만, 수억 건에서도 수십 밀리초 내에 결과를 돌려줍니다. 정확도가 필요하다면 uniqExact()를 쓸 수 있습니다.
Kafka 연동 — 고처리량 파이프라인
트래픽이 커지면 Node.js 메모리 버퍼만으로는 부족할 수 있습니다. Kafka를 중간에 두면 ClickHouse의 Kafka 엔진이 직접 컨슘하는 구조로 안정적인 고처리량을 확보할 수 있습니다.
-- Kafka 소스 테이블
CREATE TABLE events_kafka
(
service_name String,
event_type String,
user_id String,
session_id String,
properties Map(String, String),
created_at DateTime64(3)
)
ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'user-events',
kafka_group_name = 'clickhouse-consumer',
kafka_format = 'JSONEachRow',
kafka_num_consumers = 4,
kafka_max_block_size = 65536;
-- Materialized View로 실제 테이블에 삽입
CREATE MATERIALIZED VIEW events_kafka_mv TO events AS
SELECT * FROM events_kafka;kafka_max_block_size = 65536은 ClickHouse의 기본값이며 출발점입니다. 실제 적정값은 메시지 크기, 컨슈머 수, 메모리 용량에 따라 달라지므로 공식 문서를 참고해 워크로드에 맞게 튜닝해야 합니다.
Materialized View로 사전 집계
매번 전체 테이블을 스캔하지 않아도 되도록 자주 쓰는 집계를 미리 계산해둘 수 있습니다.
-- 시간별 이벤트 카운트 사전 집계 테이블
CREATE TABLE events_hourly_agg
(
service_name LowCardinality(String),
event_type LowCardinality(String),
hour DateTime,
event_count AggregateFunction(count),
unique_users AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
ORDER BY (service_name, event_type, hour);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly_agg AS
SELECT
service_name,
event_type,
toStartOfHour(created_at) AS hour,
countState() AS event_count,
uniqState(user_id) AS unique_users
FROM events
GROUP BY service_name, event_type, hour;조회 시에는 Merge 함수로 집계 상태를 최종 값으로 합칩니다.
SELECT
hour,
countMerge(event_count) AS event_count,
uniqMerge(unique_users) AS unique_users
FROM events_hourly_agg
WHERE service_name = 'my-app'
AND hour >= now() - INTERVAL 7 DAY
GROUP BY hour
ORDER BY hour;중요한 주의 사항이 있습니다. Materialized View는 새로운 데이터가 INSERT될 때만 자동으로 업데이트됩니다. MV 생성 이전에 이미 존재하던 데이터는 집계 테이블에 반영되지 않습니다. 운영 중에 MV를 추가했다면 기존 데이터를 INSERT INTO events_hourly_agg SELECT ... 방식으로 별도 백필(backfill)해야 합니다. 이를 놓치면 대시보드에서 과거 구간 수치가 0으로 나타납니다.
장단점 분석
ClickHouse가 빛나는 지점
| 항목 | 내용 |
|---|---|
| 쿼리 속도 | 집계 쿼리 기준 PostgreSQL 대비 수십~수백 배 빠른 사례가 보고되어 있습니다. 단일 노드 NVMe 환경에서 인덱스 미사용 상태의 1억 건 집계가 수십 초에서 1초 내로 줄어든 경우도 있습니다. |
| 압축률 | 컬럼 저장 + 적절한 코덱 조합으로 행 기반 DB 대비 스토리지를 크게 절감할 수 있습니다. |
| 수평 확장 | 샤딩·레플리케이션으로 페타바이트 규모까지 선형 확장됩니다. |
| 비용 효율 | 압축률과 쿼리 효율 덕분에 동일 하드웨어에서 더 많은 데이터를 처리합니다. |
| SQL 호환 | 표준 SQL 문법을 지원하고 학습 곡선이 완만합니다. |
| Node.js 지원 | TypeScript 완전 지원, 스트리밍 INSERT/SELECT, 외부 의존성 없음. |
Cloudflare는 ClickHouse를 활용해 단일 쿼리로 수십 조 건 규모의 이벤트를 초 단위로 반환하는 것으로 알려져 있습니다. Netflix 역시 대규모 로그 처리에 ClickHouse를 도입한 사례가 소개된 바 있습니다.
주의해야 할 지점
| 항목 | 내용 |
|---|---|
| OLTP 부적합 | 완전한 ACID 트랜잭션 미지원, 단건 포인트 룩업은 오히려 느림 |
| JOIN 비용 | 우측 테이블을 메모리에 완전히 로드, 수백 MB 초과 시 급격히 느려짐 |
| UPDATE/DELETE | 빈번한 수정 작업이 많은 데이터에는 부적합 |
| 운영 복잡도 | 파티션 관리, TTL, 레플리케이션 등 PostgreSQL보다 튜닝 요소 많음 |
| 스키마 변경 | 정렬 키 변경이 어렵고, 초기 설계가 성능에 큰 영향 |
실무에서 흔한 실수 세 가지
1. 단건 INSERT 루프: 반드시 배치 처리가 필요합니다. ClickHouse는 파티션당 파트 수가 150개를 초과하면 쓰기를 throttle하고, 300개를 넘으면 Too many parts 오류로 INSERT 자체를 거부합니다. "느려진다"가 아니라 "멈춘다"입니다.
2. ORDER BY 키 순서 잘못 잡기: ORDER BY (created_at, service_name)처럼 타임스탬프를 앞에 두면 서비스별 필터링 쿼리가 풀 스캔이 됩니다. "자주 WHERE 필터로 쓰이는 컬럼 중 카디널리티가 낮은 것을 앞에"가 올바른 원칙입니다.
3. ClickHouse로 PostgreSQL 대체 시도: 트랜잭션·단건 CRUD·외래키 제약은 PostgreSQL이 담당하고, 분석·집계·로그는 ClickHouse로 이원화하는 구조가 표준입니다. 하나로 모든 걸 처리하려 하면 양쪽의 약점만 남습니다.
마치며
ClickHouse는 분석 쿼리 성능 문제를 해결하는 가장 검증된 선택지 중 하나입니다. 2025~2026년에는 LLM 서비스의 토큰 사용량·지연 추적 같은 AI 관측성(observability) 레이어로도 빠르게 자리잡고 있어, 앞으로 접할 기회가 더 많아질 것입니다.
핵심을 요약하면 이렇습니다. 컬럼 저장 + 블록 단위 벡터화 실행 + 희소 인덱스가 집계 쿼리를 빠르게 만들고, 배치 INSERT와 ORDER BY 키 설계가 운영 성능을 결정하며, PostgreSQL과 역할을 나누는 이중 DB 아키텍처가 실무 표준입니다.
지금 바로 시작해볼 수 있는 3단계입니다.
- 로컬 Docker로 ClickHouse를 띄우고
@clickhouse/client로 연결해, 현재 가장 느린 PostgreSQL 집계 쿼리를 그대로 옮겨서 속도를 비교해보세요. - 이벤트 로그 테이블을 새로 설계하면서
LowCardinality,DoubleDelta코덱, 그리고ORDER BY키 순서를 직접 실험해보세요. - 배치 INSERT 버퍼를 붙이고 Materialized View로 사전 집계를 추가해 응답 시간이 어떻게 달라지는지 측정해보세요.
PostgreSQL을 버리자는 게 아닙니다. 잘 못하는 일을 ClickHouse에게 맡기면 됩니다.
참고 자료
- ClickHouse 공식 문서 — 소개
- ClickHouse 공식 JS 클라이언트 문서
- ClickHouse JS GitHub
- @clickhouse/client npm 패키지
- ClickHouse Node.js 통합 가이드
- ClickHouse vs PostgreSQL 공식 비교
- PostgreSQL 마이그레이션 가이드
- Kafka 통합 공식 문서
- 관측성 스키마 설계 가이드
- ClickHouse 베스트 프랙티스
- Cloudflare ClickHouse 사례
- MergeTree 엔진 심층 분석
- ClickHouse JOIN 한계 및 해결책
- ClickHouse를 사용하지 말아야 할 때
- Kafka → ClickHouse 파이프라인 비교
- 2026년 실시간 분석 DB 선택 가이드