ClickHouse — 열 지향 분석 DB 를 속까지 · 타입과 코덱 · 이론
타입과 코덱 — 같은 값을 더 작게 담는 법
한 줄 요약
열 지향 저장에서 디스크 크기를 정하는 손잡이는 세 개다 — 정렬 키, 타입, 코덱. 타입이 압축 전 크기를 정하고, 코덱은 그 바이트를 두 단계(값을 바꿔 놓는 전처리 → 범용 압축)로 줄인다. 어느 것이 이기는지는 자료가 정하므로, 짐작하지 말고 같은 자료로 재서 고른다.
왜 이게 필요했나
첫 모듈에서 열마다 압축률이 크게 다르다는 것을 봤다. 사이트 이름 다섯 개짜리 열은 거의 공짜였고, 계속 커지는 시각 열은 기본 코덱(LZ4)으로는 거의 줄지 않았다. 이 실습의 자료로 재 보면 10초 간격 시각 100만 개(4,000,000바이트)가 LZ4 를 거친 뒤 4,017,298바이트 — 오히려 조금 커진다. LZ4 는 "같은 바이트 덩어리가 또 나오는가" 를 찾는 알고리즘인데, 계속 커지는 정수는 매번 다른 바이트라서 찾을 게 없다.
그런데 이 열에는 뚜렷한 구조가 있다. 이웃한 값의 차이가 늘 10이다. 이 구조를 LZ4 가 알아볼 수 있는 모양(10, 10, 10 …)으로 바꿔 주기만 하면 된다. 공식 문서(Compression in ClickHouse)가 압축에 영향을 주는 세 요소로 정렬 키·데이터 타입·코덱을 꼽고 "모두 스키마로 정한다" 고 하는 이유가 이것이다. 스키마를 한 번 잘 고르면 매일 들어오는 자료 전부가 그만큼 작아진다.
어떻게 동작하나
코덱은 사슬이다. CODEC(Delta, ZSTD(1)) 은 왼쪽부터 차례로 적용된다. 공식 문서(CODEC)는 코덱을 둘로 나눈다. 범용 코덱(LZ4·LZ4HC·ZSTD)은 바이트 수준에서 압축하고, 전문 코덱은 자료의 성질을 이용해 값을 바꿔 놓는다.
| 코덱 | 하는 일 | 맞는 자료 |
| --- | --- | --- |
| Delta | 이웃 값과의 차이로 바꾼다 | 단조 증가하는 정수·시각 |
| DoubleDelta | 차이의 차이를 압축된 비트로 | 간격이 일정한 시계열 |
| T64 | 64개씩 묶어 안 쓰는 상위 비트를 잘라낸다 | 범위가 좁은 정수 |
| Gorilla | 이전 부동소수와의 XOR | 천천히 변하는 게이지 |
Delta·DoubleDelta 는 문서에 "data preparation codec" 이라고 적혀 있다 — 혼자 쓰면 아무것도 줄지 않는다. 서버도 안다. Float64 CODEC(Delta) 는 "does not compress anything" 오류로 거절되고, CODEC(ZSTD(1), Delta) 처럼 순서를 뒤집으면 "meaningless" 오류가 난다. 범용 압축 뒤에 변환을 거는 것은 뜻이 없기 때문이다.
같은 시각 열을 이 실습 자료로 재면 이렇다(압축 후, 약): LZ4 4.0MB · ZSTD 2.9MB · Delta+ZSTD 5KB · DoubleDelta+ZSTD 5KB · DoubleDelta 단독 128KB. 전처리가 구조를 드러내면 범용 코덱이 나머지를 거의 다 지운다. 문서가 권하는 "ZSTD 를 기본으로, 정수·날짜 수열에는 Delta" 가 그대로 맞는다.
그러나 전문 코덱이 늘 이기지는 않는다. 같은 실습에서 CPU 사용률(Float64) 열에 Gorilla 를 붙였더니 ZSTD 만 쓴 열보다 더 커졌다(3.0MB → 4.6MB). 소수 둘째 자리로 끊긴 값은 이진 부동소수로는 비트가 많이 달라서 XOR 이 작아지지 않는다. 반대로 0–1999 범위의 지연 시간(UInt32)은 T64 가 쓰지 않는 상위 비트를 잘라 ZSTD 단독보다 작아졌다. 코덱 선택은 규칙이 아니라 측정이다.
타입이 코덱보다 먼저다. 값이 네 종류뿐인 status 를 String 으로 두면 압축 전 10MB, 압축 후 약 1.07MB 다. LowCardinality(String) 은 값을 사전에 한 번씩 적고 행마다 작은 번호만 두므로 압축 전부터 1MB 로 줄고 압축 후 약 0.30MB 가 된다. Enum8 도 행마다 1바이트라 비슷하지만 값 목록이 표 정의에 박혀 있어 새 값이 오면 ALTER 가 필요하다. 문서(LowCardinality)는 문자열에는 Enum 대신 LowCardinality 를 먼저 고려하라고 하고, 서로 다른 값이 1만 개 아래일 때 효율이 좋고 10만 개를 넘으면 오히려 나빠질 수 있다고 적는다.
Nullable 은 공짜가 아니다. Nullable(UInt16) 은 값 파일 옆에 행마다 1바이트짜리 null 지도를 하나 더 둔다. 그래서 압축 전 크기가 행당 2바이트가 아니라 3바이트다. WHERE err IS NULL 은 null 지도만 읽고(행당 1바이트), sum(err) 는 두 파일을 다 읽는다. 뜻도 다르다 — avg() 는 NULL 을 빼고 평균을 내지만 0 은 넣는다. NULL 을 0 으로 채운 UInt16 열은 대부분이 0 이어서 이 서버가 sparse 직렬화(기본값이 아닌 행만 적는 방식)로 저장했고, system.parts_columns 의 substreams 에 err_zero.sparse.idx 가 보였다.
현장에서 만나는 모습
가장 흔한 것은 "일단 String, 일단 Nullable" 로 옮겨 온 표다. 원본 DB 의 스키마를 그대로 옮기면 모든 열이 Nullable 이 되고, 상태·국가·요금제 같은 열이 String 이 된다. 이 실습에서 그런 표(plain)와 타입·코덱을 고른 표(tuned)의 압축 후 크기는 약 18.5MB 대 7.7MB 였다. 스키마를 다듬는 일은 쿼리를 고치는 일보다 먼저이고, 한 번에 전체에 효과가 난다.
두 번째는 운영 중인 표의 코덱 변경이다. ALTER TABLE ... MODIFY COLUMN ts CODEC(...) 는 메타데이터만 바꾼다. 이미 쓰인 파트는 옛 코덱 그대로이고, 새 파트와 병합으로 다시 쓰이는 파트부터 새 코덱이 적용된다. 실습에서 ALTER 직후 크기는 1바이트도 변하지 않았고 OPTIMIZE ... FINAL 로 파트를 다시 쓴 뒤에야 줄었다. 반면 타입을 바꾸는 MODIFY COLUMN host LowCardinality(String) 은 파트를 다시 쓰는 뮤테이션이라 큰 표에서는 무겁다(뮤테이션은 마지막 모듈에서 다룬다).
세 번째는 자동 선택이다. 문서는 표 설정 enable_adaptive_codec_selection 을 켜면 기본 코덱 열에 대해 블록마다 가장 작아지는 코덱을 병합 때 고른다고 적는다. 이 파드의 26.8 서버에서는 기본값이 0(꺼짐)이었다. 켜든 끄든 판단 근거는 같다 — system.columns 의 data_compressed_bytes.
다음 실습에서 할 것
기본 타입·기본 코덱의 codecs.plain 에 100만 행을 넣고 파트를 하나로 고정한다. 같은 status 를 String·LowCardinality·Enum8 세 열에, 같은 시각을 LZ4·ZSTD·Delta·DoubleDelta 네 열에, 카운터·지연 시간·CPU 를 "ZSTD 만" 과 "전문 코덱 + ZSTD" 짝에 담아 압축 후 바이트를 비교하고, 전문 코덱이 오히려 손해를 본 열을 찾는다. Nullable 의 null 지도를 읽은 바이트로 확인한 뒤, 이긴 선택을 모은 표의 전체 크기를 재고, 마지막으로 운영 표의 코덱을 ALTER 로 바꿔 크기가 언제 변하는지 기록한다.