7장. Primary Index — 희소 인덱스의 원리와 키 설계
모든 행이 아니라 8,192행마다 하나만 기록하는 "듬성듬성한" 인덱스 — 그런데도 3천만 행에서 3개 granule만 읽어냅니다. B-Tree와 완전히 다른 이 인덱스를 이해해야 키 설계에서 실수하지 않습니다.
① sparse index가 B-Tree와 어떻게 다른가 ② granule 분할과 primary.idx가 만들어지는 과정 ③ 쿼리 시 인덱스가 granule을 건너뛰며 스캔 범위를 좁히는 원리 ④ ORDER BY 키 설계 5원칙과 흔한 실수(키 순서만 바꿔도 850배 차이!)
7.1 Sparse Index — B-Tree와 무엇이 다른가
PostgreSQL 같은 전통적 RDBMS의 B-Tree 인덱스는 모든 행을 인덱싱합니다. 행이 10억 개면 인덱스 엔트리도 10억 개죠. 반면 ClickHouse의 primary index는 희소(sparse)합니다 — 데이터를 granule(기본 8,192행)이라는 덩어리로 나누고, 각 granule의 첫 번째 행의 키 값만 기록합니다.
"The primary key itself is 'sparse'. It does not address every single row, but only some ranges of data." — 행 하나하나가 아니라 데이터의 범위를 가리키는 인덱스입니다.
| B-Tree (PostgreSQL 등) | Sparse Index (ClickHouse) | |
|---|---|---|
| 인덱싱 단위 | 모든 행 (row-level) | granule마다 1개 (8,192행) |
| 인덱스 크기 | 행 수에 비례 (대용량 시 TB급) | 행 수 ÷ 8,192 (항상 메모리 상주 가능) |
| 유니크 제약 | 지원 | 미지원 (중복 키 행 허용) |
| Point query | O(log n) 후 행 직접 접근 | O(log n) 후 granule 전체(8,192행) 읽기 |
| Range scan | 효율적 | 매우 효율적 (연속 granule skip) |
왜 이렇게 만들었을까요? 공식 문서의 답: "단일 서버에서 수조(trillions) 행을 유지하면서도 인덱스의 메모리 소비가 눈에 띄지 않아야 하기 때문". 1조 행 ÷ 8,192 ≈ 1.2억 엔트리, 키가 10바이트면 약 1.2GB — B-Tree라면 TB 단위가 필요한 데이터입니다. 대신 단일 행을 찾을 때도 최소 1 granule을 읽어야 하고, 유니크 제약을 걸 수 없다는 대가를 치릅니다.
7.2 인덱스는 어떻게 만들어지는가
예시 테이블로 INSERT 시 일어나는 일을 따라가 봅시다.
CREATE TABLE uk.uk_price_paid_simple
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
ORDER BY (town, street); -- sorting key = 인덱스의 기반
INSERT된 행들은 먼저 sorting key (town, street) 순서로 정렬됩니다. 그 후 컬럼별 파일로 분리·압축되어 디스크에 저장됩니다.
정렬된 데이터를 논리적으로 8,192행 단위의 granule로 분할합니다. granule은 ClickHouse가 처리하는 가장 작은 데이터 단위입니다.
각 granule의 첫 번째 행의 키 값만 뽑아냅니다. 중간 행들은 인덱싱하지 않습니다 — 그래서 "sparse(희소)"입니다.
이 값들이 primary.idx 파일이 됩니다. 엔트리 수가 행 수의 1/8192이므로 수조 행도 인덱스가 통째로 메모리에 상주할 수 있습니다. 참고: 각 파트는 자신만의 독립적인 primary.idx를 가집니다.
4장에서 배운 대로 테이블은 여러 파트로 구성되며, 파트마다 별도의 primary index가 있습니다. 쿼리 시 모든 파트의 인덱스가 각각 사용되므로, 파트가 많을수록(예: 과도한 파티셔닝) 인덱스 효율도 분산됩니다.
7.3 쿼리 실행 — granule을 건너뛰며 스캔 범위를 좁힌다
이제 핵심입니다. 인덱스는 "어느 행에 데이터가 있다"고 알려주는 게 아니라, "이 granule에는 절대 없다"를 증명해서 건너뛰게 해줍니다. B-Tree가 목적지를 찍어주는 내비게이션이라면, sparse index는 "이 구역은 볼 필요 없음" 딱지를 붙이는 검문소에 가깝습니다.
쿼리가 도착합니다. WHERE 조건이 sorting key (status, date)의 컬럼을 사용하고 있습니다.
메모리에 상주한 primary.idx를 이진 탐색합니다. 각 엔트리는 "granule N은 이 키 값부터 시작한다"는 정보이므로, 각 granule의 키 범위를 추론할 수 있습니다.
조건에 매칭될 수 없는 granule을 제외합니다. g0·g1은 status가 fail, g2는 date 범위 밖 → 디스크에서 읽지도 않고 skip!
살아남은 g3, g4만 디스크에서 읽어 압축을 풀고, 실제 필터링과 집계를 수행합니다. I/O가 조건과 겹치는 granule로만 한정됩니다.
실측 예: 3,609개 granule 중 3개만 선택 — 2,955만 행 테이블에서 2.5만 행만 처리하고 0.010초에 응답. 이것이 sparse index의 위력입니다.
이 과정은 EXPLAIN indexes = 1로 직접 관찰할 수 있습니다. Granules: X/Y에서 X가 충분히 작은지가 인덱스 효율의 핵심 지표입니다.
EXPLAIN indexes = 1
SELECT max(price)
FROM uk.uk_price_paid_simple
WHERE town = 'LONDON' AND street = 'OXFORD STREET';
PrimaryKey
Keys: town, street
Condition: and((street in ['OXFORD STREET', ...]), (town in ['LONDON', ...]))
Parts: 3/3
Granules: 3/3609 ← 3,609개 중 3개만 읽는다!
단일 행을 찾더라도 최소 1 granule(8,192행)을 읽고, 그 granule이 속한 압축 블록 전체를 해제해야 합니다. 공식 문서도 "ClickHouse는 단순 point query의 고부하에는 적합하지 않다"고 명시합니다. 또한 모든 행을 인덱싱하지 않으므로 유니크 제약도 불가능합니다.
7.4 ORDER BY 키 설계 — 순서만 바꿔도 850배
sparse index의 효율은 전적으로 ORDER BY 키 설계에 달려 있습니다. 공식 best-practices의 제1원칙은 이것입니다.
"쿼리 필터(WHERE 절)에 자주 사용되는 컬럼, 특히 대량의 행을 배제하는 컬럼을 우선하라."
로그인 이벤트 테이블로 나쁜 설계와 좋은 설계를 비교해 봅시다. 저카디널리티 컬럼은 status(약 4개 값), 고카디널리티 컬럼은 user_id(수백만 값)이고, 주요 쿼리는 WHERE status = 'failed' AND event_time >= ...입니다.
설계 A는 고카디널리티인 user_id가 첫 번째 키입니다. 데이터가 user_id 순으로 배치되니, ok/fail이 모든 granule에 골고루 뒤섞입니다.
WHERE status='failed' 쿼리가 오면? 첫 키(user_id) 필터가 없어 이진 탐색을 쓸 수 없고, status는 어디에나 섞여 있어 어떤 granule도 배제하지 못합니다 → 풀 스캔.
설계 B는 저카디널리티인 status가 첫 번째 키입니다. fail 행들이 물리적으로 한 구간에 모이고, 수억 건의 ok 행들은 통째로 건너뛸 수 있는 형태가 됩니다.
인덱스 이진 탐색으로 fail 구간에 바로 점프하고 날짜 조건으로 더 좁힙니다. 결과: 3/3,609 granule, 8,500ms → 10ms. 850배 차이입니다.
키 설계 5원칙 정리
1️⃣ WHERE 절 빈도 우선
실제 쿼리의 필터 조건에 맞춰라. INSERT 순서나 데이터 모델의 논리적 순서가 아니라 쿼리 패턴이 기준입니다.
2️⃣ 저카디널리티를 앞에
ORDER BY (PostTypeId, toDate(CreationDate))처럼 고유값이 적은 컬럼을 먼저. 첫 키만으로 대량 granule을 skip할 수 있습니다.
3️⃣ 상관관계 높은 컬럼 포함 = 압축률↑
ordering key로 정렬하면 모든 컬럼이 그 순서로 배치됩니다. 유사한 값이 연속되면 압축이 잘 됩니다.
4️⃣ 키는 4~5개면 충분
너무 많으면 인덱스가 커지고 정렬 비용만 늘어납니다.
5️⃣ 날짜는 정밀도를 낮춰라
toDate(CreationDate)를 쓰면 엔트리가 8바이트 → 2바이트. 일 단위 필터링으로 충분한 경우가 대부분입니다.
🚫 생성 후 변경 불가
ORDER BY 키는 테이블 생성 시에만 정의됩니다. 대안인 Projection은 데이터 복제를 발생시킵니다. 처음부터 신중하게!
인덱스가 안 먹히는 필터 패턴
-- 나쁨: 키의 앞부분(status)을 건너뛰고 date만 필터 → skip 불가
WHERE date = '2024-04-05'
-- 나쁨: 복잡한 함수 래핑 → 최적화기가 인덱스를 우회할 수 있음
WHERE transform(status) = 'failed'
-- 나쁨: 부정 조건 → 대부분의 granule이 매칭되어 skip 효과 없음
WHERE status != 'ok'
-- 좋음: 키 순서대로 필터
WHERE status = 'failed' AND date >= '2024-04-01'
-- 좋음: IN 절 — 여러 범위를 각각 이진 탐색
WHERE status IN ('failed', 'timeout')
7.5 Ordering Key vs Primary Key, 그리고 튜닝 포인트
공식 문서에서 두 용어를 혼용하지만 엄밀하게는 다릅니다.
- Ordering Key (
ORDER BY): 디스크 상 데이터의 물리적 정렬 순서를 정의 - Primary Key (
PRIMARY KEY): sparse index에 저장되는 컬럼을 정의
보통은 동일하며, PRIMARY KEY를 지정하지 않으면 ORDER BY 키가 자동으로 primary key가 됩니다. 별도 지정은 "정렬은 3개 키로, 인덱스는 앞 2개만"처럼 인덱스 크기를 줄이고 싶을 때 사용합니다.
-- 데이터는 (a, b, c)로 정렬, sparse index는 (a, b)만 저장
CREATE TABLE t (...)
ENGINE = MergeTree
ORDER BY (a, b, c)
PRIMARY KEY (a, b);
-- 인덱스 내용을 직접 들여다보기
SELECT mark_number + 1 AS entry, town, street
FROM mergeTreeIndex('uk', 'uk_price_paid_simple')
ORDER BY mark_number ASC
LIMIT 10;
index_granularity 조정 | 효과 | 트레이드오프 |
|---|---|---|
| 줄이기 (예: 1024) | granule이 작아져 skip 정밀도 향상 | 인덱스 엔트리 8배 증가 → 메모리·mark 파일 증가 |
| 늘리기 (예: 65536) | 인덱스·mark 파일 축소 | skip 정밀도 하락, point query 최소 읽기량 증가 |
프라이머리 인덱스로 커버하지 못하는 컬럼에는 ALTER TABLE mytable ADD INDEX idx_name col_name TYPE minmax GRANULARITY 4;처럼 skipping index를 추가할 수 있습니다. 단, 프라이머리 인덱스의 대체가 아닌 보조 수단이며 효과는 데이터 분포에 크게 의존합니다. 기본값 8,192는 대부분의 워크로드에 적합하니 index_granularity도 특수한 경우에만 조정하세요.
✍️ 이해도 체크
primary.idx에 저장하는 것은?ORDER BY (status, date, user_id) 테이블에서 WHERE date = '2024-04-05'만 필터하면 인덱스 효과가 미미한 이유는?이 장의 원문 문서: chapters/ch07_primary_index.md — mergeTreeIndex 실습, 쿼리 패턴 분석 쿼리, index_granularity 튜닝 상세가 담겨 있습니다.