ClickHouse의 MergeTree: 엔진이 분석을 과립으로 나누고 파트를 병합하는 방법
7년의 고통, 3번의 프로덕션 장애, 그리고 하나의 아키텍처 명판
MergeTree에 대해 처음 들었을 때, 나는 생각했다: "또 유행하는 이름의 엔진이군." 그런데 프로덕션에서 5억 건의 베팅이 있는 테이블에서 예전에는 빨랐던 쿼리가 느려지기 시작했다. EXPLAIN을 확인해보니 Read 250000 granules이 보였다. 그때 나는 granule이 무엇인지 몰랐다.
알고 보니 잘못된 ORDER BY로 테이블을 생성한 것이었다. 모든 쿼리가 단 하나의 컬럼만 필터링했음에도 데이터의 80%를 스캔하고 있었다.
MergeTree는 단순한 엔진이 아니다. 데이터가 디스크에 어떻게 저장되고, 압축되며, 가장 중요하게는 ClickHouse가 어떤 청크를 읽고 건너뛸지 결정하는 방식을 결정하는 아키텍처다. 내부를 이해함으로써 나는 세 개의 프로젝트를 구했다. 아래는 내가 5년 동안 따라온 지도다.
1. 파트, 그레뉼, 마크: 디스크 위의 마트료시카
ClickHouse는 테이블을 단일 파일로 저장하지 않는다. 데이터를 파트로 나누고, 각 파트 안에서 그레뉼로 나누며, 마크를 사용하여 탐색한다.
테이블 bets의 디스크 구조:
/var/lib/clickhouse/data/betting/bets/
├── 202401_1_1_0/ # 파트 #1 (2024년 1월)
│ ├── user_id.bin # 컬럼 user_id (바이너리 데이터)
│ ├── user_id.mrk # user_id에 대한 마크
│ ├── created_at.bin
│ ├── created_at.mrk
│ ├── amount.bin
│ ├── amount.mrk
│ └── ...
├── 202401_2_2_0/ # 파트 #2
└── 202402_3_3_0/ # 파트 #3 (2월)
파트 — MergeTree가 관리하는 최소 단위. 각 파트는 삽입 시 생성되며, 이후 백그라운드에서 인접 파트와 병합된다.
그레뉼 — index_granularity 행(기본값 8192)의 데이터 블록. ClickHouse는 데이터를 전체 그레뉼 단위로 읽는다. 단일 행만 읽을 수는 없으며, 전체 그레뉼만 읽을 수 있다.
마크 — .bin 파일에서 그레뉼의 위치를 가리키는 포인터. .mrk 파일에는 그레뉼이 디스크에서 시작하는 오프셋과 그 오프셋이 포함된다.
이것이 중요한 이유: SELECT amount FROM bets WHERE user_id = 123을 실행하면, ClickHouse는 스파스 인덱스를 사용하여 이 user_id를 포함할 수 있는 그레뉼을 결정하고 해당 그레뉼만 읽는다. 다른 그레뉼은 열어보지도 않는다.
2. 파트 병합: ClickHouse가 수백만 개의 작은 삽입에도 무너지지 않는 이유
각 INSERT는 디스크에 새로운 파트를 생성한다. 100개의 레코드를 10,000번 삽입하면 10,000개의 파트가 생긴다. 이는 재앙이다: 쿼리가 10,000개의 파일을 열어야 하기 때문이다.
ClickHouse가 구원하는 방법:
백그라운드 병합 프로세스가 작은 파트를 더 큰 파트로 결합한다. 예를 들어:
- 1GB 파트 10개 → 10GB 파트 1개
프로덕션에서 조정하는 파라미터:
<merge_tree>
<min_rows_for_wide_part>100000</min_rows_for_wide_part>
<max_bytes_for_merge>100000000000</max_bytes_for_merge> <!-- 100 GB -->
<merge_with_ttl_timeout>3600</merge_with_ttl_timeout>
</merge_tree>
프로 팁: 대량 삽입(100만 행 이상)을 수행하면 인접 파트가 나타날 때까지 해당 파트는 다른 파트와 병합되지 않는다. ClickHouse는 파트를 키 오름차순으로 저장하므로 INSERT ... ORDER BY가 도움이 된다.
내가 당한 경우: Kafka를 통해 초당 10-50개의 레코드로 베팅을 스트리밍했다. 일주일 후 300,000개의 파트가 생겼다. 각 쿼리가 모든 파일을 열어야 했기 때문에 쿼리가 느려졌다. min_rows_for_wide_part를 500k로, max_insert_block_size를 1M으로 올려 해결했다. 데이터 스트림은 Kafka에서 버퍼링해야 했지만, 파트는 500개로 줄었다.
3. PRIMARY KEY와 ORDER BY: 가장 흔한 초보자 실수
MySQL에서 PRIMARY KEY는 고유 식별자다. ClickHouse에서는 그렇지 않다.
-- 이걸 자주 본다
CREATE TABLE bets (
user_id UInt64,
created_at DateTime,
amount Decimal(18,2)
) ENGINE = MergeTree()
PRIMARY KEY (user_id) -- ← 실수
ORDER BY (user_id); -- ← 역시 실수
진실:
- ORDER BY는 디스크에서 행의 물리적 순서를 결정한다. 필수다.
- PRIMARY KEY는 지정하지 않으면 ORDER BY와 동일하다. 하지만 ORDER BY의 PREFIX가 될 수 있다.
올바른 예:
ORDER BY (created_at, user_id) -- 시간 먼저, 그 다음 사용자
PRIMARY KEY (created_at) -- 시간에만 인덱스
결과: ClickHouse는 ORDER BY를 기반으로 스파스 인덱스를 구축한다. PRIMARY KEY는 ORDER BY 중 어떤 부분을 필터링에 사용할지 알려줄 뿐이다.
프로덕션 실제 예:
-- 잘못됨 (느림)
ORDER BY (user_id, created_at)
-- 쿼리: 지난 1시간의 베팅 찾기. 인덱스가 도움이 안 되고 전체를 스캔.
-- 올바름 (빠름)
ORDER BY (created_at, user_id)
-- 쿼리: 인덱스를 통해 필요한 날짜로 이동한 후, user_id로 필터링
4. 스파스 인덱스: 8192개의 행이 하나의 인덱스 항목이 되는 방법
ClickHouse는 각 행에 대해 인덱스를 구축하지 않는다. 그레뉼(8192행)을 가져와 인덱스에 다음을 기록한다:
- 해당 그레뉼의 ORDER BY 최소값
- 해당 그레뉼의 ORDER BY 최대값
그게 전부다. B-트리도, 해시 테이블도 아니다. 단순한 min-max 쌍의 배열이다.
쿼리 속도 향상 방법:
-- 5분 동안의 베팅 찾기
SELECT * FROM bets WHERE created_at BETWEEN '2024-03-15 14:00:00' AND '2024-03-15 14:05:00';
-- (스파스) 인덱스가 각 그레뉼을 확인:
-- 그레뉼 1: min='2024-03-15 13:00:00' max='2024-03-15 14:00:00' → 일치하지 않음 (max < 14:05?)
-- 그레뉼 2: min='2024-03-15 14:00:00' max='2024-03-15 15:00:00' → 일치 (min <= 14:05)
-- 그레뉼 3: min='2024-03-15 15:00:00' max='2024-03-15 16:00:00' → 일치하지 않음 (min > 14:05)
빠른 이유: 인덱스는 (그레뉼 수) * 16바이트를 차지한다. 10억 행의 경우 약 190만 그레뉼 → 30MB 인덱스. 전체 인덱스가 메모리에 들어간다.
5. 파티셔닝: 올바른 월로 이동
PARTITION BY는 ClickHouse가 파트를 디스크의 다른 디렉토리에 배치하는 규칙이다.
PARTITION BY toYYYYMM(created_at) -- 월별 파티션
디스크 상:
/var/lib/clickhouse/data/betting/bets/
├── 202401/ # 2024년 1월
├── 202402/ # 2024년 2월
└── 202403/ # 2024년 3월
쿼리 속도 향상 방법:
SELECT * FROM bets WHERE created_at >= '2024-02-01' AND created_at < '2024-03-01';
-- ClickHouse가 바로 202402/ 폴더로 이동, 다른 파티션은 열어보지 않음
파티셔닝이 도움이 되지 않는 경우:
- 작은 파티션 (일별로 하루 1억 행 → 365개 파티션, 각 300MB → 많은 파일)
- 파티션 키가 아닌 조건으로 필터링
내 선택: 월 1천만~1억 행이면 toYYYYMM(), 하루 10억 이상이면 toYYYYMMDD() (단, 클러스터 필요)
6. .bin 및 .mrk 형식: 디스크에 데이터가 저장되는 방식
한 번 테이블 디렉토리를 들여다본 적이 있다:
$ ls -la /var/lib/clickhouse/data/betting/bets/202401_1_1_0/
-rw-r----- 1 clickhouse clickhouse 1.2G user_id.bin
-rw-r----- 1 clickhouse clickhouse 12M user_id.mrk
-rw-r----- 1 clickhouse clickhouse 900M created_at.bin
-rw-r----- 1 clickhouse clickhouse 12M created_at.mrk
-rw-r----- 1 clickhouse clickhouse 2.1G amount.bin
-rw-r----- 1 clickhouse clickhouse 12M amount.mrk
- .bin — 실제 컬럼 데이터, LZ4(또는 설정에 따라 ZSTD)로 압축됨
- .mrk — 마크: .bin에서 각 그레뉼의 위치
읽는 방법:
- 쿼리가
user_id=123에 대한amount컬럼을 요청 - 스파스 인덱스가 이 user_id가 그레뉼 #45, #46, #47에 있을 수 있다고 알려줌
- ClickHouse가
user_id.mrk를 열고, 그레뉼 #45의 오프셋을 가져옴 user_id.bin의 해당 오프셋으로 이동하여 8192개 값을 읽음- 원하는 user_id가 있는 행을 찾고, 행 번호를 기억
- 행 번호를 사용하여
amount.mrk에서 위치를 계산하고amount.bin에서 필요한 바이트만 읽음
결론: 물리적으로 데이터는 필요한 컬럼과 필요한 그레뉼에 대해서만 읽힌다. 나머지는 메타데이터다.
7. 베팅 테이블의 올바른 ORDER BY 예시
잘못된 ORDER BY (내가 했던 것):
CREATE TABLE betting.bets_wrong
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2)
)
ENGINE = MergeTree()
ORDER BY (user_id, created_at); -- 사용자 먼저 인덱스
문제: 우리 프로젝트의 90% 쿼리는 "지난 1시간의 베팅 표시"(시간으로 필터링)다. user_id가 시간보다 빠르게 변하기 때문에 인덱스가 도움이 되지 않는다. ClickHouse가 모든 파티션을 스캔한다.
올바른 ORDER BY:
CREATE TABLE betting.bets_correct
(
user_id UInt64,
created_at DateTime64(3),
amount Decimal(18,2),
sport LowCardinality(String),
outcome Enum8('win'=1,'loss'=2)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, user_id); -- 시간 먼저 인덱스
이제:
- 날짜 범위 쿼리: 즉시 올바른 그레뉼로 이동
- 날짜 내에서 user_id로 필터링 가능
- 선택적으로
SECONDARY INDEX추가 가능 (그러나 그것은 다른 이야기)
8. EXPLAIN indexes = 1: 실제로 읽히는 그레뉼 수 확인
가장 유용한 디버깅 도구:
EXPLAIN indexes = 1
SELECT user_id, sum(amount)
FROM betting.bets
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'
AND user_id = 100500
GROUP BY user_id;
출력:
Expression (Projection)
Aggregating
Expression
ReadFromMergeTree (betting.bets)
Indexes:
Partition key: partition_idx (1/3 partitions, 1 read)
Primary key: created_at (42/500 granules, 42 read)
MinMax: created_at (0 skipped, 1 read)
확인 결과: 파티션 내 500개 그레뉼 중 42개만 읽혔다. 필터링 정도 — 8%. 올바른 ORDER BY가 없었다면 500개 중 500개를 읽었을 것이다.
9. 파티션 관리 명령어
모든 파티션 보기:
SELECT
partition,
name,
rows,
bytes_on_disk,
modification_time
FROM system.parts
WHERE table = 'bets' AND active = 1;
오래된 파티션 삭제 (DELETE보다 빠름):
ALTER TABLE betting.bets DROP PARTITION '202401';
디스크를 즉시 비운다. DELETE FROM은 행 단위로 삭제한 후 병합하므로 시간 차이가 난다.
파티션 분리 (데이터 삭제 없이):
ALTER TABLE betting.bets DETACH PARTITION '202402';
-- 데이터가 detached/로 이동
다시 연결:
ALTER TABLE betting.bets ATTACH PARTITION '202402';
파티션을 다른 테이블로 복사 (실제 사례):
ALTER TABLE betting.bets_archive REPLACE PARTITION '202401' FROM betting.bets;
10. OPTIMIZE TABLE — 필요하지 않을 때 (그리고 갑자기 필요할 때)
OPTIMIZE TABLE은 파트의 수동 병합을 강제한다.
나쁜 소식: 대부분의 기사에서 주기적으로 실행하라고 권장한다. 좋은 소식: 99%의 경우 필요하지 않다. ClickHouse가 백그라운드에서 자동으로 병합한다.
실제로 OPTIMIZE를 사용한 경우:
- 대량의 과거 데이터를 로드한 후 (한 번에 1억 행 삽입) — 다른 파티션이 예약된 병합을 기다리지 않도록
- 백업 전에 테이블의 파일 수를 줄이기 위해
- 테스트 — 압축 후 실제 크기를 확인하기 위해
안전하게 수행하는 방법:
OPTIMIZE TABLE betting.bets PARTITION '202403' FINAL;
FINAL은 해당 파티션의 모든 파트를 하나로 병합한다. FINAL 없이 — 이미 준비된 파트만 병합한다.
내 조언: 자동화된 스크립트에서 OPTIMIZE를 건드리지 마라. 백그라운드 병합은 잘 튜닝되어 있다. 파트가 병합되지 않으면 max_bytes_to_merge와 디스크 여유 공간을 확인하라.
다음은?
MergeTree는 ClickHouse의 핵심이다. 이제 그것이 어떻게 작동하는지 알았다. 다음 기사 — 고급 인덱싱: skip indexes, materialized columns, projections.
← 이전 글: ClickHouse: 베팅 분석을 위한 완벽한 데이터 타입 가이드 (내가 당한 경험담)
→ 다음 글: ClickHouse에 데이터 로드: 한 행씩 삽입을 멈추고 처리 속도를 500배 향상시킨 방법
— Editorial Team
아직 댓글이 없습니다.