ClickHouse 운영
타깃 서비스 운영: 쿼리, 머지와 파트 모니터링, 딕셔너리, 그리고 Snowflake와 다른 운영 습관.
이 문서는 이 마이그레이션 랩에서 마주칠 ClickHouse 개념들을 설명한다 — 그것이 무엇이고, 왜 존재하며, 파트 1에서 사용한 Snowflake 구성물과 어떻게 다른지.
1. 테이블 엔진
ClickHouse는 단일 엔진 데이터베이스가 아니다. 만드는 모든 테이블은 엔진을 선언해야 하며, 엔진이 데이터가 디스크에 어떻게 저장되는지, 중복이 어떻게 처리되는지, 어떤 기능을 쓸 수 있는지를 결정한다. 잘못된 엔진 선택은 ClickHouse 스키마 설계에서 가장 흔한 실수다.
MergeTree
거의 모든 프로덕션 테이블의 기반 엔진이다.
CREATE TABLE analytics.dim_taxi_zones (
zone_id UInt16,
borough String,
service_zone String
) ENGINE = MergeTree()
ORDER BY zone_id;무엇을 하는가: ClickHouse는 데이터를 파트 — 디스크 위의 정렬되고 압축된 청크 — 로 저장한다. 데이터를 삽입하면 새 파트가 기록된다. 백그라운드에서 ClickHouse는 작은 파트들을 큰 파트로 계속 머지하며, 데이터를 ORDER BY 키 순서로 유지한다. 엔진 이름이 여기서 나왔다.
언제 쓰는가: 중복 제거가 필요 없고 삽입이 추가 전용이거나 벌크 로드인 모든 테이블(디멘션 테이블, 원시 이벤트 테이블, 로그 테이블).
핵심 속성: primary key 강제가 없다. ORDER BY 값이 동일한 두 행은 둘 다 저장된다. 중복 제거가 필요하면 ReplacingMergeTree를 쓰라.
ReplacingMergeTree(version_col)
중복 제거 엔진이다. MergeTree에 규칙을 하나 더한다. 백그라운드 머지 중 두 행이 같은 ORDER BY 키를 공유하면 version_col 값이 가장 큰 행만 남긴다.
CREATE TABLE analytics.fact_trips (
trip_id String,
pickup_at DateTime,
total_amount Float64,
updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (pickup_at, trip_id);중요 — 최종적 일관성: 중복 제거는 백그라운드 머지 중에만 일어난다. 임의의 시점에 테이블에는 중복 행이 있을 수 있다. 이것을 **최종적 일관성(eventual consistency)**이라고 한다. 쿼리 시점에 완전히 중복 제거된 결과를 얻으려면 SELECT에 FINAL을 추가하라.
-- Without FINAL: may return duplicates if merges haven't run
SELECT * FROM analytics.fact_trips WHERE trip_id = 'abc';
-- With FINAL: forces deduplication at query time (slower, always correct)
SELECT * FROM analytics.fact_trips FINAL WHERE trip_id = 'abc';dbt는 이것을 어떻게 쓰는가: dbt-clickhouse 어댑터는 주된 메커니즘으로 delete_insert 증분 전략을 쓴다 — 키가 일치하는 행을 명시적으로 삭제하고 새 행을 삽입하므로 항상 올바르다. ReplacingMergeTree는 빠져나간 중복(예: 실패한 부분 삽입에서 생긴 것)을 정리하는 안전망 역할을 한다.
Snowflake 대응물: 직접적인 대응물은 없다. Snowflake에서는 MERGE INTO ... WHEN MATCHED THEN UPDATE를 썼다. ClickHouse에는 MERGE 구문이 없다 — ReplacingMergeTree에 FINAL을 더하면 논리적으로 같은 결과를 얻는다.
Refreshable Materialized View
ClickHouse는 두 종류의 materialized view를 지원한다.
트리거 기반 MV(전통적 방식): 모든 INSERT에서 실행되며, 새로 삽입된 배치만 처리한다.
-- Trigger-based: only sees the rows inserted in the current batch
CREATE MATERIALIZED VIEW analytics.mv_realtime_counts
TO analytics.counts_table AS
SELECT pickup_date, count() AS trips
FROM default.trips_raw
GROUP BY pickup_date;Refreshable MV(스케줄 방식): cron 잡처럼 스케줄에 따라 전체 쿼리를 다시 실행한다.
-- Refreshable: runs the full SELECT every 3 minutes
CREATE MATERIALIZED VIEW analytics.mv_hourly_revenue
REFRESH EVERY 180 SECOND AS
SELECT
toStartOfHour(pickup_at) AS hour_bucket,
pickup_borough,
sum(total_amount) AS revenue
FROM analytics.fact_trips FINAL
GROUP BY hour_bucket, pickup_borough;어느 것을 언제 쓰는가:
- 트리거 기반: 새 데이터만 처리하면 되는 삽입 스트림 위의 실시간 집계
- Refreshable:
fact_trips FINAL을 쿼리하는 집계(중복 제거를 위해 테이블 전체를 봐야 한다), 또는 로직을 단순하게 유지하는 대신 몇 분의 지연을 감수할 수 있는 대시보드
갱신 간격 변경:
ALTER TABLE analytics.mv_hourly_revenue MODIFY REFRESH EVERY 60 SECOND;2. 정렬 키 (ORDER BY)
Snowflake에서는 CLUSTER BY를 옵티마이저에 대한 힌트로 썼다. ClickHouse에서 ORDER BY는 primary index다 — 디스크 위 데이터의 물리적 정렬 순서를 결정하고 모든 범위 스캔을 좌우한다.
동작 방식
ClickHouse는 스파스 primary index를 저장한다. 약 8,192행(데이터 그래뉼 하나)당 인덱스 항목 하나다. ORDER BY 컬럼으로 필터링하면 ClickHouse는 그래뉼 전체를 읽지 않고 건너뛴다. ClickHouse가 초당 수십억 행을 스캔할 수 있는 이유가 여기 있다 — 대부분의 데이터는 애초에 디스크를 떠나지 않는다.
카디널리티 순서가 중요하다
항상 카디널리티가 낮은 컬럼을 앞에, 카디널리티가 높은 컬럼을 뒤에 두라. 그러면 일반적인 경우에 인덱스의 건너뛰기 능력이 최대가 된다.
-- Good: low cardinality (borough, ~6 values) first, then high cardinality (trip_id)
ORDER BY (pickup_borough, toStartOfMonth(pickup_at), trip_id)
-- Bad: high cardinality first — the index can't skip anything useful
ORDER BY (trip_id, pickup_borough, pickup_at)Snowflake CLUSTER BY vs ClickHouse ORDER BY
| 항목 | Snowflake CLUSTER BY | ClickHouse ORDER BY |
|---|---|---|
| 목적 | 쿼리 성능 힌트 | 물리적 정렬 순서 (필수) |
| 강제 방식 | 백그라운드 재클러스터링 (비동기) | 삽입 시 항상 강제 |
| 범위 | 마이크로 파티션 | 데이터 그래뉼 (약 8K행) |
| 필수 여부 | 아니오 | 예 — 모든 MergeTree 테이블에 하나씩 있어야 한다 |
예시: 기존 Snowflake 클러스터 키에 맞추기
-- Snowflake
CLUSTER BY (DATE_TRUNC('month', PICKUP_AT), PICKUP_LOCATION_ID)
-- ClickHouse equivalent
ORDER BY (toStartOfMonth(pickup_at), pickup_location_id, trip_id)
-- Note: trip_id added as tiebreaker to ensure unique sort order스킵 인덱스 (간단히)
ORDER BY 키에 없는 컬럼에 대해 ClickHouse는 그래뉼별 컬럼 수준 메타데이터를 저장하는 스킵 인덱스(bloom filter, minmax, set)를 지원한다. 정렬 키에서 카디널리티가 높은 컬럼 뒤에 오는, 카디널리티가 낮은 컬럼으로 필터링할 때 유용하다.
-- Add a bloom filter skip index on payment_type
ALTER TABLE analytics.fact_trips
ADD INDEX idx_payment_type payment_type TYPE bloom_filter GRANULARITY 4;3. JSON 처리
Snowflake의 VARIANT 컬럼 타입은 중첩 JSON을 탐색하기 위해 콜론 경로 표기법을 지원한다. ClickHouse는 대신 명시적인 JSONExtract* 함수를 쓴다.
나란히 보는 변환 표
| Snowflake | ClickHouse | 비고 |
|---|---|---|
col:key::FLOAT | JSONExtractFloat(col, 'key') | 최상위 float 필드 |
col:driver.rating::FLOAT | JSONExtractFloat(col, 'driver', 'rating') | 중첩된 float 필드 |
col:app.surge_multiplier::FLOAT | JSONExtractFloat(col, 'app', 'surge_multiplier') | 중첩된 float |
col:route.waypoints[0]::STRING | JSONExtractString(col, 'route', 'waypoints', 0) | 인덱스로 배열 요소 접근 |
col:driver.id::INT | JSONExtractInt(col, 'driver', 'id') | 정수 필드 |
함수 변종
-- Float (returns 0.0 if key missing or wrong type)
JSONExtractFloat(trip_metadata, 'driver', 'rating')
-- String (returns '' if missing)
JSONExtractString(trip_metadata, 'app', 'version')
-- Integer (returns 0 if missing)
JSONExtractInt(trip_metadata, 'driver', 'id')
-- Bool (returns 0/1)
JSONExtractBool(trip_metadata, 'app', 'is_shared')
-- Raw value as string (preserves JSON sub-object)
JSONExtractRaw(trip_metadata, 'route')성능 팁
같은 JSON 컬럼을 반복해서 쿼리한다면, 하위 쿼리마다 JSONExtractFloat를 호출하는 대신 스테이징 모델 수준(stg_trips.sql)에서 필드를 타입이 있는 컬럼으로 추출하는 것을 고려하라. 이 랩의 dbt 모델이 그렇게 한다.
4. 날짜/시간 함수
Snowflake와 ClickHouse는 날짜/시간 기능은 비슷하지만 문법이 다르다. 가장 흔한 변환은 다음과 같다.
나란히 보는 변환 표
| Snowflake | ClickHouse | 비고 |
|---|---|---|
DATE_TRUNC('hour', col) | toStartOfHour(col) | 시 단위로 절단 |
DATE_TRUNC('day', col) | toStartOfDay(col) 또는 toDate(col) | 일 단위로 절단 |
DATE_TRUNC('month', col) | toStartOfMonth(col) | 월 단위로 절단 |
CURRENT_TIMESTAMP() | now() | 현재 날짜시각 |
CURRENT_DATE() | today() | 현재 날짜 |
DATEADD('day', -7, CURRENT_DATE()) | today() - INTERVAL 7 DAY | 날짜 연산 |
DATEDIFF('day', a, b) | dateDiff('day', a, b) | 두 날짜 사이의 일수 |
ClickHouse에만 있는 추가 편의 함수
yesterday() -- today() - 1 day
toStartOfWeek(col) -- Monday of the containing week
toStartOfQuarter(col) -- first day of the quarter
toYear(col) -- extract year as integer
toMonth(col) -- extract month as integer (1-12)
toDayOfWeek(col) -- 1=Monday, 7=Sunday인터벌 문법
-- ClickHouse
now() - INTERVAL 7 DAY
now() - INTERVAL 1 HOUR
now() - INTERVAL 30 MINUTE
pickup_at + INTERVAL 90 SECOND
-- Snowflake equivalent
DATEADD('day', -7, CURRENT_TIMESTAMP())
DATEADD('hour', -1, CURRENT_TIMESTAMP())5. 근사 함수
ClickHouse는 수십억 행에 대한 정확한 답이 대시보드에 충분히 정확한 근사 답보다 느린 분석 워크로드를 위해 만들어졌다. ClickHouse에는 여러 근사 집계 함수가 내장되어 있다.
고유 개수 세기
| 함수 | 정확도 | 속도 | 사용 시점 |
|---|---|---|---|
uniqExact(col) | 정확 | 가장 느림 | 컴플라이언스 보고, 청구 |
uniq(col) | 오차 약 2% | 빠름 | 대시보드, 탐색 |
uniqHLL12(col) | 오차 약 1.6% | 가장 빠름, 메모리 2.5KB 고정 | 고카디널리티, 메모리 제약 환경 |
-- Exact (like Snowflake COUNT(DISTINCT ...))
SELECT uniqExact(trip_id) FROM analytics.fact_trips FINAL;
-- Approximate — good for "how many unique passengers today?"
SELECT uniq(passenger_id) FROM analytics.fact_trips FINAL;백분위수
| 함수 | 비고 |
|---|---|
quantile(level)(col) | 정확한 분위수, 메모리를 많이 쓴다 |
quantileTDigest(level)(col) | t-digest를 쓰는 근사, 고정 메모리 |
quantileTDigestWeighted(level)(col, weight) | 가중 t-digest |
-- P95 trip duration — approximate but uses O(1) memory
SELECT quantileTDigest(0.95)(duration_minutes)
FROM analytics.fact_trips FINAL;
-- Multiple percentiles in one pass
SELECT quantileTDigestMerge(0.5)(state), quantileTDigestMerge(0.95)(state)
FROM analytics.fact_trips FINAL;경험 법칙: 인터랙티브 대시보드에는 uniq와 quantileTDigest를 쓰라. uniqExact와 quantile은 과금, SLA, 컴플라이언스처럼 정확한 값이 필요할 때만 쓰라.
6. 딕셔너리
딕셔너리는 ClickHouse가 메모리에 상주시키고 쿼리 시점에 미리 조인해 두는 인메모리 룩업 테이블이다. 전체 JOIN의 비용 없이 조인하고 싶은 작은 디멘션 테이블에 대응하는 ClickHouse 개념이다.
딕셔너리란
딕셔너리는 소스(ClickHouse 테이블, 파일, 또는 외부 데이터베이스)가 뒷받침하며, 서비스가 시작될 때나 SYSTEM RELOAD DICTIONARIES를 호출할 때 메모리에 로드된다. 조회는 키를 통해 이루어지고, 하나 이상의 속성을 반환한다.
CREATE DICTIONARY 문법
-- From 04_create_dictionary.sql
CREATE DICTIONARY analytics.taxi_zones_dict (
zone_id UInt16,
borough String,
service_zone String
)
PRIMARY KEY zone_id
SOURCE(CLICKHOUSE(
TABLE 'dim_taxi_zones'
DB 'analytics'
))
LIFETIME(MIN 300 MAX 600) -- refresh every 5-10 minutes
LAYOUT(FLAT()); -- hash map, best for < 1M rowsLAYOUT 옵션:
FLAT()— 정수 키로 인덱싱되는 배열, 가장 빠르며 순차적인 정수 키가 필요하다HASHED()— 해시 맵, 모든 정수 키에서 동작한다COMPLEX_KEY_HASHED()— 복합 키나 문자열 키를 쓰는 해시 맵
dictGet 사용법
-- Instead of: JOIN analytics.dim_taxi_zones USING (zone_id)
SELECT
trip_id,
dictGet('analytics.taxi_zones_dict', 'borough', toUInt64(pickup_location_id)) AS pickup_borough,
dictGet('analytics.taxi_zones_dict', 'borough', toUInt64(dropoff_location_id)) AS dropoff_borough
FROM analytics.fact_trips FINAL;딕셔너리와 JOIN 중 무엇을 쓸지
| 상황 | 선택 |
|---|---|
| 작고 안정적인 참조 테이블 (< 100만 행, 거의 변하지 않음) | 딕셔너리 |
| 큰 디멘션 테이블이거나 자주 갱신되는 데이터 | JOIN |
| 같은 조회를 반복해서 실행하는 대시보드 쿼리 | 딕셔너리 (최초 로드 이후 조회는 무료) |
| 일회성 분석 쿼리 | JOIN |
7. SAMPLE 절
ClickHouse는 쿼리 문법에서 직접 행 수준 샘플링을 지원한다. 샘플링은 데이터의 결정적인 일부를 읽는다 — 정확한 결과가 필요 없는 탐색적 분석에 유용하다.
문법
-- Read approximately 10% of rows
SELECT count(), avg(total_amount)
FROM analytics.fact_trips SAMPLE 0.1;
-- Read a specific number of rows (approximately)
SELECT trip_id, pickup_at, total_amount
FROM analytics.fact_trips SAMPLE 1000000;결과 스케일링
샘플링할 때는 집계값에 1 / sample_rate를 곱해 전체 테이블 값을 추정한다.
-- Estimate total revenue from 10% sample
SELECT sum(total_amount) * 10 AS estimated_total_revenue
FROM analytics.fact_trips SAMPLE 0.1;SAMPLE을 쓸 때
- 전체 테이블에서 실행하기 전의 탐색적 분석("내 쿼리 로직이 맞나?")
- 근사값을 받아들일 수 있는 대시보드 타일
- 대표성 있는 부분집합으로 ML 모델 학습
참고: SAMPLE은 테이블 ORDER BY 키가 샘플링 컬럼으로 시작해야 하거나, CREATE TABLE 문에 SAMPLE BY 절을 추가해야 한다. 이 랩의 trips_raw 테이블은 이 목적을 위해 SAMPLE BY cityHash64(trip_id)로 생성된다.
8. dbt-clickhouse 어댑터 참고 사항
dbt-clickhouse 어댑터(dbt-clickhouse>=1.8)는 대부분의 표준 dbt 기능을 지원하지만, 알아 둬야 할 ClickHouse 고유 동작이 몇 가지 있다.
delete_insert 증분 전략
ClickHouse에는 MERGE INTO가 없다. dbt-clickhouse 어댑터의 delete_insert 전략이 이를 모방한다.
- 키 컬럼이 들어오는 배치와 일치하는 행을 타깃 테이블에서 삭제한다
- 들어오는 행 전체를 삽입한다
-- What dbt generates for incremental models
DELETE FROM analytics.fact_trips WHERE trip_id IN (SELECT trip_id FROM __dbt_tmp);
INSERT INTO analytics.fact_trips SELECT * FROM __dbt_tmp;모델에서 다음과 같이 설정한다.
{{
config(
materialized='incremental',
incremental_strategy='delete_insert',
unique_key='trip_id',
engine='ReplacingMergeTree(updated_at)',
order_by='(pickup_at, trip_id)'
)
}}engine과 order_by 설정
모든 MergeTree 테이블에는 엔진과 ORDER BY가 필요하다. dbt 모델 설정에서 둘 다 지정하라.
{{
config(
engine='MergeTree()',
order_by='(zone_id)'
)
}}cluster_by 대신 order_by
Snowflake dbt 모델에서는 cluster_by를 썼을 수 있다. dbt-clickhouse에서는 대신 order_by를 쓴다. ClickHouse에는 Snowflake의 cluster_by에 대응하는 것이 없다 — ORDER BY가 항상 물리적 정렬이다.
ClickHouse Cloud용 profiles.yml
ClickHouse Cloud는 TLS를 요구한다. secure: true를 설정하라.
# ~/.dbt/profiles.yml
nyc_taxi_ch:
target: dev
outputs:
dev:
type: clickhouse
host: "{{ env_var('CLICKHOUSE_HOST') }}"
port: 8443
user: default
password: "{{ env_var('CLICKHOUSE_PASSWORD') }}"
schema: analytics # default database for models without a custom schema
secure: true
threads: 4스키마 이름 지정과 별도 데이터베이스
Snowflake가 schema라고 부르는 것을 ClickHouse는 database라고 부른다. dbt-clickhouse 어댑터는 dbt 스키마를 ClickHouse 데이터베이스로 매핑한다. 이 프로젝트의 generate_schema_name 매크로는 dbt의 기본 동작을 재정의해서, +schema: analytics가 붙은 모델이 staging_analytics가 아니라 analytics 데이터베이스에 생성되게 한다.
-- macros/generate_schema_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- if custom_schema_name is none -%}
{{ target.schema }}
{%- else -%}
{{ custom_schema_name }}
{%- endif -%}
{%- endmacro %}이것은 Snowflake dbt 프로젝트(파트 1)에서 쓴 것과 같은 패턴이다 — 두 어댑터에서 스키마 이름 지정 동작이 일관되도록 매크로를 의도적으로 동일하게 유지했다.