[ClickHouse Deep Dive] 2편: 물리 파일 구조와 초고속 조회를 가능하게 하는 Data Pruning 기술

[ClickHouse Deep Dive] 2편: 물리 파일 구조와 초고속 조회를 가능하게 하는 Data Pruning 기술

1편에서는 ClickHouse의 전반적인 아키텍처와 함께 MergeTree 엔진이 디스크 상에 'part'라는 불변의 단위를 어떻게 생성하고 병합하는지 알아봤습니다. 하지만 ClickHouse가 진정으로 빛을 발하는 순간은 수십억, 수백억 건에 달하는 대규모 데이터에서 원하는 결과를 몇 밀리초(ms) 만에 뽑아낼 때입니다.

이번 2편에서는 디스크에 저장되는 실제 물리 파일들의 세부 구조를 들여다보고, 대용량 스캔 오버헤드를 극단적으로 줄여주는 세 가지 핵심 테크닉인 Primary Key, Table Projection, Skipping Indices의 작동 원리를 깊이 있게 파헤쳐 보겠습니다.


1. 디스크에 저장되는 실제 물리적 파일 구조

MergeTree 테이블은 데이터를 물리적으로 'part' 단위의 디렉터리로 관리하며, 각 디렉터리 내부에는 컬럼별로 독립된 파일들이 존재합니다.

만약 이벤트 로그를 저장하는 테이블이 있다면, 디스크 상의 경로는 아래와 같은 구조를 띠게 됩니다.

/var/lib/clickhouse/data/db/events/20251021_1_1_0/
├─ event_time.bin
├─ event_time.mrk3
├─ user_id.bin
├─ user_id.mrk3
├─ region_id.bin
└─ region_id.mrk3

① *.bin 파일 (실체 데이터 파일)

각 컬럼의 실제 데이터가 압축되어 저장되는 파일입니다. ClickHouse는 기본적으로 LZ4 알고리즘을 사용해 데이터를 압축하며, 필요한 경우 Gorilla, FPC 등의 특화 코덱을 선택할 수 있습니다.

물리적으로 디스크에 데이터를 읽고 쓰는 최소 단위를 Block이라고 부르며, 기본적으로 약 1MB 크기 단위(max_compress_block_size)로 묶여 압축된 후 디스크에 기록됩니다.

② *.mrk3 파일 (마킹 파일)

컬럼 지향 데이터베이스의 난제 중 하나는 "압축된 데이터 안에서 내가 원하는 특정 행(Row)의 위치를 어떻게 빠르게 찾을 것인가"입니다. 이를 해결하는 맵(Map) 역할을 하는 것이 바로 마킹 파일입니다.

ClickHouse는 part 내부의 행들을 논리적으로 **Granule(알갱이)**이라는 단위로 묶어 관리합니다. (기본 크기: 8,192행). 마킹 파일은 아래 테이블과 같이 각 Granule ID가 어떤 압축 블록 오프셋(Compressed Block Offset)과 비압축 오프셋(Uncompressed Offset)에 매핑되는지 기록하고 있습니다.

Granule ID Compressed Block Offset Uncompressed Offset
0 0 0
1 1048576 8192
2 2097152 16384

특정 Granule만 읽고 싶을 때, 마킹 파일을 확인하여 디스크에서 **해당 Block만 정확히 짚어(Seek) 해제(Decompress)**함으로써 무분별한 전체 디스크 I/O를 방지합니다.

💡 작은 파트 최적화(Wide vs Compact format): 파트의 크기가 아주 작을 때(기본 10MB 미만)는 컬럼별로 파일을 따로 만들지 않고, 모든 컬럼의 데이터를 하나의 파일에 연속적으로 저장하여 파일 개수가 폭발적으로 늘어나는 것을 방지합니다.


2. Data Pruning 테크닉 (1) - Primary Key와 Sparse Index

페타바이트 규모의 데이터를 탐색할 때 속도를 보장하는 ClickHouse의 첫 번째 기술은 고유한 Primary Key(기본 키) 설계에 있습니다.

