DB Study

7장. Primary Index — 희소 인덱스의 원리와 키 설계

모든 행이 아니라 8,192행마다 하나만 기록하는 "듬성듬성한" 인덱스 — 그런데도 3천만 행에서 3개 granule만 읽어냅니다. B-Tree와 완전히 다른 이 인덱스를 이해해야 키 설계에서 실수하지 않습니다.

중급 ⏱ 약 28분 🎬 애니메이션 3개 선수 지식: 4장 Parts, 6장 Merges
🧭
이 장에서 배우는 것

① 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 queryO(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된 행들 (무작위 순서) LONDON, ABERDARE, BATH, ABINGDON ... ① (town, street)로 정렬 ABBOTS → ABERDARE → ... ② 8,192행 단위 granule로 논리 분할 granule 0 · 행 0~8191 granule 1 · 8192~16383 granule 2 · 16384~... ... ③ 각 granule의 첫 행 키만 기록 primary.idx — [0]:(ABBOTS LANGLEY, ABBEY DRIVE) [1]:(ABERDARE, ...) [2]:(ABERGELE, ...) 엔트리 수 = 전체 행 ÷ 8,192 → 항상 메모리에 상주 🧠
⚠️
각 파트가 독립적인 인덱스를 가진다

4장에서 배운 대로 테이블은 여러 파트로 구성되며, 파트마다 별도의 primary index가 있습니다. 쿼리 시 모든 파트의 인덱스가 각각 사용되므로, 파트가 많을수록(예: 과도한 파티셔닝) 인덱스 효율도 분산됩니다.

7.3 쿼리 실행 — granule을 건너뛰며 스캔 범위를 좁힌다

이제 핵심입니다. 인덱스는 "어느 행에 데이터가 있다"고 알려주는 게 아니라, "이 granule에는 절대 없다"를 증명해서 건너뛰게 해줍니다. B-Tree가 목적지를 찍어주는 내비게이션이라면, sparse index는 "이 구역은 볼 필요 없음" 딱지를 붙이는 검문소에 가깝습니다.

WHERE status = 'ok' AND date BETWEEN '04-03' AND '04-07' primary.idx (메모리) [0] (fail, 04-01) [1] (fail, 04-04) [2] (ok, 04-01) [3] (ok, 04-03) [4] (ok, 04-06) 🔍 이진 탐색으로 인덱스 스캔 💾 디스크의 granule들 g0: fail 04-01~03 g1: fail 04-04~08 g2: ok 04-01~02 g3: ok 04-03~05 g4: ok 04-06~09 ❌ status 불일치 ❌ skip ❌ date 범위 밖 겹치는 g3, g4만 로드 압축 해제 → 필터·집계 실제 사례: Granules 3/3609 — 2,955만 행 중 2.5만 행만 처리, 99.9% skip ⚡ (0.010초)

이 과정은 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개만 읽는다!
⚠️
Point Query에는 불리하다

단일 행을 찾더라도 최소 1 granule(8,192행)을 읽고, 그 granule이 속한 압축 블록 전체를 해제해야 합니다. 공식 문서도 "ClickHouse는 단순 point query의 고부하에는 적합하지 않다"고 명시합니다. 또한 모든 행을 인덱싱하지 않으므로 유니크 제약도 불가능합니다.

7.4 ORDER BY 키 설계 — 순서만 바꿔도 850배

sparse index의 효율은 전적으로 ORDER BY 키 설계에 달려 있습니다. 공식 best-practices의 제1원칙은 이것입니다.

💡
키 선택 제1원칙

"쿼리 필터(WHERE 절)에 자주 사용되는 컬럼, 특히 대량의 행을 배제하는 컬럼을 우선하라."

로그인 이벤트 테이블로 나쁜 설계와 좋은 설계를 비교해 봅시다. 저카디널리티 컬럼은 status(약 4개 값), 고카디널리티 컬럼은 user_id(수백만 값)이고, 주요 쿼리는 WHERE status = 'failed' AND event_time >= ...입니다.

😰 설계 A: (user_id, time, status) 😎 설계 B: (status, toDate(time), user_id) user_1   09:00 ok   KR user_2   09:00 fail US user_3   09:02 ok   JP user_4   09:03 fail KR ... (ok/fail이 뒤섞여 배치됨) WHERE status='failed' → user_id 필터가 없으면 이진 탐색 불가 → 전체 3,609 granule 풀 스캔 fail 04-01 ... / fail 04-02 ... fail 행들이 한 구간에 모임 (수만 건) ok   04-01 ... / ok   04-30 ... ok 행들 (수억 건) — 통째로 skip 가능 인덱스 이진 탐색으로 fail 구간에 점프 → 3 / 3,609 granule만 읽음 (0.08%) 설계 A: 2,950만 행 · 8,500ms  →  설계 B: 24,580행 · 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, 그리고 튜닝 포인트

공식 문서에서 두 용어를 혼용하지만 엄밀하게는 다릅니다.

보통은 동일하며, 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 최소 읽기량 증가
🔬
보조 수단: Data Skipping Index

프라이머리 인덱스로 커버하지 못하는 컬럼에는 ALTER TABLE mytable ADD INDEX idx_name col_name TYPE minmax GRANULARITY 4;처럼 skipping index를 추가할 수 있습니다. 단, 프라이머리 인덱스의 대체가 아닌 보조 수단이며 효과는 데이터 분포에 크게 의존합니다. 기본값 8,192는 대부분의 워크로드에 적합하니 index_granularity도 특수한 경우에만 조정하세요.

✍️ 이해도 체크

ClickHouse의 sparse primary index가 primary.idx에 저장하는 것은?
✅ granule마다 첫 행의 키 값 하나만 기록하므로 엔트리 수 = 행 수 ÷ 8,192. 덕분에 수조 행 테이블의 인덱스도 메모리에 통째로 상주할 수 있습니다. 대신 모든 행을 인덱싱하지 않으므로 유니크 제약이 불가능하고, 단일 행 조회도 최소 1 granule을 읽어야 합니다.
ORDER BY (status, date, user_id) 테이블에서 WHERE date = '2024-04-05'만 필터하면 인덱스 효과가 미미한 이유는?
✅ 데이터는 status → date 순으로 정렬되므로, 같은 날짜의 행이 status=fail 구간과 status=ok 구간 등 여러 곳에 나뉘어 존재합니다. 키의 앞부분을 건너뛴 필터는 이진 탐색을 활용하지 못해 대부분의 granule을 읽게 됩니다. 인덱스는 키 순서대로(prefix부터) 필터할 때 가장 효과적입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch07_primary_index.md — mergeTreeIndex 실습, 쿼리 패턴 분석 쿼리, index_granularity 튜닝 상세가 담겨 있습니다.