4장. Table Parts — 파트 구조
MergeTree의 모든 것은 "파트"에서 시작합니다. INSERT가 디렉터리 하나를 만들어내는 순간부터, 그 안의 column.bin·마크·인덱스가 SELECT를 움직이는 방식까지.
① INSERT가 새 파트를 만드는 4단계 (정렬→컬럼 분리→압축→기록) ② 파트 디렉터리 내부: column.bin·primary.idx·column.mrk ③ SELECT가 파트를 읽는 3단계 경로 ④ 파트의 생명주기와 "Too many parts"의 정체
4.1 파트란 무엇인가 — immutable한 최소 물리 단위
MergeTree 엔진 패밀리에서 테이블 데이터는 디스크 위에 immutable한 data part들의 집합으로 조직됩니다. 파트 하나 = 디스크의 디렉터리 하나입니다.
핵심 키워드는 immutable(불변)입니다. 파트는 생성되고 삭제될 수는 있어도, 한 번 만들어진 내용은 절대 수정되지 않습니다. 이 단순한 성질 하나가 많은 것을 공짜로 만들어 줍니다.
- 동시성: INSERT는 새 파트만 만들므로 실행 중인 SELECT와 충돌하지 않습니다.
- 스냅샷 격리: SELECT는 쿼리 시작 시점의 파트 집합만 바라봅니다.
- 장애 복구: 파트 단위로 복사·복원이 가능합니다.
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 배치가 도착합니다. 행들의 순서는 뒤죽박죽 — 이대로 저장하면 검색도 압축도 비효율적입니다.
먼저 sorting key (town, street) 순서로 정렬하고, 동시에 sparse primary index를 만듭니다. 클라이언트가 미리 정렬해 보내면 이 단계를 건너뛰어 INSERT가 더 빨라집니다.
정렬된 데이터를 컬럼별로 분리합니다. 1장에서 본 컬럼 지향 저장이 실제로 만들어지는 단계입니다.
각 컬럼을 독립적으로 압축합니다. 컬럼 안은 타입·분포가 균일하므로 범용 압축(LZ4, ZSTD)이든 특화 코덱(Delta, Gorilla)이든 높은 압축률이 나옵니다.
마지막으로 새 디렉터리에 기록 — 이 디렉터리 하나가 파트입니다. 인덱스·체크섬·통계까지 전부 들어있는 자기 완결적(self-contained) 구조라 중앙 카탈로그 없이도 해석 가능합니다.
4.3 파트 디렉터리 해부 — 세 종류의 핵심 파일
| 파일 | 내용 | 메모리 상주? |
|---|---|---|
column.bin | 컬럼 데이터. 내부는 압축 블록(비압축 기준 보통 64KB~1MB)의 연속. 모든 컬럼이 같은 행 순서 유지 | 아니오 (필요한 블록만 읽음) |
primary.idx | N번째 행마다 프라이머리 키 값 하나 (N = index_granularity, 기본 8192) | 항상 메모리에 — 희소해서 매우 작음 |
column.mrk | mark = (압축 블록의 파일 오프셋, 블록 해제 후 내부 오프셋) 쌍 | 캐시됨 |
이 밖에도 secondary skipping index, 컬럼 통계, 체크섬, (파티셔닝 시) min-max 인덱스 등이 같은 디렉터리에 들어갑니다. 이제 SELECT가 이 파일들을 어떤 순서로 사용하는지 봅시다.
town = 'LONDON'을 찾는 SELECT가 도착했습니다. 데이터는 town 순서로 정렬되어 있고, granule(8,192행 묶음)마다 시작값 하나씩만 인덱스에 기록되어 있습니다.
인덱스는 항상 메모리에 있으므로 디스크 접근 없이 이진 탐색으로 'LONDON'이 있을 granule을 특정합니다. 수조 행이어도 인덱스가 희소해서 메모리 부담이 미미합니다.
granule 번호만으로는 파일 어디를 읽을지 모릅니다. town.mrk에서 granule 2에 해당하는 압축 블록의 파일 오프셋을 얻습니다.
town.bin에서 해당 압축 블록만 읽어 해제합니다. 나머지 블록들은 디스크에서 읽지도 않죠. 필요한 다른 컬럼(price 등)도 같은 granule 범위만 읽습니다.
대가도 있습니다 — 단 1행을 찾는 point query도 최소 8,192행을 읽고 블록 전체를 해제해야 합니다. 단순 키-값 조회가 주 워크로드라면 OLTP DB가 낫습니다.
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 파트의 생명주기 — 태어나고, 합쳐지고, 사라지기까지
INSERT가 3번 실행되어 level 0 파트 3개가 만들어졌습니다. 모두 active 상태 — SELECT가 읽는 대상입니다.
파트가 쌓이면 백그라운드 머지가 여러 작은 파트를 골라 하나로 합치기 시작합니다(자세한 알고리즘은 6장).
머지가 끝나면 level 1의 새 파트가 active가 되고, 원본 3개는 inactive로 마킹만 됩니다. 파트는 immutable이라 "수정"이 아니라 항상 "새로 생성 + 마킹"입니다.
원본을 바로 지우지 않는 이유는 보험입니다. 머지된 파트가 손상됐을 때 원본이 남아 있으면 복구할 수 있습니다.
old_parts_lifetime(기본 8분)이 지나면 inactive 파트가 삭제되고 디스크가 회수됩니다. 머지 중에는 원본+결과가 공존하므로 일시적으로 최대 2배의 디스크 공간이 필요할 수 있다는 점을 기억하세요.
백그라운드 머지는 파트를 설정 가능한 최대 압축 크기(max_bytes_to_merge_at_max_space_in_pool, 기본 약 150GB)까지 키웁니다. 이 크기에 도달한 파트는 더 이상 머지 대상이 아니므로, 시간이 지나면 파트 수가 자연스럽게 안정화됩니다. 다만 동일 데이터가 INSERT 때 한 번, 머지 때 또 한 번 쓰이는 write amplification(쓰기 증폭)은 피할 수 없는 트레이드오프입니다 — 읽기 최적화를 위한 대가입니다.
활성 파트 수가 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;
system.parts에서 active = 0인 파트가 차지하는 용량을 주기적으로 확인하세요. 디스크가 빠듯하면 old_parts_lifetime을 줄이는 선택지가 있지만, 그만큼 장애 복구의 여지도 줄어듭니다.
✍️ 이해도 체크
WHERE id = 42 같은 단일 행 조회(point query)가 ClickHouse에서 상대적으로 느린 이유는?이 장의 원문 문서: chapters/ch04_table_parts.md — 압축 블록의 상세 구조, async insert 동작, 디스크 공간 관리 체크리스트가 담겨 있습니다.