6장. Part Merges — 백그라운드 머지의 동작 원리
INSERT는 가볍게, 무거운 일은 나중에 몰아서 — ClickHouse가 "Merge-time Computation"이라 부르는 이 전략의 심장부, 백그라운드 머지를 단계별로 들여다봅니다.
① 왜 백그라운드 머지가 필수인가 ② 머지의 4단계(압축 해제 → 병합 → 인덱싱 → 압축 저장)와 머지 레벨 계층 ③ ReplacingMergeTree 등 엔진별로 달라지는 머지의 실체 ④ "머지 시점은 제어할 수 없다"가 운영에 갖는 의미와 FINAL·OPTIMIZE의 함정
6.1 왜 머지가 필요한가
4장에서 배웠듯이 ClickHouse는 INSERT 한 번마다 불변(immutable) 파트를 하나씩 만듭니다. 파트는 한번 쓰이면 절대 수정되지 않죠. 그런데 이대로 방치하면 어떻게 될까요? 파트가 수천, 수만 개로 늘어나고, SELECT 쿼리는 그 파트를 전부 열어서 읽어야 합니다. 파일을 여는 비용, 인덱스를 각각 확인하는 비용이 눈덩이처럼 불어납니다.
그래서 ClickHouse는 백그라운드에서 작은 파트들을 끊임없이 큰 파트로 합칩니다. 공식 문서는 이 설계를 두 문장으로 요약합니다.
"① Inserts create sorted, immutable data parts. ② All data processing is offloaded to background part merges. — This makes data writes lightweight and highly efficient."
즉 머지는 단순한 청소부가 아닙니다. 파트 수 제어 + 엔진별 데이터 처리(중복 제거, 집계 등)라는 두 가지 임무를 수행하는, ClickHouse 성능 철학의 핵심 장치입니다. 머지는 파티션 단위로 일어나며, 압축 크기가 약 150GB(max_bytes_to_merge_at_max_space_in_pool)에 도달한 파트는 더 이상 머지 대상이 되지 않습니다.
6.2 머지 트리 — 작은 파트가 큰 파트로 수렴하는 과정
파트가 머지를 거칠 때마다 이름 끝의 merge level이 1씩 증가합니다. INSERT 직후의 파트는 level 0, 한 번 머지되면 level 1, 다시 머지되면 level 2… 이렇게 시간이 지나며 계층 구조(트리)가 만들어집니다. 이것이 MergeTree라는 이름의 유래입니다.
INSERT 3번 → level 0 파트 3개가 생깁니다. 이 시점에 SELECT 하면 파트 3개를 전부 열어 읽어야 합니다.
백그라운드 머지 스레드가 작은 파트 3개를 다음 머지 대상으로 선택해 메모리에 로드합니다.
병합 결과로 all_0_2_1 (level 1)이 생성됩니다. 원본 파트 3개는 inactive로 마킹되고 old_parts_lifetime(기본 8분) 후 삭제됩니다.
INSERT는 계속 들어오고, 새 level 0 파트들도 같은 방식으로 또 다른 level 1 파트로 합쳐집니다.
level 1 파트들끼리 다시 머지되어 level 2가 됩니다. 파티셔닝이 없다면 결국 파트 하나로 수렴 — 이 계층 구조가 MergeTree라는 이름의 유래입니다.
실제 system.parts에서 이 수렴 과정을 관찰할 수 있습니다.
-- 초기: 24개 파트가 4개로 머지된 상태 (level 1)
┌─name────────┬─level─┬────rows─┐
1. │ all_0_5_1 │ 1 │ 6368414 │
2. │ all_12_17_1 │ 1 │ 6442494 │
3. │ all_18_23_1 │ 1 │ 5977762 │
4. │ all_6_11_1 │ 1 │ 6459763 │
└─────────────┴───────┴─────────┘
-- 시간 경과 후: 하나로 최종 수렴 (level 2)
┌─name───────┬─level─┬─────rows─┐
1. │ all_0_23_2 │ 2 │ 25248433 │
└────────────┴───────┴──────────┘
system.parts에서 level = 0인 파트의 비율이 높으면, INSERT 속도를 머지가 따라잡지 못하고 있다는 신호입니다. 11장 모니터링에서 이 지표를 경보로 활용합니다.
6.3 머지 한 번의 내부 — 4단계 파이프라인
머지 스레드 하나가 파트 몇 개를 골라 합칠 때, 내부적으로는 항상 같은 4단계를 거칩니다. 압축된 컬럼 파일을 풀고 → 정렬 순서를 유지하며 합치고 → 새 인덱스를 만들고 → 다시 압축해 저장하는 흐름입니다.
① Decompression & Loading — 머지 대상 파트들의 압축된 column.bin 파일을 압축 해제하고 메모리에 로드합니다.
② Merging — sorting key 순서를 유지하며 하나의 큰 컬럼 파일로 병합합니다(merge sort). 엔진에 따라 이 단계에서 중복 제거·집계 같은 추가 처리가 일어납니다.
③ Indexing — 병합된 컬럼 파일에 대해 새 sparse primary index를 만들고, skipping index·통계·체크섬·min-max 인덱스 등 메타데이터도 재생성합니다.
④ Compression & Storage — 새 컬럼 파일과 인덱스를 압축해 새 디렉터리 = 새 파트로 저장합니다. 머지 중에는 원본 + 결과가 동시에 존재하므로 일시적으로 2배 공간이 필요합니다.
원본 파트들은 inactive로 마킹되고 old_parts_lifetime(기본 8분) 후 디스크에서 삭제됩니다.
단일 서버는 background_pool_size(기본값은 대략 CPU 코어 수의 절반 수준)만큼의 머지 스레드를 동시에 돌립니다. 파트가 너무 커서 한꺼번에 메모리에 올리기 부담스러우면, 블록 청크 단위로 나눠 처리하는 vertical merging 모드가 자동으로 선택됩니다(메모리 절약 ↔ 속도 저하 트레이드오프).
INSERT로 1번, level 0→1 머지로 2번째, level 1→2 머지로 3번째… 데이터는 머지 계층을 오를 때마다 다시 쓰입니다. ClickHouse 24.10부터는 내장 대시보드(/merges HTTP 핸들러)에서 write amplification을 시각적으로 추적할 수 있습니다.
6.4 엔진별 머지 — "Merge-time Computation"의 실체
머지의 ② 병합 단계는 테이블 엔진에 따라 근본적으로 다른 일을 합니다. "모든 데이터 처리를 백그라운드 머지로 이관한다"는 원칙이 바로 여기서 실현됩니다.
| 엔진 | 머지 시 하는 일 | 예시 |
|---|---|---|
MergeTree | 정렬 순서 유지하며 그냥 합침 (변환 없음) | 중복 행도 그대로 유지 |
ReplacingMergeTree | 같은 sorting key 중 최신 행만 유지 | UPDATE 대체 패턴 |
SummingMergeTree | 같은 키의 숫자 컬럼을 합산해 한 행으로 | price 100+200+150 → 450 |
AggregatingMergeTree | 부분 집계 상태를 결합 (90+ 집계 함수) | avg, uniq도 증분 처리 가능 |
CollapsingMergeTree | sign 컬럼(+1/−1)이 서로 상쇄 | 삽입/취소 표현 |
가장 많이 쓰이는 ReplacingMergeTree로 "머지 시점의 중복 제거"가 어떻게 동작하는지 봅시다. 핵심은 머지 전에는 중복이 그대로 보인다는 점입니다.
같은 user_id에 대해 오전(Bronze, ver=1)과 낮(Gold, ver=2)에 INSERT — 서로 다른 파트에 존재합니다. 머지 전 SELECT는 중복 2행을 반환합니다.
백그라운드 머지가 시작되면 두 파트를 정렬 병합하면서, 같은 sorting key를 가진 행들의 version을 비교합니다.
ver=2(최신)만 살아남고 ver=1은 버려집니다. 머지된 파트에는 최신 행 1개만 남습니다.
하지만 머지가 언제 일어날지는 ClickHouse 내부 스케줄러가 결정합니다. "진짜 UPDATE"가 아니므로, 정확한 최신 상태가 필요하면 FINAL이나 argMax 패턴으로 쿼리에서 직접 처리해야 합니다.
6.5 FINAL과 OPTIMIZE — 머지를 기다릴 수 없을 때의 선택지
머지 전의 중복이 문제라면 두 가지 "강제" 수단이 있지만, 둘 다 비용이 큽니다.
SELECT ... FINAL — 쿼리 시점에 머지 로직 적용
FINAL은 올바른 결과를 주지만 느립니다. 이유는 세 가지입니다: ① 파트별 병렬 읽기가 깨지고 같은 키끼리 비교하는 단일 스레드 구간이 생김, ② primary index로 좁힌 granule보다 더 넓게 읽어야 함, ③ 메모리에서 머지 로직을 실행하는 추가 CPU·메모리 비용. 파트 수가 많을수록 저하가 심해집니다. 프로덕션에서는 아래 argMax 패턴이 더 일반적입니다.
-- 머지 전: 중복 2행이 보인다
SELECT * FROM user_grade WHERE user_id = 123;
-- FINAL: 올바르지만 느림 (병렬성 깨짐 + 추가 스캔)
SELECT * FROM user_grade FINAL WHERE user_id = 123;
-- 권장 패턴: argMax — ver이 최대인 행의 grade 반환. 병렬 실행 유지
SELECT user_id, argMax(grade, ver) AS latest_grade
FROM user_grade
WHERE user_id = 123
GROUP BY user_id;
OPTIMIZE TABLE — 수동 머지 트리거
-- 하나의 머지 사이클 실행
OPTIMIZE TABLE mydb.mytable;
-- 모든 파트를 하나로 최종 머지 (매우 무거움!)
OPTIMIZE TABLE mydb.mytable FINAL;
-- 특정 파티션만 최종 머지
OPTIMIZE TABLE mydb.mytable PARTITION '2024-01-01' FINAL;
전체 테이블 데이터를 다시 쓰므로 막대한 I/O와 CPU를 소비합니다. 공식 best-practices가 "Avoid OPTIMIZE FINAL"이라는 별도 문서로 다룰 만큼 강조하는 사항입니다. 자세한 함정은 10장 안티패턴에서 다룹니다.
6.6 머지 모니터링과 운영 체크리스트
머지 상태는 system.merges(진행 중), system.part_log(이력), system.parts(파트 건강)로 관찰합니다.
-- 진행 중인 머지 확인
SELECT database, `table`, partition_id,
round(progress * 100, 1) AS progress_pct,
num_parts,
formatReadableSize(total_size_bytes_compressed) AS total_size,
formatReadableTimeDelta(elapsed) AS elapsed,
merge_type, merge_algorithm
FROM system.merges
ORDER BY elapsed DESC;
-- 파트 수가 위험 수위인 테이블 식별 (level 0가 많으면 머지 지연)
SELECT database, `table`,
count() AS active_parts,
min(level) AS min_level
FROM system.parts
WHERE active
GROUP BY database, `table`
HAVING active_parts > 100
ORDER BY active_parts DESC;
머지가 밀릴 때는 다음 순서로 원인을 확인하세요.
- INSERT 속도가 머지 속도를 초과 → 배치 크기 증가(9장) 또는
background_pool_size증가 - 디스크 I/O 병목 → 디스크 처리량 확인 (특히 HDD 환경)
- 파티셔닝 과다 → 파티션당 파트 수 확인 (5장)
- Mutation 진행 중 →
system.mutations에서is_done = 0확인. Mutation이 머지 스레드를 점유합니다
머지 중에는 원본 파트와 결과 파트가 동시에 존재합니다. 대형 테이블 머지 시 일시적으로 2배 공간이 필요하니, system.merges의 total_size_bytes_compressed로 여유를 점검하세요. 또한 background_pool_size는 Background context 설정이므로 SET이 아닌 config 파일에서 변경해야 합니다(3장 참조).
✍️ 이해도 체크
FINAL 또는 argMax 패턴을 사용하세요.이 장의 원문 문서: chapters/ch06_table_part_merges.md — vertical merging, write amplification 수치, 고객 행동 데이터로 보는 머지 전 과정 등 심화 내용이 담겨 있습니다.