LLM WikiAccess-protected knowledge portal
← 스터디 홈
4편 · 약 24분

dbt 모델링 심화: staging/intermediate/mart, incremental, snapshot

레이어가 왜 필요한가

dbt를 처음 쓸 때는 소스 테이블을 바로 집계해서 리포트를 만들고 싶은 유혹이 있다. 모델이 5개라면 그게 더 빠르다. 모델이 50개가 되면 문제가 보이기 시작한다. dim_customer를 7개 모델이 각자 다르게 조인하고 있고, 고객 등급 로직이 바뀌면 7곳을 전부 찾아서 고쳐야 한다.

dbt가 권장하는 staging → intermediate → mart 3레이어 구조는 이 중복을 없애기 위한 설계다. 각 레이어는 서로 다른 책임을 진다. 레이어 경계를 지키면 변경 영향이 한 레이어 안에 머문다.


1. 레이어별 책임

Raw Sources orders customers products 원천 적재 그대로 Staging stg_orders stg_customers stg_products 1:1 소스 매핑 컬럼명·타입 표준화 view materialize 비즈니스 로직 없음 소스당 1 모델 모델 수: 소스 수와 1:1 Intermediate int_orders_enriched int_customers_active int_products_priced 비즈니스 로직 집중 재사용 가능한 조인·필터 grain 변환 (re-graining) SCD Type 2 적용 테이블 materialize 권장 4-6개 엔티티 조합 직접 노출 금지 mart의 재료 역할 _전용 폴더 분리 권고 Mart mart_orders mart_customer_ltv mart_product_perf 도메인별 wide table BI 도구·분석가 직접 사용 테이블 materialize 영어 단수명사 (orders) 비즈니스 부서 단위 finance / marketing / product 하위 도메인 폴더 분리 재사용보다 명확성 우선 셀프서비스 분석 레이어
dbt 3레이어 구조와 데이터 흐름

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 renamed

staging에서 하면 안 되는 것: 다른 테이블과의 조인, 집계, 비즈니스 규칙 기반 파생 컬럼. 이런 것들은 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 enriched

1.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 final

2. 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 전략별 비교

append
새 행만 INSERT
중복 검사 없음
가장 빠름
이벤트 로그처럼 변경 없는 데이터
주의: 재처리 시
중복 행 발생
merge (기본값)
unique_key 기준
없으면 INSERT
있으면 UPDATE
SCD Type 1 패턴
중간 규모 테이블
주의: 대용량에서
MERGE 비용 높음
delete+insert
unique_key 삭제 후
재삽입
MERGE 미지원 엔진
배치 치환 패턴
주의: atomic하지 않음
중간에 빈 윈도우
insert_overwrite
파티션 단위 교체
BigQuery 시간 파티션
대용량 사실 테이블
주의: 파티션 외
컬럼 갱신 불가
Incremental 전략 비교

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 전략

check 전략
동작: check_cols의 값이 변경되면 새 행 삽입, 이전 행 종료

장점: 소스 테이블에 updated_at이 없어도 됨

단점: 매 실행마다 전체 비교 → 비용 높음
대용량 테이블에서 느림
check_cols=['status', 'tier'] 처럼
변경 감지가 필요한 컬럼 명시
timestamp 전략
동작: updated_at > 마지막_실행 인 행만 처리

장점: 처리 범위 좁아서 빠름
대용량 테이블에 적합

단점: 소스에 신뢰할 수 있는
updated_at이 있어야 함
updated_column_name='updated_at' 설정
타임스탬프 누락 시 변경 감지 못함
Snapshot 전략 비교

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 선택 가이드

모델 유형 결정 소스 정제(1:1)인가, 비즈니스 로직인가? 소스 정제 view staging: 항상 view 비즈니스 로직 여러 mart가 재사용하는가? (intermediate 후보) table intermediate: table 권장 아니오 → mart 대용량 + 증분 갱신? (1억 행 이상 기준) incremental merge / insert_overwrite 아니오 table mart: table 기본값
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/