본문 바로가기

server/아키텍쳐

1. dbt tutorial

 

왜 dbt인가, 그리고 언제 써야 하나

데이터 팀은 보통 원천 데이터를 웨어하우스로 옮긴 뒤 SQL로 정제하고, 지표를 만들어 대시보드·분석에 제공합니다. 이 과정이 커질수록 SQL 스크립트가 여기저기 흩어지고, 의존성·버전관리·테스트가 어려워집니다. dbt(Data Build Tool) 는 이 문제를 해결하기 위한 표준 도구입니다.

  • SQL 변환을 코드로 관리하고, ref()로 의존 그래프를 안전하게 만들며, 테스트·문서화·라인리지를 자동화합니다. PR 기반 협업과 CI/CD도 용이합니다.
  • 다음을 기준으로 사용하면 좋습니다.
    • 웨어하우스(BigQuery/Snowflake/Redshift/DuckDB 등) 안에서 SQL 기반 변환을 할 때
    • 모델 수가 늘어나 의존성/품질 관리가 중요할 때
    • 대시보드 지표 정의를 코드로 명확히 남기고 재현 가능하게 하고 싶을 때

 

 

dbt 프로젝트 생성

dbt init duck_tutorial

 

 

dbt 프로젝트 폴더별 용도는 다음과 같다. 

 

  • marts: 데이터 읽고, 최종 저장  ( 다만 원시 적재는 보통 외부 또는 seeds/외부테이블에서 처리 )
  • seeds: 초기 데이터 저장용 
  • macros: 재사용 Jinja 코드(함수·테스트·머티리얼라이제이션/후크/네이밍 오버라이드 등)
  • analyses: 컴파일만 되는 분석 쿼리 보관. 실행·테이블 생성 X (문서화/공유 목적)
  • snapshots: 속성 이력관리(SCD2) 를 위한 정의와 실행

 


 

1. duckdb를 활용한 dbt 

예시는 DuckDB 대상입니다. 로컬 csv를 duckdb에 적재하고, duckdb 테이블 데이터를 다른 테이블로 저장하고, 집계하는것을  목표로 합니다.

다른 위치의 profiles를 쓰려면 --profiles-dir 혹은 DBT_PROFILES_DIR 환경변수를 사용합니다.

 

 

profiles.yml dbt 환경 파일 생성

duck_tutorial:
  target: dev
  outputs:
    dev:
      type: duckdb
      path: ./dbt.duckdb   # DuckDB 파일 생성 위치(프로젝트 내부)
      threads: 4
      schema: main

 

 

seeds/customers.csv 파일 생성

customer_id,first_name,last_name,signup_date,tier
1,Jane,Doe,2025-01-09,free
2,John,Kim,2025-01-10,pro
3,Mina,Park,2025-02-01,free
4,Chulsoo,Lee,2025-02-03,enterprise
5,Aria,Choi,2025-02-10,pro

 

 

 

 

 

dbt seed 명령어로 csv의 데이터가 duckdb에 테이블로 적재됩니다. (dbt.duckdb 파일이 생성됩니다.)

$ dbt seed --profiles-dir ./
06:37:03  Running with dbt=1.10.8
06:37:03  Registered adapter: duckdb=1.9.4
06:37:03  Unable to do partial parsing because profile has changed
06:37:04  Found 2 models, 1 seed, 4 data tests, 429 macros
06:37:04  
06:37:04  Concurrency: 4 threads (target='dev')
06:37:04  
06:37:04  1 of 1 START seed file main.customers .......................................... [RUN]
06:37:04  1 of 1 OK loaded seed file main.customers ...................................... [INSERT 5 in 0.06s]
06:37:04  
06:37:04  Finished running 1 seed in 0 hours 0 minutes and 0.20 seconds (0.20s).
06:37:04  
06:37:04  Completed successfully
06:37:04  
06:37:04  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=1

 

 


 

