ClickHouse의 INDEX 개념
🗂️

ClickHouse의 INDEX 개념

ClickHouse 분류
Core Architecture
Type
Research
작성자

Ken

ClickHouse의 인덱스는 전통적인 관계형 데이터베이스(RDBMS)와 근본적으로 다릅니다. B-Tree 인덱스나 해시 인덱스 대신, ClickHouse는 분석 워크로드에 최적화된 독특한 인덱싱 전략을 사용합니다. 이 글에서는 ClickHouse의 인덱스 시스템을 처음부터 끝까지 깊이 있게 살펴보겠습니다.

  • ClickHouse 인덱스의 기본 철학
  • Granule: ClickHouse 인덱싱의 기본 단위
  • Granule이란?
  • 적응형 Granularity
  • Primary Key와 ORDER BY
  • Primary Key ≠ 유일성 보장
  • ORDER BY vs PRIMARY KEY
  • Primary Index의 구조
  • 쿼리 실행 과정
  • Primary Key 컬럼 순서의 중요성
  • Data Skipping Index (보조 인덱스)
  • Data Skipping Index의 원리
  • Skipping Index의 GRANULARITY
  • 주요 Skipping Index 타입
  • MinMax Index
  • Set Index
  • Bloom Filter Index
  • 텍스트 검색용 Bloom Filter Index
  • tokenbf_v1
  • ngrambf_v1
  • Skipping Index의 한계와 주의사항
  • Full-Text Index (전문 검색 인덱스)
  • Bloom Filter 기반 인덱스의 한계
  • Full-Text Index의 구조
  • Full-Text Index의 내부 파일 구조
  • Full-Text 검색 쿼리
  • 인덱스 관리 및 모니터링
  • 인덱스 추가 및 제거
  • 인덱스 사용 여부 확인
  • 인덱스 효과 측정
  • 인덱스 설계 모범 사례
  • Primary Key 설계 원칙
  • Skipping Index 사용 시기
  • 피해야 할 실수
  • 대안 고려
  • 결론

ClickHouse 인덱스의 기본 철학

전통적인 RDBMS에서 인덱스는 특정 행(row)의 위치를 빠르게 찾기 위해 사용됩니다. B-Tree 인덱스는 개별 행을 가리키는 포인터를 저장하여, WHERE id = 12345 같은 포인트 쿼리에서 뛰어난 성능을 발휘합니다.

그러나 ClickHouse는 OLAP(Online Analytical Processing) 워크로드를 위해 설계되었습니다. 수십억 행의 데이터를 집계하고, 범위 검색을 수행하며, 대량의 데이터를 스캔하는 것이 주된 작업입니다. 이러한 환경에서 행 단위 인덱스는 오히려 오버헤드가 됩니다.

ClickHouse는 대신 희소 인덱스(Sparse Index) 개념을 도입했습니다. 모든 행을 인덱싱하는 대신, 일정 간격(기본 8,192행)마다 하나의 인덱스 항목만 저장합니다. 이를 통해 인덱스 크기를 극적으로 줄이면서도 대량 데이터 스캔에서 효율적인 데이터 스킵을 가능하게 합니다.

Granule: ClickHouse 인덱싱의 기본 단위

ClickHouse의 인덱스를 이해하려면 먼저 **Granule(그래뉼)**의 개념을 이해해야 합니다.

Granule이란?

Granule은 ClickHouse가 데이터를 처리할 때 사용하는 최소 단위입니다. 쿼리 실행 시 ClickHouse는 개별 행이 아닌 Granule 단위로 데이터를 읽습니다. 기본적으로 하나의 Granule은 8,192행으로 구성되며, index_granularity 설정으로 조정할 수 있습니다.

CREATE TABLE events
(
    event_time DateTime,
    user_id UInt64,
    event_type String,
    value Float64
)
ENGINE = MergeTree
ORDER BY (event_time, user_id)
SETTINGS index_granularity = 8192;  -- 기본값

중요한 점은 Granule이 물리적으로 데이터를 분리하지 않는다는 것입니다. 이는 순전히 논리적인 개념으로, 데이터 처리와 인덱싱의 단위를 정의합니다.

