ClickHouse의 보조(스킵) 인덱스: ORDER BY 인덱스만으로 부족할 때
1. 스킵 인덱스의 작동 방식 — 데이터 블록 건너뛰기
기존 데이터베이스(PostgreSQL, MySQL)에서 인덱스는 조건을 만족하는 행을 정확히 가리키는 구조입니다. B-트리는 "user_id = 123 값은 #45678 행에 있다"고 말합니다.
ClickHouse에서 기본 인덱스(ORDER BY에 의한 희소 인덱스)는 다르게 작동합니다. 8192번째 행(그래뉼)마다 값을 저장하며 ORDER BY에서 앞쪽에 있는 열에 대해 전체 블록을 효율적으로 걸러낼 수 있습니다.
하지만 ORDER BY에 없는 열로 검색해야 한다면 어떨까요? 예를 들어, ORDER BY가 (user_id, created_at)인데 특정 IP 주소로 모든 베팅을 찾고 싶다면 ClickHouse는 모든 그래뉼을 읽고 읽은 후에 IP를 필터링해야 합니다. 이를 풀 스캔이라고 합니다.
보조(스킵) 인덱스가 이 문제를 해결합니다. 특정 행을 가리키지 않고 "N개 그래뉼의 이 블록에는 해당 값이 확실히 없다 — 건너뛸 수 있다"고 말합니다. 인덱스가 "아마도"라고 하면 ClickHouse는 여전히 블록을 읽습니다.
실제 비유: 도서관에서 초록색 표지의 책을 찾고 있다고 상상해보세요. 기본 인덱스(저자 성별 목록)는 도움이 되지 않습니다. 하지만 선반 사이를 걸으며 빠르게 훑어봅니다: "이 선반의 모든 책은 파란색이다 — 건너뛴다. 이 선반에는 초록색이 있다 — 확인해보자." 스킵 인덱스는 정확한 포인터가 아니라 색상으로 구분된 선반과 같습니다.
왜 스킵이라고 부를까요? 인덱스의 주요 임무는 확실히 필요 없는 블록을 건너뛰는 것이기 때문입니다. 더 많은 블록을 건너뛸수록 쿼리가 빨라집니다.
중요한 제한: 스킵 인덱스는 그래뉼 수준에서만 작동합니다. 그래뉼 내에서 정확한 행 위치를 찾을 수 없습니다. 따라서 원하는 값이 드문 경우(낮은 선택성)에 유용합니다. 행의 80%가 조건과 일치하면 여전히 모든 것을 읽어야 합니다.
2. INDEX ... TYPE minmax — 범위 쿼리용
가장 간단한 스킵 인덱스는 minmax입니다. 각 그래뉼 그룹에 대해 열의 최소값과 최대값을 저장합니다.
CREATE TABLE player_events
(
user_id UInt64,
event_time DateTime,
amount Decimal(18,2),
outcome String -- 'win', 'loss', 'push'
)
ENGINE = MergeTree()
ORDER BY (user_id, event_time) -- 기본 순서
INDEX idx_outcome_minmax outcome TYPE minmax GRANULARITY 4;
매개변수 설명:
INDEX idx_outcome_minmax— 인덱스 이름 (원하는 대로 지정하되 의미 있게).outcome— 인덱스를 구축할 열.TYPE minmax— 인덱스 유형: 그래뉼 그룹의 최소값과 최대값을 저장.GRANULARITY 4— 인덱스 항목 하나에 결합되는 그래뉼(각 8192행) 수. 여기서는 4 × 8192 = 32768행.
쿼리에서의 작동 방식:
-- 특정 결과의 이벤트 찾기
SELECT * FROM player_events
WHERE outcome = 'win' AND event_time >= '2025-06-01';
ClickHouse가 idx_outcome_minmax 인덱스를 읽습니다:
- 그룹 1: min='loss', max='push' → 'win' 없음 → 32768행 건너뜀.
- 그룹 2: min='loss', max='win' → 'win' 포함 → 이 그룹 읽음.
- 그룹 3: min='win', max='win' → 'win'만 있음 → 읽음.
minmax가 효과적인 경우:
- 단조 변화가 있는 열 (시간, ID, 온도).
- 고유 값이 적지만 고르게 분포되지 않은 열.
- 범위 쿼리 (
BETWEEN,>=,<=).
무용한 경우:
- 무작위 값 (예: 해시, UUID). 최소값과 최대값이 전체 범위를 포함하므로 인덱스가 아무것도 건너뛰지 못합니다.
3. INDEX ... TYPE set — 낮은 카디널리티 열의 동등 조건용
set 인덱스는 그래뉼 그룹에 대해 고유 값을 저장합니다. 검색 값이 이 집합에 없으면 그룹을 건너뜁니다.
CREATE TABLE bets
(
user_id UInt64,
sport_id UInt8, -- 20개 스포츠만
amount Decimal(18,2),
created_at DateTime
)
ENGINE = MergeTree()
ORDER BY (created_at, user_id)
INDEX idx_sport sport_id TYPE set(10) GRANULARITY 2;
매개변수:
set(10)— 인덱스가 그룹에 대해 저장할 최대 고유 값 수. 그룹에 10개 이상의 고유 sport_id 값이 있으면 인덱스는 10개만 기억합니다(그리고 거짓 긍정이 발생할 수 있음). 예상 열 카디널리티보다 약간 큰 숫자를 선택하세요.
작동 방식:
-- 특정 스포츠 쿼리
SELECT sum(amount) FROM bets WHERE sport_id = 1;
idx_sport 인덱스는 각 그래뉼 그룹에 대해 어떤 sport_id 값이 있는지 알고 있습니다. 그룹에 sport_id=1이 없으면 전체 그룹을 건너뜁니다. 있으면 읽습니다.
set이 효과적인 경우:
- 열 카디널리티가 낮음 (수백 개 값까지).
- 동등 쿼리 (
=,IN). - 데이터가 그래뉼 내에서 잘 그룹화됨 (예: 한 시간 동안의 모든 축구 베팅이 컴팩트하게 저장됨).
도박 예시: ORDER BY (created_at, user_id)인 베팅 테이블. sport_id 열(20개 값)은 ORDER BY에 없습니다. sport_id에 set 인덱스를 사용하면 모든 것을 스캔하지 않고도 모든 하키 베팅을 빠르게 찾을 수 있습니다.
4. bloom_filter 인덱스 — 높은 카디널리티 문자열 열용
블룸 필터는 확률적 데이터 구조입니다. "값이 그룹에 확실히 없다" 또는 "값이 있을 수도 있다"고 말할 수 있습니다. "확실히 있다"고 말하지 않으며 거짓 긍정 쪽으로만 오류를 범할 수 있습니다.
CREATE TABLE player_events
(
user_id UInt64,
ip_address String, -- 수백만 개의 고유 IP
event_type String,
created_at DateTime
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 3;
매개변수:
bloom_filter(0.01)— 1%의 거짓 긍정률. 숫자가 작을수록 인덱스가 더 정확하지만 더 많은 공간을 차지합니다. 일반적으로 0.01(1%) 또는 0.001(0.1%)을 사용합니다.GRANULARITY 3— 인덱스 항목당 3개 그래뉼 (3 × 8192 = 24576행).
작동 방식:
-- 의심스러운 IP의 모든 이벤트 찾기
SELECT * FROM player_events WHERE ip_address = '192.168.1.100';
각 그래뉼 그룹에 대한 인덱스는 블룸 필터를 통해 확인합니다: "이 그룹에 IP=192.168.1.100이 포함될 수 있나?" "아니오"면 그룹을 건너뜁니다. "예"(거짓 긍정 포함)면 그룹을 읽습니다.
블룸 필터가 효과적인 경우:
- 높은 카디널리티 열 (IP 주소, 이메일, user_agent).
- 정확 일치 쿼리.
- 검색 값이 드문 경우 (예: 1000만 개 중 특정 IP).
IP에 minmax가 적합하지 않은 이유: 무작위 분포로 인해 그룹의 최소 및 최대 IP가 거의 전체 범위를 포함하므로 가지치기가 작동하지 않습니다.
실제 예 — 다중 계정 탐지 (하나의 IP, 많은 user_id):
-- 주어진 IP의 모든 사용자 찾기
SELECT DISTINCT user_id FROM player_events
WHERE ip_address = '192.168.1.100';
인덱스 없음 — 풀 스캔. ip_address에 bloom_filter 사용 — IP가 행의 0.1%에 나타나더라도 빠름.
5. ngrambf_v1 — 문자열 LIKE/ILIKE 검색용
때로는 부분 문자열로 검색해야 합니다: WHERE player_name LIKE '%John%'. 일반 인덱스는 도움이 되지 않습니다. %가 앞에 있으면 B-트리 사용을 막기 때문입니다.
ngrambf_v1은 문자열을 n-gram — 길이 N의 부분 문자열로 분할합니다. 예를 들어, N=3인 경우 'Johny' → 'Joh', 'ohn', 'hny'. 인덱스는 이 n-gram에 대한 블룸 필터를 구축합니다.
CREATE TABLE players
(
player_id UInt64,
player_name String,
country String
)
ENGINE = MergeTree()
ORDER BY player_id
INDEX idx_name player_name TYPE ngrambf_v1(3, 500000, 2, 0.01) GRANULARITY 4;
ngrambf_v1의 매개변수:
3— n-gram 길이 (보통 2–4). 클수록 더 정확하지만 더 많은 메모리를 사용합니다.500000— 인덱스 항목당 블룸 필터 크기(바이트).2— 해시 함수 수 (보통 2–4).0.01— 거짓 긍정 확률.
쿼리에서 사용 방법:
-- 이름에 'Alex'가 포함된 플레이어 찾기
SELECT * FROM players WHERE player_name LIKE '%Alex%';
인덱스는 'Alex'를 n-gram('Ale', 'lex')으로 분할하고 이 n-gram이 그룹에 있는지 확인합니다. 그룹에 이 n-gram이 없으면 그룹을 건너뜁니다.
제한 사항:
LIKE및ILIKE(대소문자 구분 없음)에서만 작동합니다.- 검색 문자열이 n-gram보다 길어야 합니다 (최소 3자).
- 짧은 문자열(예:
'a')에는 적합하지 않습니다.
사용 시기: 플레이어 닉네임, 부분 이메일, 주소 검색. 도박에서 — 고객 지원을 위해 이름의 일부로 플레이어 찾기.
6. tokenbf_v1 — 토큰(단어) 검색용
tokenbf_v1은 ngrambf_v1과 유사하지만 문자열을 겹치는 조각이 아닌 토큰 — 공백, 구두점, 숫자로 구분된 단어로 분할합니다.
CREATE TABLE logs
(
log_time DateTime,
message String,
user_agent String
)
ENGINE = MergeTree()
ORDER BY log_time
INDEX idx_msg message TYPE tokenbf_v1(500000, 2, 0.01) GRANULARITY 2;
tokenbf_v1의 매개변수:
500000— 블룸 필터 크기(바이트).2— 해시 함수 수.0.01— 거짓 긍정 확률.
작동 방식:
문자열 "User 123 logged in from Ukraine"의 토큰: 'User', '123', 'logged', 'in', 'from', 'Ukraine'.
-- 오류를 언급하는 모든 로그 찾기
SELECT * FROM logs WHERE message LIKE '%error%';
인덱스는 'error'를 토큰(그냥 'error')으로 분할하고 그룹에 이 토큰이 있는지 확인합니다.
tokenbf_v1이 ngrambf_v1보다 나은 경우:
- 전체 단어(부분이 아닌) 검색.
- 영어 텍스트, 로그, user_agent.
- ngrambf_v1보다 거짓 긍정이 적습니다.
도박 예시: 'fraud' 또는 'suspicious'가 포함된 메시지에 대한 베팅 로그 검색.
7. 인덱스가 사용되는지 확인하는 방법 — EXPLAIN indexes=1
인덱스를 만들었지만 작동하나요? ClickHouse는 EXPLAIN indexes = 1 명령을 제공합니다.
-- 인덱스 사용 분석 활성화
EXPLAIN indexes = 1
SELECT user_id, amount FROM bets
WHERE sport_id = 1 AND created_at >= '2025-06-01';
출력 예시:
Expression
...
ReadFromMergeTree
Indexes:
PrimaryKey
Condition: (created_at >= '2025-06-01')
Used keys: (created_at)
Granules: 150 / 12000
Skip
Name: idx_sport
Type: set
Condition: sport_id = 1
Granules: 80 / 12000
숫자의 의미:
Granules: 150 / 12000— 기본 키가 11850개 그래뉼을 걸러내고 150개 남음.Skip ... Granules: 80 / 150— 스킵 인덱스가 추가로 70개 그래뉼을 걸러내고 80개 남음.- 최종 이득: 12000 → 80개 그래뉼 읽음.
인덱스가 사용되지 않는 경우:
Skip섹션에 표시되지 않음 → 생성되지 않았거나 쿼리가 인덱스 유형과 일치하지 않음.Granules: 12000 / 12000— 모든 것을 읽음, 인덱스가 도움이 되지 않음.
인덱스가 사용되지 않는 이유:
- 인덱스 유형이 연산자와 일치하지 않음 (
minmax는=에 비효율적). - 그래뉼러리티가 너무 큼 (거친 인덱스).
- 검색 값이 거의 모든 곳에 나타남 (인덱스가 블록을 건너뛸 수 없음).
8. 스킵 인덱스가 도움이 되지 않는 경우
시나리오 1: 높은 카디널리티 + 무작위 분포
user_id 열(수백만 값)이고 ORDER BY가 user_id로 시작하지 않으면 스킵 인덱스(심지어 bloom_filter)가 블록을 잘 걸러내지 못합니다. user_id=123 값이 테이블 전체에 흩어져 있을 수 있기 때문입니다.
시나리오 2: "좋은" 열에 대한 필터링 없는 쿼리
sport_id에 대한 인덱스는 WHERE에 amount > 1000만 있고 amount에 대한 인덱스가 없으면 도움이 되지 않습니다.
시나리오 3: 너무 큰 GRANULARITY
GRANULARITY = 64(그룹당 524k행)이고 테이블에 1000만 행이 있으면 약 20개 그룹만 있습니다. 20개 블록만 건너뛸 수 있어 무시할 수 있습니다.
시나리오 4: 검색 값이 행의 50%+에 나타남
스킵 인덱스는 드문 값에 좋습니다. 행의 절반이 조건과 일치하면 인덱스는 거의 모든 블록에 대해 "아마도"라고 말하고 모든 것을 읽게 됩니다.
시나리오 5: 인덱스가 너무 작음
-- 나쁨: 너무 작은 블룸 필터 (10000바이트)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 4;
작은 블룸 필터는 많은 거짓 긍정을 제공합니다 (실제로 아닐 때 "아마도"라고 자주 말함). 인덱스가 블록을 건너뛰지 못합니다.
9. 스킵 인덱스의 비용 — 메모리와 삽입 속도
모든 인덱스에는 비용이 있습니다. "만약을 대비해" 인덱스를 만들지 마세요.
비용 #1: 추가 디스크 공간
minmax— 매우 저렴 (그룹당 열당 8바이트).set(100)— 더 비싸지만 그룹당 수천 바이트 이내.bloom_filter— 비쌈: 500k바이트이고 GRANULARITY=1인 경우, 10k 그룹의 테이블에 대해 인덱스만 5GB.
비용 #2: 느린 INSERT
각 삽입 시 ClickHouse는 각 그래뉼에 대한 모든 인덱스를 업데이트합니다. 테이블에 5개의 인덱스가 있으면 삽입 속도가 2-3배 느려질 수 있습니다.
경험 법칙:
- 대형 테이블(수십억 행)에는 2-3개 이하의 스킵 인덱스.
- 자주 필터링되는 열에만 인덱스.
- 테스트 워크로드 — 실험. 프로덕션 — 측정.
인덱스 비용 추정 방법:
-- 테이블의 인덱스 크기 확인
SELECT
table,
index_name,
formatReadableSize(index_size) AS size
FROM system.indexes
WHERE table = 'bets';
인덱스 크기가 데이터 크기에 가까우면 과도하게 만든 것일 수 있습니다.
10. 실제 예: IP 주소로 사기 탐지
카지노에서 플레이어 그룹이 다중 계정(규칙 위반)을 위해 하나의 IP 주소를 사용한다고 상상해보세요. 의심스러운 IP에서 로그인한 모든 사람을 찾아야 합니다.
이벤트 테이블:
- 5억 행.
- ORDER BY = (user_id, event_time) — 사용자별 빠른 쿼리.
- 빈번한 쿼리:
SELECT user_id FROM events WHERE ip_address = 'x.x.x.x'.
해결책 — bloom_filter 인덱스:
CREATE TABLE player_events
(
user_id UInt64,
event_time DateTime,
ip_address String,
event_type String, -- 'login', 'bet', 'withdraw'
amount Decimal(18,2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (user_id, event_time)
INDEX idx_ip ip_address TYPE bloom_filter(0.01) GRANULARITY 4;
성능 비교:
| 시나리오 | 인덱스 없음 | bloom_filter (0.01) 사용 |
|---|---|---|
| 드문 IP 쿼리 시간 (행의 0.001%) | 60초 (5억 풀 스캔) | 0.3초 |
| 빈번한 IP 쿼리 시간 (행의 5%) | 60초 | 45초 (인덱스 도움 거의 없음) |
| 테이블 크기 (압축) | 100 GB | 108 GB (+8%) |
| INSERT 시간 (초당 10k행) | 배치당 0.5ms | 배치당 0.7ms (+40%) |
안티 사기 쿼리 작성 방법:
-- 의심스러운 IP를 사용한 모든 사용자 찾기
SELECT DISTINCT user_id
FROM player_events
WHERE ip_address = '192.168.1.100' -- bloom_filter 도움
AND event_time >= today() - 30; -- 파티션이 오래된 데이터 제거
-- 그런 다음 이 IP를 사용하는 다른 계정 수 확인
SELECT count(DISTINCT user_id) AS suspicious_accounts
FROM player_events
WHERE ip_address = '192.168.1.100';
bloom_filter를 사용하고 minmax를 사용하지 않는 이유:
- IP 주소는 무작위로 분포됨; 그룹의 최소/최대는 거의 항상 전체 범위를 포함합니다.
- 블룸 필터는 집합 멤버십 확인에 이상적입니다.
다음 단계
이제 ClickHouse 보조 인덱스의 모든 유형을 알게 되었습니다. 다음 주제:
- 인덱스 결합 — 여러 스킵 인덱스가 함께 작동하는 방식.
- 그래뉼러리티 튜닝 — 다양한 데이터 유형에 최적의 그래뉼 크기를 선택하는 방법.
- 분산 테이블의 인덱스 — 클러스터에서 스킵 인덱스가 작동하는 방식.
결론: ClickHouse의 스킵 인덱스는 만능 해결책이 아닙니다. PostgreSQL의 B-트리처럼 작동하지 않습니다. 하지만 적절한 시나리오(드문 값, 블룸 필터, n-gram)에서는 풀 스캔을 매우 빠른 쿼리로 바꿔줍니다. 핵심 규칙:
- 문제(풀 스캔)를 확인하기 전까지 인덱스를 만들지 마세요.
- 높은 카디널리티 열에는 bloom_filter로 시작하고, 낮은 카디널리티에는 set을 사용하세요.
- 항상
EXPLAIN indexes = 1로 확인하세요. - 비용(디스크 공간 + INSERT 속도 저하)을 기억하세요.
← 이전 글: ClickHouse의 구체화된 뷰: 증분 처리의 힘
— Editorial Team
아직 댓글이 없습니다.