2. 파이프라인을 만들어보자. 

  • staging/: 원천(또는 seed) 데이터를 '깨끗한 표준 스키마'로 만드는 정제 계층
    • 테이블 1:1 대응(가능하면), 컬럼명/타입 정리, 불필요 컬럼 제거, 이상치/NULL 처리, 중복 제거
    • 비즈니스 규칙 최소화(집계·복잡한 조인 지양)
    • 보통 뷰(view) 또는 ephemeral(CTE 인라인) 로 머티리얼라이즈
    • 테스트: not_null, unique, accepted_values, relationships 등 기본 무결성 검증

 

 

staging 모델 작성

이번엔 단순하게 데이터를 정제만 하고 저장하는 예제이다. staging 만 추가해주면 된다. 

models/staing/clean_customers.sql

{{ config(materialized='table', schema='dbt_tutorial', alias='clean_customers', on_schema_change='sync_all_columns') }}

with source as (
  select * from {{ ref('customers') }}
),
clean as (
  select
    cast(customer_id as integer) as customer_id,
    upper(substr(first_name, 1, 1)) || lower(substr(first_name, 2)) as first_name,
    upper(substr(last_name, 1, 1)) || lower(substr(last_name, 2))  as last_name,
    cast(signup_date as date) as signup_date,
    lower(tier) as tier
  from source
)
select * from clean

customers는 seed 이름이므로 ref('customers')로 참조됩니다.

위의 쿼리로 duckdb에 저장된 customers 의 테이블을 읽고, 정제를 하고  dbt_tutorial.clean_customers 테이블에 저장된다. 

 

 

dbt를 실행해보자. 

$ dbt run -s models/staging/clean_customers.sql

 

 

duckdb에 접속하여 저장된 데이터를 확인해 보자. 

$ duckdb ./dbt.duckdb
D set schema='main_dbt_tutorial';
D select * from clean_customers;
┌─────────────┬────────────┬───────────┬─────────────┬────────────┐
│ customer_id │ first_name │ last_name │ signup_date │    tier    │
│    int32    │  varchar   │  varchar  │    date     │  varchar   │
├─────────────┼────────────┼───────────┼─────────────┼────────────┤
│           1 │ Jane       │ Doe       │ 2025-01-09  │ free       │
│           2 │ John       │ Kim       │ 2025-01-10  │ pro        │
│           3 │ Mina       │ Park      │ 2025-02-01  │ free       │
│           4 │ Chulsoo    │ Lee       │ 2025-02-03  │ enterprise │
│           5 │ Aria       │ Choi      │ 2025-02-10  │ pro        │
└─────────────┴────────────┴───────────┴─────────────┴────────────┘

 

 

 


 

이번엔 정제 후 집계까지 하는 예제이다. 

staging 과 mart를 구성해 주면된다. 

 

  • marts/: 분석가/대시보드가 바로 쓰는 비즈니스 지향 모델
    • 차원/팩트(스타 스키마), 지표 집계, 기간별 스냅샷, 복잡한 조인/비즈니스 로직
    • 보통 테이블(table) 로 머티리얼라이즈(성능/비용 최적화 목적)
    • 대규모는 증분(incremental) 로 구성, BQ면 파티션/클러스터 설정

 

모델 작성 (staging → mart)

models/staging/str_customers.sql 작성

with source as (
  select * from {{ ref('customers') }}
),
clean as (
  select
    cast(customer_id as integer)                           as customer_id,
    upper(substr(first_name, 1, 1)) || lower(substr(first_name, 2)) as first_name,
    upper(substr(last_name, 1, 1))  || lower(substr(last_name, 2))  as last_name,
    cast(signup_date as date)                              as signup_date,
    lower(tier)                                            as tier
  from source
)
select * from clean

customers는 seed 이름이므로 ref('customers')로 참조됩니다.

 

mart 모델

모델에 ORDER BY는 넣지 않는 것을 권장합니다. 물리 테이블 생성(CTAS) 시 정렬은 저장 순서를 보장하지 않고, 특히 파티션/클러스터를 함께 사용할 때 에러의 원인이 될 수 있습니다. (정렬이 필요하면  조회 시점에 사용하세요.)

 

이번엔 mart를 추가해준다.  가입 월별 티어 카운트를 내보는 예제입니다.

models/marts/customer_metrics.sql