적응형 Granularity

ClickHouse 19.11부터 적응형 인덱스 Granularity가 도입되었습니다. index_granularity_bytes 설정(기본 10MB)에 따라 행 수가 8,192보다 적더라도 데이터 크기가 10MB에 도달하면 새로운 Granule이 생성됩니다. 이는 컬럼 데이터 크기가 매우 큰 경우(예: 긴 문자열)에 더 효율적인 인덱싱을 가능하게 합니다.

-- 적응형 granularity 비활성화 (고정 8192행)
CREATE TABLE events (...)
ENGINE = MergeTree
ORDER BY (...)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;

Primary Key와 ORDER BY

ClickHouse에서 Primary Key는 RDBMS의 그것과 매우 다릅니다.

Primary Key ≠ 유일성 보장

ClickHouse의 Primary Key는 유일성을 보장하지 않습니다. 동일한 Primary Key 값을 가진 여러 행이 존재할 수 있습니다. Primary Key의 목적은 데이터의 물리적 정렬 순서와 인덱스 구조를 정의하는 것입니다.

ORDER BY vs PRIMARY KEY

대부분의 경우 ORDER BY와 PRIMARY KEY는 동일합니다. PRIMARY KEY를 명시하지 않으면 ORDER BY가 Primary Key로 사용됩니다.

PRIMARY KEY를 ORDER BY의 접두사(prefix)로 설정하면 인덱스 크기를 줄일 수 있습니다. 위 예시에서 인덱스는 event_time만 저장하지만, 데이터는 event_time, user_id 순으로 정렬됩니다.

Primary Index의 구조

Primary Key가 정의되면, ClickHouse는 각 Granule의 첫 번째 행에서 Primary Key 컬럼 값을 추출하여 primary.idx 파일에 저장합니다.

쿼리 실행 과정

쿼리가 실행되면 ClickHouse는 다음 과정을 거칩니다.

1단계: Primary Index에서 이진 검색

WHERE 조건에 Primary Key 컬럼이 포함되어 있으면, primary.idx에서 이진 검색을 수행하여 해당 조건을 만족할 수 있는 Granule을 찾습니다.

2단계: Mark 파일 참조

각 컬럼에는 .mrk2 파일(Mark 파일)이 있으며, 이 파일은 각 Granule이 해당 컬럼의 .bin 파일에서 어디에 위치하는지를 저장합니다.

3단계: 데이터 로드

찾아낸 Granule들을 병렬로 메모리에 로드하고, WHERE 조건에 맞는 행을 필터링합니다.

-- 예시 쿼리
SELECT sum(value)
FROM events
WHERE event_time >= '2024-01-15 00:00:00'
  AND event_time < '2024-01-16 00:00:00'
  AND user_id = 12345;

-- 실행 과정:
-- 1. primary.idx에서 event_time이 해당 범위인 Granule 마크 찾기
-- 2. 찾아낸 마크로 해당 Granule들만 로드
-- 3. Granule 내에서 user_id = 12345인 행 필터링
-- 4. value 합계 계산

Primary Key 컬럼 순서의 중요성

복합 Primary Key에서 컬럼 순서는 쿼리 성능에 큰 영향을 미칩니다. 가장 일반적으로 필터링에 사용되는 컬럼을 앞에 배치해야 합니다.

카디널리티(Cardinality) 규칙

카디널리티가 낮은 컬럼을 앞에 배치하면 인덱스 효율이 높아집니다.

-- 좋은 예: 낮은 카디널리티 → 높은 카디널리티
ORDER BY (country_code, user_id, event_time)

-- 나쁜 예: 높은 카디널리티 → 낮은 카디널리티
ORDER BY (user_id, country_code, event_time)

첫 번째 예시에서 country_code(예: 200개 국가)가 앞에 있으면, 같은 국가의 모든 데이터가 연속적으로 저장됩니다. 특정 국가의 데이터를 검색할 때 필요한 Granule 수가 줄어듭니다.

