ClickHouse의 파티션 이해
🍕

ClickHouse의 파티션 이해

ClickHouse 분류
Core Architecture
Type
Introduction
작성자

Ken

"파티션을 잘게 나누면 쿼리가 빨라진다" — 다른 데이터베이스에서 넘어온 사용자들이 가장 흔하게 갖는 오해입니다. ClickHouse에서 파티션은 쿼리 최적화 도구가 아니라 데이터 수명 관리(lifecycle management) 도구입니다. 이 차이를 이해하지 못하면 일 단위·시간 단위 파티션으로 파티션 수천 개를 만들고, Too Many Parts와 메타데이터 폭증으로 이어지는 전형적인 경로를 밟게 됩니다. 이번 글에서는 파티션이 실제로 어떻게 동작하는지, 왜 "월 단위"가 대부분의 시계열 워크로드에서 정답에 가까운지를 다룹니다.

파티션은 물리적으로 무엇인가

MergeTree 테이블에서 PARTITION BY 표현식의 각 유니크 값은 독립된 파티션이 되고, 데이터는 파티션별로 완전히 분리된 Part 집합으로 저장됩니다.

여기서 파생되는 세 가지 물리적 사실이 이 글 전체의 근거입니다.

  1. Merge는 파티션 경계를 넘지 않습니다. 서로 다른 파티션의 Part는 절대 하나로 병합되지 않습니다. 파티션이 잘게 나뉠수록 "끝까지 작게 남는 Part"가 많아집니다.
  2. Part 수 임계치(parts_to_delay_insert/parts_to_throw_insert)는 파티션당 기준입니다. 이 내용은 1편(Too Many Parts)에서 다뤘습니다.
  3. 파티션은 통째로 조작할 수 있는 최소 관리 단위입니다. DROP PARTITION, MOVE PARTITION, TTL에 의한 파티션 단위 삭제 모두 파일 삭제 수준의 저비용 연산입니다.

3번이 파티션의 존재 이유입니다. 1·2번은 파티션을 잘못 쓸 때 대가를 치르는 지점입니다.

오해 1: "파티션이 잘게 나뉘어야 조회가 빠르다"

실제 스캔 범위를 줄이는 것은 파티션이 아니라 인덱스

"월 파티션이면 하루치 조회할 때 한 달 전체를 읽는 것 아닌가?"라는 걱정은 하실 필요가 없습니다. ClickHouse의 스캔 범위 축소는 파티션 pruning 이후에도 두 단계가 더 있기 때문입니다.

  • Part 단위 min/max 인덱스 — 각 Part는 파티션 키에 사용된 컬럼(및 minmax 인덱스 대상 컬럼)의 최소/최대값을 갖고 있어, 조건 범위 밖의 Part는 열어보지도 않습니다. 시계열 데이터는 시간 순서로 적재되므로 월 파티션 내부의 Part들도 자연스럽게 시간 구간별로 나뉘어 있습니다.
  • Primary index (granule 스킵) — Part 내부에서는 ORDER BY 키 기반의 sparse index가 granule(기본 8,192행) 단위로 스캔 범위를 자릅니다.

단, ③이 확실히 동작하려면 ORDER BY 키에 시간 컬럼이 포함되어 있어야 합니다. 일반적인 시계열 스키마의 정석은 다음과 같습니다.

CREATE TABLE events (
    event_time  DateTime,
    tenant_id   UInt32,
    event_type  LowCardinality(String),
    payload     String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)          -- 수명 관리 단위
ORDER BY (tenant_id, event_type, event_time);  -- 조회 패턴 + 시간

파티션 키는 "언제 지울 것인가"에 맞추고, 조회 성능은 ORDER BY가 담당하도록 역할을 분리하는 것입니다.

직접 확인하기

파티션과 인덱스가 각각 얼마나 걸러내는지는 EXPLAIN으로 확인할 수 있습니다.

EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE event_time >= '2026-07-15 00:00:00'
  AND event_time <  '2026-07-16 00:00:00';
