정렬 키, 기본 키, 파티션 (스키마 설계의 8할)
ORDER BY, PRIMARY KEY, PARTITION BY를 어떤 기준으로 정하는지, 카디널리티 순서와 열 수의 원칙, 파티션을 잘못 잡았을 때 생기는 문제, 그리고 기존 테이블의 키를 바꾸는 현실적인 방법을 정리합니다.
세 가지 키의 역할
| 키 | 결정하는 것 | 바꿀 수 있나 |
|---|---|---|
ORDER BY (정렬 키) | 파트 안에서 행이 저장되는 순서. 압축률과 범위 읽기 성능 | 뒤에 열을 덧붙이는 것만 가능 (ALTER TABLE ... MODIFY ORDER BY) |
PRIMARY KEY (기본 키) | primary.idx에 기록되는 열. 생략하면 정렬 키와 같음 | 정렬 키의 접두(prefix)여야 함 |
PARTITION BY (파티션 키) | 파트를 묶는 상위 단위. 삭제, TTL, 이동, 머지 범위 | 바꾸려면 테이블 재생성 |
세 키는 서로 다른 질문에 답합니다. 정렬 키는 "쿼리가 어떤 열로 필터하고 그룹하는가", 파티션 키는 "데이터를 어떤 단위로 버리고 옮기는가"입니다. 둘을 섞어 생각하면 보통 파티션을 너무 잘게 쪼개는 실수로 이어집니다.
정렬 키 고르는 순서
1. 자주 필터하는 열부터
WHERE에 거의 항상 등장하는 열을 앞에 둡니다. 대시보드가 항상 tenant_id로 거르면 첫 열은 tenant_id입니다. 시간은 거의 항상 필터에 들어가지만 보통 마지막에 둡니다.
2. 카디널리티는 낮은 것에서 높은 것으로
희소 인덱스는 접두 열이 같은 값으로 길게 이어질 때 가장 효율적입니다. (country, event, user_id, ts)처럼 값 종류가 적은 열을 앞에, 많은 열을 뒤에 둡니다. 첫 열이 user_id(수억 개)면 두 번째 열부터는 인덱스가 거의 쓸모없어집니다.
3. 열은 셋에서 다섯 개
열을 더 붙여도 뒤쪽 열의 인덱스 효과는 급격히 떨어집니다. 뒤쪽 열은 인덱스보다 압축률에 기여합니다(같은 값이 몰리므로). 그래도 필요한 조합이 여럿이면 프로젝션으로 다른 정렬의 복사본을 두는 쪽이 낫습니다.
4. 시간은 어떻게
| 패턴 | 정렬 키 예시 | 설명 |
|---|---|---|
| 특정 테넌트의 최근 데이터 | (tenant_id, ts) | 가장 흔하고 안전 |
| 전체 테넌트 시간 범위 집계가 대부분 | (toStartOfHour(ts), tenant_id, ts) | 시간을 거칠게 앞에 두어 범위를 먼저 자름 |
| 포인트 조회 비슷한 패턴 | (tenant_id, user_id, ts) | user_id가 많아도 tenant 안에서는 유효 |
시간을 정렬 키 맨 앞에 그대로(ts 자체) 두면 사실상 INSERT 순서 정렬이 되어 다른 열의 인덱스 효과를 모두 잃습니다. 앞에 두려면 toStartOfDay(ts)처럼 거칠게 만듭니다.
-- 좋은 예: 테넌트별 필터가 기본인 멀티테넌트 이벤트 테이블
CREATE TABLE events
(
tenant_id UInt32,
ts DateTime64(3),
event LowCardinality(String),
user_id UInt64,
props JSON
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (tenant_id, event, ts);
기본 키를 정렬 키와 다르게 두는 경우
기본 키는 정렬 키의 접두여야 하고, primary.idx에는 기본 키 열만 들어갑니다. 정렬 키에는 압축을 위해 열을 길게 두고, 인덱스는 앞 몇 열만 쓰고 싶을 때 분리합니다. SummingMergeTree와 AggregatingMergeTree에서 "집계 차원은 정렬 키에 다 넣되 인덱스는 앞 몇 개"로 쓰는 패턴이 대표적입니다.
CREATE TABLE daily_stats
(
day Date,
tenant_id UInt32,
event LowCardinality(String),
country LowCardinality(String),
cnt UInt64
)
ENGINE = SummingMergeTree
PARTITION BY toYYYYMM(day)
PRIMARY KEY (tenant_id, day)
ORDER BY (tenant_id, day, event, country);메모리 효과도 있습니다. 기본 키가 짧으면 primary.idx가 작아져 수천 개 테이블이 있는 서버에서 메모리를 아낍니다.
파티션 키: 적을수록 좋다
파티션은 삭제와 수명 관리의 단위이고, 성능 도구가 아닙니다. 파티션별로 파트가 따로 생기고 머지도 파티션 안에서만 일어나므로, 파티션이 많아지면 파트 수가 곱으로 늘어납니다.
| 데이터 규모 | 권장 파티션 |
|---|---|
| 하루 수 GB 이하 | toYYYYMM(ts) (월) 또는 파티션 없음 |
| 하루 수십 GB 이상 | toYYYYMMDD(ts) (일) |
| 수 TB 이상, 보존 기간 짧음 | 일 단위, 때로 시간 단위. 그래도 전체 파티션 수가 수천을 넘지 않게 |
주의
PARTITION BY (tenant_id, toYYYYMM(ts))처럼 고카디널리티 열을 파티션에 넣으면 테넌트 수 x 월 수만큼 파티션이 생기고, 각 파티션에 작은 파트가 흩어져 Too many parts와 느린 쿼리로 바로 이어집니다. 테넌트 분리는 정렬 키로 합니다.
파티션 단위로 할 수 있는 작업입니다.
ALTER TABLE events DROP PARTITION '202501'; -- 즉시 삭제, 뮤테이션 아님
ALTER TABLE events DETACH PARTITION '202502'; -- 분리(폴더는 유지)
ALTER TABLE events ATTACH PARTITION '202502';
ALTER TABLE events MOVE PARTITION '202503' TO VOLUME 'cold'; -- 콜드 볼륨으로 이동
ALTER TABLE events_new ATTACH PARTITION '202510' FROM events; -- 구조가 같은 테이블로 복사
ALTER TABLE events FREEZE PARTITION '202510'; -- 하드링크 스냅숏쿼리에서 파티션 키 조건이 있으면 파티션 프루닝으로 파트 자체를 건너뜁니다. WHERE ts >= '2026-10-01'은 toYYYYMM(ts) 파티션에서 10월 이전 파트를 열지도 않습니다. 단, WHERE toYYYYMM(ts) = 202610처럼 함수를 씌워 쓰지 않아도 ClickHouse가 단조 함수를 알아보고 프루닝합니다.
잘못된 설계가 보이는 신호
| 증상 | 의심할 것 |
|---|---|
EXPLAIN indexes = 1에서 Granules가 거의 전체 | 필터 열이 정렬 키 앞쪽에 없음 |
파티션별 파트가 수백 개, Too many parts | 파티션이 너무 잘거나 INSERT가 너무 작음 |
| 압축률이 3배 이하 | 정렬 키 뒤쪽에 반복 값이 적음, 코덱 미지정 (압축과 코덱) |
| 특정 테넌트 쿼리만 느림 | 정렬 키 첫 열이 시간 등 다른 열 |
ORDER BY ts LIMIT 100이 느림 | 정렬 키와 ORDER BY 방향, 열이 달라 읽기 순서 최적화 미적용 |
-- 실제로 읽은 범위 확인
EXPLAIN indexes = 1
SELECT count() FROM events WHERE tenant_id = 42 AND ts >= today() - 7;기존 테이블의 키를 바꾸기
정렬 키는 끝에 열을 추가하는 것만 ALTER로 가능하고, 추가되는 열은 기본값이 있는 새 열이어야 합니다. 순서를 바꾸거나 파티션을 바꾸려면 테이블을 새로 만들고 데이터를 옮깁니다.
-- 1. 새 구조로 빈 테이블
CREATE TABLE events_v2 AS events
ENGINE = MergeTree PARTITION BY toYYYYMMDD(ts) ORDER BY (tenant_id, ts);
-- 2. 파티션 단위로 옮기기 (대용량은 밤 시간대에 나눠서)
INSERT INTO events_v2 SELECT * FROM events WHERE ts >= '2026-10-01' AND ts < '2026-11-01';
-- 3. 적재 경로를 바꾼 뒤 이름 교체
EXCHANGE TABLES events AND events_v2;INSERT ... SELECT는 서버 자원을 많이 쓰므로max_threads,max_insert_threads,max_memory_usage를 조절하고 파티션별로 나눕니다.- 수십 TB면 ClickHouse Cloud에서는 새 서비스로 백업 복원 후 재적재하거나, 셀프 호스팅에서는
clickhouse-copier대신remote()테이블 함수로 파티션별 복사를 권장합니다. - 복제 테이블은
EXCHANGE TABLES가 Atomic 데이터베이스에서만 동작합니다. 기본 데이터베이스 엔진이 Atomic이므로 보통 문제없습니다.
팁 정렬 키를 결정하기 전에 상위 10개 쿼리의 WHERE 열 빈도를
system.query_log에서 세어 보세요. 사람 기억과 실제 쿼리는 자주 다릅니다. 느린 쿼리 진단에 쿼리가 있습니다.
샘플링 키
SAMPLE BY intHash32(user_id)를 두면 SELECT ... SAMPLE 0.1로 10% 표본을 빠르게 읽을 수 있습니다. 샘플링 키는 기본 키에 포함되어야 하고, 값이 균등 분포여야 의미가 있습니다. 근사치로 충분한 탐색 쿼리에 유용하지만, 최근에는 근사 집계 함수(uniq, quantile)와 빠른 하드웨어 덕에 쓰는 빈도가 줄었습니다. 필요 없다면 두지 않는 편이 설계가 단순합니다.
설계 체크리스트
- 상위 쿼리의 WHERE 열을 세었다.
- 정렬 키는 저카디널리티에서 고카디널리티 순, 3개에서 5개, 시간은 뒤.
- 파티션은 월 또는 일, 전체 파티션 수가 수천 이하.
- 고카디널리티 열은 파티션에 없다.
EXPLAIN indexes = 1로 대표 쿼리의 Granules 비율을 확인했다.- 다른 정렬이 꼭 필요한 쿼리는 프로젝션이나 MV로 분리했다.