Data Skipping Index (보조 인덱스)

Primary Key만으로는 모든 쿼리 패턴을 효율적으로 처리할 수 없습니다. 예를 들어, Primary Key가 (event_time, user_id)일 때 WHERE product_id = 'ABC123' 같은 쿼리는 전체 테이블 스캔이 필요합니다.

이런 상황에서 Data Skipping Index(데이터 스킵 인덱스)가 도움이 됩니다.

Data Skipping Index의 원리

Data Skipping Index는 전통적인 보조 인덱스와 다릅니다. 개별 행의 위치를 저장하는 대신, Granule 단위로 "요약 정보"를 저장합니다. 쿼리 실행 시 이 요약 정보를 통해 "이 Granule에 원하는 데이터가 절대 없다"고 판단되면 해당 Granule을 건너뜁니다.

CREATE TABLE events (
    event_time DateTime,
    user_id UInt64,
    product_id String,
    category String,
    value Float64,

    -- Data Skipping Index 정의
    INDEX idx_product product_id TYPE minmax GRANULARITY 4,
    INDEX idx_category category TYPE set(100) GRANULARITY 2
)
ENGINE = MergeTree
ORDER BY (event_time, user_id);

Skipping Index의 GRANULARITY

Skipping Index의 GRANULARITY는 몇 개의 Granule을 하나의 인덱스 블록으로 묶을지를 정의합니다.

GRANULARITY 1은 각 Granule마다 별도의 인덱스 항목을 생성합니다. 더 정밀하지만 인덱스 크기가 커집니다. GRANULARITY 4는 4개의 Granule을 하나의 인덱스 블록으로 묶습니다. 인덱스 크기는 줄어들지만, 스킵할 수 있는 단위가 커집니다.

주요 Skipping Index 타입

MinMax Index

각 인덱스 블록의 최소값과 최대값만 저장합니다. 범위 검색에 효과적입니다.

INDEX idx_value value TYPE minmax GRANULARITY 4

-- 효과적인 쿼리:
WHERE value >= 100 AND value <= 200
WHERE value > 1000

-- 비효과적인 쿼리:
WHERE value = 150  -- (범위가 넓으면 대부분의 블록이 포함됨)

Set Index

각 인덱스 블록에 존재하는 고유 값의 집합을 저장합니다. 정확한 값 매칭에 효과적입니다.

INDEX idx_status status TYPE set(100) GRANULARITY 2
-- 최대 100개의 고유 값을 저장

-- 효과적인 쿼리:
WHERE status = 'completed'
WHERE status IN ('pending', 'processing')

set(100)에서 100은 저장할 최대 고유 값 수입니다. 실제 고유 값이 이를 초과하면 인덱스가 비활성화됩니다.

Bloom Filter Index

확률적 자료구조인 Bloom Filter를 사용합니다. "값이 확실히 없다"는 것만 보장하며, false positive가 발생할 수 있습니다.

INDEX idx_user user_id TYPE bloom_filter(0.025) GRANULARITY 3
-- 0.025는 허용 false positive 비율 (2.5%)

-- 효과적인 쿼리:
WHERE user_id = 12345
WHERE user_id IN (100, 200, 300)

텍스트 검색용 Bloom Filter Index

문자열 검색을 위한 특수한 Bloom Filter 변형이 있습니다.

tokenbf_v1

문자열을 비영숫자 문자로 분리하여 토큰화한 후 Bloom Filter에 저장합니다.

ngrambf_v1

문자열을 n-gram(연속된 n개 문자)으로 분리하여 저장합니다. 부분 문자열 검색에 유용합니다.

INDEX idx_description description TYPE ngrambf_v1(4, 1024, 2, 0) GRANULARITY 1
-- 파라미터: (n-gram 크기, Bloom filter 크기, 해시 함수 수, 시드)

-- "hello world"를 4-gram으로:
-- ['hell', 'ello', 'llo ', 'lo w', 'o wo', ' wor', 'worl', 'orld']

-- 효과적인 쿼리:
WHERE description LIKE '%world%'

