본문으로 건너뛰기
clickhouse검색
핵심 구조

데이터 타입 고르기 (LowCardinality, Nullable, JSON, DateTime64, Decimal)

저장 공간과 속도를 좌우하는 타입 선택 규칙을 정리합니다. 정수 폭 줄이기, LowCardinality 적용 기준, Nullable을 피하는 이유, 25.3에서 GA가 된 JSON 타입, Variant와 Dynamic, 시간과 소수 타입의 함정을 다룹니다.

데이터 타입LowCardinalityNullableJSONDateTime64Decimal2026-10 기준, 약 11분상태 표시 설명

타입이 왜 성능인가

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, UInt640 이상ID, 카운트, 상태 코드. 음수가 없으면 Unsigned
Int8 ~ Int256, UInt128, UInt256부호 있음, 큰 정수큰 해시, 블록체인 값
Float32, Float64부동소수측정값, 좌표. 돈에는 쓰지 않음
Decimal32(S) ~ Decimal256(S)고정 소수금액, 정확한 합이 필요한 값
Booltrue/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, UUIDString 또는 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 ~ 21492
Date32일1900 ~ 22994
DateTime초1970 ~ 21064
DateTime64(P)10^-P 초 (P 0~9)1900 ~ 22998
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 서브컬럼)를 씁니다.

그 밖에 자주 쓰는 타입

타입비고
UUID16바이트. 문자열로 저장하면 36바이트이므로 반드시 UUID 타입
IPv4, IPv64, 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으로 물리화할 수 있습니다.

설계 체크리스트

  1. 모든 정수 열의 폭을 값 범위에 맞게 줄였다.
  2. 종류가 적은 문자열은 LowCardinality 또는 Enum.
  3. Nullable은 꼭 필요한 열에만 두고, 정렬 키 열에는 없다.
  4. 시간은 DateTime 범위(2106년)와 정밀도를 확인했고, 시간대를 열에 명시했다.
  5. UUID, IP는 전용 타입이다.
  6. 반정형 데이터는 JSON 타입에 자주 쓰는 경로를 힌트로 선언했다.

공식 문서

이 문서에서