데이터 타입 선택하기

데이터 타입 선택하기

ClickHouse 쿼리 성능의 핵심 이유 중 하나는 효율적인 데이터 압축입니다. 이 문서에서는 압축 효율을 극대화하기 위해 테이블 스키마에서 적절한 데이터 타입을 선택하는 원칙과 실제 예제를 설명할게요.

출처: 문서

본문

ClickHouse 쿼리 성능의 핵심 이유 중 하나는 효율적인 데이터 압축입니다. 디스크에 데이터가 적을수록 I/O 오버헤드를 최소화하여 쿼리와 삽입이 빨라집니다. ClickHouse의 컬럼 지향 아키텍처는 유사한 데이터를 자연스럽게 인접하게 배치하여 압축 알고리즘과 코덱이 데이터 크기를 극적으로 줄일 수 있게 합니다. 이러한 압축 이점을 극대화하려면 적절한 데이터 타입을 신중하게 선택하는 것이 필수적입니다. ClickHouse의 압축 효율은 주로 세 가지 요소에 달려 있습니다: 정렬 키, 데이터 타입, 코덱. 이 모두는 테이블 스키마를 통해 정의됩니다. 최적의 데이터 타입을 선택하면 저장 공간과 쿼리 성능 모두에서 즉각적인 개선을 얻을 수 있습니다. 몇 가지 간단한 지침이 스키마를 크게 향상시킬 수 있습니다:

  • 엄격한 타입 사용: 항상 컬럼에 올바른 데이터 타입을 선택하세요. 숫자 및 날짜 필드는 범용 String 타입이 아닌 적절한 숫자 및 날짜 타입을 사용해야 합니다. 이렇게 해야 필터링과 집계의 의미가 정확해집니다.
  • Null 컬럼 피하기: Nullable 컬럼은 null 값을 추적하기 위한 별도 컬럼을 유지하므로 추가 오버헤드를 발생시킵니다. 빈 상태와 null 상태를 구분해야 할 때만 Nullable을 사용하세요. 그렇지 않으면 기본값이나 0에 해당하는 값으로 충분한 경우가 많습니다. 이 타입을 필요한 경우가 아니면 피해야 하는 이유에 대한 자세한 내용은 Nullable 컬럼 피하기를 참고하세요.
  • 숫자 정밀도 최소화: 예상 데이터 범위를 수용하면서도 가장 작은 비트 폭을 가진 숫자 타입을 선택하세요. 예를 들어 음수 값이 필요 없고 범위가 0~65535 안에 들어간다면 Int32보다 UInt16을 선호하세요.
  • 날짜 및 시간 정밀도 최적화: 쿼리 요구 사항을 충족하는 가장 조잡한(coarse-grained) 날짜 또는 datetime 타입을 선택하세요. 날짜 전용 필드에는 Date 또는 Date32를 사용하고, 밀리초 이상의 정밀도가 꼭 필요하지 않다면 DateTime64보다 DateTime을 선호하세요.
  • LowCardinality 및 특수 타입 활용: 약 10,000개 미만의 고유 값을 가진 컬럼에는 LowCardinality 타입을 사용해 사전 인코딩으로 저장 공간을 크게 줄이세요. 마찬가지로 컬럼 값이 엄격한 고정 길이 문자열(예: 국가 또는 통화 코드)일 때만 FixedString을 사용하고, 가능한 값의 유한 집합을 가진 컬럼에는 효율적인 저장과 내장 데이터 검증을 위해 Enum 타입을 선호하세요.
  • 데이터 검증용 Enum: Enum 타입은 열거형 값을 효율적으로 인코딩하는 데 사용할 수 있습니다. Enum은 저장해야 하는 고유 값 수에 따라 8비트 또는 16비트일 수 있습니다. 삽입 시점의 관련 검증(미선언 값은 거부됨)이 필요하거나 Enum 값의 자연스러운 순서를 활용하는 쿼리를 수행하고 싶다면 이를 고려하세요. 예를 들어 사용자 응답 Enum(':( ' = 1, ':|' = 2, ':) ' = 3)을 담은 피드백 컬럼을 상상해 보세요.

