1장. ClickHouse 개요 — 왜 빠른가
같은 데이터, 같은 쿼리인데 100배 빠른 이유가 있습니다. 열 저장·벡터화 실행·immutable 파트 — ClickHouse 속도의 3대 비밀을 그림으로 파헤칩니다.
① OLAP과 OLTP의 근본적 트레이드오프 ② 행 저장 vs 열 저장 — 집계 쿼리가 읽는 데이터량의 차이 ③ 벡터화 실행이 CPU를 쥐어짜는 방법 ④ MergeTree 스토리지의 설계 철학 (immutable 파트 + 백그라운드 머지)
1.1 OLAP의 극단에 서 있는 데이터베이스
ClickHouse는 OLAP(Online Analytical Processing, 온라인 분석 처리) 워크로드에 특화된 컬럼 지향(column-oriented) DBMS입니다. 모든 데이터베이스는 OLTP와 OLAP 사이의 스펙트럼 위 어딘가에 있는데, ClickHouse는 처음부터 "가능한 한 빠른 OLAP 시스템"을 목표로 OLAP 쪽 극단에 자리 잡았습니다.
| OLTP (예: PostgreSQL, MySQL) | OLAP (예: ClickHouse) | |
|---|---|---|
| 대표 작업 | 주문 1건 저장, 회원 정보 수정 | "지난 1년간 국가별 매출 합계" |
| 쿼리 패턴 | 소량의 행을 자주 읽고 씀 | 드물지만 한 번에 수억 행을 읽음 |
| 최적화 방향 | 행 단위 읽기/쓰기 | 컬럼 단위 대량 읽기 |
| 저장 방식 | 행형(row-oriented) | 컬럼형(columnar) |
| 트랜잭션 | 완전 지원 | 미지원 (mutation은 제한적) |
공식 문서는 ClickHouse를 "true column-oriented DBMS"라고 부릅니다. 단순히 데이터를 컬럼 단위로 저장하는 것을 넘어, 쿼리 실행 자체가 컬럼(벡터) 단위로 이루어진다는 뜻입니다. 이 두 가지가 이 장의 핵심 주제입니다.
1.2 행 저장 vs 열 저장 — 집계 쿼리가 읽는 데이터량
100개 컬럼을 가진 테이블에서 딱 2개 컬럼만 쓰는 분석 쿼리를 떠올려 보세요. 행 저장에서는 한 행의 모든 컬럼이 디스크에 붙어 있으므로 필요 없는 98개 컬럼도 함께 읽어야 합니다. 열 저장에서는 컬럼마다 파일이 분리되어 있어 필요한 파일만 읽습니다.
같은 테이블을 두 가지 방식으로 저장했습니다. 왼쪽은 한 행의 모든 컬럼이 연속 배치, 오른쪽은 컬럼마다 별도 파일입니다. 쿼리는 country로 필터해 amount를 합산합니다.
행 저장에서는 amount 하나를 얻기 위해 행 전체를 읽어야 합니다. name, age처럼 쿼리에 필요 없는 데이터까지 디스크에서 올라옵니다.
이 예시(5컬럼 중 2컬럼)에서도 I/O의 60%가 낭비입니다. 실무의 100컬럼 테이블에서 5컬럼만 쓰는 쿼리라면 95%가 낭비되는 셈이죠.
열 저장에서는 필요한 2개 파일만 읽습니다. name.bin, age.bin은 디스크를 건드리지도 않습니다.
읽는 데이터량 자체가 다르니 속도 차이는 필연입니다. 여기에 압축(다음 절)까지 더해지면 격차는 수십 배로 벌어집니다.
1.3 보너스: 압축률의 극적인 차이
열 저장의 이점은 I/O 감소만이 아닙니다. 같은 타입·같은 분포의 값이 물리적으로 연속 배치되므로 압축 알고리즘이 반복 패턴을 훨씬 잘 찾아냅니다.
-- country 컬럼만 놓고 비교해 보면:
-- 행 저장: Korea, (다른 컬럼들...), USA, (다른 컬럼들...), Japan, ...
-- → 패턴이 끊겨서 LZ4 압축률 ~50%
-- 열 저장: Korea, Korea, Korea, USA, USA, Japan, Japan, ...
-- → 반복 패턴이 명확 → LowCardinality + LZ4로 ~95%까지 압축
CREATE TABLE user_events
(
user_id UInt64,
country LowCardinality(String), -- 고유값이 적은 문자열에 특효
amount UInt32
)
ENGINE = MergeTree
ORDER BY (country, user_id);
압축은 디스크 절약 이상의 의미가 있습니다. 디스크에서 읽는 바이트 수 자체가 줄어들므로 쿼리 성능에 직결됩니다. ClickHouse는 범용 압축(LZ4, ZSTD) 외에도 Delta, Gorilla 같은 타입 특화 코덱을 제공합니다.
1.4 벡터화 실행 — CPU를 쥐어짜는 방법
저장만 열 단위인 것이 아니라, 연산도 열(배열) 단위로 합니다. 행 하나씩 처리(row-at-a-time)하는 대신 수천 개 값의 배열(vector/chunk)에 연산을 한 번에 적용하는 것을 벡터화 쿼리 실행(vectorized query execution)이라고 합니다. 이 아이디어는 1957년 APL 언어까지 거슬러 올라가는, 과학 계산 분야에서 수십 년간 검증된 접근입니다.
amount 컬럼의 값들이 도착했습니다. 이 값들을 모두 더하는 sum(amount)을 두 방식으로 계산해 봅시다.
row-at-a-time 방식은 행마다 함수를 호출합니다. 100만 행이면 100만 번의 호출 오버헤드와 조건 분기가 발생합니다.
벡터화 방식은 값들을 연속된 배열로 두고, SIMD 명령어로 한 번에 4~8개씩 처리합니다. ClickHouse는 하드웨어가 지원하는 가장 최신 SIMD 명령 세트를 자동 선택합니다.
정리하면 ① 캐시 친화적 메모리 배치 ② SIMD 병렬 처리 ③ 함수 호출 횟수 감소 — 이 세 가지가 벡터화 실행의 힘입니다.
쿼리 처리 속도를 높이는 또 다른 접근으로 런타임 코드 생성(runtime code generation)이 있습니다. 쿼리마다 기계어 코드를 즉석에서 만들어 여러 연산을 하나로 융합(fuse)하는 방식입니다. 공식 문서는 CMU 연구를 인용하며 두 접근의 결합이 최선이라고 밝히고, 실제로 ClickHouse는 벡터화를 주축으로 삼되 제한적인 코드 생성을 보조적으로 사용합니다.
| 접근 | 원리 | 장점 | 단점 |
|---|---|---|---|
| Vectorized Execution | 데이터를 배열 단위로 처리 | SIMD 활용 용이, 구현 단순 | 중간 벡터가 L2 캐시를 넘으면 성능 저하 |
| Runtime Code Generation | 실행 시 기계어를 동적 생성 | 연산 융합, 간접 호출 제거 | 생성 오버헤드, 구현 복잡 |
1.5 스토리지의 설계 철학 — MergeTree는 LSM Tree가 아니다
ClickHouse의 대표 스토리지 엔진 MergeTree는 겉보기에 RocksDB류의 LSM Tree와 닮았지만, 결정적 차이가 있습니다. MEMTABLE(인메모리 쓰기 버퍼)도 WAL(Write-Ahead Log)도 없습니다. INSERT된 데이터는 프라이머리 키 순서로 정렬되어 즉시 디스크에 새 파트(part)로 기록됩니다.
양쪽 모두 INSERT가 도착했습니다. LSM은 빈번한 소량 쓰기(OLTP 스타일), MergeTree는 배치 단위 대량 쓰기(OLAP 스타일)를 전제로 설계됐습니다.
LSM Tree는 먼저 메모리의 MEMTABLE에 쓰고, 장애 대비를 위해 WAL에도 중복 기록합니다. 쓰기마다 이중 작업이 발생하죠.
MEMTABLE이 가득 차면 SSTable로 flush되고, 레벨 간 compaction이 이어집니다. 읽기는 MEMTABLE + 여러 레벨을 모두 확인해야 합니다.
MergeTree는 다릅니다. INSERT는 정렬·압축을 거쳐 즉시 디스크에 파트로 기록됩니다. MEMTABLE도 WAL도 없어 단순하고, 배치 INSERT가 초고속입니다.
쌓인 파트는 백그라운드 머지가 합칩니다. 중복 제거·사전 집계·TTL 처리 같은 무거운 변환을 INSERT 시점이 아닌 머지 시점으로 미루는 것 — 이것이 INSERT를 가볍게 유지하는 비결입니다.
이 구조에서 세 가지 중요한 성질이 나옵니다.
- INSERT와 SELECT의 완전 격리: INSERT는 새 파트를 만들 뿐 기존 데이터를 건드리지 않으므로, 동시 실행 중인 SELECT에 영향을 주지 않습니다. SELECT는 쿼리 시작 시점의 파트 스냅샷을 봅니다.
- 동시 INSERT 간 동기화 불필요: 각자 자기 파트만 만들면 되니까요.
- Merge-time Computation: 머지는 어차피 파트를 읽고 다시 쓰는 작업이므로, 그 김에 중복 제거·집계 같은 변환을 얹어도 추가 비용이 미미합니다.
INSERT마다 파트(디렉터리+파일들)가 생성되므로, 초당 수천 번의 소량 INSERT는 파트 폭증으로 이어집니다("Too many parts" 에러). 배치로 모아 넣거나 async insert를 사용해야 합니다. 4장과 9장에서 자세히 다룹니다.
1.6 Data Pruning — 읽지 않는 것이 가장 빠르다
공식 문서의 명언: "The fastest way to read data is to not read it at all." ClickHouse는 불필요한 데이터를 건너뛰기 위한 세 가지 장치를 제공합니다.
🔑 Sparse Primary Index
테이블의 정렬 순서를 정의하는 희소 인덱스. WHERE 필터를 풀 스캔 대신 이진 탐색으로 — O(n)이 O(log n)으로. → 7장
🪞 Table Projections
같은 데이터를 다른 프라이머리 키로 정렬한 내부 복제본. 자주 쓰는 필터 조건이 여러 개일 때 유용하지만, 데이터가 중복됩니다.
⏭ Skipping Indexes
컬럼에 min/max, 고유값 집합 같은 통계를 내장해 블록을 건너뜁니다. 프라이머리 키와 직교하는 보조 수단입니다.
-- 잘 설계된 프라이머리 키는 스캔량을 극적으로 줄인다
EXPLAIN indexes = 1
SELECT count() FROM user_events WHERE country = 'Korea';
-- PrimaryKey Parts: 1/4 Granules: 12/3257 ← 3257개 중 12개만 읽음!
1.7 확장 모델 — 수직과 수평
수직 확장: ClickHouse는 쿼리 플랜을 여러 lane으로 펼쳐 코어당 하나씩 병렬 실행합니다. 각 lane이 테이블의 서로 다른 범위를 처리하므로 코어 수에 비례해 빨라집니다(3장에서 스레드 모델을 다룹니다).
수평 확장: 단일 노드로 부족하면 테이블을 샤드(shard)로 분할해 여러 노드에 분산합니다(8장). 단, 공식 문서가 인정하는 제약이 있습니다 — 클러스터는 elastic하지 않습니다. 새 샤드를 추가해도 데이터가 자동으로 리밸런싱되지 않으므로, 초기 샤딩 전략이 중요합니다.
① 배치 INSERT 필수 (파트 폭증 방지) ② Mutation(UPDATE/DELETE) 최소화 — immutable 파트 기반이라 파트 전체 재작성 ③ 프라이머리 키 설계가 곧 성능 — 생성 후 ORDER BY 변경 불가 ④ 샤드 자동 리밸런싱 없음을 전제로 설계
✍️ 이해도 체크
이 장의 원문 문서: chapters/ch01_clickhouse_overview.md — 벡터화 실행의 역사(APL 계보), MergeTree vs LSM 상세 비교 다이어그램이 담겨 있습니다.