데이터 타입 고르기 (LowCardinality, Nullable, JSON, DateTime64, Decimal)
저장 공간과 속도를 좌우하는 타입 선택 규칙을 정리합니다. 정수 폭 줄이기, LowCardinality 적용 기준, Nullable을 피하는 이유, 25.3에서 GA가 된 JSON 타입, Variant와 Dynamic, 시간과 소수 타입의 함정을 다룹니다.
타입이 왜 성능인가
ClickHouse는 열마다 타입에 맞는 저장 방식과 코덱을 씁니다. UInt8이면 행당 1바이트, UInt64면 8바이트, String이면 길이 + 내용입니다. 같은 데이터를 Int64 대신 UInt16으로, String 대신 LowCardinality(String)으로 바꾸는 것만으로 디스크와 메모리가 몇 배 줄고 스캔 속도가 오릅니다. 스키마를 처음 잡을 때 가장 투자 대비 효과가 큰 작업입니다.
-- 열별 실제 크기와 압축률 확인
SELECT column, type,
formatReadableSize(sum(column_data_compressed_bytes)) AS compressed,
formatReadableSize(sum(column_data_uncompressed_bytes)) AS raw,
round(sum(column_data_uncompressed_bytes) / sum(column_data_compressed_bytes), 1) AS ratio
FROM system.parts_columns
WHERE active AND table = 'events'
GROUP BY column, type
ORDER BY sum(column_data_compressed_bytes) DESC;숫자
| 타입 | 범위 | 쓰는 곳 |
|---|---|---|
UInt8, UInt16, UInt32, UInt64 | 0 이상 | ID, 카운트, 상태 코드. 음수가 없으면 Unsigned |
Int8 ~ Int256, UInt128, UInt256 | 부호 있음, 큰 정수 | 큰 해시, 블록체인 값 |
Float32, Float64 | 부동소수 | 측정값, 좌표. 돈에는 쓰지 않음 |
Decimal32(S) ~ Decimal256(S) | 고정 소수 | 금액, 정확한 합이 필요한 값 |
Bool | true/false | 내부적으로 UInt8 |
- 값 범위를 알면 가장 작은 폭을 씁니다. HTTP 상태 코드는
UInt16, 국가 코드 숫자는UInt8. Decimal은 연산할 때 스케일이 커지면 오버플로가 날 수 있습니다.Decimal64(2)끼리 곱하면Decimal128(4)가 됩니다. 합계만 필요하면Decimal64(2)에sum은 안전합니다.- 나누기 결과를 Decimal로 보려면
toDecimal64(a / b, 4)보다divideDecimal(a, b, 4)가 정확합니다.
문자열과 LowCardinality
String은 길이 제한이 없는 바이트열입니다. 고정 길이가 확실할 때만 FixedString(N)(예: MD5 16바이트, 국가 코드 2바이트)을 씁니다.
LowCardinality(T) 는 열 값을 사전(dictionary)으로 바꿔 저장하는 래퍼입니다. 값 종류가 1만 개 이하면 거의 항상 이득이고, 수십만 개까지도 보통 유리합니다. 필터와 GROUP BY도 사전 인덱스로 처리되어 빨라집니다.
| 열 예시 | 권장 |
|---|---|
| 이벤트 이름, 국가, 디바이스, 상태, 서비스 이름 | LowCardinality(String) |
| URL 경로(종류 수백만), 세션 ID, UUID | String 또는 UUID |
| 사용자 에이전트 전체 문자열 | 보통 LowCardinality(String)이 이득 (종류가 많아 보여도 반복이 큼). 실측 권장 |
ALTER TABLE events MODIFY COLUMN event LowCardinality(String);팁 LowCardinality를 적용할지 애매하면
SELECT uniq(col), count() FROM t로 종류 수와 행 수 비율을 봅니다. 종류 수가 행 수의 10% 미만이면 적용, 이미Enum으로 관리되는 값은Enum8/Enum16도 좋지만 값이 추가될 때 ALTER가 필요하므로 운영에서는 LowCardinality가 편합니다.
Nullable은 피한다
Nullable(T)는 값 열 옆에 NULL 여부를 담는 별도 비트맵 열을 하나 더 만듭니다. 저장이 늘고, 모든 연산이 NULL 분기를 거치며, 정렬 키에는 기본적으로 넣을 수 없습니다. 대부분의 분석 데이터는 기본값으로 대체해도 의미가 유지됩니다.
| 상황 | 대신 쓰는 것 |
|---|---|
| 없는 문자열 | 빈 문자열 '' |
| 없는 숫자 | 0 또는 -1처럼 의미 없는 값 (문서화 필요) |
| 없는 날짜 | toDate(0) (1970-01-01) |
| 정말 "없음"과 "0"을 구분해야 함 | Nullable, 또는 별도 Bool 열 |
-- 적재 시점에 NULL 을 기본값으로 바꾸기
INSERT INTO events SELECT ifNull(duration, 0) AS duration, ... FROM input(...);
-- 입력 포맷 수준에서 자동 변환
SET input_format_null_as_default = 1;
날짜와 시간
| 타입 | 정밀도 | 범위 | 바이트 |
|---|---|---|---|
Date | 일 | 1970 ~ 2149 | 2 |
Date32 | 일 | 1900 ~ 2299 | 4 |
DateTime | 초 | 1970 ~ 2106 | 4 |
DateTime64(P) | 10^-P 초 (P 0~9) | 1900 ~ 2299 | 8 |
Time, Time64(P) | 하루 안 시각 | 최근 버전 추가 | 4, 8 |
- 시간대는 값이 아니라 열 메타데이터입니다.
DateTime('Asia/Seoul')은 저장은 UTC 타임스탬프, 표시와toStartOfDay같은 함수 계산을 서울 기준으로 합니다. 시간대를 지정하지 않으면 서버 시간대를 따릅니다. DateTime은 2106년까지만 담습니다. 미래 날짜나 1970년 이전이 있으면DateTime64. 밀리초가 필요하면DateTime64(3).Date는 2바이트라 매우 싸고 압축도 좋습니다. 일 단위 집계 테이블은Date.- 문자열에서 변환할 때 형식이 섞여 있으면
parseDateTimeBestEffort, 실패를 NULL로 받으려면parseDateTimeBestEffortOrNull.
시간 함수와 윈도우는 날짜와 시간, 윈도우 함수에서 다룹니다.
JSON, Variant, Dynamic
JSON 타입 (25.3 GA)
반정형 데이터를 열 하나에 넣되, 내부에서는 경로(path)마다 서브컬럼으로 쪼개 저장해 열 지향의 이점을 유지합니다. 자주 쓰는 경로는 타입을 힌트로 고정하고, 나머지는 동적으로 추론합니다.
CREATE TABLE logs
(
ts DateTime64(3),
body JSON(
max_dynamic_paths = 256,
`request.method` LowCardinality(String),
`request.status` UInt16,
SKIP `headers.cookie`
)
)
ENGINE = MergeTree ORDER BY ts;
SELECT body.request.status AS status, count()
FROM logs
WHERE body.request.method = 'POST'
GROUP BY status;
-- 점이나 공백이 든 키는 대괄호로 (26.9)
SELECT body['user.agent'] FROM logs LIMIT 1;max_dynamic_paths를 넘는 경로는 공유 데이터 영역에 묶여 저장되어 접근이 느려집니다. 경로가 많은 로그는 자주 쓰는 경로를 힌트로 선언하세요.- 서브컬럼 타입은
dynamicType(body.x)로 확인합니다. 숫자와 문자열이 섞인 경로는Dynamic으로 추론되어 비교 때 캐스팅이 필요합니다. - 예전
Object('json')은 제거 대상이고,String에 JSON을 넣고JSONExtract*로 꺼내는 방식은 여전히 유효하지만 매 쿼리 파싱 비용이 듭니다.
Variant와 Dynamic
Variant(T1, T2, ...): 열 하나에 지정한 타입 가운데 하나를 담습니다.Variant(String, UInt64).Dynamic: 타입을 미리 정하지 않고 값마다 타입을 저장합니다. JSON 타입의 동적 경로가 이것으로 저장됩니다. 둘 다 JSON 타입과 함께 GA가 되었습니다.
반정형 데이터를 다루는 함수와 패턴은 배열, Map, JSON 다루기에 있습니다.
배열, 튜플, Map, Nested
| 타입 | 설명 | 예 |
|---|---|---|
Array(T) | 같은 타입 목록. 길이 가변 | 태그 목록, 측정값 시리즈 |
Tuple(T1, T2) 또는 Tuple(a T1, b T2) | 고정 구조 | 좌표 (lat, lon), 함수 반환값 |
Map(K, V) | 키-값. 내부는 Array(Tuple(K, V)) | 라벨, 속성 가방 |
Nested(a T1, b T2) | 같은 길이의 배열 묶음 | 주문 안의 품목 목록 |
Map은 키 종류가 수백 개 이하이고 키별로 자주 필터하지 않을 때 적당합니다. 특정 키를 자주 필터하면 JSON 타입이나 별도 열이 빠릅니다. Map 열의 특정 키 읽기는 map['key']로 하고, 키 열만 읽는 최적화(map.keys, map.values 서브컬럼)를 씁니다.
그 밖에 자주 쓰는 타입
| 타입 | 비고 |
|---|---|
UUID | 16바이트. 문자열로 저장하면 36바이트이므로 반드시 UUID 타입 |
IPv4, IPv6 | 4, 16바이트. 범위 비교와 IPv4CIDRToRange 같은 함수 사용 |
Enum8('a' = 1, 'b' = 2) | 값 집합이 고정되고 작을 때. 유효하지 않은 값은 INSERT 오류 |
Point, Ring, Polygon, MultiPolygon | 지리 타입. 폴리곤 딕셔너리와 함께 |
AggregateFunction(f, T), SimpleAggregateFunction(f, T) | 집계 상태 저장. MergeTree 가족 |
Nothing, Interval* | 내부용, 날짜 연산용 |
타입 바꾸기
ALTER TABLE events MODIFY COLUMN duration UInt32; -- 뮤테이션 (파트 재작성)
ALTER TABLE events MODIFY COLUMN country LowCardinality(String);
ALTER TABLE events ADD COLUMN region LowCardinality(String) DEFAULT '' AFTER country;- 타입 변경은 뮤테이션이라 모든 파트를 다시 씁니다. 큰 테이블은 시간이 오래 걸리고 디스크를 두 배로 씁니다.
system.mutations로 진행을 봅니다. - 호환되는 변경(Int32에서 Int64 등)은 메타데이터만 바꾸는 경우도 있습니다. 상세는 삭제와 갱신.
- 새 열은 기본값 표현식과 함께 추가하면 기존 파트를 건드리지 않고 읽을 때 계산됩니다.
MATERIALIZE COLUMN으로 물리화할 수 있습니다.
설계 체크리스트
- 모든 정수 열의 폭을 값 범위에 맞게 줄였다.
- 종류가 적은 문자열은 LowCardinality 또는 Enum.
- Nullable은 꼭 필요한 열에만 두고, 정렬 키 열에는 없다.
- 시간은
DateTime범위(2106년)와 정밀도를 확인했고, 시간대를 열에 명시했다. - UUID, IP는 전용 타입이다.
- 반정형 데이터는 JSON 타입에 자주 쓰는 경로를 힌트로 선언했다.