DB Study

6장. Part Merges — 백그라운드 머지의 동작 원리

INSERT는 가볍게, 무거운 일은 나중에 몰아서 — ClickHouse가 "Merge-time Computation"이라 부르는 이 전략의 심장부, 백그라운드 머지를 단계별로 들여다봅니다.

중급 ⏱ 약 25분 🎬 애니메이션 3개 선수 지식: 4장 Parts, 5장 Partitions
🧭
이 장에서 배우는 것

① 왜 백그라운드 머지가 필수인가 ② 머지의 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라는 이름의 유래입니다.

level 0 (INSERT 직후) level 1 (1회 머지) level 2 (최종 수렴) all_0_0_0 all_1_1_0 all_2_2_0 ⚙️ 백그라운드 머지 스레드가 3개를 선택 all_0_2_1 (level 1) ← inactive (8분 후 삭제) all_3_3_0 all_4_4_0 all_5_5_0 all_3_5_1 (level 1) all_0_5_2 (level 2) 추가 INSERT가 없으면 결국 하나로 수렴 🌲

실제 system.parts에서 이 수렴 과정을 관찰할 수 있습니다.

-- 초기: 24개 파트가 4개로 머지된 상태 (level 1)
   ┌─name────────┬─level─┬────rows─┐
1. │ all_0_5_1   │     16368414 │
2. │ all_12_17_1 │     16442494 │
3. │ all_18_23_1 │     15977762 │
4. │ all_6_11_1  │     16459763 │
   └─────────────┴───────┴─────────┘

-- 시간 경과 후: 하나로 최종 수렴 (level 2)
   ┌─name───────┬─level─┬─────rows─┐
1. │ all_0_23_2 │     225248433 │
   └────────────┴───────┴──────────┘
⚠️
level 0 파트가 많다 = 머지가 밀리고 있다

system.parts에서 level = 0인 파트의 비율이 높으면, INSERT 속도를 머지가 따라잡지 못하고 있다는 신호입니다. 11장 모니터링에서 이 지표를 경보로 활용합니다.

6.3 머지 한 번의 내부 — 4단계 파이프라인

머지 스레드 하나가 파트 몇 개를 골라 합칠 때, 내부적으로는 항상 같은 4단계를 거칩니다. 압축된 컬럼 파일을 풀고 → 정렬 순서를 유지하며 합치고 → 새 인덱스를 만들고 → 다시 압축해 저장하는 흐름입니다.

💾 디스크 (압축된 파트들) all_0_0_0 col.bin 🗜️ all_1_1_0 col.bin 🗜️ ① 압축 해제 & 로드 column.bin을 풀어 메모리에 올림 ② 병합 (merge sort) sorting key 순서 유지 + 엔진별 처리 ③ 인덱싱 새 sparse index + mark·checksum 재생성 ④ 압축 & 저장 새 디렉터리 all_0_1_1 = 새 파트 inactive 마킹 → 8분 후 삭제 🧹 old_parts_lifetime

단일 서버는 background_pool_size(기본값은 대략 CPU 코어 수의 절반 수준)만큼의 머지 스레드를 동시에 돌립니다. 파트가 너무 커서 한꺼번에 메모리에 올리기 부담스러우면, 블록 청크 단위로 나눠 처리하는 vertical merging 모드가 자동으로 선택됩니다(메모리 절약 ↔ 속도 저하 트레이드오프).

💡
Write Amplification — 같은 데이터를 여러 번 쓴다

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도 증분 처리 가능
CollapsingMergeTreesign 컬럼(+1/−1)이 서로 상쇄삽입/취소 표현

가장 많이 쓰이는 ReplacingMergeTree로 "머지 시점의 중복 제거"가 어떻게 동작하는지 봅시다. 핵심은 머지 전에는 중복이 그대로 보인다는 점입니다.

파트 A (오전 INSERT) user_123 · Bronze · ver=1 파트 B (낮 INSERT) user_123 · Gold · ver=2 머지 전 SELECT → 행 2개 (중복!) user_123 Bronze ver=1 user_123 Gold   ver=2 ⚙️ replacing merge 같은 키 → ver 비교 ver=1 (Bronze) 은 버려짐 🗑️ user_123 · Gold · ver=2 최신 버전만 유지 ✨ ⚠️ 머지 시점은 사용자가 제어할 수 없다 머지 전에 정확한 결과가 필요하면 → SELECT ... FINAL (느림) 또는 argMax(grade, ver) + GROUP BY 패턴 (권장)

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;
🚨
OPTIMIZE 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;

머지가 밀릴 때는 다음 순서로 원인을 확인하세요.

  1. INSERT 속도가 머지 속도를 초과 → 배치 크기 증가(9장) 또는 background_pool_size 증가
  2. 디스크 I/O 병목 → 디스크 처리량 확인 (특히 HDD 환경)
  3. 파티셔닝 과다 → 파티션당 파트 수 확인 (5장)
  4. Mutation 진행 중 → system.mutations에서 is_done = 0 확인. Mutation이 머지 스레드를 점유합니다
⚠️
디스크 여유 공간 — 일시적으로 2배

머지 중에는 원본 파트와 결과 파트가 동시에 존재합니다. 대형 테이블 머지 시 일시적으로 2배 공간이 필요하니, system.mergestotal_size_bytes_compressed로 여유를 점검하세요. 또한 background_pool_size는 Background context 설정이므로 SET이 아닌 config 파일에서 변경해야 합니다(3장 참조).

✍️ 이해도 체크

ReplacingMergeTree 테이블에 같은 키로 두 번 INSERT한 직후(머지 전) SELECT하면 어떤 결과가 나올까요?
✅ ReplacingMergeTree의 중복 제거는 백그라운드 머지 시점에 일어나며, 머지가 언제 실행될지는 사용자가 제어할 수 없습니다. 머지 전에는 두 행이 서로 다른 파트에 그대로 존재하므로 중복이 보입니다. 정확한 결과가 필요하면 FINAL 또는 argMax 패턴을 사용하세요.
머지의 4단계 파이프라인 순서로 올바른 것은?
✅ ① 압축된 column.bin을 풀어 메모리에 로드 → ② sorting key 순서를 유지하며 병합(엔진별 추가 처리 포함) → ③ 새 sparse primary index와 메타데이터 생성 → ④ 압축해서 새 디렉터리(=새 파트)에 저장. 원본 파트는 inactive로 마킹되어 8분 후 삭제됩니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch06_table_part_merges.md — vertical merging, write amplification 수치, 고객 행동 데이터로 보는 머지 전 과정 등 심화 내용이 담겨 있습니다.