예제

ClickHouse는 타입 최적화를 간소화하는 내장 도구를 제공합니다. 예를 들어 스키마 추론은 초기 타입을 자동으로 식별할 수 있습니다. Parquet 형식으로 공개 제공되는 Stack Overflow 데이터셋을 고려해 보세요. DESCRIBE 명령으로 간단한 스키마 추론을 실행하면 초기 비최적화 스키마를 얻을 수 있습니다.

기본적으로 ClickHouse는 이를 동등한 Nullable 타입에 매핑합니다. 스키마가 행 샘플에만 기반하므로 이것이 선호됩니다.

DESCRIBE TABLE s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/stackoverflow/parquet/posts/*.parquet')
SETTINGS describe_compact_output = 1
┌─name───────────────────────┬─type──────────────────────────────┐
│ Id                         │ Nullable(Int64)                   │
│ PostTypeId                 │ Nullable(Int64)                   │
│ AcceptedAnswerId           │ Nullable(Int64)                   │
│ CreationDate               │ Nullable(DateTime64(3, 'UTC'))    │
│ Score                      │ Nullable(Int64)                   │
│ ViewCount                  │ Nullable(Int64)                   │
│ Body                       │ Nullable(String)                  │
│ OwnerUserId                │ Nullable(Int64)                   │
│ OwnerDisplayName           │ Nullable(String)                  │
│ LastEditorUserId           │ Nullable(Int64)                   │
│ LastEditorDisplayName      │ Nullable(String)                  │
│ LastEditDate               │ Nullable(DateTime64(3, 'UTC'))    │
│ LastActivityDate           │ Nullable(DateTime64(3, 'UTC'))    │
│ Title                      │ Nullable(String)                  │
│ Tags                       │ Nullable(String)                  │
│ AnswerCount                │ Nullable(Int64)                   │
│ CommentCount               │ Nullable(Int64)                   │
│ FavoriteCount              │ Nullable(Int64)                   │
│ ContentLicense             │ Nullable(String)                  │
│ ParentId                   │ Nullable(String)                  │
│ CommunityOwnedDate         │ Nullable(DateTime64(3, 'UTC'))    │
│ ClosedDate                 │ Nullable(DateTime64(3, 'UTC'))    │
└────────────────────────────┴───────────────────────────────────┘

22 rows in set. Elapsed: 0.130 sec.

아래에서는 *.parquet glob 패턴을 사용해 stackoverflow/parquet/posts 폴더의 모든 파일을 읽는 점을 유의하세요.

앞서 살펴본 간단한 규칙을 posts 테이블에 적용하면 각 컬럼에 최적의 타입을 식별할 수 있습니다:

컬럼 숫자? Min, Max 고유 값 Null 비고 최적화된 타입
PostTypeId 1, 8 8 없음 Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8)
AcceptedAnswerId 0, 78285170 12282094 있음 Null을 0 값으로 구분 UInt32
CreationDate 아니오 2008-07-31 21:42:52.667000000, 2024-03-31 23:59:17.697000000 - 없음 밀리초 세분화 불필요, DateTime 사용 DateTime
Score -217, 34970 3236 없음 Int32
ViewCount 2, 13962748 170867 없음 UInt32
Body 아니오 - - 없음 String
OwnerUserId -1, 4056915 6256237 있음 Int32
OwnerDisplayName 아니오 - 181251 있음 Null을 빈 문자열로 간주 String
LastEditorUserId -1, 9999993 1104694 있음 0은 미사용 값으로 Null에 사용 가능 Int32
LastEditorDisplayName 아니오 - 70952 있음 Null을 빈 문자열로 간주. LowCardinality 테스트했으나 이점 없음 String
LastEditDate 아니오 2008-08-01 13:24:35.051000000, 2024-04-06 21:01:22.697000000 - 없음 밀리초 세분화 불필요, DateTime 사용 DateTime
LastActivityDate 아니오 2008-08-01 12:19:17.417000000, 2024-04-06 21:01:22.697000000 - 없음 밀리초 세분화 불필요, DateTime 사용 DateTime
Title 아니오 - - 없음 Null을 빈 문자열로 간주 String
Tags 아니오 - - 없음 Null을 빈 문자열로 간주 String
AnswerCount 0, 518 216 없음 Null과 0을 같게 간주 UInt16
CommentCount 0, 135 100 없음 Null과 0을 같게 간주 UInt8
FavoriteCount 0, 225 6 있음 Null과 0을 같게 간주 UInt8
ContentLicense 아니오 - 3 없음 LowCardinality가 FixedString보다 우수 LowCardinality(String)
ParentId 아니오 - 20696028 있음 Null을 빈 문자열로 간주 String
CommunityOwnedDate 아니오 2008-08-12 04:59:35.017000000, 2024-04-01 05:36:41.380000000 - 있음 Null에 기본 1970-01-01 고려. 밀리초 세분화 불필요, DateTime 사용 DateTime
ClosedDate 아니오 2008-09-04 20:56:44, 2024-04-06 18:49:25.393000000 - 있음 Null에 기본 1970-01-01 고려. 밀리초 세분화 불필요, DateTime 사용 DateTime

컬럼의 타입을 식별하는 것은 숫자 범위와 고유 값 수를 이해하는 데 달려 있습니다. 모든 컬럼의 범위와 고유 값 수를 찾으려면 간단한 쿼리 SELECT * APPLY min, * APPLY max, * APPLY uniq FROM table FORMAT Vertical을 사용할 수 있습니다. 이것은 비쌀 수 있으므로 더 작은 데이터 하위 집합에 대해 수행하는 것을 권장합니다.

이 결과로 다음의 (타입 측면에서) 최적화된 스키마가 나옵니다:

CREATE TABLE posts
(
   Id Int32,
   PostTypeId Enum('Question' = 1, 'Answer' = 2, 'Wiki' = 3, 'TagWikiExcerpt' = 4, 'TagWiki' = 5, 
   'ModeratorNomination' = 6, 'WikiPlaceholder' = 7, 'PrivilegeWiki' = 8),
   AcceptedAnswerId UInt32,
   CreationDate DateTime,
   Score Int32,
   ViewCount UInt32,
   Body String,
   OwnerUserId Int32,
   OwnerDisplayName String,
   LastEditorUserId Int32,
   LastEditorDisplayName String,
   LastEditDate DateTime,
   LastActivityDate DateTime,
   Title String,
   Tags String,
   AnswerCount UInt16,
   CommentCount UInt8,
   FavoriteCount UInt8,
   ContentLicense LowCardinality(String),
   ParentId String,
   CommunityOwnedDate DateTime,
   ClosedDate DateTime
)
ENGINE = MergeTree
ORDER BY tuple()

Nullable 컬럼 피하기

Nullable 컬럼(예: Nullable(String))은 UInt8 타입의 별도 컬럼을 생성합니다. 이 추가 컬럼은 사용자가 Nullable 컬럼을 다룰 때마다 처리되어야 하므로 추가 저장 공간을 사용하고 거의 항상 성능에 부정적인 영향을 줍니다. Nullable 컬럼을 피하려면 해당 컬럼에 기본값을 설정하는 것을 고려해 보세요. 예를 들어 다음과 같은 대신:

CREATE TABLE default.sample
(
    `x` Int8,
    `y` Nullable(Int8)
)
ENGINE = MergeTree
ORDER BY x

이렇게 사용하세요:

CREATE TABLE default.sample2
(
    `x` Int8,
    `y` Int8 DEFAULT 0
)
ENGINE = MergeTree
ORDER BY x

사용 사례를 잘 고려하세요. 기본값이 적절하지 않을 수도 있습니다.

더 알아보기 (Learn more)