DB Study

2장. 내부 컴포넌트와 쿼리 처리

SELECT 한 줄이 결과가 되기까지 — Parser, Interpreter, Processor, Block이라는 부품들이 조립 라인처럼 맞물리는 과정을 따라갑니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 1장 개요
🧭
이 장에서 배우는 것

① 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;
SQL 문자열 "SELECT event_type, max(…)" Parser AST (구문 트리) SelectQuery ├ columns ├ from ├ where └ groupBy Interpreter 분석·최적화·플랜 생성 AST → (QueryTree) → Pipeline QueryPipeline (DAG) Source Filter Aggregate Output PipelineExecutor Block(~8,192행) 단위로 실행 📦 클라이언트로 결과 전송 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중 구조
💡
Leaky Abstraction — 의도된 추상화 누수

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가 쿼리 하나로 모든 코어를 채우는 메커니즘입니다.

💾 디스크 Part A Part B Part C Source A 코어 1 Source B 코어 2 Source C 코어 3 Filter A user_id=123 Filter B user_id=123 Filter C user_id=123 Aggregation Processor GROUP BY + max(amount) Output Processor → 클라이언트 📦 Source~Filter는 파트별 병렬, Aggregation에서 합류 — 코어 수만큼 빨라진다

2.5 Functions — 강타입, 그리고 short-circuit이 없는 세계

일반 함수(ordinary functions)는 행 수를 바꾸지 않고 Block 단위로 벡터화 실행됩니다. 두 가지 특성에 주의해야 합니다.

WHERE f(x) AND g(y) 일반 DB (행 단위 단락 평가) row1: f=false → g 건너뜀 ⏭ row2: f=true → g 계산 row3: f=false → g 건너뜀 ⏭ ClickHouse (배열 단위) f: [0, 1, 0, 1, 0, 0, 1, …] 전체 계산 g: [1, 1, 0, 1, 1, 0, 0, …] 전체 계산 ⚠️ f=false인 행에서도 g가 계산된다! AND: [0, 1, 0, 1, 0, 0, 0, …] g(y)가 비싸다면? → f(x)로 먼저 필터링(서브쿼리)한 뒤 g(y)를 계산하는 멀티패스가 유리

한편 집계 함수(aggregate functions)는 상태를 가지는(stateful) 함수입니다. count의 상태는 UInt64 하나지만, uniqCombined는 선형 배열 + 해시 테이블 + HyperLogLog의 조합입니다. 고카디널리티 GROUP BY에서는 수많은 상태를 Arena(메모리 풀)에서 할당하고, 분산 쿼리에서는 이 상태를 직렬화해 네트워크로 전송합니다. 집계 상태를 테이블에 저장하는 AggregatingMergeTree의 증분 집계가 이 직렬화 위에 서 있습니다.

⚠️
AggregatingMergeTree 사용자는 업그레이드 주의

집계 상태의 직렬화 포맷은 현재 버전 관리되지 않습니다. AggregatingMergeTree로 증분 집계를 운영 중이라면, ClickHouse 버전 업그레이드 전에 집계 함수 직렬화 포맷의 호환성을 확인하세요.

2.6 IStorage와 I/O — 테이블 엔진의 경계

테이블은 IStorage 인터페이스로 표현되고, 그 구현체가 곧 테이블 엔진입니다(StorageMergeTree, StorageMemory, StorageDistributed…). 설계 원칙은 명확합니다 — read()는 지정된 컬럼을 읽는 것까지만 담당하고, 이후 처리는 파이프라인에 위임합니다. 단 두 가지 예외가 있습니다.

  1. 인덱스 활용: AST가 read()에 전달되므로 테이블 엔진이 인덱스로 읽기 범위를 줄일 수 있습니다.
  2. 분산 푸시다운: StorageDistributed는 쿼리를 원격 서버로 보내 일정 단계까지 처리된 결과를 받아옵니다.

바이트 수준 I/O는 C++ iostream 대신 자체 ReadBuffer/WriteBuffer를 씁니다. CompressedWriteBuffer(압축), ConcatReadBuffer(연결), HashingWriteBuffer(해시 계산)처럼 조합 가능한 부품들입니다.

2.7 서버 인터페이스 — 무엇으로 접속해야 하나

인터페이스용도특징
HTTP외부 애플리케이션단순, 사용 쉬움 — 공식 권장
TCP (Native)네이티브 클라이언트, 서버 간 분산 쿼리내부 Block 포맷에 강결합. C 라이브러리조차 없음
Replication레플리카 간 데이터 전송압축된 파트 단위 전송

서버 자체는 코루틴·파이버 없는 단순한 멀티스레드 서버입니다. 대량의 단순 쿼리가 아니라 소수의 복잡한 쿼리(각각 방대한 데이터 처리)를 위한 의도적 설계입니다. TCP 프로토콜은 전·후방 호환성을 유지하되, 약 1년 후 구버전 지원을 제거합니다.

🔬
설정 리로드의 에러 내성 — 양날의 검

설정 파일에 문법 오류가 있어도 서버는 죽지 않고 이전 설정으로 계속 동작합니다. 안전하지만, "설정을 바꿨는데 적용 안 된 채 운영"하는 상황을 놓치기 쉽습니다. 변경 후엔 system.settings로 실제 적용 여부를 확인하세요. 설정의 6단계 계층 구조는 3장에서 자세히 다룹니다.

✍️ 이해도 체크

쿼리 실행 중 Processor 사이를 흐르는 데이터 단위인 Block의 구성은?
✅ Block = { (IColumn, IDataType, column_name), … }입니다. 함수 실행 결과는 Block에 새 컬럼으로 추가될 뿐 기존 컬럼은 수정되지 않는데(immutable), 이 성질이 공통 부분식 제거 같은 최적화를 쉽게 만듭니다.
WHERE cheap_filter(x) AND expensive_func(y) 쿼리가 예상보다 느립니다. 가장 가능성 높은 원인은?
✅ ClickHouse는 배열 전체를 한 번에 계산하는 벡터화 방식이라 단락 평가가 없습니다. 선택성 높은 필터로 먼저 걸러낸 뒤(서브쿼리) 비싼 함수를 적용하는 멀티패스 구조로 바꾸면 개선됩니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch02_internal_components.md — Field의 타입 소실 문제, ReadBuffer/WriteBuffer 조합, 쿼리 하나를 7단계로 따라가는 상세 다이어그램이 담겨 있습니다.