5장. Table Partitions — 파티션
파티셔닝은 쿼리를 빠르게 하는 마법이 아닙니다. 데이터 lifecycle을 관리하는 도구입니다 — 오해하면 실측 7.5배 느려지는 이유까지, 숫자로 확인합니다.
① PARTITION BY가 INSERT를 바꾸는 방식 (파티션마다 별도 파트) ② "같은 파티션끼리만 머지"라는 절대 규칙 ③ 파티션의 진짜 용도 — TTL·tiered storage·DROP PARTITION ④ 파티션 프루닝이 통하는 조건과 역효과 실측
5.1 파티션 = 파트들의 논리적 그룹
4장의 파트가 물리적 저장 단위라면, 파티션은 파트들을 특정 기준(시간 범위, 카테고리 등)으로 묶는 논리적 상위 개념입니다. 테이블 생성 시 PARTITION BY 절로 정의합니다.
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
ORDER BY (town, street)
PARTITION BY toStartOfMonth(date); -- 월 단위 파티션
toStartOfMonth(date) 값이 같은 행들은 같은 파티션에 속합니다. 2020년 1월 데이터는 2020-01-01 파티션, 2월 데이터는 2020-02-01 파티션이 되는 식이죠. 시작하기 전에 이 장의 결론을 공식 문서 한 문장으로 미리 밝힙니다.
"Partitioning is primarily a data management technique and not a query optimization tool." — ClickHouse 공식 Best Practices
5.2 INSERT가 달라진다 — 파티션마다 별도의 파트
파티셔닝이 없으면 INSERT 하나는 파트 하나를 만듭니다(4장). 파티셔닝이 있으면 ClickHouse는 삽입할 행들을 먼저 파티션 키 값으로 분류한 뒤, 파티션마다 별도의 파트를 만듭니다.
한 번의 INSERT에 1월 데이터 2행과 3월 데이터 2행이 섞여 들어왔습니다.
ClickHouse는 파트를 만들기 전에, 행들을 파티션 키 값별로 분류합니다. toStartOfMonth(date)가 2020-01-01인 그룹과 2020-03-01인 그룹으로 나뉩니다.
그리고 파티션마다 별도의 파트를 생성합니다. 각 파트는 4장에서 배운 4단계(정렬→컬럼 분리→압축→기록)를 독립적으로 거칩니다. 파트 이름의 접두어가 파티션을 나타냅니다.
파티셔닝이 켜지면 각 파트에 파티션 키 컬럼의 MinMax 인덱스(최솟값/최댓값)가 자동 생성됩니다. 나중에 배울 파티션 프루닝의 기반입니다.
주의할 점 — INSERT 하나가 여러 파티션에 걸치면 파티션 수만큼 파트가 생성됩니다. 파티셔닝이 파트 수를 늘리는 메커니즘이 바로 이것입니다.
5.3 절대 규칙 — 머지는 같은 파티션 안에서만
파티셔닝의 가장 중요한 동작 특성입니다.
ClickHouse는 같은 파티션 내의 파트만 머지합니다. 파티셔닝이 없는 테이블은 모든 파트가 머지 대상이라 소수의 큰 파트로 수렴하지만, 파티셔닝된 테이블은 파티션별로만 머지되므로 파티션 수 × 파티션당 파트 수만큼의 파트가 유지됩니다. 공식 권장: 파티션 키 카디널리티는 1,000~10,000 이하.
여기서 고카디널리티 파티션 키의 재앙이 시작됩니다. 실제로 어떻게 무너지는지 애니메이션으로 봅시다.
날짜 컬럼을 그대로 파티션 키로 쓰면(toDate, 일 단위) 3년치 데이터에 파티션 1,095개가 생깁니다. 권장 상한(1,000~10,000)의 경계를 이미 위협하죠.
여기에 소량 빈번 INSERT가 겹치면 최악입니다. INSERT마다 걸치는 날짜 수만큼 파트가 생성되어, 각 파티션에 작은 파트들이 흩뿌려집니다.
머지는 같은 파티션 안에서만 가능합니다. 옆 파티션의 파트와는 절대 합쳐지지 않으므로, 파트 정리가 파티션 단위로 갇힙니다.
결과: 1,095개 파티션 × 수십 개 파트 = 전체 파트 수만 개. 머지가 도저히 따라잡을 수 없는 규모입니다.
활성 파트가 parts_to_throw_insert(기본 3000)를 넘는 순간 INSERT가 거부되고 데이터 유입이 멈춥니다. 해법은 toStartOfMonth 같은 저카디널리티 키 — 3년치가 파티션 36개로 끝납니다.
5.4 파티션의 진짜 용도 — 데이터 lifecycle 관리
그럼 파티셔닝은 언제 쓰는 걸까요? 데이터를 덩어리째 관리해야 할 때입니다. 파티션 단위 작업은 행 단위 DELETE(mutation)와 달리 파트를 다시 쓰지 않는 메타데이터 수준 연산이라 수억 행도 즉시 처리됩니다.
-- ① TTL: 12개월 지난 데이터 자동 삭제 (만료 파티션을 통째로 제거)
CREATE TABLE uk.uk_price_paid_simple_partitioned ( ... )
ENGINE = MergeTree
PARTITION BY toStartOfMonth(date)
ORDER BY (town, street)
TTL date + INTERVAL 12 MONTH DELETE;
-- ② Tiered storage: 오래된 데이터를 저렴한 볼륨으로 이동 (hot/cold 분리)
TTL date + INTERVAL 12 MONTH TO VOLUME 'slow_but_cheap';
-- ③ 수동 파티션 관리 — 전부 메타데이터 수준이라 즉시 완료
ALTER TABLE mytable DROP PARTITION '2023-01-01'; -- 삭제
ALTER TABLE mytable DETACH PARTITION '2023-01-01'; -- 분리
ALTER TABLE mytable ATTACH PARTITION '2023-01-01'; -- 재연결
ALTER TABLE source MOVE PARTITION '2023-01-01' TO TABLE archive;
① 목적 먼저 — "어떤 데이터 관리 작업을 할 것인가"에서 출발 (쿼리 최적화가 목적이면 프라이머리 키·projection을 먼저 고려) ② 저카디널리티 — toStartOfMonth, toMonday 등으로 1,000~10,000 이하 유지 ③ 쿼리 패턴과 정합 — 대부분의 쿼리가 특정 파티션만 본다면 도움, 전체 스캔이 잦다면 해악.
5.5 파티션 프루닝 — 도움이 되는 유일한 시나리오
파티셔닝이 쿼리 성능을 도울 수 있는 경우는 조건이 모두 맞아떨어질 때뿐입니다: ① 쿼리가 소수의 파티션만 대상 (이상적으로 1개) ② 파티션 키가 프라이머리 키에 포함되지 않음 ③ WHERE 절에서 파티션 키 컬럼으로 필터. 이때 ClickHouse는 3단계로 스캔 범위를 좁힙니다.
월 단위로 파티셔닝된 테이블에 파트 436개, granule 3,257개가 있습니다. 쿼리는 2020년 12월의 LONDON 데이터만 필요합니다.
① MinMax 프루닝: 각 파트에 저장된 date의 최솟값/최댓값을 검사해 12월 범위 밖의 파트를 전부 제외합니다. 436개 파트 중 1개만 생존.
② 파티션 프루닝: toStartOfMonth(date) 파티션 키 조건으로 남은 파트를 재확인합니다.
③ 프라이머리 키 프루닝: 살아남은 파트 안에서 town의 희소 인덱스로 granule을 좁힙니다. 11개 중 1개만 읽음.
최종적으로 granule 1개 = 8,192행만 스캔해 6ms에 끝납니다. EXPLAIN indexes = 1로 각 단계의 Parts/Granules 수치를 직접 확인할 수 있습니다.
-- 프루닝 각 단계를 눈으로 확인
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE date >= '2020-12-01' AND date <= '2020-12-31'
AND town = 'LONDON';
-- MinMax: Parts: 1/436, Granules: 11/3257
-- Partition: Parts: 1/1, Granules: 11/11
-- PrimaryKey: Parts: 1/1, Granules: 1/11
5.6 역효과 실측 — 파티션 키로 필터하지 않으면
같은 데이터, 같은 쿼리를 파티셔닝 유무만 바꿔 실측한 공식 문서의 비교입니다. 쿼리는 WHERE town = 'LONDON' — town은 프라이머리 키에는 있지만 파티션 키가 아니므로 파티션 프루닝이 작동하지 않습니다.
| 지표 | 파티셔닝 없음 | 파티셔닝 있음 |
|---|---|---|
| 활성 파트 수 | 1 (완전 머지됨) | 436 |
| 스캔한 파트 | 1/1 | 431/436 |
| 스캔한 granule | 241/3,083 | 671/3,257 |
| 처리 행 수 | 약 197만 | 약 548만 |
| 소요 시간 | 12ms | 90ms (7.5배 느림) |
| 피크 메모리 | 62 MiB | 163 MiB (2.6배) |
느려진 이유는 명확합니다. 파티셔닝 없는 테이블은 하나의 큰 파트로 머지되어 primary index가 최대 효율로 작동하는 반면, 파티셔닝된 테이블은 431개 파트를 각각 열어 인덱스를 검사해야 하고, 파트마다 인덱스가 독립적이라 프루닝 효율도 떨어집니다.
① PARTITION BY date — 일 단위 고카디널리티 폭탄 ② 프라이머리 키로 충분한데 파티셔닝 추가 — 불필요한 복잡성 ③ 파티션 키를 프라이머리 키에도 포함 — 이미 PK로 필터 가능하므로 프루닝 이득이 사라짐
5.7 파티션 모니터링
-- 파티션별 파트 수·행 수 확인
SELECT partition, count() AS parts, sum(rows) AS rows
FROM system.parts
WHERE database = 'uk'
AND `table` = 'uk_price_paid_simple_partitioned'
AND active
GROUP BY partition
ORDER BY partition;
-- 파티셔닝 건강 진단: 평균 파트/파티션과 총 파티션 수
SELECT
count() AS total_active_parts,
countDistinct(partition) AS total_partitions,
total_active_parts / total_partitions AS avg_parts_per_partition
FROM system.parts
WHERE database = 'mydb' AND `table` = 'mytable' AND active;
-- total_partitions가 수천 이상이면 파티션 키 재검토!
✍️ 이해도 체크
PARTITION BY toDate(event_time)(일 단위)로 3년치 데이터를 운영하면 위험한 이유는?parts_to_throw_insert(기본 3000)를 넘으면 INSERT가 거부됩니다. toStartOfMonth로 바꾸면 36개 파티션으로 끝납니다.이 장의 원문 문서: chapters/ch05_table_partitions.md — 파티셔닝 유무 실측 비교의 전체 수치, 파티션 키 선택 원칙, 진단 쿼리 모음이 담겨 있습니다.