DB Study

4장. Table Parts — 파트 구조

MergeTree의 모든 것은 "파트"에서 시작합니다. INSERT가 디렉터리 하나를 만들어내는 순간부터, 그 안의 column.bin·마크·인덱스가 SELECT를 움직이는 방식까지.

중급 ⏱ 약 28분 🎬 애니메이션 3개 선수 지식: 1장 개요
🧭
이 장에서 배우는 것

① INSERT가 새 파트를 만드는 4단계 (정렬→컬럼 분리→압축→기록) ② 파트 디렉터리 내부: column.bin·primary.idx·column.mrk ③ SELECT가 파트를 읽는 3단계 경로 ④ 파트의 생명주기와 "Too many parts"의 정체

4.1 파트란 무엇인가 — immutable한 최소 물리 단위

MergeTree 엔진 패밀리에서 테이블 데이터는 디스크 위에 immutable한 data part들의 집합으로 조직됩니다. 파트 하나 = 디스크의 디렉터리 하나입니다.

핵심 키워드는 immutable(불변)입니다. 파트는 생성되고 삭제될 수는 있어도, 한 번 만들어진 내용은 절대 수정되지 않습니다. 이 단순한 성질 하나가 많은 것을 공짜로 만들어 줍니다.

4.2 INSERT의 4단계 — 파트가 태어나는 과정

이 장 전체에서 사용할 예시 테이블입니다.

CREATE TABLE uk.uk_price_paid_simple
(
    date   Date,
    town   LowCardinality(String),
    street LowCardinality(String),
    price  UInt32
)
ENGINE = MergeTree
ORDER BY (town, street);   -- sorting key = 파트 내부의 정렬 순서
① INSERT 배치 도착 York · Main St · 25000 Leeds · Park Rd · 18000 London · Oak Ave · 90000 순서 뒤죽박죽 ② 정렬 (town, street) Leeds · Park Rd · 18000 London · Oak Ave · 90000 York · Main St · 25000 + sparse primary index 생성 ③ 컬럼 분리 town: Leeds·London·York street: Park·Oak·Main price: 18000·90000·25000 ④ 컬럼별 독립 압축 LZ4 / ZSTD / Delta… 💾 ⑤ 새 파트 디렉터리 완성! uk_price_paid_simple/all_1_1_0/ ├─ town.bin · street.bin · price.bin ├─ town.mrk · street.mrk · price.mrk ├─ primary.idx └─ checksums, 통계, min-max…

4.3 파트 디렉터리 해부 — 세 종류의 핵심 파일

파일내용메모리 상주?
column.bin컬럼 데이터. 내부는 압축 블록(비압축 기준 보통 64KB~1MB)의 연속. 모든 컬럼이 같은 행 순서 유지아니오 (필요한 블록만 읽음)
primary.idxN번째 행마다 프라이머리 키 값 하나 (N = index_granularity, 기본 8192)항상 메모리에 — 희소해서 매우 작음
column.mrkmark = (압축 블록의 파일 오프셋, 블록 해제 후 내부 오프셋) 쌍캐시됨

이 밖에도 secondary skipping index, 컬럼 통계, 체크섬, (파티셔닝 시) min-max 인덱스 등이 같은 디렉터리에 들어갑니다. 이제 SELECT가 이 파일들을 어떤 순서로 사용하는지 봅시다.

SELECT … WHERE town = 'LONDON' 🧠 primary.idx — 항상 메모리에 granule 0 → town 시작값 "BATH" granule 1 → town 시작값 "LEEDS" granule 2 → town 시작값 "LONDON" granule 3 → town 시작값 "YORK" 이진 탐색 → 'LONDON'은 granule 2에! town.mrk granule 2의 파일 오프셋 계산 town.bin 블록 0 — 스킵 블록 1 — 읽음! 블록 2 — 스킵 블록 3 — 스킵 압축 해제 → Block 생성 ⚠️ 희소 인덱스의 대가: point query도 최소 granule 하나(8,192행)를 통째로 읽는다 → 대량의 단순 point query에는 ClickHouse가 부적합한 이유

4.4 파트 이름 읽는 법 — all_0_5_1의 의미