MinMax
  Parts: 12/48          -- Part-level min/max로 스킵
Partition
  Parts: 12/12          -- 파티션 pruning 결과
PrimaryKey
  Granules: 310/98304   -- primary index로 granule 스킵

월 파티션 하나만 읽더라도 실제로 읽는 granule은 해당 날짜 범위뿐임을 볼 수 있습니다.

오해 2: "파티션을 잘게 나누면 Too Many Parts를 피할 수 있다"

1편에서 본 것처럼 Part 수 임계치는 파티션당 기준이니, 파티션을 잘게 나누면 파티션 하나에 쌓이는 Part도 분산되지 않을까요? 시계열 워크로드에서는 그렇지 않습니다. 실시간 인입은 항상 "현재" 시간의 파티션 하나에 집중되기 때문에, 일 단위로 나누든 월 단위로 나누든 지금 이 순간 Part가 쌓이는 활성 파티션은 어차피 1개입니다. 즉 임계치와 싸우는 전선은 그대로인데, 대신 다음 비용이 추가됩니다.

  • 이미 지나간 파티션들에 병합이 덜 된 Part가 잔류 (파티션 경계를 넘는 merge는 없으므로)
  • 테이블 전체 Part 수 증가 → max_parts_in_total(기본 100,000) 압박
  • 파티션·Part 메타데이터의 메모리 점유, 서버 기동 시간 증가, 쿼리 플래닝 비용 증가
  • Replicated 환경에서는 Keeper에 저장되는 메타데이터 증가

활성 파티션의 Part 수가 늘어나는 문제의 해법은 파티션 분할이 아니라 INSERT 단위를 키워 Part 생성 속도를 낮추는 것(1편의 배칭·async insert·insert block 설정)이고, 판단 기준은 순간 피크가 아니라 merge가 따라잡은 뒤 정상 상태(steady-state)에서 Part 수가 수렴하는지입니다. 피크 시점에 파티션당 Part가 수백 개까지 올라가더라도, 이후 수십 개 수준으로 수렴한다면 정상 동작 범위입니다.

그러면 파티션 수는 얼마가 적당한가

공식 가이드 기준으로 파티션 키는 카디널리티가 낮아야 하며, 테이블당 파티션 수는 100~1,000개를 넘지 않는 것이 권장되고 실무 감각으로는 "수십~수백 개"가 적정선입니다.

장기 보관 시계열에서 단위별로 계산해 보면 차이가 분명합니다.

파티션 단위
3년 보관 시 테이블당 파티션 수
테이블 30개 기준 서버 전체
시간 단위
26,280
788,400 ❌
일 단위 (toDate)
1,095
32,850 ⚠️
주 단위 (toMonday)
156
4,680 ✅
월 단위 (toYYYYMM)
36
1,080 ✅

일 단위 1,095개는 그 자체로 상한선 언저리인데, 문제는 이것이 테이블당이라는 점입니다. 로그 파이프라인처럼 유사한 스키마의 테이블 수십 개를 운영한다면 서버 전체 파티션이 수만 개, 각 파티션이 최소 1개 이상의 Part를 유지하므로 활성 Part는 그 이상이 됩니다.

선정 가이드:

  • 기본값은 월 단위(toYYYYMM) — 대부분의 로그/이벤트 워크로드에서 관리 단위와 규모가 균형
  • 특정 초대형 테이블의 월 파티션이 수백 GB를 크게 넘는다면 해당 테이블만 주 단위(toMonday) 검토 — 전 테이블 일괄 일 단위는 권장하지 않음
  • user_id, tenant_id 같은 고카디널리티 컬럼, toStartOfHour 같은 세밀한 시간 함수는 파티션 키로 사용 금지
  • 파티션이 아예 필요 없는 경우(소규모 테이블, 삭제 정책 없음)라면 PARTITION BY를 생략하는 것도 유효한 선택

파티션의 본업: 데이터 수명 관리

