2장. 내부 컴포넌트와 쿼리 처리
SELECT 한 줄이 결과가 되기까지 — Parser, Interpreter, Processor, Block이라는 부품들이 조립 라인처럼 맞물리는 과정을 따라갑니다.
① SQL → AST → QueryPipeline → 결과로 이어지는 전체 처리 흐름 ② 데이터 표현의 4총사: IColumn·Field·IDataType·Block ③ Processor DAG가 멀티코어를 활용하는 방법 ④ 벡터화 실행의 함정 — short-circuit 평가가 없는 이유
2.1 조감도 — 쿼리 하나가 통과하는 파이프라인
ClickHouse 내부를 이해하는 가장 좋은 방법은 쿼리 하나를 끝까지 따라가 보는 것입니다. 이 장 전체에서 아래 쿼리를 예시로 사용합니다.
SELECT event_type, max(amount)
FROM user_events
WHERE user_id = 123
GROUP BY event_type;
클라이언트가 보낸 쿼리는 처음엔 그저 문자열입니다. 이 문자열이 실행 가능한 형태가 되려면 여러 단계를 거쳐야 합니다.
Parser가 문자열을 분해해 AST(추상 구문 트리)를 만듭니다. SELECT의 컬럼 목록, FROM, WHERE, GROUP BY가 각각 트리의 가지가 됩니다.
Interpreter가 AST를 분석합니다. "어떤 테이블에서, 어떤 컬럼만 읽고, 어떻게 필터·집계할지"를 결정하고 규칙 기반 최적화를 적용합니다. 새 분석기는 중간에 QueryTree 단계를 거칩니다.
결과물은 QueryPipeline — Processor들을 연결한 DAG(방향 비순환 그래프)입니다. 아직 실행 전의 "설계도" 상태입니다.
PipelineExecutor가 설계도를 실행합니다. 데이터는 약 8,192행 묶음인 Block 단위로 Processor 사이를 흐릅니다.
마지막 Processor가 결과를 JSON/CSV 등으로 직렬화(Format)하고, WriteBuffer를 통해 클라이언트에 전송하면 끝입니다. 이제 각 부품을 하나씩 뜯어봅시다.
2.2 데이터 표현의 4총사 — IColumn · Field · IDataType · Block
파이프라인 속을 흐르는 데이터는 네 가지 자료구조로 표현됩니다.
| 구조 | 역할 | 비유 |
|---|---|---|
IColumn | 메모리 상의 컬럼(청크). 거의 모든 연산이 immutable — 원본을 두고 새 컬럼을 생성 | 값들이 담긴 컨베이어 벨트 위 상자 |
Field | 개별 값 하나 (UInt64 | Int64 | Float64 | String | Array의 discriminated union). 공식 문서가 "not very efficient"라고 명시 | 상자에서 꺼낸 낱개 물건 |
IDataType | 직렬화/역직렬화 담당. 메타데이터만 저장하며 IColumn과 느슨하게 결합 | 상자에 붙은 취급 설명서 |
Block | (IColumn, IDataType, 컬럼명) 트리플의 집합. 쿼리 실행의 데이터 전달 단위 | 상자 여러 개를 실은 팔레트 |
IColumn의 대표 연산이 그대로 SQL 절과 대응된다는 점이 재미있습니다.
// IColumn의 연산 = SQL 절의 구현체
IColumn::filter(mask) // WHERE / HAVING
IColumn::permute(perm) // ORDER BY
IColumn::cut(start, n) // LIMIT
// 정수 컬럼은 연속 배열 → SIMD를 바로 적용 가능
// ColumnString은 데이터 벡터 + 오프셋 벡터의 2중 구조
IColumn 인터페이스만으로는 모든 연산을 빠르게 구현할 수 없습니다. 그래서 ClickHouse는 ColumnUInt64::getData()처럼 내부 배열을 직접 노출해 외부 루틴이 메모리를 직접 읽고 쓰게 합니다. 공식 문서는 이를 "leaky abstractions to allow efficient specializations"라 부릅니다. 추상화의 순수성보다 성능을 택한 의도적 설계입니다.
2.3 Parser와 Interpreter — 문자열에서 실행 계획으로
ClickHouse의 파서는 손으로 작성한 재귀 하강 파서(hand-written recursive descent parser)입니다. yacc나 ANTLR 같은 파서 생성기를 쓰지 않는 덕에, 에러 메시지를 세밀하게 제어하고 독자적 SQL 확장을 유연하게 추가할 수 있습니다.
Interpreter는 AST를 받아 QueryPipeline을 만듭니다. 파이프라인에는 세 가지 모드가 있습니다.
| 모드 | 동작 | 사용 예 |
|---|---|---|
| Pulling | 출력 포트에서 결과를 "당겨" 읽음 | SELECT |
| Pushing | 입력 포트에 데이터를 "밀어" 넣음 | INSERT |
| Completed | 입출력 없이 내부에서 자체 실행 | INSERT SELECT |
쿼리 분석·변환을 담당하던 ExpressionAnalyzer는 공식 문서 스스로 "quite messy and should be rewritten"이라 평가할 만큼 복잡해졌고, 이를 대체하기 위해 AST와 파이프라인 사이에 QueryTree라는 추상화 레이어를 둔 새 분석기가 도입됐습니다. enable_analyzer 설정으로 제어하며 기본값은 true입니다.
-- 새 분석기(QueryTree 기반) 활성화 여부 확인
SELECT name, value FROM system.settings WHERE name = 'enable_analyzer';
-- 동작 차이가 의심되면 쿼리 단위로 끄고 비교 가능
SELECT ... SETTINGS enable_analyzer = 0;
2.4 Processor DAG — 멀티코어를 채우는 병렬 실행
Processor는 포트(port)로 서로 연결되어 Block을 소비하고 생산하는 실행 단위입니다. 테이블의 read 메서드가 Processor를 여러 개 반환하면, 이들이 서로 다른 파트를 병렬로 읽습니다. 이것이 ClickHouse가 쿼리 하나로 모든 코어를 채우는 메커니즘입니다.
테이블 데이터는 여러 개의 파트로 나뉘어 디스크에 있습니다(4장). 이 물리적 분할이 그대로 병렬 처리의 단위가 됩니다.
IStorage::read()가 Source Processor를 여러 개 반환합니다. 각 Source가 서로 다른 파트를 맡아 서로 다른 CPU 코어에서 동시에 읽습니다.
각 lane에 독립적으로 Filter Processor가 연결됩니다. WHERE 조건은 IColumn::filter(mask) 호출로, 원본 Block은 건드리지 않고 새 Block을 만듭니다(immutable).
병렬 lane들의 결과가 Aggregation Processor에서 합류합니다. GROUP BY 키별 집계 상태(해시 테이블)가 여기서 병합됩니다.
마지막으로 Output Processor가 포맷 변환(JSON/CSV도 Processor입니다!)을 거쳐 클라이언트로 보냅니다. 읽기~필터는 병렬, 합류는 한 곳 — 이것이 수직 확장의 실체입니다.
2.5 Functions — 강타입, 그리고 short-circuit이 없는 세계
일반 함수(ordinary functions)는 행 수를 바꾸지 않고 Block 단위로 벡터화 실행됩니다. 두 가지 특성에 주의해야 합니다.
- 강타입(Strong Typing): 암묵적 타입 변환이 없습니다. 지원하지 않는 타입 조합이면 예외를 던지며, 대신
plus함수처럼 수많은 타입 조합의 오버로드를 제공합니다. - Short-circuit 평가 없음:
WHERE f(x) AND g(y)에서f(x)가 false여도g(y)가 계산됩니다.
행 단위로 실행하는 일반 DB는 단락 평가(short-circuit)가 가능합니다. f(x)가 false인 행에서는 g(y)를 아예 건너뛰죠.
ClickHouse는 조건 분기 없이 배열 전체를 한 번에 계산하는 벡터화 방식이므로, 우선 f(x)를 모든 행에 대해 계산합니다.
이어서 g(y)도 모든 행에 대해 계산합니다. f가 false인 행도 예외가 아닙니다. 이것이 벡터화의 대가입니다.
마지막에 두 배열을 AND로 결합합니다. 만약 g(y)가 매우 비싼 함수라면, f로 먼저 필터링한 뒤 남은 행에만 g를 적용하는 멀티패스 구조(서브쿼리)로 다시 쓰는 것이 효율적입니다.
한편 집계 함수(aggregate functions)는 상태를 가지는(stateful) 함수입니다. count의 상태는 UInt64 하나지만, uniqCombined는 선형 배열 + 해시 테이블 + HyperLogLog의 조합입니다. 고카디널리티 GROUP BY에서는 수많은 상태를 Arena(메모리 풀)에서 할당하고, 분산 쿼리에서는 이 상태를 직렬화해 네트워크로 전송합니다. 집계 상태를 테이블에 저장하는 AggregatingMergeTree의 증분 집계가 이 직렬화 위에 서 있습니다.
집계 상태의 직렬화 포맷은 현재 버전 관리되지 않습니다. AggregatingMergeTree로 증분 집계를 운영 중이라면, ClickHouse 버전 업그레이드 전에 집계 함수 직렬화 포맷의 호환성을 확인하세요.
2.6 IStorage와 I/O — 테이블 엔진의 경계
테이블은 IStorage 인터페이스로 표현되고, 그 구현체가 곧 테이블 엔진입니다(StorageMergeTree, StorageMemory, StorageDistributed…). 설계 원칙은 명확합니다 — read()는 지정된 컬럼을 읽는 것까지만 담당하고, 이후 처리는 파이프라인에 위임합니다. 단 두 가지 예외가 있습니다.
- 인덱스 활용: AST가
read()에 전달되므로 테이블 엔진이 인덱스로 읽기 범위를 줄일 수 있습니다. - 분산 푸시다운:
StorageDistributed는 쿼리를 원격 서버로 보내 일정 단계까지 처리된 결과를 받아옵니다.
바이트 수준 I/O는 C++ iostream 대신 자체 ReadBuffer/WriteBuffer를 씁니다. CompressedWriteBuffer(압축), ConcatReadBuffer(연결), HashingWriteBuffer(해시 계산)처럼 조합 가능한 부품들입니다.
2.7 서버 인터페이스 — 무엇으로 접속해야 하나
| 인터페이스 | 용도 | 특징 |
|---|---|---|
| HTTP | 외부 애플리케이션 | 단순, 사용 쉬움 — 공식 권장 |
| TCP (Native) | 네이티브 클라이언트, 서버 간 분산 쿼리 | 내부 Block 포맷에 강결합. C 라이브러리조차 없음 |
| Replication | 레플리카 간 데이터 전송 | 압축된 파트 단위 전송 |
서버 자체는 코루틴·파이버 없는 단순한 멀티스레드 서버입니다. 대량의 단순 쿼리가 아니라 소수의 복잡한 쿼리(각각 방대한 데이터 처리)를 위한 의도적 설계입니다. TCP 프로토콜은 전·후방 호환성을 유지하되, 약 1년 후 구버전 지원을 제거합니다.
설정 파일에 문법 오류가 있어도 서버는 죽지 않고 이전 설정으로 계속 동작합니다. 안전하지만, "설정을 바꿨는데 적용 안 된 채 운영"하는 상황을 놓치기 쉽습니다. 변경 후엔 system.settings로 실제 적용 여부를 확인하세요. 설정의 6단계 계층 구조는 3장에서 자세히 다룹니다.
✍️ 이해도 체크
WHERE cheap_filter(x) AND expensive_func(y) 쿼리가 예상보다 느립니다. 가장 가능성 높은 원인은?이 장의 원문 문서: chapters/ch02_internal_components.md — Field의 타입 소실 문제, ReadBuffer/WriteBuffer 조합, 쿼리 하나를 7단계로 따라가는 상세 다이어그램이 담겨 있습니다.