-- 파트 디렉터리 이름의 구조
all_0_5_1
 │  │ │ └── merge level (1 = 한 번 머지됨, 0 = 갓 INSERT됨)
 │  │ └──── max block number
 │  └────── min block number
 └───────── partition 이름 ("all" = 파티셔닝 미사용)

all_0_5_1은 "블록 0~5번을 포함하며, 머지를 1번 거친 파트"입니다. level이 높을수록 더 많은 머지를 거친 큰 파트입니다.

4.5 파트의 생명주기 — 태어나고, 합쳐지고, 사라지기까지

💾 테이블 디렉터리 all_1_1_0 level 0 all_2_2_0 level 0 all_3_3_0 level 0 INSERT 3번 = active 파트 3개 🔧 백그라운드 머지 실행 중… all_1_3_1 (level 1) 새 active 파트 원본 3개는 inactive로 마킹 (당장 삭제 안 됨) 🛟 왜 바로 안 지울까? 머지 결과가 손상되면 원본 파트로 롤백 가능 🧹 old_parts_lifetime(기본 480초) 경과 → inactive 파트 삭제, 디스크 회수

백그라운드 머지는 파트를 설정 가능한 최대 압축 크기(max_bytes_to_merge_at_max_space_in_pool, 기본 약 150GB)까지 키웁니다. 이 크기에 도달한 파트는 더 이상 머지 대상이 아니므로, 시간이 지나면 파트 수가 자연스럽게 안정화됩니다. 다만 동일 데이터가 INSERT 때 한 번, 머지 때 또 한 번 쓰이는 write amplification(쓰기 증폭)은 피할 수 없는 트레이드오프입니다 — 읽기 최적화를 위한 대가입니다.

🚨
"Too many parts" — MergeTree의 대표 장애

활성 파트 수가 parts_to_throw_insert(기본 3000)를 넘으면 INSERT가 거부됩니다. 주범은 ① 배칭 없는 소량 빈번 INSERT ② 과도한 파티셔닝(5장) ③ 머지가 못 따라가는 INSERT 속도 ④ 디스크 I/O 병목입니다. 예방책: 배치 최소 20,000행, 불가피하면 async_insert = 1로 서버 측 배칭.

4.6 파트 관찰하기 — system.parts

-- 가상 컬럼 _part로 어떤 파트에서 읽었는지 확인
SELECT _part FROM uk.uk_price_paid_simple GROUP BY _part ORDER BY _part;

-- 파트 상세: level이 높을수록 머지를 많이 거친 파트
SELECT name, level, rows, bytes_on_disk, modification_time
FROM system.parts
WHERE database = 'uk' AND `table` = 'uk_price_paid_simple' AND active
ORDER BY name;

-- 테이블별 활성 파트 수 — "Too many parts" 위험 감지
SELECT database, `table`, count() AS active_parts,
       formatReadableSize(sum(bytes_on_disk)) AS total_size
FROM system.parts
WHERE active
GROUP BY database, `table`
ORDER BY active_parts DESC;
💡
inactive 파트의 용량도 모니터링하라

system.parts에서 active = 0인 파트가 차지하는 용량을 주기적으로 확인하세요. 디스크가 빠듯하면 old_parts_lifetime을 줄이는 선택지가 있지만, 그만큼 장애 복구의 여지도 줄어듭니다.

✍️ 이해도 체크

INSERT가 실행될 때 MergeTree가 실제로 하는 일은?
✅ 파트는 immutable이므로 기존 파트에 추가하는 일은 없습니다. MEMTABLE도 WAL도 없이 INSERT마다 새 파트가 즉시 디스크에 생성됩니다. 그래서 배치 INSERT가 필수이고, 소량 빈번 INSERT는 "Too many parts"를 부릅니다.
WHERE id = 42 같은 단일 행 조회(point query)가 ClickHouse에서 상대적으로 느린 이유는?
✅ primary.idx는 8,192행(index_granularity)마다 값 하나만 기록하는 희소 인덱스입니다. 범위를 granule보다 잘게 좁힐 수 없으므로 point query도 granule 하나를 통째로 읽습니다. 대량의 단순 point query가 주 워크로드라면 OLTP DB를 고려하세요.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch04_table_parts.md — 압축 블록의 상세 구조, async insert 동작, 디스크 공간 관리 체크리스트가 담겨 있습니다.