보관 정책(예: N년 후 삭제)은 다음 둘 중 하나로 처리합니다. 둘 다 row 단위 DELETE(mutation)와는 비교할 수 없이 저렴한, 사실상 파일 삭제 수준의 연산입니다.

방법 1: TTL — Part 단위 drop이 되도록 설정

ALTER TABLE events
    MODIFY TTL event_time + INTERVAL 3 YEAR DELETE;

-- row 단위 재기록 대신 만료된 Part를 통째로 drop
ALTER TABLE events
    MODIFY SETTING ttl_only_drop_parts = 1;

ttl_only_drop_parts = 1이 핵심입니다. 기본 동작(0)은 만료 row가 섞인 Part를 다시 써야 하지만, 1이면 Part 안의 모든 row가 만료됐을 때 Part를 통째로 삭제합니다. 파티션 단위가 곧 만료 단위가 되도록 파티션 키와 TTL 기준 컬럼을 일치시키면 가장 깔끔하게 동작합니다.

방법 2: 운영 잡으로 파티션 drop

-- 월 1회 배치 잡
ALTER TABLE events DROP PARTITION '202307';

삭제 시점을 명시적으로 통제하고 싶거나, 삭제 전 아카이빙(예: MOVE PARTITION TO ... 또는 오브젝트 스토리지로 export) 단계가 있다면 이 방식이 낫습니다.

이 밖에도 파티션 단위 연산은 운영 전반에서 활용됩니다.

-- 특정 기간 데이터만 다른 테이블로 이동/복제
ALTER TABLE events_archive ATTACH PARTITION '202601' FROM events;

-- 잘못 적재된 기간만 삭제 후 재적재
ALTER TABLE events DROP PARTITION '202607';

-- 티어링: 오래된 파티션을 저비용 스토리지로
ALTER TABLE events MOVE PARTITION '202501' TO VOLUME 'cold';

파티션 키를 "삭제·이동의 단위"에 맞춰 설계하면 이 연산들이 전부 공짜에 가까워집니다. 이것이 "파티션은 수명 관리 도구"라는 말의 실체입니다.

모니터링

활성 파티션(현재 월)의 part 수가 시간이 지나도 계속 우상향한다면 merge가 못 따라가는 것이므로 1편의 진단 절차(INSERT당 평균 행 수, system.merges, DelayedInserts)로 넘어갑니다.

핵심 체크리스트

역할 분리

  • 파티션 키 = 삭제/이동 단위 (수명 관리)
  • ORDER BY = 조회 패턴 (성능), 시간 컬럼 포함 여부 확인

파티션 키 설계

  • 시계열 기본값은 toYYYYMM, 초대형 테이블만 toMonday 예외
  • 테이블당 파티션 수 수십~수백 개 유지 (상한 1,000)
  • 고카디널리티 컬럼·시간 단위 파티션 금지

수명 관리

  • TTL + ttl_only_drop_parts = 1 또는 정기 DROP PARTITION
  • 파티션 단위와 만료 단위 일치시키기

검증

  • EXPLAIN indexes = 1로 pruning·granule 스킵 확인
  • 활성 파티션 part 수의 정상 상태 수렴 여부 모니터링

결론

ClickHouse의 파티션은 "쿼리를 빠르게 하는 장치"가 아니라 "데이터를 통째로 버리고 옮기는 장치"입니다. 조회 성능은 ORDER BY 기반의 primary index와 Part 단위 min/max 인덱스가 담당하며, 시간 조건 조회는 월 파티션 안에서도 필요한 granule만 읽습니다. 반대로 파티션을 잘게 나누는 것은 조회를 빠르게 해주지 못하면서 merge되지 못하는 Part 잔류, 메타데이터 폭증, 기동·플래닝 비용이라는 청구서만 남깁니다. "파티션은 크게(월 단위), Part 생성은 적게(큰 INSERT), 삭제는 통째로(TTL/DROP PARTITION)" — 이 세 문장이 장기 보관 시계열 테이블 설계의 요약입니다.