dbt 모델링 심화: staging/intermediate/mart, incremental, snapshot
레이어가 왜 필요한가
dbt를 처음 쓸 때는 소스 테이블을 바로 집계해서 리포트를 만들고 싶은 유혹이 있다. 모델이 5개라면 그게 더 빠르다. 모델이 50개가 되면 문제가 보이기 시작한다. dim_customer를 7개 모델이 각자 다르게 조인하고 있고, 고객 등급 로직이 바뀌면 7곳을 전부 찾아서 고쳐야 한다.
dbt가 권장하는 staging → intermediate → mart 3레이어 구조는 이 중복을 없애기 위한 설계다. 각 레이어는 서로 다른 책임을 진다. 레이어 경계를 지키면 변경 영향이 한 레이어 안에 머문다.
1. 레이어별 책임
1.1 Staging: 소스의 거울
Staging 레이어의 규칙은 간단하다. 원천 테이블 하나당 staging 모델 하나, 비즈니스 로직 없이 정제만한다.
-- models/staging/orders/stg_orders.sql
with source as (
select * from {{ source('raw', 'orders') }}
),
renamed as (
select
-- 식별자 표준화
id as order_id,
user_id as customer_id,
-- 타입 정규화
cast(created_at as timestamp) as created_at,
cast(total_amount as numeric(12,2)) as amount,
-- 컬럼명 명확화
status as order_status,
-- 불필요 컬럼 제거: internal_debug_flag 제외
_fivetran_deleted as is_deleted
from source
where _fivetran_deleted = false
)
select * from renamedstaging에서 하면 안 되는 것: 다른 테이블과의 조인, 집계, 비즈니스 규칙 기반 파생 컬럼. 이런 것들은 intermediate로 간다.
1.2 Intermediate: 재사용 가능한 비즈니스 로직
Intermediate는 mart의 재료다. 여러 mart에서 공통으로 쓰이는 조인·변환을 여기서 한 번 정의한다.
-- models/intermediate/orders/int_orders_enriched.sql
with orders as (
select * from {{ ref('stg_orders') }}
),
customers as (
select * from {{ ref('stg_customers') }}
where is_current = true -- SCD 현재 행
),
enriched as (
select
o.order_id,
o.created_at,
o.amount,
o.order_status,
-- 고객 속성 비정규화 (mart 조인 비용 절감)
c.customer_id,
c.customer_tier,
c.signup_date,
-- 비즈니스 파생 컬럼
case
when o.order_status in ('paid', 'completed', 'fulfilled')
then true else false
end as is_paid,
date_trunc('month', o.created_at) as order_month
from orders o
left join customers c using (customer_id)
)
select * from enriched1.3 Mart: 분석가가 직접 쓰는 테이블
Mart는 도메인별로 분리하고, 분석가가 SQL 없이도 이해할 수 있게 wide table로 만든다.
-- models/marts/finance/mart_orders.sql
{{
config(
materialized='table',
schema='finance'
)
}}
with orders as (
select * from {{ ref('int_orders_enriched') }}
),
final as (
select
order_id,
created_at,
order_month,
customer_id,
customer_tier,
amount,
-- 재무팀 정의: paid 상태만 집계 대상
case when is_paid then amount else 0 end as paid_amount,
order_status,
is_paid
from orders
)
select * from final2. Incremental 모델: 전략 비교
대용량 사실 테이블을 매번 전체 재생성하면 비용과 시간이 너무 크다. materialized='incremental'은 마지막 실행 이후의 새 데이터만 처리한다.
2.1 기본 패턴
-- models/marts/finance/mart_daily_revenue.sql
{{
config(
materialized='incremental',
unique_key='order_date',
incremental_strategy='merge'
)
}}
with orders as (
select * from {{ ref('int_orders_enriched') }}
{% if is_incremental() %}
-- 마지막 적재 이후 데이터만 처리
where created_at > (select max(created_at) from {{ this }})
{% endif %}
),
daily_agg as (
select
order_month as order_date,
sum(paid_amount) as revenue,
count(order_id) as order_count
from orders
group by 1
)
select * from daily_agg{% if is_incremental() %} 블록은 첫 실행(테이블이 없을 때)에는 평가되지 않는다. 전체 재생성이 필요할 때는 dbt run --full-refresh로 강제한다.
2.2 전략별 비교
중복 검사 없음
가장 빠름
이벤트 로그처럼 변경 없는 데이터
중복 행 발생
없으면 INSERT
있으면 UPDATE
SCD Type 1 패턴
중간 규모 테이블
MERGE 비용 높음
재삽입
MERGE 미지원 엔진
배치 치환 패턴
중간에 빈 윈도우
BigQuery 시간 파티션
대용량 사실 테이블
컬럼 갱신 불가
2.3 엔진별 권장 전략
BigQuery:
- 시간 파티션 사실 테이블 → insert_overwrite
- 차원 테이블 (row-level 갱신 필요) → merge
- 이벤트 로그 (중복 없음) → append
Snowflake:
- 100M 행 미만 → merge
- 100M 행 초과 → delete+insert
- insert_overwrite는 Snowflake에서 동작 다름 (사용 비권장)
Redshift:
- 기본 delete+insert (MERGE 지원 추가됨)
- 대용량 → insert_overwrite (sort key 파티션 기반)2.4 incremental_predicates
대용량 테이블에서 MERGE가 전체 테이블을 스캔하는 문제를 방지하기 위해 incremental_predicates를 사용한다.
{{
config(
materialized='incremental',
unique_key='event_id',
incremental_strategy='merge',
incremental_predicates=[
"DBT_INTERNAL_DEST.event_date >= current_date - 7"
]
)
}}이 설정은 MERGE의 대상 범위를 최근 7일로 제한한다. 7일보다 오래된 데이터는 변경이 없다는 보장이 있을 때만 사용 가능하다.
3. Snapshot: SCD Type 2 자동화
Incremental 모델은 현재 상태를 유지한다. "고객이 2026-01-01에 Bronze 등급이었고 2026-04-01에 Gold로 변경되었다"는 이력을 추적하려면 Snapshot이 필요하다. 이것이 Slowly Changing Dimension Type 2다.
3.1 Snapshot 정의
-- snapshots/customers_snapshot.sql
{% snapshot customers_snapshot %}
{{
config(
target_schema='snapshots',
unique_key='customer_id',
strategy='check',
check_cols=['customer_tier', 'email', 'country'],
invalidate_hard_deletes=True
)
}}
select
customer_id,
email,
customer_tier,
country,
updated_at
from {{ source('raw', 'customers') }}
{% endsnapshot %}dbt가 자동으로 dbt_scd_id, dbt_updated_at, dbt_valid_from, dbt_valid_to 컬럼을 추가한다. 현재 유효한 행은 dbt_valid_to IS NULL로 필터링한다.
3.2 check vs timestamp 전략
장점: 소스 테이블에 updated_at이 없어도 됨
단점: 매 실행마다 전체 비교 → 비용 높음
대용량 테이블에서 느림
변경 감지가 필요한 컬럼 명시
장점: 처리 범위 좁아서 빠름
대용량 테이블에 적합
단점: 소스에 신뢰할 수 있는
updated_at이 있어야 함
타임스탬프 누락 시 변경 감지 못함
3.3 Snapshot 결과 활용
Snapshot 테이블을 downstream 모델에서 참조할 때는 dbt_valid_to로 현재/과거 행을 구분한다.
-- models/intermediate/int_customers_historical.sql
select
customer_id,
customer_tier,
dbt_valid_from,
dbt_valid_to,
-- 현재 행 여부
case when dbt_valid_to is null then true else false end as is_current
from {{ ref('customers_snapshot') }}-- 특정 날짜 기준 고객 등급 조회 (point-in-time query)
select *
from {{ ref('int_customers_historical') }}
where customer_id = 'cust_001'
and '2026-03-15' between dbt_valid_from and coalesce(dbt_valid_to, '9999-12-31')4. Materialization 선택 가이드
| Materialization | 비용 | 사용 레이어 | 비고 |
|---|---|---|---|
view | 조회마다 계산 | staging | 데이터 저장 없음 |
table | 빌드마다 전체 재생성 | intermediate, mart | 단순하고 명확 |
incremental | 신규 데이터만 처리 | 대용량 mart | 복잡도 증가 |
ephemeral | 호출 시 CTE로 인라인 | 임시 중간 계산 | 디버깅 어려움 |
5. 실전 팁: 흔한 실수와 해결책
5.1 Incremental 모델의 late-arriving data
이벤트가 생성 시각 기준이 아닌 서버 수신 시각으로 적재될 때, 늦게 도착한 이벤트가 incremental 필터에 걸리지 않는다.
-- 안전 마진: 최근 3일 데이터를 항상 재처리
{% if is_incremental() %}
where event_at >= (select max(event_at) from {{ this }}) - interval '3 days'
{% endif %}3일 안전 마진은 중복을 허용하는 대신 누락을 방지한다. unique_key를 설정하면 MERGE가 중복을 처리해준다.
5.2 Snapshot 실행 주기
Snapshot은 실행할 때만 변경을 감지한다. 하루 한 번 실행하면 하루 안에 여러 번 변경된 경우 중간 상태를 놓친다. 비즈니스적으로 얼마나 세밀한 이력이 필요한지에 따라 실행 주기를 결정한다.
5.3 레이어 간 참조 방향
허용: mart → intermediate → staging → source
mart → staging (intermediate를 거치지 않고 직접)
금지: staging → intermediate (역방향)
staging → mart (역방향)
다른 도메인의 mart를 mart에서 ref()로 직접 참조
(공통 로직은 intermediate로 올려야 함)마무리
staging/intermediate/mart 3레이어 구조는 규칙의 목적을 이해할 때 지키기 쉬워진다. 규칙의 핵심은 "변경이 퍼지는 범위를 한 레이어 안에 가두는 것"이다. Incremental 모델은 그 범위에서 비용을 줄이는 방법이고, Snapshot은 이력을 남기는 방법이다.
다음 편에서는 이 모델들이 올바르게 작동하는지 검증하는 dbt 테스트와, 코드와 함께 데이터를 문서화하는 방법을 다룬다.
References
- dbt Labs, "Staging: Preparing our atomic building blocks": https://docs.getdbt.com/best-practices/how-we-structure/2-staging
- dbt Labs, "Intermediate: Purpose-built transformation steps": https://docs.getdbt.com/best-practices/how-we-structure/3-intermediate
- dbt Labs, "Marts: Business-defined entities": https://docs.getdbt.com/best-practices/how-we-structure/4-marts
- dbt Labs, "Incremental models in-depth": https://docs.getdbt.com/best-practices/materializations/4-incremental-models
- dbt Labs, "About incremental strategy": https://docs.getdbt.com/docs/build/incremental-strategy
- dbt Labs, "Configure incremental models": https://docs.getdbt.com/docs/build/incremental-models
- Adrienne Vermorel, "Incremental Strategy Decision Framework": https://adriennevermorel.com/notes/incremental-strategy-decision-framework/
- Adrienne Vermorel, "Merge vs. Delete+Insert vs. Insert_Overwrite: Choosing the Right dbt Strategy": https://adriennevermorel.com/articles/dbt-incremental-strategy-comparison/
- Manik Hossain, "DBT Models in Snowflake: Best Practices for Staging, Intermediate, and Mart Layers": https://medium.com/@manik.ruet08/dbt-models-in-snowflake-best-practices-for-staging-intermediate-and-mart-layers-2abf37d08f65
- DataEngineerAcademy, "Incremental Data Models in dbt: Append, Merge, and Snapshot Strategies": https://dataengineeracademy.com/blog/incremental-data-models-in-dbt-append-merge-and-snapshot-strategies/