홈으로 돌아가기

ClickHouse 건너뛰기 인덱스: bloom, set, minmax

이 기사는 ClickHouse에서 보조(건너뛰기) 인덱스의 메커니즘을 설명하며, ORDER BY 외부 열을 검색할 때 데이터 블록을 건너뛸 수 있게 합니다. 인덱스 유형으로는 범위를 위한 minmax, 낮은 카디널리티를 위한 set, 높은 카디널리티 문자열을 위한 bloom_filter, LIKE 검색을 위한 ngrambf_v1, 토큰 기반 전체 텍스트 검색을 위한 tokenbf_v1을 다룹니다. EXPLAIN indexes=1을 통해 인덱스 사용을 확인하는 방법, 인덱스가 쓸모없는 시나리오, 비용(디스크 공간, INSERT 속도 저하)을 설명합니다. 사기 탐지를 위한 IP 기반 다중 계정 검색의 실제 예제를 제공합니다.

ClickHouse 보조(건너뛰기) 인덱스: 완전 가이드
Advertisement 728x90

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를 필터링해야 합니다. 이를 풀 스캔이라고 합니다.

Google AdInline article slot

보조(스킵) 인덱스가 이 문제를 해결합니다. 특정 행을 가리키지 않고 "N개 그래뉼의 이 블록에는 해당 값이 확실히 없다 — 건너뛸 수 있다"고 말합니다. 인덱스가 "아마도"라고 하면 ClickHouse는 여전히 블록을 읽습니다.

실제 비유: 도서관에서 초록색 표지의 책을 찾고 있다고 상상해보세요. 기본 인덱스(저자 성별 목록)는 도움이 되지 않습니다. 하지만 선반 사이를 걸으며 빠르게 훑어봅니다: "이 선반의 모든 책은 파란색이다 — 건너뛴다. 이 선반에는 초록색이 있다 — 확인해보자." 스킵 인덱스는 정확한 포인터가 아니라 색상으로 구분된 선반과 같습니다.

왜 스킵이라고 부를까요? 인덱스의 주요 임무는 확실히 필요 없는 블록을 건너뛰는 것이기 때문입니다. 더 많은 블록을 건너뛸수록 쿼리가 빨라집니다.

Google AdInline article slot

중요한 제한: 스킵 인덱스는 그래뉼 수준에서만 작동합니다. 그래뉼 내에서 정확한 행 위치를 찾을 수 없습니다. 따라서 원하는 값이 드문 경우(낮은 선택성)에 유용합니다. 행의 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;

매개변수 설명:

Google AdInline article slot
  • 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이 없으면 그룹을 건너뜁니다.

제한 사항:

  • LIKEILIKE(대소문자 구분 없음)에서만 작동합니다.
  • 검색 문자열이 n-gram보다 길어야 합니다 (최소 3자).
  • 짧은 문자열(예: 'a')에는 적합하지 않습니다.

사용 시기: 플레이어 닉네임, 부분 이메일, 주소 검색. 도박에서 — 고객 지원을 위해 이름의 일부로 플레이어 찾기.

6. tokenbf_v1 — 토큰(단어) 검색용

tokenbf_v1ngrambf_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 속도 저하)을 기억하세요.

이전 글:

— Editorial Team

Advertisement 728x90

다음 읽기