ClickHouse의 dbt
dbt-clickhouse 구성: delete_insert 증분 전략, ReplacingMergeTree 모델, 그리고 refreshable materialized view.
이 가이드는 Part 3에서 사용할 dbt-clickhouse 고유 패턴을 다룹니다. 워크시트 1–4를 완료한 후, 워크시트 5(dbt 모델 설계) 전에 읽으세요.
dbt-snowflake에서 오는 경우 대부분의 dbt 개념은 동일합니다 — 소스, ref, 테스트, 매크로, staging/intermediate/analytics 계층 패턴. 달라지는 것은 ClickHouse 고유의 구성 계층입니다: engine, order_by, 증분 전략, 그리고 FINAL 의미론.
1. Materialization 유형
dbt-clickhouse는 다섯 가지 materialization을 지원합니다. 선호도가 아니라 갱신 패턴에 따라 선택하세요.
| Materialization | 물리적 객체 | 사용 시점 |
|---|---|---|
view | ClickHouse 뷰 | 스테이징 모델: 소스 데이터 정리 및 타입 캐스팅; 스토리지 비용 없음; 매 쿼리마다 재계산 |
ephemeral | 객체 없음 (CTE로 인라인) | JOIN으로 여러 스테이징 모델을 결합하는 중간 모델; 불필요한 물리 테이블 생성을 방지 |
table | 스테이징 관계에 전체 대체본을 만든 뒤 EXCHANGE TABLES(구버전에서는 rename 쌍)로 원자적으로 제자리에 교체; 교체 후 기존 테이블 삭제 | dbt run마다 전체가 교체되는 작은 차원 테이블; 부분 갱신이 필요 없는 경우. 주의: 큰 테이블에서는 전체 재구축이 현실적이지 않습니다 — 수천 행을 넘는 테이블에는 incremental을 사용하세요. |
incremental | 첫 실행에는 CREATE TABLE; 이후 실행에는 선택적 UPDATE 패턴 | 실행마다 신규/변경된 행만 처리해야 하는 팩트 테이블과 사전 집계 테이블 |
materialized_view | ClickHouse Materialized View | 자동 갱신 집계; dbt의 incremental과는 다릅니다. 표준(트리거 기반) MV는 INSERT마다 한 번 실행되며 해당 배치만 볼 수 있으므로 전체 기간 집계를 계산할 수 없습니다. REFRESHABLE MV는 대신 전체 쿼리를 일정에 따라 다시 실행하므로 가능합니다. |
Snowflake와의 핵심 차이: dbt-snowflake는 스토리지 세부 사항을 내부적으로 처리합니다. dbt-clickhouse에서는 table과 incremental 모델에 명시적인 +engine 구성이 필요합니다 — dbt는 이를 사용해 CREATE TABLE ... ENGINE = ... DDL을 생성합니다.
뷰에는 엔진이 없습니다. 실수로 view materialization에 +engine을 추가하면 dbt-clickhouse는 이를 무시합니다. 엔진이 필요한 영구 스토리지를 생성하는 것은 table과 incremental materialization뿐입니다.
Refreshable materialized view. dbt-clickhouse의 materialized_view materialization은 refreshable 구성 블록 — interval(그리고 선택적으로 randomize) — 을 받아들이며, 이는 생성되는 CREATE MATERIALIZED VIEW 문에 REFRESH 절을 직접 삽입합니다. 이 랩의 mv_live_trip_feed 모델은 refreshable을 설정하지 않으며, 그래서 이 모델이 만드는 MV에는 갱신 일정이 없습니다.
2. dbt에서 ClickHouse 구성 표현하기
ClickHouse 고유 설정은 dbt 모델 config로 표현하며, dbt_project.yml(프로젝트 전역 기본값) 또는 모델의 config() 블록(모델별 재정의)에 둘 수 있습니다.
dbt_project.yml에서
models:
your_project:
analytics:
+schema: analytics
+materialized: table
+engine: "MergeTree()" # default for all analytics tables
fact_trips:
+materialized: incremental
+engine: "ReplacingMergeTree(updated_at)" # overrides the default
+incremental_strategy: delete_insert
+unique_key: trip_id
+order_by: "(toStartOfMonth(pickup_at), pickup_at, trip_id)"모델의 config() 블록에서
{{ config(
materialized = 'incremental',
engine = 'ReplacingMergeTree(updated_at)',
incremental_strategy = 'delete_insert',
unique_key = 'trip_id',
order_by = '(toStartOfMonth(pickup_at), pickup_at, trip_id)'
) }}두 방식은 동등합니다. 프로젝트 전역 패턴에는 dbt_project.yml이 선호되고, 모델별 재정의나 구성을 SQL과 같은 위치에 두고 싶을 때는 config() 블록이 선호됩니다.
주요 config 파라미터
| 파라미터 | 제어하는 대상 | ClickHouse 매핑 |
|---|---|---|
+engine | 테이블 스토리지 엔진 | CREATE TABLE의 ENGINE = ... |
+order_by | primary key / 정렬 순서 | CREATE TABLE의 ORDER BY ...; 생략하면 tuple()이 기본값 |
+unique_key | delete_insert 중복 제거용 키 | 삽입 전에 어떤 행을 삭제할지 결정 |
+incremental_strategy | 증분 실행이 데이터를 갱신하는 방식 | ClickHouse에서는 delete_insert로 설정 |
스코핑 규칙: dbt_project.yml의 설정은 부모에서 자식으로 전파됩니다. 모델 수준의 config() 블록은 항상 프로젝트 config보다 우선합니다. 가장 흔한 엔진을 프로젝트 기본값으로 설정하고, 다른 모델만 재정의하세요.
3. delete_insert 동작 원리
delete_insert는 dbt-clickhouse 커뮤니티의 표준 증분 전략입니다. Snowflake의 MERGE INTO에 가장 가까운 등가물이지만 동작 방식은 다릅니다.
버전 요구사항:
delete_insert는 ClickHouse 경량 삭제(lightweight delete)를 사용하며, 이는 22.8에서 도입(실험적)되어 23.3+에서 프로덕션 준비 상태가 되었습니다. ClickHouse Cloud는 이 요구사항을 충족합니다. 활성화하려면~/.dbt/profiles.yml의 ClickHouse 타깃에use_lw_deletes: true를 추가하거나,query_settings에allow_experimental_lightweight_delete=1을 설정하세요.
하는 일
각 증분 실행마다:
- 들어오는 배치의 어떤 행과
unique_key가 일치하는 대상 테이블의 행을 DELETE - 들어오는 배치의 모든 행을 INSERT
-- Step 1: dbt generates this DELETE
ALTER TABLE analytics.fact_trips
DELETE WHERE trip_id IN (SELECT trip_id FROM incoming_batch);
-- Step 2: dbt generates this INSERT
INSERT INTO analytics.fact_trips
SELECT * FROM incoming_batch;Snowflake MERGE INTO와의 차이
Snowflake의 merge 전략은 행 단위 WHEN MATCHED THEN UPDATE / WHEN NOT MATCHED THEN INSERT를 생성합니다. ClickHouse에는 MERGE INTO 문이 없습니다. delete_insert는 배치 삭제 후 전체 삽입을 통해 동일한 최종 결과 — 유니크 키당 한 행 — 를 달성합니다.
ReplacingMergeTree와의 상호작용
delete_insert가 주된 정확성 경로입니다. ReplacingMergeTree는 안전망입니다.
delete_insert 실행이 정상적으로 완료되면: 테이블은 깨끗합니다(trip_id당 한 행), 중복 없음.
delete_insert 실행이 중간에 중단되면(DELETE 후 INSERT 전 크래시): 데이터가 유효하지 않은 상태일 가능성이 높습니다 — 삭제된 행이 다시 삽입되지 않았을 수 있습니다. 다음 성공적인 실행이 올바른 상태를 복원하지만, 실패한 DELETE와 재실행 사이에는 테이블을 쿼리하지 마세요.
어떤 이유로든 실행이 중복을 만들어내면: ReplacingMergeTree의 백그라운드 병합이 결국 중복을 제거하고, 버전 컬럼 값이 가장 큰 행을 남깁니다.
delete_insert 없이 RMT만 의존하지 마세요 — 백그라운드 병합은 비동기이며 큰 테이블에서는 수 분에서 수 시간이 걸릴 수 있습니다.
append를 사용할 때
append는 기존 행을 건드리지 않고 새 행을 삽입합니다. 행이 절대 갱신되지 않는, 순수하게 삽입만 하는 테이블에 올바른 전략입니다 — 예를 들어 불변 이벤트 로그나, 유니크성이 보장된 ID를 가지며 정정이 없는 원시 인제스트 테이블. append에는 버전 요구사항이 없고 뮤테이션 위험도 없습니다.
fact_trips에서는 append가 잘못된 선택입니다: 트립은 사후에 정정될 수 있으므로(요금 조정, 상태 변경) 같은 trip_id가 새 값과 함께 다시 도착합니다. append를 쓰면 두 버전이 영구적으로 누적되고, 집계(요금의 SUM, 트립의 COUNT)는 다음 백그라운드 RMT 병합까지 과다 집계됩니다. 행이 갱신될 수 있다면 항상 delete_insert를 사용하세요.
merge 전략은 왜 아닌가?
merge 전략(delete_insert 이전의 레거시 기본값)은 임시 테이블을 만들고, 변경되지 않은 기존 행과 새 배치를 채운 뒤, 원본 테이블을 원자적으로 교체합니다. delete_insert와 달리 경량 삭제를 사용하지 않으며 — 증분 실행마다 테이블 전체를 다시 씁니다. 5천만 행 규모의 fact_trips 테이블에서는 이 비용이 극도로 큽니다. delete_insert는 현재 배치의 행만 처리하고, merge는 테이블의 모든 행을 건드립니다. delete_insert를 사용하세요.
4. FINAL 배치 전략
ReplacingMergeTree의 중복 제거는 백그라운드에서 일어납니다 — ClickHouse는 파트를 비동기로 병합합니다. 병합 사이에는 중복 행이 공존합니다. FINAL은 읽기 시점에 동기적 중복 제거를 강제합니다.
dbt 파이프라인에서 FINAL이 들어갈 위치
ReplacingMergeTree 소스에서 읽어 깨끗한 분석 데이터를 만들어내는 계층에.
NYC Taxi 워크로드의 경우:
trips_raw (RMT)
↓
stg_trips (view): SELECT ... FROM trips_raw FINAL ← FINAL goes here
↓
int_trips_enriched (ephemeral CTE)
↓
fact_trips (incremental, RMT) ← NO FINAL in model
↓
Dashboard queries: SELECT ... FROM fact_trips FINAL ← FINAL goes here (externally)stg_trips는 trips_raw 중복 제거의 단일 시행 지점입니다. stg_trips를 읽는 모든 다운스트림 모델은 자동으로 깨끗하게 중복 제거된 소스 데이터를 얻습니다. int_trips_enriched나 fact_trips에는 FINAL이 필요 없습니다. 이들은 stg_trips(RMT 테이블이 아닌 뷰)에서 읽기 때문입니다.
fact_trips를 직접 읽는 대시보드 쿼리와 dbt 테스트는 외부에서 FINAL을 사용합니다. 모델 자체에 FINAL을 넣지 않는 이유는, 그렇게 하면 모델 쿼리 내부의 모든 스캔에 적용되기 때문입니다 — {{ this }}에서 max(updated_at)을 읽는 is_incremental() 서브쿼리까지 포함해서요.
FINAL의 성능 영향
FINAL은 중복 행 수에 비례해 지연을 추가합니다. 잘 관리된 RMT 테이블(백그라운드 병합이 자주 일어나는)에서는 해소할 중복이 적으므로 FINAL의 오버헤드가 미미합니다. 병합되지 않은 파트가 많은 갓 로드된 테이블에서는 FINAL이 상당히 느려질 수 있습니다.
dbt 테스트와 검증 쿼리에서는 RMT 테이블에 항상 FINAL을 사용하세요. Snowflake와의 지연 비교가 목적인 벤치마크 쿼리에서는 ClickHouse 쿼리가 이미 FINAL을 사용하므로 — 비교는 공정합니다.
5. generate_schema_name 매크로
기본적으로 dbt는 모델 스키마 앞에 프로필의 타깃 스키마 이름을 붙입니다. dbt 프로필이 스키마 nyc_taxi_ch를 타깃으로 한다면, +schema: analytics를 가진 모델은 analytics가 아니라 nyc_taxi_ch_analytics에 생성됩니다.
Snowflake에서는 이것이 무해하지만(스키마는 데이터베이스 내의 네임스페이스입니다) 스키마가 곧 데이터베이스인 ClickHouse에서는 어색한 이름을 만듭니다. nyc_taxi_ch_analytics는 유효한 ClickHouse 데이터베이스 이름이지만, analytics보다 보기 안 좋고 Part 3의 ClickHouse 아키텍처에서 사용하는 타깃 데이터베이스 이름과 맞지 않습니다.
해결책은 generate_schema_name 매크로 재정의입니다:
-- macros/generate_schema_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- if custom_schema_name is none -%}
{{ target.schema | lower }}
{%- else -%}
{{ custom_schema_name | lower }}
{%- endif -%}
{%- endmacro %}이 매크로는:
- 모델이
+schema: analytics를 지정하면custom_schema_name을 그대로(소문자로) 반환합니다 - 커스텀 스키마가 없는 모델에는 프로필의 타깃 스키마를 (소문자로) 반환합니다
| lower 필터는 또한 스키마 이름을 일관되게 소문자로 만들어 ClickHouse의 대소문자 구분 식별자 규칙에 맞춥니다(Snowflake Part 1에서는 | upper를 사용했습니다).
위치: macros/generate_schema_name.sql — 최상위 macros/ 디렉터리 안에 있으며, dbt_project.yml이 macro-paths: ["macros"]를 설정합니다.
종합: NYC Taxi dbt 구성 요약
# dbt_project.yml (abbreviated)
models:
nyc_taxi_dbt_ch:
staging:
+schema: staging
+materialized: view # no engine — views need none
intermediate:
+schema: staging
+materialized: ephemeral # inlined as CTE
analytics:
+schema: analytics
+materialized: table
+engine: "MergeTree()" # default for dim_* tables
fact_trips:
+materialized: incremental
+engine: "ReplacingMergeTree(updated_at)"
+incremental_strategy: delete_insert
+unique_key: trip_id
agg_hourly_zone_trips:
+materialized: incremental
+engine: "ReplacingMergeTree(updated_at)"
+incremental_strategy: delete_insert
+unique_key: [hour_bucket, zone_id]-- stg_trips.sql (staging view — the FINAL enforcement point)
SELECT ... FROM {{ source('raw', 'trips_raw') }} FINAL
-- fact_trips.sql (incremental — no FINAL in model body)
SELECT ... FROM {{ ref('int_trips_enriched') }}
{% if is_incremental() %}
WHERE updated_at > (SELECT max(updated_at) FROM {{ this }})
{% endif %}