LabHub

블로그

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

한국어English

들어가며 — 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 쿼리를 생각하자.

이점은 더 있다:

  1. 압축률: 같은 타입의 값이 연속돼 있으니 압축이 극적으로 잘 된다. int 컬럼은 delta encoding, string 컬럼은 dictionary encoding. 일반적으로 5–20배 압축.
  2. CPU 캐시: 한 번에 같은 타입 값을 처리하니 CPU 파이프라이닝과 SIMD에 유리.
  3. 선택적 스캔: 컬럼을 골라 읽으니 스캔량이 줄어든다.

단점:

  1. Row 재구성 비용: 한 row의 모든 값을 보려면 여러 컬럼 파일에서 같은 인덱스를 다 읽어와야 함.
  2. 쓰기가 비쌈: INSERT 하나가 여러 파일 수정. 그래서 OLAP는 배치 INSERT를 선호.
  3. 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의 의미:

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' 쿼리는 어떻게 처리되는가?

  1. primary.idx를 메모리에서 이진 탐색.
  2. 해당 값이 00:15:00 < value < 00:32:00 사이 → granule 1에 있을 수 있음.
  3. 마크 파일 created_at.mrk2에서 granule 1의 .bin 오프셋 조회.
  4. .bin의 해당 블록을 decompress.
  5. Granule 내부(최대 8192 rows)를 linear scan.

정확히 어디에 있는지는 모르지만, 어느 granule에 있을 수 있는지는 안다. 나머지는 스캔.

이 구조의 장점:

단점:

2.4 index_granularity 튜닝

기본 8192. 상황에 따라:

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)

Good: ORDER BY (user_id, event_time)

일반적인 규칙: 카디널리티가 낮은 컬럼을 앞에. 국가 코드, 이벤트 타입 같은 것을 앞에 두고, 시간 컬럼을 뒤에.

3.3 PARTITION BY — 물리적 분할

PARTITION BY toYYYYMM(event_time)은 월별로 part를 분리한다. 중요한 점:

파티션 키를 잘못 고르면 성능 파탄:

월별이 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)

이렇게 분리하면:


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 하나.

지원 타입:

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 특수 코덱

시계열 테이블 (메트릭, 로그)에서는 적절한 코덱 조합만으로 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 수를 제한한다:

이 신호가 나오면: INSERT 빈도가 너무 잦거나 merge가 못 따라가고 있음.

해결책:

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):

8.2 쿼리 분산

SELECT ... FROM events_distributed WHERE ... 는:

  1. 코디네이터 노드가 모든 shard에 쿼리 전파.
  2. 각 shard가 로컬 part에서 부분 결과 생성.
  3. 코디네이터가 결과를 모아 최종 집계.

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 서버에 내장 또는 독립 실행 가능.

장점:

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할 때마다:

  1. 원본은 그대로 저장.
  2. events_hourly에 시간별/타입별 부분 집계가 INSERT.
  3. 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 주의점

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
}

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 코어를 어떻게 활용하는가:

  1. Part 병렬: 쿼리가 읽을 part 여럿을 각 코어에 분배.
  2. Pipeline 병렬: 연산자 파이프라인을 여러 스레드로.
  3. 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 생성:

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;

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 안티 패턴

14.4 마이그레이션 전략

기존 Postgres/MySQL OLTP에서 OLAP 쿼리를 ClickHouse로 옮기기:

  1. Debezium + Kafka로 CDC. 원본 OLTP의 변경을 캡처.
  2. ClickHouse Kafka engine이 consume.
  3. Materialized view로 Kafka table → MergeTree table 적재.

이 파이프라인은 분 이하의 지연으로 OLTP 데이터를 OLAP 분석 가능하게 만든다.


15. ClickHouse의 한계 — 모든 것의 답은 아니다

15.1 잘 맞지 않는 워크로드

15.2 경쟁 구도

15.3 ClickHouse의 현재 가치 제안

그래서 Observability(로그/메트릭/트레이스), 웹 분석, 리얼타임 대시보드, CDN 로그 분석 등에서 표준이 됐다.


맺음 — 10억 줄을 1초에

ClickHouse의 비밀을 한 줄로: "컬럼 저장 + 희소 인덱스 + 벡터화 실행". 이 세 가지가 서로를 증폭시켜 10–1000배의 속도를 만든다.

운영자로서 본능적으로 체크할 세 가지:

  1. ORDER BY를 제대로 설계했는가. 이 한 줄이 성능을 결정한다. 카디 낮은 컬럼 먼저.
  2. 작은 INSERT를 막았는가. AsyncInsert, 배치, Kafka 엔진 중 하나로 안정화.
  3. Part 수를 감시하는가. 수천 넘으면 merge가 못 따라가는 중. 확인하라.

이 세 가지가 깔려 있으면 ClickHouse는 거의 마법 같은 성능을 보여준다.

더 큰 그림에서, 지난 세 글은 저장 엔진의 스펙트럼을 훑었다:

세 시스템은 같은 "데이터베이스"라는 단어를 공유하지만 완전히 다른 생명체다. 각자의 유전자를 이해하는 것이 올바른 도구를 고르는 첫걸음이다.

다음 글은 TLS 1.3과 QUIC의 내부를 다룬다. 모든 HTTPS 요청의 숨은 춤을 바이트 수준에서 풀어낸다. 암호화도, 네트워크도, 성능도 전부 한 주제에서 만난다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다