Google Colab에서 실습하기 Bronze 데이터를 바로 쓰면 생기는 일 2편 에서 Bronze에 원본을 있는 그대로 적재했다. 변환 없이. 그 원칙은 맞다. 문제는 Bronze 데이터가 분석에 쓸 수 있는 상태가 아니라는 것이다. jaffle_shop의 bronze.orders를 보자. order_date 컬럼이 VARCHAR로 들어와 있다. 날짜 함수를 쓸 수 없다. status 컬럼에는 returned, return_pending, completed, placed, shipped가 섞여 있는데, 어느 값이 최종 상태인지 스키마만 봐서는 모른다. bronze.payments의 amount 컬럼은 센트 단위 정수다. 달러로 바꾸려면 100으로 나눠야 한다. 이걸 분석할 때마다 매번 나누는 건 실수를 부르는 구조다. Silver는 이런 것들을 한 번에 정리하는 레이어다. 타입을 맞추고, 컬럼명을 통일하고, 단위를 변환한다. 비즈니스 로직은 아직 넣지 않는다. “분석에 쓸 수 있는 깨끗한 상태"를 만드는 게 Silver의 역할이다. Silver에서 하는 일, 안 하는 일 경계를 지키는 게 중요하다. Silver에서 비즈니스 로직을 넣기 시작하면 Bronze와 Silver를 나눈 의미가 사라진다. Silver에서 하는 일: 타입 캐스팅 - VARCHAR를 DATE, INTEGER를 DECIMAL로 컬럼명 표준화 - user_id와 userId를 user_id로 통일 단위 변환 - 센트를 달러로, 밀리초를 초로 중복 제거 - 같은 레코드가 두 번 적재된 경우 NULL 처리 - 빈 문자열을 NULL로 통일 Silver에서 안 하는 일: KPI 계산 - 매출, 마진율 같은 비즈니스 지표 테이블 조인 - 주문과 고객을 합쳐서 하나의 뷰로 만드는 것 집계 - GROUP BY로 요약하는 것 조인과 집계는 Gold의 몫이다. Silver는 개별 테이블 단위로 정제만 한다. dbt가 필요한 이유 1편 에서 dbt를 도구로 소개했다. 왜 SQL 파일을 직접 실행하지 않고 dbt를 쓰는가. SQL 파일을 하나씩 실행하면 처음엔 문제가 없다. Silver 테이블이 5개, 10개로 늘어나면 상황이 달라진다. 어떤 테이블이 어떤 Bronze 테이블에 의존하는지, 어떤 순서로 실행해야 하는지, 마지막 실행이 언제인지 추적이 안 된다. dbt는 이걸 해결한다. SQL 파일 하나가 하나의 모델이다. ref() 함수로 모델 간 의존 관계를 선언하면 dbt가 실행 순서를 알아서 정한다. 변환 로직이 SQL 파일에 남으니 Git으로 이력 추적도 된다. dbt 프로젝트 세팅 Colab에서 dbt 프로젝트를 만든다. !pip install -q duckdb dbt-core dbt-duckdb import os # dbt 프로젝트 디렉토리 구조 생성 os.makedirs('jaffle_shop/models/staging', exist_ok=True) os.makedirs('jaffle_shop/models/marts', exist_ok=True) dbt 설정 파일을 만든다. DuckDB를 데이터베이스로 쓰도록 지정한다. %%writefile jaffle_shop/dbt_project.yml name: 'jaffle_shop' version: '1.0.0' profile: 'jaffle_shop' model-paths: ["models"] %%writefile jaffle_shop/profiles.yml jaffle_shop: target: dev outputs: dev: type: duckdb path: /content/warehouse.duckdb Silver 모델 작성 dbt에서는 models/staging/ 디렉토리에 Silver 레이어 모델을 둔다. stg_ 접두어가 staging(=Silver)을 뜻한다. stg_orders %%writefile jaffle_shop/models/staging/stg_orders.sql with source as ( select * from bronze.orders ), cleaned as ( select id as order_id, user_id as customer_id, cast(order_date as date) as order_date, status from source ) select * from cleaned Bronze의 id를 order_id로 바꿨다. 여러 테이블을 조인할 때 id만으로는 어느 테이블의 ID인지 알 수 없으니까. user_id도 customer_id로 바꿔서 의미를 명확히 했다. order_date를 DATE로 캐스팅했다. stg_customers %%writefile jaffle_shop/models/staging/stg_customers.sql with source as ( select * from bronze.customers ), cleaned as ( select id as customer_id, first_name, last_name from source ) select * from cleaned stg_payments %%writefile jaffle_shop/models/staging/stg_payments.sql with source as ( select * from bronze.payments ), cleaned as ( select id as payment_id, order_id, payment_method, amount / 100.0 as amount_dollars from source ) select * from cleaned amount를 100으로 나눠서 달러 단위로 바꿨다. 컬럼명도 amount_dollars로 변경해서 단위가 뭔지 이름에서 바로 읽힌다. dbt 실행 !cd jaffle_shop && dbt run --select staging.* dbt가 stg_orders, stg_customers, stg_payments 세 모델을 실행한다. 각각 DuckDB에 뷰로 생성된다. 결과 확인 import duckdb conn = duckdb.connect('warehouse.duckdb') # Silver 레이어 확인 conn.execute("SELECT * FROM stg_orders LIMIT 5").fetchdf() # 타입 확인 — order_date가 DATE로 바뀌었는가 conn.execute(""" SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'stg_orders' """).fetchdf() # payments의 amount가 달러 단위로 변환되었는가 conn.execute("SELECT * FROM stg_payments LIMIT 5").fetchdf() Bronze에서는 VARCHAR였던 order_date가 DATE로 바뀌었다. amount가 센트에서 달러로 변환됐다. 컬럼명이 통일됐다. 이게 Silver다. CTE 패턴 위 SQL에서 반복적으로 쓰인 패턴이 있다. with source as (...), cleaned as (...) select * from cleaned. dbt 커뮤니티에서 널리 쓰이는 CTE(Common Table Expression) 패턴이다. with source as ( -- 1단계: Bronze에서 원본을 가져온다 select * from bronze.orders ), cleaned as ( -- 2단계: 정제 로직을 적용한다 select id as order_id, cast(order_date as date) as order_date from source ) -- 3단계: 최종 결과를 반환한다 select * from cleaned source → cleaned → select. 각 단계가 뭘 하는지 이름에서 읽힌다. 정제 로직이 복잡해지면 CTE를 추가하면 된다. renamed, filtered, deduplicated 같은 이름으로 단계를 나누는 팀도 있다. 중복 제거 패턴 Bronze에 같은 레코드가 두 번 들어오는 경우가 있다. 소스 시스템에서 데이터를 다시 보냈거나, 증분 적재 로직에 버그가 있었거나. Silver에서 이걸 잡아야 한다. with source as ( select * from bronze.orders ), deduplicated as ( select *, row_number() over ( partition by id order by _loaded_at desc ) as row_num from source ), cleaned as ( select id as order_id, user_id as customer_id, cast(order_date as date) as order_date, status from deduplicated where row_num = 1 ) select * from cleaned row_number()로 같은 id가 여러 개 있으면 가장 최근에 적재된 것만 남긴다. 2편 에서 추가한 _loaded_at 메타데이터 컬럼이 여기서 쓰인다. Silver를 함부로 바꾸면 Gold가 깨진다 Gold 모델은 Silver 테이블의 컬럼명, 타입, 단위를 믿고 쓴다. stg_orders의 order_date가 DATE라는 전제로 Gold에서 날짜 함수를 쓰고 있는데, 누군가 Silver에서 컬럼명을 ordered_at으로 바꾸면 Gold 모델이 전부 에러를 뱉는다. 컬럼을 추가하는 건 괜찮다. 기존 컬럼의 이름이나 타입을 바꾸는 게 위험하다. dbt의 ref() 함수가 의존 관계를 추적하니까 어디가 영향 받는지는 알 수 있다. 실무 참고: Airflow에서 dbt 실행 Airflow에서 dbt를 실행하는 방법은 여러 가지다. 가장 간단한 건 BashOperator로 dbt run을 호출하는 것이고, 더 정교하게 하려면 cosmos 라이브러리를 쓴다. from airflow import DAG from airflow.operators.bash import BashOperator from datetime import datetime with DAG( dag_id='silver_transformation', schedule='0 6 * * *', start_date=datetime(2026, 1, 1), catchup=False, ) as dag: # Bronze 적재 완료를 기다린 뒤 Silver 변환 실행 run_staging = BashOperator( task_id='dbt_run_staging', bash_command='cd /opt/dbt/jaffle_shop && dbt run --select staging', ) # dbt test로 Silver 데이터 품질 검증 test_staging = BashOperator( task_id='dbt_test_staging', bash_command='cd /opt/dbt/jaffle_shop && dbt test --select staging', ) run_staging >> test_staging dbt run 다음에 dbt test를 건다. Silver 변환이 끝나면 바로 품질 검증을 돌린다. 테스트가 실패하면 Gold 변환으로 넘어가지 않는다. 불량 데이터가 Gold까지 올라가는 걸 막는 구조다. cosmos 라이브러리를 쓰면 dbt 모델 하나하나를 Airflow 태스크로 분리할 수 있다. stg_orders가 실패해도 stg_customers는 독립적으로 성공한다. 모델이 수십 개로 늘어나면 이 세분화가 의미 있어진다. 다음 글에서는 SCD(Slowly Changing Dimension)를 다룬다. 고객의 주소가 바뀌었을 때 과거 주소를 어떻게 보존하는가. Type 1, 2, 3의 차이와 선택 기준. Google Colab에서 실습하기