ClickHouse: 베팅 분석을 위한 완벽한 데이터 타입 가이드 (내가 당한 경험담)
500GB 디스크 공간을 날려먹은 1바이트
ClickHouse를 처음 사용할 때, 저는 모든 것에 String을 사용했습니다: user_id, event_time, bet amount. 한 달 후, 20억 행의 테이블이 4TB가 되었습니다. 동료가 스키마를 보고 "왜 숫자를 문자열로 저장하나요?"라고 물었습니다. 알고 보니, String으로 user_id를 저장하면 UInt64보다 8배 더 많은 공간을 차지합니다. 타입을 변경하자 테이블이 800GB로 줄었습니다.
ClickHouse는 수십 가지 데이터 타입을 제공합니다. 올바른 타입을 사용하는 것은 기가바이트를 절약하는 것뿐만 아니라 쿼리 속도(디스크에서 읽을 데이터가 적음)와 안정성(Float 대신 Decimal을 사용하면 반올림 오류가 발생하지 않음)에도 중요합니다.
아래는 실제 프로젝트(베팅 분석, 사기 탐지, LTV)에서 배운 모든 것입니다. 마지막에는 베팅 플랫폼을 위한 바로 사용 가능한 스키마가 있습니다.
1. 정수 타입: 사용자와 베팅 수 계산
ClickHouse는 8비트에서 256비트까지 signed(Int)와 unsigned(UInt) 정수를 지원합니다.
| 타입 | 범위 | 크기 | 사용 시기 |
|---|---|---|---|
UInt8 |
0..255 | 1바이트 | 상태(0/1), 오류 코드 |
UInt16 |
0..65535 | 2바이트 | 포트 번호, 작은 카운터 |
UInt32 |
0..42억 | 4바이트 | 국가 ID, 이벤트 타입 |
UInt64 |
0..1800경 | 8바이트 | user_id, event_id, 센트 단위 금액 |
Int128/256 |
매우 큼 | 16/32바이트 | 암호화 해시, 매우 큰 카운터 |
프로덕션 사례:
user_id UInt64, -- 80억 사용자는 충분
age UInt8, -- 255년 이상 사는 사람 없음
country_code UInt16, -- 세계 197개국, UInt16이 더 적당
is_fraud UInt8, -- 0 또는 1, 더 필요할까?
흔한 실수: 모든 것에 UInt64를 사용하는 것. 필드가 0 또는 1(플래그)만 취한다면 UInt8이 8배 더 작습니다. 10억 행에서 8GB 대 1GB입니다.
내가 당한 경험: timestamp를 UInt64(Unix 시간)로 저장했습니다. 작동은 하지만 toDate(), toHour() 등의 날짜 함수를 사용할 수 없습니다. DateTime을 사용하세요.
2. Float32/Float64: 지갑인가 구멍인가?
odds Float64, -- 배당률은 2.5, 1.85, 100.0 가능
probability Float32, -- 백분율 0.1..1.0, 32비트 정밀도로 충분
금융에서 Float가 위험한 이유:
SELECT 0.1 + 0.2 AS float_sum;
-- 결과: 0.30000000000000004 (고전적인 IEEE 754)
0.01센트의 베팅이 100만 개 있다고 상상해보세요. 반올림 오류가 실제 돈이 됩니다. 베팅 금액과 지급액에는 Decimal을 사용하세요.
Float가 괜찮은 경우: 배당률(2.15, 1.85), 확률, 백분율, 머신러닝 메트릭.
3. Decimal(P, S): 돈은 정밀함을 사랑한다
bet_amount Decimal(18, 2), -- 최대 10^16 루블, 소수점 2자리
payout Decimal(20, 2), -- 지급액은 베팅보다 클 수 있음
balance Decimal(32, 2) -- 플레이어의 평생 잔액
P(precision) — 총 자릿수 (최대 38)S(scale) — 소수점 이하 자릿수
내가 도출한 규칙: 루블과 달러의 경우 Decimal(18,2)로 충분합니다(조 단위까지). 암호화폐의 경우 Decimal(38,8).
Decimal 연산:
SELECT
bet_amount * odds AS potential_payout, -- Decimal * Float64 → Decimal
bet_amount + 0.01 AS rounded_up -- 작동하지만 주의
FROM bets;
내가 당한 경험: ClickHouse는 스케일이 다른 Decimal * Decimal을 처리할 때 더 큰 쪽으로 올립니다. 우리는 소수점 4자리에서 반올림되지 않는 센트가 있었습니다. 해결책: toDecimal32()로 명시적 캐스팅.
4. String vs FixedString vs LowCardinality(String)
String — 긴 모든 것에 사용
session_id String, -- 하이픈 없는 UUID, 가변 길이
user_agent String, -- 긴 문자열, 고유 값
raw_json String -- JSON 로그
FixedString(N) — 고정 길이(거의 필요 없음)
country_code FixedString(2), -- 'RU', 'US', 'DE' 정확히 2바이트
md5_hash FixedString(32) -- 항상 32자
거의 사용하지 않음: 더 짧은 문자열을 삽입하면 ClickHouse가 널 바이트로 채워서 비교 시 문제가 발생합니다.
LowCardinality(String) — 반복 값에 마법
sport LowCardinality(String), -- 'football', 'basketball', 'tennis' (반복)
device LowCardinality(String), -- 'ios', 'android', 'web' (10-20개 고유)
outcome LowCardinality(String) -- 'win', 'loss', 'void'
작동 방식: ClickHouse가 고유 값의 사전을 만들고 인덱스만 저장합니다. 10개의 고유 값을 가진 열의 경우 100배 절약됩니다.
실제 사례: 베팅 테이블에서 sport 필드가 수십억 번 반복되었습니다. String을 LowCardinality(String)으로 바꾼 후 열 크기가 40GB에서 400MB로 줄었습니다.
사용하지 말아야 할 때: 고유 값이 10,000개를 초과하는 경우(예: user_agent). 사전이 비대해지고 성능이 저하됩니다.
5. DateTime vs DateTime64 vs Date: 시간은 돈이다
| 타입 | 정밀도 | 크기 | 사용 시기 |
|---|---|---|---|
Date |
일 | 2바이트 | 파티셔닝, 일별 보고서 |
Date32 |
일 (2106년까지) | 4바이트 | 2149년 이후가 필요할 때 |
DateTime |
초 | 4바이트 | 대부분의 이벤트 |
DateTime64(3) |
밀리초 | 8바이트 | 실시간 베팅, 이벤트 순서 |
DateTime64(6) |
마이크로초 | 8바이트 | 로그, 메트릭 |
프로덕션 프로젝트에서 사용하는 것:
event_time DateTime64(3), -- 실시간 분석을 위한 밀리초
registration_date Date, -- 일별 파티셔닝
last_update DateTime -- 초 단위 정밀도로 충분
흔한 실수: 시간을 Unix 타임스탬프(UInt64)로 저장하는 것. 모든 날짜/시간 함수를 잃게 됩니다:
-- 작동하지 않음:
SELECT toHour(event_time_uint) ... -- 오류
-- 필요:
SELECT toHour(toDateTime(event_time_uint)) ... -- 추가 변환
내가 당한 경험: 실시간 베팅에 DateTime을 사용했습니다. 중요한 순간에 같은 초에 10개의 이벤트가 구분되지 않았습니다. DateTime64(3)으로 바꾸자 순서가 복원되었습니다.
6. UUID: 속도보다 표준이 중요할 때
session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()
UUID는 16바이트(두 개의 UInt64와 같음)를 차지합니다. 비교는 숫자보다 느립니다.
그래도 사용하는 경우: DB 접근 없이 클라이언트에서 ID를 생성해야 할 때, 외부 시스템과의 통합, 단일 생성기가 없는 분산 시스템.
대안: UInt128을 두 개의 64비트 숫자로 사용하지만, toUUID() 같은 함수를 잃게 됩니다.
7. Array(T): 정규화 없이 리스트 저장
tags Array(String), -- ['football', 'live', 'prematch']
coeff_history Array(Float64), -- [1.5, 1.8, 2.1] 배당률 변화
bet_bundle Array(UInt64) -- 누적 베팅의 베팅 ID
실제로 도움이 된 경우: 단일 이벤트의 배당률 변화 내역을 배열에 저장합니다. 관계형 DB에서는 별도 테이블이 필요합니다. ClickHouse는 arrayMap, arrayFilter, arrayJoin과 잘 작동합니다.
실제 쿼리: 배당률이 30% 이상 하락한 이벤트 찾기:
SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);
제한 사항: 중첩 배열(Array(Array(String)))은 거의 지원되지 않습니다. 평면 구조로 비정규화하세요.
8. Nullable(T): 피해야 할 악마
bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)
Nullable은 값당 하나의 추가 플래그(비트마스크)를 추가합니다. 이는 다음을 의미합니다:
- 행당 추가 바이트
- 더 느린 집계(SUM, AVG가 NULL을 확인해야 함)
- 일부 엔진과 호환되지 않음(예: ORDER BY 키)
내 입장: ClickHouse에서는 NULL을 피합니다. 대신:
- 숫자: NULL 대신
0 - 문자열:
''(빈 문자열) - 날짜:
'1970-01-01'
예외: 0이 유효한 값인 경우. 예를 들어, 0루블 보너스는 보너스가 지급되지 않은 것과 다릅니다. 그때는 Nullable을 사용하세요.
9. Enum8/Enum16: 유한 값 목록용
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
event_status Enum8('scheduled' = 1, 'live' = 2, 'finished' = 3, 'cancelled' = 4)
장점: 1바이트(Enum8) 또는 2바이트(Enum16)로 저장, 빠른 비교, 읽기 쉬운 출력.
내부: ClickHouse는 숫자를 저장하지만 SELECT는 문자열을 출력합니다.
INSERT INTO bets (outcome) VALUES ('win'); -- 문자열로
INSERT INTO bets (outcome) VALUES (1); -- 또는 숫자로
흔한 실수: ALTER TABLE ... MODIFY COLUMN으로 Enum에 새 값을 추가하려는 것. ClickHouse는 테이블을 다시 생성하지 않고 Enum을 변경할 수 없습니다. 모든 가능한 값을 미리 계획해야 합니다.
의심스러우면 LowCardinality(String)을 사용하세요. 유연성을 위해 1바이트를 희생하세요.
10. IPv4/IPv6: 다중 계정 탐지
ip_address IPv4,
client_ip IPv6 -- 모바일 통신사는 IPv6 사용
바이너리(4 또는 16바이트)로 저장되며, 빠른 서브넷 연산이 가능합니다.
실제 사용 사례: 동일 IP의 사용자 찾기:
SELECT user_id, count() AS bets
FROM bets
WHERE ip_address = IPv4StringToNum('192.168.1.1')
AND created_at >= today() - 7
GROUP BY user_id
HAVING bets > 50; -- 잠재적 봇
유용한 함수: IPv4NumToString(), IPv4CIDRToRange(), isIPv4String().
베팅 플랫폼을 위한 완전한 스키마 (프로덕션 검증)
CREATE TABLE betting.bets_full
(
-- 식별자
bet_id UInt64 DEFAULT generateUUIDv4() (materialized) ???
-- 아니요, UUID는 별도로
bet_uuid UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_id UInt64,
session_id String, -- UUID 아님, nginx 로그에서 옴
-- 타임스탬프
created_at DateTime64(3), -- 실시간용 밀리초
updated_at DateTime,
bet_date Date DEFAULT toDate(created_at), -- materialized 컬럼
-- 금전 필드 (Decimal만!)
bet_amount Decimal(18, 2),
odds Float64, -- 배당률 — Float 괜찮음
potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
real_payout Decimal(20, 2),
-- 반복되는 카테고리
sport LowCardinality(String),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
device_type LowCardinality(String),
-- 리스트 (변경 내역)
odds_history Array(Float64), -- 시간에 따른 배당률 변화
cashout_attempts Array(DateTime64(3)), -- 캐시아웃 시도
-- 사기 탐지
ip_address IPv4,
fingerprint FixedString(32), -- 브라우저 해시
-- 정말 필요한 경우에만 Nullable
refund_amount Nullable(Decimal(18, 2)), -- 환불 없으면 NULL
cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;
이 스키마가 프로덕션에서 살아남은 이유:
bet_date는created_at에서 materialized — 추가 계산 없이 날짜 기반 파티셔닝- sport와 device_type에
LowCardinality— 80% 공간 절약 potential_payout에ALIAS— 저장되지 않고 쿼리 시 계산- 0이나 빈 문자열로 충분한 곳에
Nullable없음
다음은?
올바른 타입을 선택하는 것이 기본입니다. 다음 글에서는 이 데이터에 대한 집계, 윈도우 함수, materialized view 구축을 다룰 것입니다.
이 글의 테이블은 1년 동안 프로덕션 클러스터에서 3조 개의 레코드로 운영되었습니다. 크기는 12TB(ZSTD 압축 사용)입니다. 모든 것이 String이었다면 40TB였을 것입니다. 타입을 현명하게 선택하세요.
← 이전 글: ClickHouse 클라이언트: 콘솔과 HTTP API와 친해진 방법 (도박 프로젝트 사례)
→ 다음 글: ClickHouse의 MergeTree: 엔진이 분석을 과립으로 나누고 파트를 병합하는 방법
— Editorial Team
아직 댓글이 없습니다.