with base as (
  select
    customer_id,
    tier,
    date_trunc('month', signup_date) as signup_month
  from {{ ref('stg_customers') }}
)
select
  signup_month,
  tier,
  count(*) as customer_cnt
from base
group by 1,2

 

테스트 정의 (models/schema.yml)

version: 2

models:
  - name: stg_customers
    columns:
      - name: customer_id
        tests:
          - not_null
          - unique
      - name: tier
        tests:
          - accepted_values:
              values: ['free', 'pro', 'enterprise']

  - name: customer_metrics
    columns:
      - name: signup_month
        tests:
          - not_null
      - name: customer_cnt
        tests:
          - not_null

 

 

현재 models의 최종 형태는 다음과 같다.

 

 

# 모델 빌드
dbt run

# 테스트
dbt test

 

dbt run
06:44:39  Running with dbt=1.10.8
06:44:40  Registered adapter: duckdb=1.9.4
06:44:40  [WARNING][MissingArgumentsPropertyInGenericTestDeprecation]: Deprecated
functionality
Found top-level arguments to test `accepted_values`. Arguments to generic tests
should be nested under the `arguments` property.`
06:44:40  [WARNING]: Configuration paths exist in your dbt_project.yml file which do not apply to any resources.
There are 1 unused configuration paths:
- models.duck_tutorial.example
06:44:40  Found 1 seed, 2 models, 5 data tests, 429 macros
06:44:40  
06:44:40  Concurrency: 4 threads (target='dev')
06:44:40  
06:44:40  1 of 2 START sql view model main.stg_customers ................................. [RUN]
06:44:40  1 of 2 OK created sql view model main.stg_customers ............................ [OK in 0.05s]
06:44:40  2 of 2 START sql view model main.customer_metrics .............................. [RUN]
06:44:40  2 of 2 OK created sql view model main.customer_metrics ......................... [OK in 0.02s]
06:44:40  
06:44:40  Finished running 2 view models in 0 hours 0 minutes and 0.26 seconds (0.26s).
06:44:41  
06:44:41  Completed successfully
06:44:41  
06:44:41  Done. PASS=2 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=2

 

 

dbt test

dbt test
06:45:13  Running with dbt=1.10.8
06:45:13  Registered adapter: duckdb=1.9.4
06:45:13  [WARNING]: Configuration paths exist in your dbt_project.yml file which do not apply to any resources.
There are 1 unused configuration paths:
- models.duck_tutorial.example
06:45:13  Found 1 seed, 2 models, 5 data tests, 429 macros
06:45:13  
06:45:13  Concurrency: 4 threads (target='dev')
06:45:13  
06:45:13  3 of 5 START test not_null_customer_metrics_signup_month ....................... [RUN]
06:45:13  4 of 5 START test not_null_stg_customers_customer_id ........................... [RUN]
06:45:13  2 of 5 START test not_null_customer_metrics_customer_cnt ....................... [RUN]
06:45:13  1 of 5 START test accepted_values_stg_customers_tier__free__pro__enterprise .... [RUN]
06:45:13  4 of 5 PASS not_null_stg_customers_customer_id ................................. [PASS in 0.06s]
06:45:13  1 of 5 PASS accepted_values_stg_customers_tier__free__pro__enterprise .......... [PASS in 0.06s]
06:45:13  3 of 5 PASS not_null_customer_metrics_signup_month ............................. [PASS in 0.06s]
06:45:13  2 of 5 PASS not_null_customer_metrics_customer_cnt ............................. [PASS in 0.06s]
06:45:13  5 of 5 START test unique_stg_customers_customer_id ............................. [RUN]
06:45:13  5 of 5 PASS unique_stg_customers_customer_id ................................... [PASS in 0.01s]
06:45:13  
06:45:13  Finished running 5 data tests in 0 hours 0 minutes and 0.16 seconds (0.16s).
06:45:13  
06:45:13  Completed successfully
06:45:13  
06:45:13  Done. PASS=5 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=5

 

 

duckdb에서 데이터 확인 

$ duckdb ./dbt.duckdb 

set schema='main_dbt_tutorial';
D select * from customer_metrics;
┌──────────────┬────────────┬──────────────┐
│ signup_month │    tier    │ customer_cnt │
│     date     │  varchar   │    int64     │
├──────────────┼────────────┼──────────────┤
│ 2025-01-01   │ free       │            1 │
│ 2025-01-01   │ pro        │            1 │
│ 2025-02-01   │ enterprise │            1 │
│ 2025-02-01   │ free       │            1 │
│ 2025-02-01   │ pro        │            1 │
└──────────────┴────────────┴──────────────┘

 

 

 


3. BigQuery에 저장하기

이번엔 duckdb가 아닌 bigquery에 데이터를 저장하려고 한다. 

 

어댑터 설치

pip install dbt-bigquery

 

 

 

profile.yaml을 수정한다. 

(credentials.json 에는 bigquery 권한이 있는 키를 생성해서 넣어준다.)

duck_tutorial:
  target: bq
  outputs:
    bq:
      type: bigquery
      method: service-account
      keyfile: ./auth/credentials.json
      project: your-gcp-project-id
      dataset: dbt_tutorial             
      location: asia-northeast3 
      threads: 4
      timeout_seconds: 300

 

 

 

dbt_project.yml 보강

seed를 통해 원천 데이터가 있는곳을 bigquery로 변경합니다.

(칼럼 타입이 없으면 에러가 발생합니다.)

name: 'duck_tutorial'
profile: 'duck_tutorial'
version: '1.0'

models:
  duck_tutorial: 
    staging: { +schema: stg }    
    marts:   { +schema: mart }   

seeds:
  duck_tutorial:
    +schema: raw                  
    +column_types:
      customers:                  # seeds/customers.csv → 테이블명 customers
        customer_id: INT64
        first_name: STRING
        last_name: STRING
        signup_date: DATE
        tier: STRING

 

모델 SQL(BigQuery 문법으로 수정)

마지막으로 models/staging/clean_customers.sql에서 캐스팅을 BigQuery 타입으로 바꾸고, config에 파티션 정보를 추가해줍니다.

{{
  config(
    materialized='table',
    schema='dbt_tutorial',           
    alias='clean_customers',         
    partition_by={'field': 'signup_date', 'data_type': 'date'}, 
    cluster_by=['tier'],             
    on_schema_change='sync_all_columns'
  )
}}

with source as (
  select * from {{ ref('customers') }}
),
clean as (
  select
    cast(customer_id as integer) as customer_id,
    upper(substr(first_name, 1, 1)) || lower(substr(first_name, 2)) as first_name,
    upper(substr(last_name, 1, 1)) || lower(substr(last_name, 2))  as last_name,
    cast(signup_date as date) as signup_date,
    lower(tier) as tier
  from source
)
select * from clean

 

 

customers 데이터를 빅쿼리에 업로드하고, clean_custoemers를 실행합니다.

# 시드 적재(스키마 충돌 방지를 위해 최초에는 --full-refresh 권장)
dbt seed --full-refresh --profiles-dir ./

# 모델 실행: raw.customers → stg.clean_customers
dbt run -s models/staging/clean_customers.sql --profiles-dir ./

 

dbt run -s models/staging/clean_customers.sql --profiles-dir ./

10:55:32  Running with dbt=1.10.8
10:55:34  Registered adapter: bigquery=1.10.1
10:55:34  Found 3 models, 1 seed, 8 data tests, 496 macros
10:55:34  
10:55:34  Concurrency: 4 threads (target='bq')
10:55:34  
10:55:39  1 of 1 START sql table model dbt_tutorial_stg.clean_customers .................. [RUN]
10:55:42  1 of 1 OK created sql table model dbt_tutorial_stg.clean_customers ............. [CREATE TABLE (5.0 rows, 174.0 Bytes processed) in 2.73s]
10:55:42  
10:55:42  Finished running 1 table model in 0 hours 0 minutes and 7.40 seconds (7.40s).
10:55:42  
10:55:42  Completed successfully
10:55:42  
10:55:42  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=1

 

 

dbt_tutorial_raw에 원본 데이터, dbt_tutorial_stg에 clean 된 데이터가 생성되었다.  (해당 데이터셋의 이름은 profile.yaml의 셋팅과 mart에 있는 sql의 config에 설정되어 있다)

 

 


4. 스토리지 -> 빅쿼리로 데이터 저장

마지막으로 원천 데이터가 빅쿼리가 아닌 스토리지에 있다고 가정해보자. 

로컬 seed는 더 이상 쓰지 않고, BigQuery 외부 테이블을 통해 GCS를 직접 참조합니다.

 

패키지 설치

packages.yml

packages:
  - package: dbt-labs/dbt_external_tables
    version: 0.11.1

 

dbt deps명령어를 통해 패키지를 설치한다. 

$ dbt deps

 dbt deps
11:07:18  Running with dbt=1.10.8
11:07:19  Updating lock file in file path: /Users/hyeon.seungjae/devloper/test-dbt/duck_tutorial/package-lock.yml
11:07:19  Installing dbt-labs/dbt_external_tables
11:07:19  Installed from version 0.11.1
11:07:19  Up to date!

 

 

소스 선언(외부 테이블)

models/sources/gcs_raw.yml

version: 2

sources:
  - name: raw
    schema: raw                 # 외부 테이블 메타가 기록될 BigQuery Dataset (선택)
    tables:
      - name: customers_gcs     # 외부 테이블 이름
        description: "GCS의 customers CSV"
        external:
          location: "gs://<버킷명>/path/to/customers/*.csv"
          options:
            format: CSV
            skip_leading_rows: 1
            field_delimiter: ","
            allow_jagged_rows: true
            allow_quoted_newlines: true
        columns:                 # 컬럼/타입 정의(특히 CSV는 권장)
          - name: customer_id
            data_type: INT64
          - name: first_name
            data_type: STRING
          - name: last_name
            data_type: STRING
          - name: signup_date
            data_type: DATE
          - name: tier
            data_type: STRING

 

외부 테이블 생성/갱신됩니다. 

dbt run-operation stage_external_sources --vars 'ext_full_refresh: true'

 

 

스키마 네이밍(접두사 결합) 제어

BigQuery의 기본 규칙은 target.schema(=profiles.yml의 dataset)에 +schema를 _로 결합합니다.
원하는 이름 그대로 쓰고 싶다면 매크로로 오버라이드해서 사용하면 된다.

macros/generate_schema_name.sql

{% macro generate_schema_name(custom_schema_name, node) -%}
  {%- set custom = (custom_schema_name or '') | trim -%}
  {%- if custom == '' -%}
    {{ target.schema }}
  {%- else -%}
    {{ custom }}   
  {%- endif -%}
{%- endmacro %}

 

 

실행 

$ dbt run -s models/staging/clean_customers.sql

dbt run -s models/staging/clean_customers.sql
07:02:26  Running with dbt=1.10.8
07:02:28  Registered adapter: bigquery=1.10.1
07:02:29  Found 3 models, 1 seed, 8 data tests, 1 source, 547 macros
07:02:29  
07:02:29  Concurrency: 4 threads (target='bq')
07:02:29  
07:02:31  1 of 1 START sql table model stg.clean_customers ............................... [RUN]
07:02:34  1 of 1 OK created sql table model stg.clean_customers .......................... [CREATE TABLE (5.0 rows, 143.0 Bytes processed) in 3.15s]
07:02:34  
07:02:34  Finished running 1 table model in 0 hours 0 minutes and 5.82 seconds (5.82s).
07:02:34  
07:02:34  Completed successfully
07:02:34  
07:02:34  Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 TOTAL=1

 

 

데이터 확인

 

 

 


5. 스토리지 → BigQuery 집계(marts)까지

 

마지막으로 스토리지의 csv 데이터를 통해서 빅쿼리에 집계된 데이터를 저장해보자. 

 

 

기존 duckdb를 사용하여 config가 없었음. 

빅쿼리에 저장하도록 config 추가 (저장할곳 셋팅)

기존 from 절의 customers -> raw.cutoemrs_gcs를 보도록 변경한다. 

{{
  config(
    materialized='table',
    schema='dbt_tutorial_mart',       
    alias='customer_metrics',      
    partition_by={'field': 'signup_month', 'data_type': 'date'},
    cluster_by=['tier'],           
    on_schema_change='sync_all_columns'
  )
}}



with src as (
  select
    safe_cast(signup_date as date) as signup_date,
    lower(tier) as tier
  from {{ source('raw','customers_gcs') }}
  where signup_date is not null
),
base as (
  select
    DATE_TRUNC(signup_date, MONTH) as signup_month,
    tier
  from src
  where signup_date is not null
)
select
  signup_month,
  tier,
  count(*) as customer_cnt
from base
group by 1,2

 

 

해당 집계 sql을 실행해서, 테이블 생성 및 데이터를 저장한다. 

dbt run -s models/marts/customer_metrics.sql --profiles-dir ./ --no-partial-parse

 

 

 

결과 확인

 

 

 

 

 

 

 

 

 

 

 

 

 


 

번외

 

07:24:30  Finished running 1 table model in 0 hours 0 minutes and 2.96 seconds (2.96s).
07:24:30  
07:24:30  Completed with 1 error, 0 partial successes, and 0 warnings:
07:24:30  
07:24:30  Failure in model customer_metrics (models/marts/customer_metrics.sql)
07:24:30    Database Error in model customer_metrics (models/marts/customer_metrics.sql)
  Result of ORDER BY queries cannot be partitioned by field 'signup_month'
  compiled code at target/run/duck_tutorial/models/marts/customer_metrics.sql

 

빅쿼리의 파티셔닝 테이블에서는 order by 키워드를 넣으면 에러가 발생한다. 

그래서 해당 쿼리에서 삭제 했다. 

with src as (
  select
    safe_cast(signup_date as date) as signup_date,
    lower(tier) as tier
  from {{ source('raw','customers_gcs') }}
  where signup_date is not null
),
base as (
  select
    DATE_TRUNC(signup_date, MONTH) as signup_month,
    tier
  from src
  where signup_date is not null
)
select
  signup_month,
  tier,
  count(*) as customer_cnt
from base
group by 1,2

 

하지만 계속 같은 에러가 발생해서 

 

dbt clean
dbt compile -s models/marts/customer_metrics.sql --profiles-dir ./ --no-partial-parse
07:26:14  Found 3 models, 1 seed, 8 data tests, 1 source, 547 macros
07:26:14  
07:26:14  Concurrency: 4 threads (target='bq')
07:26:14  
Compiled node 'customer_metrics' is:




with src as (
  select
    -- 문자열일 수 있으므로 안전 캐스팅
    safe_cast(signup_date as date) as signup_date,
    lower(tier) as tier
  from 프로젝트이름.`dbt_tutorial`.`customers_gcs`
  where signup_date is not null
),
base as (
  select
    -- BigQuery 표준 표기: DATE_TRUNC(date_expr, DATE_PART)
    DATE_TRUNC(signup_date, MONTH) as signup_month,
    tier
  from src
  where signup_date is not null
)
select
  signup_month,
  tier,
  count(*) as customer_cnt
from base
group by 1,2
order by 1,2

 

컴파일한 파일에서는 예전 파일을 계속 참조 잇는것으로 나온다. clean으로 지웠지만, 소용이 없었다. 

 

실제 바라보는 경로가 맞는지 확인하기 

dbt ls -s models/marts/customer_metrics.sql --output path

 

정말 해당 파일이 맞는지 확인하기 (저장안했느지, IDE의 에러인지 확인하기)

sed -n '1,200p' models/marts/customer_metrics.sql

 

나의 경우 IDE 에러로, 저장이 안되고 있었다. 확인을 잘하자.....

 

 

끝 

 

'server > 아키텍쳐' 카테고리의 다른 글

2. airflow + dbt  (1) 2025.08.31
로또 시스템 아키텍처  (3) 2025.08.03
rabbitmq 심화 (persistent / cluster)  (0) 2024.05.05
rabbitmq start  (1) 2024.05.04
airflow scheduler high cpu usage  (1) 2021.11.29