tokenbf_v1 vs ngrambf_v1 선택 기준

단어 단위 검색(URL 파라미터, 태그 등)에는 tokenbf_v1이 적합합니다. 부분 문자열 검색이 필요하면 ngrambf_v1을 사용합니다. ngrambf_v1의 n 값이 작을수록 더 짧은 검색어를 지원하지만 false positive가 증가합니다.

Skipping Index의 한계와 주의사항

프라이머리 키와의 상관관계

Skipping Index가 효과적이려면 인덱스 컬럼과 Primary Key 사이에 상관관계가 있어야 합니다. 예를 들어, Primary Key가 timestamp이고 Skipping Index가 user_age에 있다면, 특정 시간대에 특정 연령대 사용자가 집중되어 있어야 스킵이 효과적입니다.

인덱스 오버헤드

Skipping Index는 INSERT와 MERGE 시 추가 계산이 필요합니다. Bloom Filter의 경우 해시 계산 비용이 상당할 수 있습니다. 인덱스가 대부분의 Granule을 스킵하지 못한다면 순수한 오버헤드가 됩니다.

인덱스 크기 확인

SELECT
    table,
    name as index_name,
    type_full,
    formatReadableSize(data_compressed_bytes) as compressed_size,
    formatReadableSize(data_uncompressed_bytes) as uncompressed_size
FROM system.data_skipping_indices
WHERE database = 'default'
ORDER BY data_compressed_bytes DESC;

Full-Text Index (전문 검색 인덱스)

ClickHouse 21.11부터 실험적으로 도입되었던 Inverted Index가 발전하여 현재는 Full-Text Index로 명명되었습니다. 이는 Bloom Filter 기반 인덱스의 한계를 극복하기 위해 설계되었습니다.

Bloom Filter 기반 인덱스의 한계

첫째, 튜닝이 어렵습니다. Bloom Filter 크기, 해시 함수 수 등 최적의 파라미터를 찾기 위해 전문 지식이 필요합니다. 둘째, False Positive 문제가 있습니다. 검색어가 없는 Granule도 읽을 가능성이 있어 쿼리 성능이 불안정합니다. 셋째, 기능 제한이 있습니다. 부정 연산자(!=, NOT LIKE)를 지원하지 않습니다.

Full-Text Index의 구조

Full-Text Index는 진정한 역인덱스(Inverted Index)입니다.

딕셔너리: 모든 문서에서 추출한 고유 토큰을 저장합니다. 포스팅 리스트: 각 토큰이 어떤 행에 존재하는지 행 번호 목록을 저장합니다.