전통적인 RDBMS의 Primary Key가 모든 행의 고유 위치를 가리키는 무거운 인덱스(Dense Index) 구조를 가졌다면, ClickHouse의 Primary Key는 각 파트(part) 내부에서 행을 정렬하는 기준이자 희소 인덱스(Sparse Index) 구조를 취합니다.

8.1M 행을 단 1,000개의 엔트리로 인덱싱하는 비결

ClickHouse는 전체 행을 하나하나 인덱싱하지 않고, Granule 단위로 인덱스 엔트리를 하나씩만 저장합니다. 즉, 8,192행마다 첫 번째 행의 Primary Key 값만 인덱스에 기록해 두는 방식입니다.

이 덕분에 인덱스의 크기가 극단적으로 작아져서 수십억 건의 데이터에 대한 인덱스 정보를 메모리(RAM)에 항상 상주시킬 수 있습니다. 공식 논문에 따르면 810만 행을 인덱싱하는 데 단 1,000개의 엔트리만으로 충분하다고 밝히고 있습니다.

[Sparse Index (In-Memory)]
Granule 0: Key='2026-03-01'  ──>  [event_time.mrk3] ──>  [event_time.bin (Block 0)]
Granule 1: Key='2026-03-12'  ──>  [event_time.mrk3] ──>  [event_time.bin (Block 1)]

작동 방식

  1. 쿼리에서 WHERE event_time BETWEEN '2026-03-12' AND '2026-03-15' 조건이 들어옵니다.
  2. 메모리에 상주하는 Sparse Index를 이진 탐색(Binary Search)하여 해당 범위의 데이터가 속해 있을 가능성이 있는 Granule들의 범위를 찾아냅니다.
  3. 조건에 맞지 않는 수많은 Granule은 디스크에서 읽지도 않고 그대로 제쳐버립니다(Data Pruning).
  4. 선택된 Granule만 마킹 파일을 거쳐 디스크에서 정확히 Load 합니다.


3. Data Pruning 테크닉 (2) - Table Projection

Primary Key는 강력하지만 한계가 있습니다. 테이블이 ORDER BY (date, region)으로 정렬되어 있다면, region 컬럼만으로 필터링해 쿼리할 때는 인덱스의 이점을 보기 어렵습니다. 이를 보완하기 위해 도입된 물리적 구조가 바로 **Table Projection(프로젝션)**입니다.

Projection은 메인 테이블의 데이터 복사본이자, 다른 정렬 기준으로 구성된 독립적인 물리 구조입니다. 메인 테이블과 행(Row) 수는 완벽하게 같지만 Primary Key(정렬 기준)를 다르게 가져가는 전략입니다.

-- region 중심의 조회를 위해 메인 테이블에 Projection 추가
ALTER TABLE events ADD PROJECTION p_by_region
(
    SELECT *
    ORDER BY (region, date)
);

스마트한 옵티마이저의 선택

Projection을 생성해 두면 사용자가 명시적으로 이 프로젝션 테이블을 호출하지 않아도 됩니다. ClickHouse의 내장 옵티마이저가 유입된 쿼리의 WHERE 필터 조건을 확인한 뒤, 메인 테이블의 정렬 키를 타는 것보다 프로젝션 구조를 타는 것이 더 빠르다고 판단되면 자동으로 프로젝션 데이터 사본을 바라보고 쿼리를 수행합니다.

주의할 점: Projection은 별도의 데이터 사본을 관리하는 것이므로 데이터가 삽입되거나 병합될 때 함께 업데이트됩니다. 따라서 쓰기 작업 부하와 디스크 사용량이 증가하는 트레이드 오프가 있습니다. 또한, 추가하기 이전에 있던 기존 데이터는 자동으로 채워지지 않으므로 ALTER TABLE ... MATERIALIZE PROJECTION 명령을 통해 수동으로 반영해 주어야 합니다.


4. Data Pruning 테크닉 (3) - Skipping Indices (데이터 건너뛰기 인덱스)

