본문으로 건너뛰기
clickhouse검색
핵심 구조

MergeTree 엔진 (파트, 머지, 그래뉼, 마크)

ClickHouse의 기본 테이블 엔진 MergeTree가 데이터를 파트로 쓰고 백그라운드에서 합치는 방식, 그래뉼과 마크로 읽기 범위를 줄이는 원리, 운영에서 보는 system.parts와 system.merges를 정리합니다.

MergeTree파트머지그래뉼마크system.parts2026-10 기준, 약 11분

MergeTree가 기본인 이유

ClickHouse 테이블의 9할은 MergeTree 가족입니다. 이름 그대로 쓰기는 파트(part)를 쌓고, 뒤에서 머지(merge)해 트리처럼 합쳐 가는 엔진입니다. 정렬 키로 데이터를 물리적으로 정렬해 저장하고, 희소 기본 키 인덱스와 파티션으로 읽기 범위를 줄입니다. 복제, TTL, 스킵 인덱스, 프로젝션, 샘플링 같은 기능이 모두 이 엔진 위에 얹힙니다.

CREATE TABLE events
(
    ts        DateTime,
    user_id   UInt64,
    event     LowCardinality(String),
    country   LowCardinality(String),
    url       String,
    duration  UInt32
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (country, event, ts)
SETTINGS index_granularity = 8192;

ORDER BY는 필수(없으면 tuple()), PARTITION BY와 PRIMARY KEY, SAMPLE BY, TTL은 선택입니다. 키 설계는 정렬 키, 기본 키, 파티션에서 따로 다룹니다.

파트: 쓰기의 단위

INSERT 한 번(정확히는 한 블록, 기본 max_insert_block_size 1,048,449행)이 파티션마다 파트 하나를 만듭니다. 파트는 디스크의 폴더이고, 한 번 만들어지면 내용이 바뀌지 않는 불변(immutable) 객체입니다.

202610_1_1_0        <파티션ID>_<최소 블록 번호>_<최대 블록 번호>_<머지 레벨>
202610_2_2_0
202610_1_2_1        1번과 2번을 머지한 결과. 레벨 1
202610_1_2_1_5      뮤테이션 5번이 적용된 버전 (뒤에 뮤테이션 번호가 붙음)
  • 블록 번호는 파티션 안에서 INSERT마다 1씩 증가합니다. 머지된 파트는 원본들의 최소, 최대 번호를 품습니다.
  • 머지 후 원본 파트는 active = 0이 되고 몇 분 뒤(old_parts_lifetime, 기본 480초) 삭제됩니다. 디스크 사용량이 잠시 두 배로 보이는 이유입니다.
  • 파트 안에서 데이터는 정렬 키 순서로 저장됩니다. 파트끼리는 정렬이 보장되지 않으므로, 쿼리는 파트별로 읽고 필요하면 병합 정렬합니다.

컴팩트 파트와 와이드 파트

종류조건 (기본값)구조
컴팩트(compact)압축 전 10MB 미만이고 행 수가 min_rows_for_wide_part 미만모든 열이 data.bin 하나, 마크도 data.cmrk3 하나
와이드(wide)그 이상열마다 <열>.bin과 <열>.cmrk2

작은 INSERT가 수백 개 열의 파일을 각각 만들지 않도록 컴팩트 파트가 있습니다. 머지로 커지면 와이드로 바뀝니다. 열이 수백 개인 넓은 테이블은 min_bytes_for_wide_part를 올려 컴팩트 상태를 더 오래 유지하는 것도 선택지입니다.

그래뉼과 마크: 읽기의 단위

파트 안의 데이터는 index_granularity(기본 8,192) 행씩 그래뉼(granule) 로 나뉩니다. 그래뉼은 ClickHouse가 디스크에서 읽는 최소 논리 단위입니다.

  • primary.idx: 그래뉼마다 첫 행의 기본 키 값을 하나씩 저장합니다. 1억 행 테이블이면 마크가 약 12,000개로, 몇 백 KB면 됩니다. 그래서 전부 메모리에 올려 둡니다.
  • <열>.cmrk2: 그래뉼마다 그 열의 데이터가 압축 블록 어디에서 시작하는지(압축 파일 오프셋, 블록 안 오프셋) 기록한 마크 파일입니다.
  • 쿼리는 기본 키 인덱스에서 조건에 맞는 그래뉼 범위를 고르고, 마크 파일로 각 열의 해당 위치를 찾아 그 블록만 읽습니다.
WHERE country = 'KR' AND event = 'purchase'

primary.idx  (마크 0) ('JP','click', ...)
             (마크 1) ('JP','view', ...)
             (마크 2) ('KR','click', ...)      <- 여기부터
             (마크 3) ('KR','purchase', ...)
             (마크 4) ('KR','view', ...)       <- 여기까지만 읽음
             (마크 5) ('US','click', ...)

마크 N과 마크 N+1 사이 값이 조건 범위에 걸칠 수 있으므로 경계 그래뉼은 넉넉히 읽습니다. 즉 희소 인덱스는 "정확히 어느 행"이 아니라 "어느 그래뉼들"을 알려 줍니다. 그래뉼 안에서는 전부 읽은 뒤 필터링합니다.

적응형 그래뉼

index_granularity_bytes(기본 10MB)가 켜져 있으면 행이 아주 크면 8,192행 전에 그래뉼을 끊습니다. 한 그래뉼 읽기가 수십 MB가 되지 않게 막는 장치입니다. 행이 작은 테이블은 거의 항상 8,192행입니다.

팁 index_granularity를 줄이면(예: 1024) 포인트 조회에 가까운 쿼리는 빨라지지만 마크가 늘어 메모리와 머지 비용이 오릅니다. 대시보드형 집계 쿼리가 대부분이면 기본값을 유지하세요. 바꾸려면 PoC에서 EXPLAIN indexes = 1의 Granules 수와 실제 지연을 함께 보고 결정합니다.

머지: 뒤에서 일어나는 일

백그라운드 스레드 풀(background_pool_size, Cloud에서는 자동)이 같은 파티션의 파트들을 골라 하나로 합칩니다. 머지는 다음 일을 동시에 처리합니다.

  1. 여러 파트의 정렬된 데이터를 병합 정렬해 하나의 정렬된 파트로 만듭니다.
  2. 엔진에 따라 중복 제거(Replacing), 합산(Summing), 집계 상태 병합(Aggregating), 상쇄(Collapsing)를 수행합니다. MergeTree 가족 참고.
  3. 만료된 TTL 행과 열을 제거합니다. TTL, 디스크 티어링 참고.
  4. 스킵 인덱스와 프로젝션을 다시 만듭니다.

머지는 선택 알고리즘이 비슷한 크기의 파트를 묶어 전체 쓰기 증폭을 로그 수준으로 억제합니다. 한 파트는 보통 max_bytes_to_merge_at_max_space_in_pool(기본 150GB)까지 커집니다. 그 이상은 더 합치지 않습니다.

-- 지금 돌아가는 머지
SELECT database, table, elapsed, progress, num_parts,
       formatReadableSize(total_size_bytes_compressed) AS size, is_mutation
FROM system.merges;

-- 파티션별 파트 수. 수백 개가 넘으면 적재 패턴을 의심
SELECT partition, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE active AND table = 'events'
GROUP BY partition
ORDER BY parts DESC;

-- 수동 머지. 운영 중 큰 테이블에 함부로 쓰지 않습니다
OPTIMIZE TABLE events PARTITION '202610' FINAL;

주의 OPTIMIZE TABLE ... FINAL은 파티션의 모든 파트를 하나로 다시 쓰는 작업입니다. 수백 GB 파티션에서는 디스크와 CPU를 오랫동안 점유하므로 "중복이 안 사라져요"라는 요청에 습관적으로 실행하지 마세요. 중복 제거는 쿼리 시점 FINAL이나 설계 변경으로 푸는 것이 보통입니다.

INSERT가 파트가 되는 과정

  1. 클라이언트가 보낸 데이터가 max_insert_block_size 단위 블록으로 쪼개집니다.
  2. 블록은 파티션 키로 나뉘어 파티션마다 한 파트가 됩니다. 한 INSERT가 100개 파티션에 걸치면 파트 100개가 생기므로, 적재 배치는 가능하면 같은 파티션(예: 같은 날)으로 묶습니다. max_partitions_per_insert_block(기본 100)을 넘으면 오류가 납니다.
  3. 파트는 임시 폴더(tmp_insert_...)에 쓴 뒤 원자적으로 이름을 바꿔 활성화됩니다. 그래서 INSERT 한 블록은 전부 들어가거나 전혀 들어가지 않습니다.
  4. 복제 테이블이면 Keeper에 로그를 남기고, 다른 복제본이 파트를 가져갑니다. 복제와 샤딩 참고.

파트 폭증을 막는 적재 규칙(배치 크기, 비동기 INSERT, 멱등 재시도)은 INSERT 모범 사례에 있습니다.

자주 쓰는 테이블 수준 설정

설정기본값쓰는 때
index_granularity8192그래뉼 행 수. 거의 바꾸지 않음
index_granularity_bytes10485760적응형 그래뉼 상한. 0이면 끔
min_bytes_for_wide_part10485760컴팩트에서 와이드로 바뀌는 크기
merge_with_ttl_timeout14400TTL 머지 최소 간격(초)
parts_to_throw_insert3000파티션 파트 수가 이 값이면 INSERT 거부 (Too many parts)
parts_to_delay_insert1000이 값부터 INSERT를 늦춤
max_suspicious_broken_parts100서버 시작 시 깨진 파트 허용 수
storage_policydefault디스크, 볼륨 정책 이름 (TTL, 디스크 티어링)
allow_nullable_key0정렬 키에 Nullable 허용 (권장하지 않음)
ALTER TABLE events MODIFY SETTING parts_to_throw_insert = 5000;
SELECT name, value, changed FROM system.merge_tree_settings WHERE changed;

운영에서 보는 것

  • system.parts: 파트 수, 크기, 압축률, modification_time. 파티션별 파트 수 추이는 가장 중요한 지표입니다.
  • system.merges: 진행 중 머지. 수가 계속 많고 progress가 느리면 디스크 I/O나 CPU 부족입니다.
  • system.part_log: 파트 생성(NewPart), 머지(MergeParts), 다운로드(DownloadPart), 삭제(RemovePart) 이력. 켜 두면 적재 패턴 분석이 쉽습니다.
  • system.events의 InsertedRows, MergedRows, MergesTimeMilliseconds: 머지가 적재보다 뒤처지는지 봅니다.
-- 최근 1시간 머지된 행 수 대비 적재된 행 수 (쓰기 증폭 감)
SELECT event, value FROM system.events
WHERE event IN ('InsertedRows', 'MergedRows', 'InsertedBytes', 'MergedUncompressedBytes');

머지가 적재를 따라가지 못해 파트가 쌓이는 증상과 해결은 Too many parts와 머지 지연에서 다룹니다.

다른 엔진은 언제 쓰나

엔진쓰는 때
MergeTree 가족거의 모든 영속 테이블
Log, TinyLog, StripeLog수백만 행 이하 임시 데이터, 테스트. 인덱스 없음
Memory세션 중 임시 데이터, 작은 조회 테이블. 재시작하면 사라짐
BufferINSERT를 메모리에 모았다가 MergeTree로 밀어 넣기. 지금은 비동기 INSERT로 대체하는 추세
Distributed, Merge, View, Dictionary, Null데이터를 직접 갖지 않는 특수 엔진
Kafka, S3, PostgreSQL, MySQL 등 통합 엔진외부 시스템을 테이블처럼 읽고 쓰기 (Kafka와 스트리밍 수집, 오브젝트 스토리지)

공식 문서

이 문서에서