ClickHouse MergeTree 파티션과 TTL로 로그 데이터 보관 비용을 줄이는 법
페타바이트급 로그를 운영하다 보면 어느 순간 스토리지 청구서가 두려워지기 시작합니다. "오래된 데이터 지워야 하는데 어떻게 하지?" — 처음엔 cron 잡을 만들고, DELETE 쿼리를 예약하고, 나중엔 별도 파이프라인까지 만들어봤는데, 솔직히 다 번거롭고 실수도 잦았습니다.
ClickHouse를 제대로 쓰기 시작하면서 그 고민이 많이 줄었습니다. 파티션 키와 TTL 선언 하나로 데이터 생명주기 상당 부분을 엔진이 관리해주기 때문입니다. 다만 완전 자동은 아닙니다 — 파티션 단위와 TTL 간격을 어긋나게 잡으면 오히려 IO 비용이 폭증하고, 컬럼 TTL의 동작을 오해하면 GDPR 감사에서 문제가 되기도 합니다. 이 글에서는 두 가지를 맞물리게 설계하는 방법과 함께, 제가 실무에서 겪은 함정들을 같이 짚어보겠습니다.
이 글을 읽고 나면 MergeTree 파티션이 TTL과 어떻게 연동되어 IO 비용을 줄이는지, hot/warm/cold 티어링을 선언적으로 설정하는 방법, 그리고 파티션 설계에서 흔히 저지르는 실수를 파악할 수 있습니다.
MergeTree의 작동 방식을 먼저 잡고 가야 합니다
파트, 파티션, 그리고 병합
ClickHouse에 데이터를 INSERT하면 디스크 위에 파트(part)라는 디렉터리가 생깁니다. 이 파트들이 백그라운드에서 병합(merge)되면서 점점 큰 파트로 합쳐지는 것이 MergeTree의 핵심 동작입니다.
파티션 키(PARTITION BY)는 이 파트들을 물리적으로 분리하는 기준입니다. toYYYYMM(ts)으로 선언하면 2026-01에 들어온 데이터와 2026-02에 들어온 데이터는 절대 같은 파트로 병합되지 않습니다. 이 분리가 TTL 삭제 효율과 직접 연결됩니다.
여기서 중요한 점은 TTL이 merge_with_ttl_timeout(기본값 14400초, 4시간) 간격으로 백그라운드 머지 시점에 평가된다는 것입니다. 즉, TTL이 지났다고 해서 데이터가 즉시 사라지지는 않습니다.
TTL의 세 가지 레벨
TTL은 단순히 "얼마 후 삭제"가 아닙니다. 적용 위치에 따라 완전히 다른 동작을 합니다.
| 레벨 | 선언 위치 | 동작 |
|---|---|---|
| Row TTL | 테이블 DDL | 만료된 행 전체 삭제 |
| Column TTL | 컬럼 DDL | 해당 컬럼 값을 컬럼 기본값으로 교체, 행은 유지 |
| Part TTL (TO VOLUME/DISK) | 테이블 DDL | 파트를 다른 디스크·볼륨으로 이동 |
Column TTL은 특히 주의가 필요합니다. NULL로 교체가 아니라 컬럼 타입의 기본값으로 교체됩니다. String이면 빈 문자열(''), UInt64이면 0이 들어갑니다. NULL로 만들고 싶다면 컬럼을 Nullable(String)로 선언해야 합니다. 이 차이는 GDPR 대응에서 결정적입니다 — 빈 문자열이 남은 상태로 감사(audit)를 받으면 "값이 완전히 삭제됐다"고 주장하기 어렵기 때문입니다.
실전 설계 — 몇 가지 시나리오
시나리오 1: 기본 로그 테이블 — 1년 후 자동 삭제
CREATE TABLE user_events (
ts DateTime,
user_id UInt64,
event LowCardinality(String),
payload String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (user_id, ts)
TTL ts + INTERVAL 1 YEAR DELETE;월 단위 파티션 + 연 단위 TTL의 조합이 핵심입니다. 만료 시점에 파티션 하나가 통째로 만료되므로, 파트 파일 자체를 드롭하는 방식으로 삭제됩니다. 이를 더 확실히 활성화하려면 서버 설정에 아래를 추가해두면 좋습니다.
<!-- config.xml 또는 users.xml -->
<merge_tree>
<ttl_only_drop_parts>1</ttl_only_drop_parts>
</merge_tree>ttl_only_drop_parts=1이 설정되면, 파트의 모든 행이 만료된 경우에만 파트를 드롭하고 row-level rewrite를 피합니다. 만약 이 설정이 없으면 만료된 행이 일부라도 섞여 있으면 전체 파트를 다시 쓰는 비용이 발생합니다.
시나리오 2: Observability 플랫폼 — hot/warm/cold 3단계 티어링
규모가 커지면 "전부 NVMe에 저장"은 비용 면에서 현실적이지 않습니다. 조회 빈도가 높은 최근 데이터는 빠른 스토리지에, 오래될수록 저렴한 스토리지로 자동으로 내려보내는 구성입니다.
먼저 스토리지 정책을 설정합니다.
<!-- config.xml의 storage_configuration -->
<storage_configuration>
<disks>
<nvme>
<path>/mnt/nvme/</path>
</nvme>
<hdd>
<path>/mnt/hdd/</path>
</hdd>
<s3>
<type>s3</type>
<endpoint>https://s3.amazonaws.com/my-bucket/</endpoint>
<access_key_id>...</access_key_id>
<secret_access_key>...</secret_access_key>
</s3>
</disks>
<policies>
<tiered>
<volumes>
<hot> <disk>nvme</disk> </hot>
<warm> <disk>hdd</disk> </warm>
<cold> <disk>s3</disk> </cold>
</volumes>
</tiered>
</policies>
</storage_configuration>그다음 테이블에 TTL 체인을 선언합니다.
CREATE TABLE logs (
ts DateTime,
level LowCardinality(String),
host LowCardinality(String),
message String
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (level, ts)
SETTINGS storage_policy = 'tiered'
TTL
ts + INTERVAL 7 DAY TO VOLUME 'warm',
ts + INTERVAL 90 DAY TO VOLUME 'cold',
ts + INTERVAL 1 YEAR DELETE;이 테이블에 쿼리를 날리면 어느 티어에 데이터가 있든 ClickHouse가 투명하게 처리합니다. 애플리케이션 코드는 건드릴 필요가 없습니다.
한 가지 주의할 점은 cold 볼륨(S3)에 대한 쿼리는 네트워크 latency가 붙는다는 것입니다. 최근 데이터 위주로 조회하는 워크로드라면 문제가 없지만, 장기 트렌드 분석이 필요하면 warm 보관 기간을 늘리거나 materialized view로 미리 집계해두는 방식을 함께 고려해야 합니다.
시나리오 3: PII 컬럼만 골라서 만료 — Nullable 선언 주의
앞서 표에서 언급한 Column TTL 동작을 실전에 적용하는 예시입니다. 이벤트 자체(ts, user_id, event_type)는 장기 보관하되, 개인식별 정보인 email만 30일 후 지우고 싶다고 해봅시다.
기본값(빈 문자열)으로 교체돼도 무방하다면 아래처럼 씁니다.
ALTER TABLE events
MODIFY COLUMN email String TTL ts + INTERVAL 30 DAY;이 경우 30일이 지난 행의 email은 ''(빈 문자열)로 교체됩니다. 컬럼 자체가 사라지는 게 아니라 값만 초기화되는 방식입니다.
그런데 GDPR "잊혀질 권리" 대응처럼 값이 명시적으로 존재하지 않는 상태(NULL)로 남겨야 감사 요구를 만족하는 경우가 있습니다. 그럴 때는 컬럼을 처음부터 Nullable로 선언해야 합니다.
CREATE TABLE events (
ts DateTime,
user_id UInt64,
event_type LowCardinality(String),
email Nullable(String) TTL ts + INTERVAL 30 DAY
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (user_id, ts);이렇게 하면 만료된 행의 email은 NULL로 교체됩니다. Nullable은 스토리지·쿼리 오버헤드가 있으므로 정말 필요한 컬럼에만 적용하는 게 좋습니다.
시나리오 4: raw 데이터 롤업 — TTL GROUP BY
7일 이후에는 raw 로그 대신 시간 단위 집계 값만 남기고 싶을 때 쓸 수 있는 방식입니다. 여기서 놓치기 쉬운 제약이 하나 있는데, TTL GROUP BY의 표현식은 테이블 ORDER BY의 접두사(prefix)와 일치해야 한다는 것입니다.
즉, ORDER BY (host, toStartOfHour(ts))로 선언했다면 GROUP BY도 같은 순서인 host, toStartOfHour(ts)로 시작해야 합니다.
CREATE TABLE metrics (
ts DateTime,
host LowCardinality(String),
value Float64
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts)
ORDER BY (host, toStartOfHour(ts), ts)
TTL ts + INTERVAL 7 DAY
GROUP BY host, toStartOfHour(ts)
SET value = avg(value);7일 이후 raw 행들이 (host, 시간) 단위 평균으로 자동 집계됩니다. 스토리지 절감 효과가 상당합니다. 순서를 뒤집어 GROUP BY toStartOfHour(ts), host로 쓰면 접두사 제약을 어겨 DDL 자체가 실패합니다.
트레이드오프 — 설계할 때 꼭 고민해야 할 것들
파티션 키 선택이 거의 모든 것을 결정합니다
저도 처음엔 "파티션을 촘촘하게 나누면 쿼리도 빠르고 TTL도 잘 되겠지?"라고 생각했는데, 틀렸습니다. 파티션이 너무 많아지면 오히려 독입니다.
고카디널리티 컬럼(user_id, UUID 등)을 파티션 키로 쓰면 파티션이 수백만 개가 생깁니다. INSERT는 물론 SELECT 성능도 급락합니다. 파티션 키는 원칙적으로 시간 기반(월 또는 일)이어야 합니다.
파티션 경계와 TTL 기간을 맞추는 게 핵심
row-level rewrite를 피하려면 단순히 "TTL 기간 ≥ 파티션 단위"만으로는 부족합니다. TTL 기간이 파티션 단위의 정수 배여야 파티션 경계와 만료 시점이 정확히 맞아떨어집니다.
예를 들어 파티션이 월 단위(toYYYYMM)인데 TTL이 45일이라고 해봅시다. 조건 ≥는 만족하지만 45일이 한 달의 정수 배가 아니기 때문에, 2026-03 파티션 안에 만료된 행과 아직 유효한 행이 섞이는 상태가 반드시 발생합니다. 그 결과 파트 드롭이 아닌 row-level rewrite가 일어나 IO 비용이 크게 뜁니다.
반대로 파티션=월, TTL=30일도 미묘하게 어긋납니다. 3월 1일 데이터는 3월 31일 만료(아직 3월 파티션 안), 3월 31일 데이터는 4월 30일 만료(4월 파티션 존재 중)라 결국 파티션 내 혼재가 생깁니다. 안전하게 하려면 파티션=월일 때 TTL은 12개월, 24개월 같은 월의 정수 배로 잡고, 파티션=일일 때는 TTL도 일 단위 정수로 맞추는 편이 확실합니다.
흔한 실수들
| 실수 | 증상 | 해결 |
|---|---|---|
| TTL 단위와 파티션 단위 불일치 | 파티션 내 만료/미만료 행 혼재 → row-level rewrite | TTL 기간을 파티션 단위의 정수 배로 설계 |
ttl_only_drop_parts 미설정 |
만료 데이터 삭제 시 IO 비용 과다 발생 | 서버 설정에 ttl_only_drop_parts=1 추가 |
MATERIALIZE TTL 남용 |
즉시 TTL 적용을 위해 실행했다가 heavy IO로 서버 부하 급증 | 운영 중 강제가 꼭 필요한 경우에만, 부하 시간대 피해서 사용 |
| INSERT 소량 반복 | Too Many Parts 예외 발생 |
INSERT는 1회에 수천~수만 행 단위로 배치 처리 |
| cold 볼륨 SELECT 지연 | S3 데이터 쿼리 시 네트워크 latency로 응답 느림 | warm 보관 기간을 늘리거나 materialized view로 집계 유지 |
| Column TTL을 GDPR 삭제로 오해 | 만료 후 빈 문자열이 남아 감사 시 문제 | 필요 시 컬럼을 Nullable로 선언 |
운영 중 상태 확인하는 법
설정을 잘 해뒀다면 이제 제대로 동작하는지 확인이 필요합니다. system.parts의 컬럼 구성은 버전마다 조금씩 다르므로, 우선 자신의 클러스터에서 어떤 컬럼이 노출되는지부터 확인하는 게 안전합니다.
-- 현재 서버의 system.parts에 어떤 TTL 관련 컬럼이 있는지 확인
SELECT name, type
FROM system.columns
WHERE database = 'system' AND table = 'parts'
AND name LIKE '%ttl%';일반적으로 존재하는 TTL 관련 컬럼은 move_ttl_info(Nested 구조)입니다. Row TTL과 관련된 정보 노출 여부는 버전에 따라 다르므로, 위 쿼리로 실제 존재를 확인한 뒤 이름을 골라 쓰시면 됩니다.
-- 파트별 기본 상태 및 티어 확인
SELECT
partition,
name,
rows,
disk_name,
min_time,
max_time,
modification_time
FROM system.parts
WHERE table = 'logs' AND active
ORDER BY partition DESC;
-- 파트별 move TTL 정보 (다음 티어 이동 예정 시각)
SELECT
partition,
name,
disk_name,
move_ttl_info.expression,
move_ttl_info.min,
move_ttl_info.max
FROM system.parts
WHERE table = 'logs' AND active
AND notEmpty(move_ttl_info.expression);
-- 현재 진행 중인 백그라운드 머지 확인
SELECT
table,
elapsed,
progress,
is_mutation
FROM system.merges
WHERE table = 'logs';
-- 스토리지 정책 및 볼륨 확인
SELECT *
FROM system.storage_policies
WHERE policy_name = 'tiered';예상보다 파트가 오래 남아있다면 merge_with_ttl_timeout 설정과 system.merges의 실제 실행 빈도를 함께 확인해보면 좋습니다.
스토리지 비용을 더 줄이려면: 코덱 조합
TTL과 파티션 외에도 스토리지 비용을 줄이는 데 효과적인 설정이 있습니다.
CREATE TABLE metrics_compact (
ts DateTime CODEC(Delta, ZSTD),
value Float64 CODEC(Gorilla, ZSTD),
host LowCardinality(String),
level LowCardinality(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (host, ts);CODEC(Delta, ZSTD)는 단조 증가하는 시계열(타임스탬프, 카운터 등)에 압축률이 좋고, Gorilla는 부동소수점 시계열에 적합합니다. LowCardinality(String)는 상태값, 로그 레벨처럼 반복되는 문자열의 인코딩을 최적화합니다.
정리
ClickHouse의 파티션 + TTL 조합이 강력한 이유는 단순히 자동화가 아닙니다. 파티션 경계와 TTL 만료가 맞물릴 때 파트 파일 자체를 드롭하는, IO가 거의 없는 삭제가 가능하다는 점이 핵심입니다.
설계할 때 챙겨야 할 것들을 정리하면:
- 파티션 키는 시간 기반(월/일)으로, 고카디널리티 컬럼은 파티션 키로 쓰지 않기
- TTL 기간을 파티션 단위의 정수 배로 맞추기 (월 파티션이면 12/24개월 등)
ttl_only_drop_parts=1서버 설정은 운영 필수- 3단계 티어링(hot → warm → cold S3)으로 스토리지 비용 최적화
- Column TTL은 컬럼 기본값으로 교체됨을 인지하고, GDPR 대응이 필요하면
Nullable로 선언 - TTL GROUP BY 표현식은
ORDER BY접두사와 일치해야 함 - 운영 중엔
system.parts,system.merges로 정기적으로 상태 확인
처음엔 파티션 설계를 너무 복잡하게 가져가려다 Too Many Parts 오류를 만나기도 했고, TTL 단위와 파티션 단위를 맞추지 않아 row-level rewrite로 IO 비용이 예상보다 많이 나왔던 경험도 있습니다. 설계는 단순하게, TTL 선언은 명확하게 — 이게 결국 운영을 편하게 만드는 방향입니다.
참고 자료
- MergeTree table engine — ClickHouse 공식 문서
- Manage data with TTL — ClickHouse 공식 문서
- Managing data (Observability) — ClickHouse 공식 문서
- Custom Partitioning Key — ClickHouse 공식 문서
- system.parts — ClickHouse 공식 문서
- ClickHouse Partitioning: When It Helps, When It Hurts — BigData Boutique
- Scaling ClickHouse with Hot-Warm-Cold Storage Via TTL — Towards Dev
- ClickHouse TTL in Production — Medium
- Mastering TTL in ClickHouse — TechTrends Digest
- ClickHouse Tiered Storage: Volumes, Storage Policies and TTL — Towards Dev
- ClickHouse Fine-Tuning for Time Series and Logs — Logalarm Devs
- Successful ClickHouse Partitioning for Optimal Query Speed — ChistaData
- MODIFY TTL in ClickHouse — Altinity KB