CREATE TABLE logs (
    timestamp DateTime,
    message String,

    INDEX idx_message message TYPE full_text GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY timestamp;

-- 또는 파라미터와 함께
INDEX idx_message message TYPE full_text(0) GRANULARITY 1
-- 0: 기본 토크나이저

Full-Text Index의 내부 파일 구조

Full-Text Index는 파트 단위로 생성되며, 내부적으로 세 가지 주요 파일로 구성됩니다.

Bloom Filter 파일은 딕셔너리와 포스팅 리스트 로드를 피하기 위한 프리필터입니다. 딕셔너리 파일은 모든 세그먼트 딕셔너리를 FST(Finite State Transducer)로 저장합니다. 포스팅 리스트 파일은 압축된 정수 시퀀스로 행 번호를 저장합니다.

Full-Text 검색 쿼리

-- hasToken: 정확한 토큰 매칭
SELECT * FROM logs
WHERE hasToken(message, 'error');

-- multiSearchAny: 여러 토큰 중 하나 포함
SELECT * FROM logs
WHERE multiSearchAny(message, ['error', 'warning', 'critical']);

-- LIKE와 함께 사용 가능
SELECT * FROM logs
WHERE message LIKE '%database connection%';

인덱스 관리 및 모니터링

인덱스 추가 및 제거

-- 기존 테이블에 인덱스 추가
ALTER TABLE events ADD INDEX idx_product product_id TYPE minmax GRANULARITY 4;

-- 기존 데이터에 인덱스 적용 (MATERIALIZE)
ALTER TABLE events MATERIALIZE INDEX idx_product;

-- 특정 파티션에만 인덱스 적용
ALTER TABLE events MATERIALIZE INDEX idx_product IN PARTITION '2024-01';

-- 인덱스 제거
ALTER TABLE events DROP INDEX idx_product;

인덱스 사용 여부 확인

-- EXPLAIN으로 인덱스 사용 확인
EXPLAIN indexes = 1
SELECT * FROM events WHERE product_id = 'ABC123';

-- 더 자세한 JSON 형식
EXPLAIN json = 1, indexes = 1
SELECT * FROM events WHERE product_id = 'ABC123';

인덱스 효과 측정

인덱스 설계 모범 사례

Primary Key 설계 원칙

첫째, 가장 자주 필터링되는 컬럼을 앞에 배치합니다. 둘째, 카디널리티가 낮은 컬럼을 앞에 배치하여 데이터 지역성을 높입니다. 셋째, 시계열 데이터의 경우 시간 컬럼을 포함합니다. 넷째, 너무 많은 컬럼을 포함하지 않습니다. 보통 2-4개가 적당합니다.

Skipping Index 사용 시기

Skipping Index는 다음 상황에서 고려합니다.

Primary Key로 커버되지 않는 자주 사용되는 필터 조건이 있을 때, 해당 컬럼이 Primary Key와 상관관계가 있을 때, 필터 조건의 선택성(Selectivity)이 높을 때(소수의 Granule만 매칭) 사용합니다.

피해야 할 실수

첫째, 모든 컬럼에 인덱스 추가는 지양합니다. 인덱스 오버헤드가 이점을 초과할 수 있습니다. 둘째, Primary Key 컬럼에 Skipping Index 추가는 불필요합니다. Primary Key가 이미 해당 역할을 합니다. 셋째, 테스트 없이 인덱스 배포는 위험합니다. 실제 데이터로 효과를 측정해야 합니다.

대안 고려

Skipping Index가 효과가 없다면 다음 대안을 고려합니다.

Primary Key 변경이 가장 효과적인 경우가 많습니다. Projection 사용으로 다른 정렬 순서의 데이터 복사본을 유지합니다. Materialized View로 쿼리 패턴에 최적화된 별도 테이블을 생성합니다.

-- Projection 예시
ALTER TABLE events ADD PROJECTION proj_by_product (
    SELECT * ORDER BY (product_id, event_time)
);

-- 데이터 적용
ALTER TABLE events MATERIALIZE PROJECTION proj_by_product;

-- 이후 product_id로 필터링하는 쿼리가 자동으로 Projection 사용
SELECT * FROM events WHERE product_id = 'ABC123';

결론

ClickHouse의 인덱스 시스템은 OLAP 워크로드에 최적화된 독특한 설계를 가지고 있습니다.

Sparse Primary Index는 Granule 단위로 동작하여 인덱스 크기를 최소화하면서도 효율적인 범위 검색을 지원합니다. Data Skipping Index는 Primary Key로 커버되지 않는 쿼리 패턴을 위한 보조 수단으로, MinMax, Set, Bloom Filter 등 다양한 타입을 제공합니다. Full-Text Index는 진정한 역인덱스로서 텍스트 검색에 최적화되어 있습니다.

효과적인 인덱스 설계를 위해서는 데이터 특성과 쿼리 패턴을 깊이 이해하고, 실제 데이터로 테스트하여 효과를 검증하는 것이 중요합니다. 무분별한 인덱스 추가보다는 Primary Key 최적화를 우선 고려하고, Skipping Index는 명확한 이점이 있을 때만 사용하는 것이 좋습니다.

참고 자료

  • A Practical Introduction to Primary Indexes in ClickHouse
  • Understanding ClickHouse Data Skipping Indexes
  • ClickHouse Black Magic: Skipping Indices | Altinity
  • Inside ClickHouse full-text search: fast, native, and columnar
  • Using Bloom filter indexes for real-time text search | Tinybird