Primary Key에 포함되지 않은 일반 컬럼들에 대해 디스크 스캔량을 줄이고 싶을 때 사용하는 가장 가볍고 영리한 최적화 기법이 바로 Skipping Indices입니다.

여러 개의 Granule을 묶어 Index Block이라는 단위를 만들고, 그 단위별로 컬럼 데이터의 요약된 메타데이터를 저장해 두었다가 조건에 맞지 않으면 통째로 Skip합니다. ClickHouse는 데이터의 특성에 맞게 세 가지 형태의 Skipping Index를 제공합니다.

① Min-max Index (최소-최대 인덱스)

각 Index Block 구간 내 컬럼 값의 최소값(Min)과 최대값(Max)만 저장합니다.

  • 적합한 경우: 날짜(Date), 정수형 순차 ID와 같이 데이터가 시간 순으로 정렬되어 있거나 값의 범위가 좁게 뭉쳐 있는 컬럼에 유수합니다.
  • 동작: WHERE age < 20 조건으로 조회할 때, 어떤 Index Block의 Min-Max 범위가 [25, 40]이라면 해당 구간에 포함된 수만 개의 행은 쳐다보지도 않고 스킵합니다.

② Set Index (집합 인덱스)

구간 내에 존재하는 유니크한 값들의 목록(Set)을 직접 저장합니다.

  • 적합한 경우: 상태 코드(status_code), 국가 코드(country), 카테고리 등 Cardinality(값의 종류)가 비교적 작은 컬럼에 적합합니다.
  • 동작: WHERE status = '404' 쿼리가 돌 때, 해당 인덱스 블록 집합에 404가 존재하지 않는다면 그 구간 전체를 Skip합니다.

③ Bloom Filter Index (블룸 필터 인덱스)

공간 효율적인 확률적 자료구조인 블룸 필터(Bloom Filter)를 각 인덱스 블록마다 생성합니다.

  • 적합한 경우: 긴 텍스트, 로그 메시지, 사용자 태그 등 값의 종류가 너무 많아 Set 구조를 쓸 수 없는 문자열 검색(LIKE, IN)에 매우 유용합니다.
  • 동작: "값이 확실히 없는 상태"를 100% 판별해 주기 때문에, 대규모 에러 로그 메시지 검색 시 불필요한 디스크 읽기 오버헤드를 경이로운 수준으로 낮춰줍니다.


요약: ClickHouse의 데이터 필터링 순서

ClickHouse는 단일 쿼리를 수행할 때 앞서 소개한 최적화 구조를 조합하여 아래와 같은 순서로 정교하게 데이터를 필터링(Pruning)해 나갑니다.

  1. Primary Key Index를 통해 조건에 부합하는 대략적인 Granule 범위 선정
  2. 설정된 Skipping Index 메타데이터를 대조하여 불필요한 인덱스 블록 구간을 통째로 누락
  3. 서로 다른 컬럼에 대한 필터 조건이 있다면, 선택도(Selectivity)가 높을 것으로 추정되는 컬럼 조건부터 순차적으로(Sequentially) 평가하여 데이터 청크를 단계별로 정제

이러한 다층적인 가지치기 레이어 덕분에 페타바이트급 저장소에서도 디스크 밴드위스를 낭비하지 않고, 극단적으로 최소한의 물리 블록만 열어서 결과를 반환할 수 있는 것입니다.

다음 3편에서는 이렇게 필터링을 거쳐 메모리에 올라온 데이터 청크들을 CPU 단에서 어떻게 미친 듯한 속도로 연산해 내는지, SIMD 병렬화, 멀티코어 파이프라인 파헤치기, 그리고 JIT(런타임 기계어 컴파일) 기술의 비밀을 완벽하게 파헤쳐 보겠습니다.


References (참고 문헌)

  • Schulze, A., Krotov, Artem., & ClickHouse Team. (2024). ClickHouse: Lightning Fast Analytics for Everyone. In Proceedings of the 2026 International Conference on Management of Data (SIGMOD '24).[cite: 1]
  • ClickHouse Official Documentation: Data Filtering in MergeTree Engines