- Published on
ClickHouse MergeTree 완벽 가이드 — Columnar, Sparse Index, Part, Materialized View 모든 것 (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — OLAP의 왕좌는 어떻게 바뀌었나
2020년대 초반까지만 해도 "대규모 분석"이라고 하면 선택지는 정해져 있었다. Redshift, BigQuery, Snowflake 같은 클라우드 데이터 웨어하우스. 아니면 Druid, Presto/Trino, Impala 같은 현장 배치형 도구. 다들 각자의 트레이드오프가 있었다 — 비용, 지연, 쿼리 언어, 운영 복잡도.
그리고 ClickHouse가 나타났다.
Yandex(러시아의 구글)가 2009년부터 내부 웹 분석 시스템 Yandex.Metrica를 위해 개발했고 2016년 오픈소스화했다. 2026년 현재 ClickHouse는 오픈소스 OLAP 시장의 사실상 표준이다. Uber, Cloudflare, Spotify, eBay가 쓴다. Observability 도구의 새로운 세대 (SigNoz, Posthog)가 기반 엔진으로 쓴다. ClickHouse Cloud (회사)는 유니콘이 됐다.
왜 이긴 건가? 한마디로: 속도. 10억 row 테이블에 GROUP BY 쿼리를 초당 한 개씩 돌릴 수 있다. 같은 하드웨어에서 Postgres가 수 분 걸릴 일을 1초 안에 끝낸다. 이 10–1000배의 속도 차이는 단일 최적화가 아니라 저장, 인덱스, 실행 엔진의 모든 레이어에서의 의도적 선택의 결과다.
이 글은 ClickHouse의 내부, 특히 MergeTree 엔진 계열의 모든 것을 1,400줄에 걸쳐 파헤친다. Columnar 저장의 바이트 레이아웃, Part와 Granule 구조, Sparse Primary Index가 B-tree와 어떻게 다른가, Materialized View가 어떻게 실시간 집계를 만드는가, Distributed table과 Replication의 작동 방식, Vectorized execution engine이 CPU 캐시를 어떻게 뚝뚝 끊는가.
이 글은 LSM-Tree 글의 후속이다. MergeTree는 LSM의 OLAP용 사촌이며, 많은 설계 결정이 LSM의 원리를 응용한다. 동시에 Postgres 글의 반대편에 선다 — OLTP와 OLAP가 같은 "데이터베이스"라는 단어로 묶이기엔 너무 다른 시스템이라는 증거다.
1. Columnar Storage — 왜 열로 저장하는가
1.1 행 저장 vs 열 저장
전통적인 DB는 row 단위로 저장한다. 한 row의 모든 컬럼 값이 디스크에 연속적으로 있다.
Row layout:
(id=1, name="Alice", age=30, city="Seoul")
(id=2, name="Bob", age=25, city="Tokyo")
(id=3, name="Carol", age=35, city="Seoul")
디스크에서는:
1, Alice, 30, Seoul | 2, Bob, 25, Tokyo | 3, Carol, 35, Seoul
이 레이아웃은 OLTP에 최적이다. 한 row 전체를 한 번에 읽거나 쓰는 작업이 빠르다.
Columnar는 완전히 뒤집는다:
id: [1, 2, 3]
name: [Alice, Bob, Carol]
age: [30, 25, 35]
city: [Seoul, Tokyo, Seoul]
각 컬럼이 독립된 파일/영역에 저장된다.
1.2 왜 OLAP에 유리한가
SELECT city, AVG(age) FROM users GROUP BY city 쿼리를 생각하자.
- Row store: 모든 row의 모든 컬럼을 읽는다. 10개 컬럼이면 필요한 데이터의 10배를 I/O.
- Column store:
city와age컬럼 파일만 읽는다. I/O가 80% 줄어든다.
이점은 더 있다:
- 압축률: 같은 타입의 값이 연속돼 있으니 압축이 극적으로 잘 된다. int 컬럼은 delta encoding, string 컬럼은 dictionary encoding. 일반적으로 5–20배 압축.
- CPU 캐시: 한 번에 같은 타입 값을 처리하니 CPU 파이프라이닝과 SIMD에 유리.
- 선택적 스캔: 컬럼을 골라 읽으니 스캔량이 줄어든다.
단점:
- Row 재구성 비용: 한 row의 모든 값을 보려면 여러 컬럼 파일에서 같은 인덱스를 다 읽어와야 함.
- 쓰기가 비쌈: INSERT 하나가 여러 파일 수정. 그래서 OLAP는 배치 INSERT를 선호.
- UPDATE/DELETE가 매우 비쌈: ClickHouse는 "ALTER로 수정" 모델을 쓰며, 실시간 업데이트는 거의 포기.
1.3 ClickHouse의 컬럼 파일
MergeTree 테이블의 각 컬럼은 디스크에 두 개의 파일로 저장된다:
column_name.bin # 실제 데이터 (압축됨)
column_name.mrk2 # 마크 파일 — granule 경계 오프셋
.bin 파일은 블록 단위로 압축된다 (기본 LZ4, 옵션 ZSTD). 한 블록은 index_granularity_bytes (기본 10MB) 또는 max_compress_block_size (기본 1MB)에 맞춰진다.
.mrk2는 "각 granule이 .bin 파일의 어느 오프셋에서 시작하는가"를 기록한 인덱스다. 다음 절에서 granule 개념을 보자.
2. Part와 Granule — MergeTree의 단위들
2.1 Part — MergeTree의 SSTable
MergeTree에서 part는 LSM의 SSTable과 같은 역할. 불변(immutable)의 정렬된 데이터 청크.
INSERT를 하면 새 part가 만들어진다. INSERT INTO t VALUES (...) 한 번이 하나의 part. 백그라운드 merge가 여러 part를 합쳐 큰 part로 만든다.
디스크 레이아웃:
/var/lib/clickhouse/data/<db>/<table>/
├── 202604_1_1_0/ # part 이름
│ ├── checksums.txt
│ ├── columns.txt # 컬럼 목록
│ ├── count.txt # row 수
│ ├── primary.idx # sparse primary index
│ ├── minmax_created_at.idx # 파티션 min/max
│ ├── partition.dat
│ ├── id.bin, id.mrk2
│ ├── name.bin, name.mrk2
│ ├── age.bin, age.mrk2
│ └── ...
├── 202604_2_2_0/
└── 202604_1_2_1/ # merge 결과
part 이름 202604_1_2_1의 의미:
202604: partition id (기본은 월별).1_2: min_block_number = 1, max_block_number = 2 (이 part가 포함한 INSERT 범위)._1: merge level (0 = 방금 INSERT됨).
2.2 Granule — 인덱싱의 최소 단위
Granule은 part 내부에서 인덱스가 가리키는 최소 단위. 기본 8192 rows가 1 granule.
왜 granule인가? B-tree는 매 row마다 인덱스 엔트리를 가질 수 있다. 하지만 10억 row면 인덱스가 수 GB. ClickHouse는 sparse index를 쓴다: 8192 rows마다 하나의 인덱스 엔트리. 덕분에 10억 row 테이블의 primary index가 수 MB.
granule 0: row 0 - row 8191
granule 1: row 8192 - row 16383
granule 2: row 16384 - row 24575
...
각 granule의 첫 row의 primary key가 primary.idx 파일에 기록된다:
primary.idx:
granule 0: (2026-04-01 00:00:00)
granule 1: (2026-04-01 00:15:00)
granule 2: (2026-04-01 00:32:00)
...
2.3 Sparse Primary Index — B-tree와의 결정적 차이
WHERE created_at = '2026-04-01 00:20:00' 쿼리는 어떻게 처리되는가?
- primary.idx를 메모리에서 이진 탐색.
- 해당 값이
00:15:00 < value < 00:32:00사이 → granule 1에 있을 수 있음. - 마크 파일
created_at.mrk2에서 granule 1의 .bin 오프셋 조회. - .bin의 해당 블록을 decompress.
- Granule 내부(최대 8192 rows)를 linear scan.
즉 정확히 어디에 있는지는 모르지만, 어느 granule에 있을 수 있는지는 안다. 나머지는 스캔.
이 구조의 장점:
- 인덱스가 매우 작음 → 메모리에 전체 로드 가능.
- 인덱스 유지비용이 작음 → 쓰기 빠름.
- 범위 쿼리에 유리 → "연속된 granule들"만 읽으면 됨.
단점:
- point lookup은 항상 granule 하나 (8192 rows)를 스캔해야 함 → OLTP에는 부적합.
2.4 index_granularity 튜닝
기본 8192. 상황에 따라:
- 매우 선택적 쿼리(대부분 rare row 찾기) → 1024나 2048로 낮춤.
- 대량 스캔이 주 → 16384나 32768로 높임. 인덱스 더 작아짐.
CREATE TABLE events (
event_time DateTime,
user_id UInt64,
event_type LowCardinality(String),
data String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;
index_granularity_bytes (Postgres 21.8+ 기본 10MB)도 있다. row 수 기준 granule이 너무 커질 경우 바이트 크기로 잘라준다.
3. ORDER BY — MergeTree의 심장
3.1 ORDER BY는 곧 clustered index
ORDER BY (event_time, user_id)는 데이터가 이 순서로 물리적으로 정렬됨을 의미한다. MySQL의 clustered index와 유사. 그리고 이 컬럼들이 primary key에 자동 포함된다.
이 선택 하나가 성능을 전부 결정한다. 쿼리의 WHERE 조건이 ORDER BY 컬럼을 앞에서부터 맞출수록 극적으로 빨라진다.
3.2 Good vs Bad ORDER BY
서비스에서 WHERE user_id = 123 AND event_time > '2026-04-01' 패턴의 쿼리가 많다면?
Bad: ORDER BY (event_time, user_id)
event_time으로 정렬됨 → user_id 필터는 전체 스캔.
Good: ORDER BY (user_id, event_time)
- user_id로 정렬됨 → 해당 user_id의 연속된 블록만 읽음.
- 그 안에서 event_time이 정렬돼 있어 다시 빠른 범위 조회.
일반적인 규칙: 카디널리티가 낮은 컬럼을 앞에. 국가 코드, 이벤트 타입 같은 것을 앞에 두고, 시간 컬럼을 뒤에.
3.3 PARTITION BY — 물리적 분할
PARTITION BY toYYYYMM(event_time)은 월별로 part를 분리한다. 중요한 점:
- 다른 partition의 part는 절대 merge되지 않음.
- 오래된 partition을
ALTER TABLE t DROP PARTITION '202501'로 통째 삭제. WHERE event_time > ...쿼리에서 관련 없는 partition은 건너뜀 (partition pruning).
파티션 키를 잘못 고르면 성능 파탄:
- 너무 세밀 (예: 시간별) → part 수 폭발 → merge 폭풍.
- 너무 거칠 (예: 연별) → 오래된 데이터 삭제 불가능.
월별이 sweet spot. 일별도 괜찮지만 part 수 관리 필요.
3.4 Primary Key와 ORDER BY의 분리
Postgres와 달리 ClickHouse의 primary key는 unique constraint가 아니다. 단지 sparse index의 기준일 뿐.
ENGINE = MergeTree
PRIMARY KEY (country, event_type)
ORDER BY (country, event_type, event_time)
이렇게 분리하면:
- 인덱스는 (country, event_type)만 포함 → 작음.
- 실제 정렬은 (country, event_type, event_time)까지 → event_time 기준 범위 쿼리도 빠름.
4. Data Skipping Index — 추가 인덱스
4.1 왜 필요한가
Primary index는 ORDER BY 컬럼에 대해서만 효율적이다. 다른 컬럼으로 필터링하면 풀 granule 스캔. data skipping index가 이를 보완한다.
4.2 인덱스 종류
ALTER TABLE events ADD INDEX idx_user_id user_id TYPE bloom_filter GRANULARITY 4;
GRANULARITY 4는 "4개 granule마다 하나의 skip index entry". 즉 32768 rows 묶음마다 bloom filter 하나.
지원 타입:
- minmax: 각 묶음의 min/max 값. 범위 쿼리에 유용. 숫자/날짜에 최적.
- set(N): N개의 unique 값까지 집합 저장. equality 쿼리에 유용. 저-카디널리티 컬럼에.
- bloom_filter: bloom filter. equality 쿼리에 유용. 고-카디널리티에.
- ngrambf_v1: n-gram bloom filter. 문자열 부분 일치 (
LIKE '%foo%')에 유용. - tokenbf_v1: 토큰 bloom filter. 단어 단위 검색에.
4.3 실전 예
User ID 기반 쿼리가 많지만 ORDER BY에 user_id를 앞에 두기는 어려운 경우:
ALTER TABLE events ADD INDEX idx_user_bf user_id TYPE bloom_filter(0.01) GRANULARITY 4;
이제 WHERE user_id = 12345가 대부분의 granule 그룹을 bloom filter로 건너뛴다.
주의: skipping index는 잘 정렬된 데이터에서만 효과적이다. 같은 user_id의 이벤트가 전체에 흩뿌려져 있으면 bloom filter가 거의 모든 그룹에서 true를 뱉어 도움 안 됨. ORDER BY에 정렬되지 않은 컬럼에 skipping index를 거는 게 기본 전략.
5. Compression — 5–20배의 비밀
5.1 블록 단위 압축
.bin 파일은 블록 단위로 압축된다. 기본 블록 크기 min_compress_block_size = 65536 (64KB). 한 블록은 여러 granule을 포함할 수 있다.
LZ4가 기본. 빠르고(decompression 수 GB/s) 합리적인 압축률. 저장 공간이 중요하면 ZSTD:
CREATE TABLE t (...)
ENGINE = MergeTree
ORDER BY id
SETTINGS
min_compress_block_size = 65536,
max_compress_block_size = 1048576,
compress_on_write = 1;
또는 컬럼별 압축 코덱 지정:
CREATE TABLE t (
id UInt64 CODEC(Delta, ZSTD), -- 증분 값에 최적
created_at DateTime CODEC(DoubleDelta, ZSTD), -- 시계열에 최적
value Float64 CODEC(Gorilla, LZ4), -- 시계열 float에 최적 (Facebook Gorilla)
category LowCardinality(String), -- 저 카디널리티 string
big_text String CODEC(ZSTD(3)) -- 긴 텍스트
)
5.2 특수 코덱
- Delta:
x_n - x_{n-1}. 단조 증가 integer에 완벽. - DoubleDelta: delta의 delta. 시계열 타임스탬프에 최적. 규칙적 간격이면 거의 0 바이트.
- Gorilla: Facebook이 시계열 float용으로 개발. 값 변화가 작으면 수 비트.
- T64: 64비트 integer를 transpose + delta. 비트 수준 압축.
- LZ4, ZSTD: 범용. ZSTD는 LZ4보다 느리지만 압축률 30% 높음.
시계열 테이블 (메트릭, 로그)에서는 적절한 코덱 조합만으로 20–50배 압축을 보는 일이 흔하다.
5.3 LowCardinality — 딕셔너리 인코딩
event_type LowCardinality(String)
ClickHouse가 자동으로 딕셔너리를 만든다. 내부적으로는 UInt8/UInt16으로 저장되고 딕셔너리 테이블로 복원. distinct 값 수가 1만 이하인 문자열 컬럼에 최적. 크기 10배 감소 + 쿼리 5–10배 빠름.
상태값, enum, 카테고리 이름 등 거의 모든 저-카디널리티 문자열은 LowCardinality로 감싸는 게 기본.
6. Merge — Part는 계속 합쳐진다
6.1 왜 Merge인가
LSM과 같은 이유. 매 INSERT가 새 part → part 수가 폭증 → 읽기마다 모든 part를 뒤져야 함. 그래서 백그라운드 스레드가 주기적으로 작은 part들을 큰 part로 merge.
Merge는 정렬된 다수 입력을 정렬된 단일 출력으로 합치는 k-way merge다. O(N log k) — 입력이 이미 정렬돼 있으므로.
6.2 Merge 스케줄링
ClickHouse는 part 수를 제한한다:
- 기본
parts_to_throw_insert = 300. 파티션 내 part 300개 넘으면 INSERT에 에러. - 기본
parts_to_delay_insert = 150. 150개 넘으면 INSERT 지연.
이 신호가 나오면: INSERT 빈도가 너무 잦거나 merge가 못 따라가고 있음.
해결책:
- INSERT를 배치로. 초당 수천 INSERT는 재앙. 수 초마다 10만 row 한 번씩 넣기.
- Buffer engine이나 AsyncInsert 활용 (후술).
6.3 Merge 크기 조절
max_bytes_to_merge_at_max_space_in_pool = 150GB # 한 merge의 최대 크기
max_bytes_to_merge_at_min_space_in_pool = 1MB
background_pool_size = 16 # merge 스레드 수
background_pool_size를 올리면 merge 처리량 증가. 하지만 CPU를 훔쳐가므로 쿼리가 느려질 수 있음. 보통 CPU 코어의 절반 정도가 적당.
6.4 OPTIMIZE — 강제 Merge
OPTIMIZE TABLE events PARTITION '202604' FINAL;
해당 파티션의 모든 part를 하나로 merge. 배치 작업 후 조회 성능 극대화에 쓴다. 단 비싸므로 운영 중에는 신중.
FINAL 키워드의 의미: "최종 병합까지 수행". ReplacingMergeTree 같은 엔진에서 중복 제거도 수행.
7. MergeTree의 변형들 — Engine 계열
7.1 ReplacingMergeTree
같은 primary key를 가진 row를 merge 시 최신 하나만 남김.
CREATE TABLE user_state (
user_id UInt64,
name String,
version UInt64
)
ENGINE = ReplacingMergeTree(version)
ORDER BY user_id;
UPDATE가 아닌 INSERT로 상태를 갱신한다. version 컬럼이 큰 것이 이긴다.
주의: merge가 일어나기 전에는 중복이 남아있다. 쿼리에 FINAL을 붙이면 merge되지 않은 중복을 런타임에 제거 (비쌈). SELECT argMax(name, version) FROM user_state GROUP BY user_id 같은 쿼리로 우회하는 게 보통.
7.2 SummingMergeTree
같은 primary key row를 merge 시 숫자 컬럼을 합산.
CREATE TABLE metrics_hourly (
metric_name String,
hour DateTime,
value UInt64,
count UInt64
)
ENGINE = SummingMergeTree((value, count))
ORDER BY (metric_name, hour);
매 INSERT마다 증분 값을 넣어도 merge가 자동으로 합산. 사전 집계 테이블에 유용.
7.3 AggregatingMergeTree
가장 강력. 커스텀 집계 함수의 중간 상태를 저장.
CREATE TABLE metrics_agg (
metric_name String,
hour DateTime,
unique_users AggregateFunction(uniq, UInt64),
p99_latency AggregateFunction(quantile(0.99), Float64)
)
ENGINE = AggregatingMergeTree
ORDER BY (metric_name, hour);
INSERT INTO metrics_agg SELECT
metric_name,
toStartOfHour(event_time),
uniqState(user_id),
quantileState(0.99)(latency)
FROM events
GROUP BY metric_name, toStartOfHour(event_time);
SELECT
metric_name,
hour,
uniqMerge(unique_users),
quantileMerge(0.99)(p99_latency)
FROM metrics_agg
GROUP BY metric_name, hour;
uniqState/uniqMerge 같은 "-State" / "-Merge" 함수가 트릭. State는 HyperLogLog 같은 sketch를 저장, Merge는 sketch들을 합쳐 최종 값 계산.
수 TB 원본 테이블에서 GROUP BY가 초 단위로 답하게 만든다.
7.4 CollapsingMergeTree / VersionedCollapsingMergeTree
Sign 컬럼으로 "생성"과 "삭제"를 토글. merge 시 상쇄.
CREATE TABLE user_state (
user_id UInt64,
name String,
sign Int8 -- +1 = insert, -1 = cancel
)
ENGINE = CollapsingMergeTree(sign)
ORDER BY user_id;
INSERT INTO user_state VALUES (1, 'Alice', 1);
-- Alice 변경: 이전 row 취소 후 새 row
INSERT INTO user_state VALUES (1, 'Alice', -1);
INSERT INTO user_state VALUES (1, 'Alice_v2', 1);
Merge 후 sign=-1과 대응되는 sign=1이 상쇄. 상태 머신 저장에 유용하지만 사용 난이도가 높다.
7.5 ReplicatedMergeTree — 복제
위의 모든 엔진에 Replicated 접두사를 붙이면 복제 대상. ZooKeeper(또는 ClickHouse Keeper)가 메타데이터 조정.
CREATE TABLE events_local (
event_time DateTime,
user_id UInt64,
...
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
경로 안의 shard/replica 플레이스홀더는 config에서 치환된다. 같은 shard의 replica끼리는 part 단위로 동기화 — INSERT한 replica가 part를 만들면 ZooKeeper에 기록, 다른 replica가 당겨옴.
8. Distributed Table — Sharding
8.1 구조
ClickHouse는 shared-nothing. 각 shard가 독립된 노드, 데이터를 분산 저장. 그 위에 Distributed 가상 테이블이 올라간다.
CREATE TABLE events ON CLUSTER my_cluster (...)
ENGINE = ReplicatedMergeTree(...)
ORDER BY (...);
CREATE TABLE events_distributed ON CLUSTER my_cluster
AS events
ENGINE = Distributed(my_cluster, default, events, rand());
Distributed(cluster, db, table, sharding_key):
- cluster: config에 정의된 클러스터 이름.
- sharding_key: INSERT 시 어느 shard로 보낼지 결정.
8.2 쿼리 분산
SELECT ... FROM events_distributed WHERE ... 는:
- 코디네이터 노드가 모든 shard에 쿼리 전파.
- 각 shard가 로컬 part에서 부분 결과 생성.
- 코디네이터가 결과를 모아 최종 집계.
GROUP BY는 자동으로 2단계: 각 shard에서 partial aggregate, 코디네이터에서 final aggregate.
8.3 Sharding Key 설계
rand(): 균등 분산. 조인 어려움.
cityHash64(user_id): 같은 user_id는 같은 shard로. user별 JOIN/GROUP BY에 유리. 단 skew 위험.
사용자 정의: 비즈니스 도메인에 따라. 예: tenant_id % N.
원칙: 조인/집계가 같은 shard 안에서 일어나게 하라. Cross-shard join은 치명적.
8.4 Replication + Sharding
전형적 셋업: 3 shard × 2 replica = 6 노드.
shard1: node1 (replica1), node2 (replica2)
shard2: node3 (replica1), node4 (replica2)
shard3: node5 (replica1), node6 (replica2)
데이터는 3-way sharding, 각 shard는 2-way replication.
9. ClickHouse Keeper — ZooKeeper와의 작별
9.1 ZooKeeper의 짐
ClickHouse는 오랫동안 ZooKeeper를 메타데이터 조정용으로 썼다. Replication, DDL 등. 하지만 ZK는 JVM 기반이라 운영 복잡도가 높고, 매우 많은 znode에서 성능이 떨어진다.
9.2 Keeper의 등장
2021년 ClickHouse Keeper가 등장. ZooKeeper와 wire protocol 호환. C++로 작성, ClickHouse 서버에 내장 또는 독립 실행 가능.
장점:
- SSD 친화적 (Raft 기반 snapshot).
- JVM 의존성 제거.
- 수 GB의 메타데이터도 원활.
Keeper는 Raft 글의 실제 응용이다. 내부는 NuRaft 라이브러리 기반.
10. Materialized View — 실시간 집계
10.1 개념
INSERT 트리거. 원본 테이블에 INSERT하면 자동으로 대상 테이블에 집계된 row가 INSERT된다.
CREATE MATERIALIZED VIEW events_hourly
ENGINE = SummingMergeTree
ORDER BY (hour, event_type)
AS SELECT
toStartOfHour(event_time) AS hour,
event_type,
count() AS cnt,
sum(value) AS sum_value
FROM events
GROUP BY hour, event_type;
이제 events에 INSERT할 때마다:
- 원본은 그대로 저장.
events_hourly에 시간별/타입별 부분 집계가 INSERT.SummingMergeTree가 백그라운드에서 합산.
쿼리는 events_hourly에서 직접:
SELECT hour, event_type, sum(cnt), sum(sum_value)
FROM events_hourly
WHERE hour >= now() - INTERVAL 7 DAY
GROUP BY hour, event_type;
10억 row events에서 시간당 집계를 초 단위로 답한다.
10.2 주의점
- Materialized view는 INSERT 시점에만 실행. 과거 데이터는 다시 넣거나
POPULATE써야. - 원본 테이블에 INSERT가 실패하면 MV도 롤백.
- 너무 많은 MV는 INSERT를 느리게 한다.
- MV는 "view"가 아니라 실제 테이블이다. 디스크를 먹고 자체 part를 만든다.
10.3 TO 절
CREATE TABLE events_hourly (...) ENGINE = SummingMergeTree ORDER BY (...);
CREATE MATERIALIZED VIEW events_hourly_mv TO events_hourly AS
SELECT ... FROM events;
TO로 기존 테이블 지정. 더 명시적, 권장.
10.4 여러 단계 집계
-- 1단계: 시간별
CREATE MATERIALIZED VIEW events_hourly TO events_hourly_dest AS
SELECT toStartOfHour(event_time) AS hour, ... FROM events GROUP BY hour, ...;
-- 2단계: 일별 (시간별 위에)
CREATE MATERIALIZED VIEW events_daily TO events_daily_dest AS
SELECT toDate(hour) AS day, ... FROM events_hourly_dest GROUP BY day, ...;
계단형 집계. BI 대시보드에 매우 효과적.
11. Projection — 같은 테이블에 여러 인덱스
11.1 문제
MergeTree는 ORDER BY 하나만 가능. 하지만 WHERE user_id = ... 쿼리와 WHERE event_time BETWEEN ... 쿼리가 둘 다 빠르려면?
예전에는 두 개의 MV로 해결: 동일 데이터, 다른 ORDER BY. 저장소 2배.
11.2 Projection
Projection은 같은 데이터의 "다른 정렬 순서 + 집계" 저장. 같은 part 내부에 보관.
ALTER TABLE events ADD PROJECTION user_proj (
SELECT * ORDER BY user_id
);
ALTER TABLE events MATERIALIZE PROJECTION user_proj;
이제 옵티마이저가 자동으로 판단: "WHERE user_id ... 쿼리면 user_proj를 쓰자".
저장은 증가하지만 (1.5–2배) 운영은 단일 테이블.
11.3 Aggregating Projection
MV의 AggregatingMergeTree와 비슷한 효과:
ALTER TABLE events ADD PROJECTION hourly_agg (
SELECT
toStartOfHour(event_time) AS hour,
event_type,
count(),
sum(value)
GROUP BY hour, event_type
);
12. Vectorized Execution Engine — 속도의 진짜 비밀
12.1 Row-at-a-time vs Vectorized
전통적 DB 엔진(Volcano 모델)은 한 번에 한 row씩 처리:
while (row = scan->next()) {
if (filter(row)) {
project(row);
aggregate(row);
}
}
각 연산이 함수 호출. 분기 예측 실패, CPU 캐시 misses.
Vectorized는 배치 (보통 65536 rows)씩 처리:
while (batch = scan->next_batch(65536)) {
mask = filter(batch); // SIMD로 필터링
projected = project(batch, mask); // 컬럼 전체 변환
aggregate_batch(projected); // 누적 합 SIMD
}
- 함수 호출 오버헤드 분산 (65536 rows당 한 번).
- SIMD 활용.
- CPU 캐시 친화적 (한 컬럼 배치가 L1/L2에 머무름).
DuckDB가 비슷한 모델 — DuckDB 글에서 다뤘다.
12.2 ClickHouse의 블록
ClickHouse 내부 단위는 Block. 컬럼들의 묶음. 기본 max_block_size = 65505. 한 스캔 단계는 한 블록을 읽어 다음 단계에 전달.
Block[0] = { col1: [val, val, ...], col2: [val, val, ...], ... } // 65505 rows
Block[1] = ...
연산자 트리: ReadFromMergeTree → Filter → Projection → Aggregator → .... 각 단계가 블록 단위로 소비/생산.
12.3 JIT Compilation
ClickHouse는 LLVM을 활용해 런타임 JIT 컴파일. 예:
SELECT (a + b) * c - d FROM t;
이 표현식은 매 row마다 interpreter로 실행하면 느림. JIT가 한번에 네이티브 코드로 변환:
; 벡터화된 어셈블리
vpaddd ymm0, ymm1, ymm2 ; a + b
vpmulld ymm0, ymm0, ymm3 ; * c
vpsubd ymm0, ymm0, ymm4 ; - d
10–100배 빠름. compile_expressions = 1 (기본).
12.4 Parallelism
한 쿼리가 CPU 코어를 어떻게 활용하는가:
- Part 병렬: 쿼리가 읽을 part 여럿을 각 코어에 분배.
- Pipeline 병렬: 연산자 파이프라인을 여러 스레드로.
- Shard 병렬: Distributed 테이블이면 여러 노드에 분산.
max_threads 파라미터가 스레드 수 제한. 기본은 코어 수.
13. AsyncInsert — 작은 INSERT의 구원
13.1 문제
ClickHouse는 배치 INSERT 선호. 하지만 Kafka consumer나 이벤트 스트림은 작은 INSERT를 자주 보낸다. 각 INSERT가 새 part → merge 폭풍 → 성능 붕괴.
13.2 해결책
INSERT INTO events SETTINGS async_insert=1 VALUES ...;
서버가 여러 INSERT를 메모리 버퍼에 모음. 조건 충족 시 한 번에 part 생성:
async_insert_max_data_size(기본 1MB)async_insert_busy_timeout_ms(기본 200ms)
wait_for_async_insert=1이면 클라이언트는 실제 디스크 flush까지 기다림 (durability 보장). 0이면 버퍼 도달만 확인 (빠르지만 손실 가능).
13.3 Buffer Engine (구식)
CREATE TABLE events_buffer AS events
ENGINE = Buffer('default', 'events', 16, 10, 60, 10000, 1000000, 10485760, 1073741824);
Buffer engine도 비슷한 역할. 메모리 버퍼 + 주기적 flush. 단 이 테이블이 죽으면 유실. AsyncInsert가 권장 방식.
14. 실전 운영
14.1 좋은 스키마의 체크리스트
CREATE TABLE events (
event_time DateTime CODEC(DoubleDelta, ZSTD),
user_id UInt64,
event_type LowCardinality(String), -- 저-카디널리티
country LowCardinality(String),
device LowCardinality(String),
url String CODEC(ZSTD(3)),
duration_ms UInt32 CODEC(T64, LZ4),
session_id UUID,
data String CODEC(ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time) -- 월별
ORDER BY (country, event_type, event_time, user_id) -- 카디 낮은 것 먼저
SETTINGS index_granularity = 8192;
ALTER TABLE events ADD INDEX idx_user user_id TYPE bloom_filter GRANULARITY 4;
DateTime에DoubleDelta코덱.- 저-카디 string은
LowCardinality. - ORDER BY 앞은 저-카디.
- skipping index로 후순위 컬럼 보완.
14.2 모니터링 쿼리
part 수 체크:
SELECT database, table, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY parts DESC;
part 수가 수천 넘으면 경고.
압축률:
SELECT
database, table,
formatReadableSize(sum(bytes_on_disk)) AS disk,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
sum(data_uncompressed_bytes) / sum(bytes_on_disk) AS ratio
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY ratio;
ratio 5 미만이면 코덱/스키마 재검토.
slow queries:
SELECT query_duration_ms, query, user, memory_usage
FROM system.query_log
WHERE event_date >= today() AND type = 'QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 10;
14.3 안티 패턴
- 자주 UPDATE/DELETE: ClickHouse에 UPDATE는 비동기 ALTER. 수초~수분 지연. 사용 지양.
- 작은 INSERT: 초당 수천 INSERT → 재앙. 배치 또는 AsyncInsert.
- 과도한 컬럼: 수백 컬럼 테이블은 쓰기 비용 폭발. wide → narrow 테이블 고려.
- ORDER BY에 고-카디 컬럼 앞: primary index가 무쓸모.
- partition 너무 세밀: hour 단위 partition은 거의 항상 재앙. day 이하로 가지 말 것.
14.4 마이그레이션 전략
기존 Postgres/MySQL OLTP에서 OLAP 쿼리를 ClickHouse로 옮기기:
- Debezium + Kafka로 CDC. 원본 OLTP의 변경을 캡처.
- ClickHouse Kafka engine이 consume.
- Materialized view로 Kafka table → MergeTree table 적재.
이 파이프라인은 분 이하의 지연으로 OLTP 데이터를 OLAP 분석 가능하게 만든다.
15. ClickHouse의 한계 — 모든 것의 답은 아니다
15.1 잘 맞지 않는 워크로드
- OLTP: point update, 단일 row 조회. 가능하지만 느림.
- 많은 조인: star schema의 한두 조인은 괜찮지만 6–10 way join은 고통.
- Ad-hoc 탐색: Trino/Presto가 더 유리 (Parquet/Iceberg 직접 조회).
- 실시간 개별 이벤트 조회: 개별 레코드 조회는 granule 스캔 필요 → 느림.
15.2 경쟁 구도
- Snowflake / BigQuery: 완전 관리형, 거의 모든 SQL. 비쌈. ClickHouse는 10–100배 저렴.
- Druid / Pinot: 실시간 ingest + 쿼리. ClickHouse와 가까운 경쟁자. 운영 더 복잡.
- DuckDB: 단일 노드. 인메모리/작은 데이터. 역할 다름.
- StarRocks / Doris: 중국발 MPP DB. 조인 성능이 ClickHouse보다 낫다고 주장.
15.3 ClickHouse의 현재 가치 제안
- 오픈소스.
- 단일 노드에서 TB급, 클러스터로 PB급.
- SQL 호환성 괜찮음.
- 초당 수십억 row 스캔 가능.
- 운영 비용이 매우 낮음.
그래서 Observability(로그/메트릭/트레이스), 웹 분석, 리얼타임 대시보드, CDN 로그 분석 등에서 표준이 됐다.
맺음 — 10억 줄을 1초에
ClickHouse의 비밀을 한 줄로: "컬럼 저장 + 희소 인덱스 + 벡터화 실행". 이 세 가지가 서로를 증폭시켜 10–1000배의 속도를 만든다.
운영자로서 본능적으로 체크할 세 가지:
- ORDER BY를 제대로 설계했는가. 이 한 줄이 성능을 결정한다. 카디 낮은 컬럼 먼저.
- 작은 INSERT를 막았는가. AsyncInsert, 배치, Kafka 엔진 중 하나로 안정화.
- Part 수를 감시하는가. 수천 넘으면 merge가 못 따라가는 중. 확인하라.
이 세 가지가 깔려 있으면 ClickHouse는 거의 마법 같은 성능을 보여준다.
더 큰 그림에서, 지난 세 글은 저장 엔진의 스펙트럼을 훑었다:
- Postgres: 행 저장 + B-tree + MVCC. OLTP의 황제.
- LSM / RocksDB: 행 저장 + LSM + KV. 쓰기 중심 분산 시스템.
- ClickHouse: 열 저장 + MergeTree + 벡터 실행. 분석의 챔피언.
세 시스템은 같은 "데이터베이스"라는 단어를 공유하지만 완전히 다른 생명체다. 각자의 유전자를 이해하는 것이 올바른 도구를 고르는 첫걸음이다.
다음 글은 TLS 1.3과 QUIC의 내부를 다룬다. 모든 HTTPS 요청의 숨은 춤을 바이트 수준에서 풀어낸다. 암호화도, 네트워크도, 성능도 전부 한 주제에서 만난다.