Google Colab에서 실습하기 고객이 이사했다. 과거 주문의 배송지는? jaffle_shop의 고객 한 명이 서울에서 부산으로 이사했다고 하자. customers 테이블에서 city를 ‘부산’으로 UPDATE한다. 이제 과거 주문을 조회하면 배송지가 전부 “부산"으로 나온다. 실제로는 서울로 배송된 주문인데. DW 모델링 2편 에서 다뤘던 “시점 데이터” 문제와 같은 구조다. OLTP는 현재 상태만 관리한다. DW는 “그 시점에 어떤 값이었는가"를 알아야 한다. 담당자가 바뀌면 과거 실적은 누구 기준으로 볼 것인가. 고객 등급이 바뀌면 과거 주문은 어느 등급으로 집계할 것인가. 같은 문제다. Kimball이 이걸 체계적으로 정리했다. SCD(Slowly Changing Dimension) — 천천히 변하는 차원. 차원 속성이 바뀌면 어떻게 할 것인가를 유형별로 나눈 체계다. “천천히"라는 이름은 팩트 데이터(주문, 로그)처럼 끊임없이 쌓이는 것과 대비해서 붙었다. 고객 주소, 상품 카테고리, 사원 소속 부서. 자주 바뀌지 않지만, 바뀌기는 한다. SCD Type 1 - 덮어쓴다 가장 단순한 방법이다. 현재 값으로 UPDATE하고 끝낸다. 과거 이력은 사라진다. -- Type 1: 현재 값으로 덮어쓴다 UPDATE dim_customers SET city = '부산' WHERE customer_id = 1; 실행하고 나면 해당 고객의 과거 주문을 조인하든 현재 주문을 조인하든 전부 “부산"으로 나온다. 서울에 살던 시절의 정보는 없다. Type 1이 맞는 경우가 있다. 오타 수정이 대표적이다. “서울특별시"를 “서울시"로 바꾸는 건 이력을 남길 이유가 없다. 코드 테이블의 설명 문구 변경, 고객 이름의 오타 교정. 과거를 알 필요가 없는 속성에 쓴다. SCD Type 2 - 이력을 쌓는다 과거 값을 보존해야 하면 Type 2를 쓴다. 기존 행을 닫고, 새 행을 추가한다. 테이블에 세 개의 컬럼을 추가한다. valid_from -이 행이 유효하기 시작한 시점 valid_to -이 행이 유효하지 않게 된 시점 (현재 행은 9999-12-31) is_current -현재 유효한 행인지 여부 -- 초기 상태: customer_id = 1, 서울 -- dim_customers_sk | customer_id | city | valid_from | valid_to | is_current -- 1001 | 1 | 서울 | 2025-01-01 | 9999-12-31 | true 고객이 부산으로 이사하면 두 단계를 실행한다. -- 1단계: 기존 행을 닫는다 UPDATE dim_customers SET valid_to = '2026-02-15', is_current = false WHERE customer_id = 1 AND is_current = true; -- 2단계: 새 행을 추가한다 INSERT INTO dim_customers (dim_customers_sk, customer_id, city, valid_from, valid_to, is_current) VALUES (1002, 1, '부산', '2026-02-15', '9999-12-31', true); 이제 한 customer_id에 행이 두 개다. -- dim_customers_sk | customer_id | city | valid_from | valid_to | is_current -- 1001 | 1 | 서울 | 2025-01-01 | 2026-02-15 | false -- 1002 | 1 | 부산 | 2026-02-15 | 9999-12-31 | true 여기서 dim_customers_sk 가 서로게이트 키(surrogate key)다. 한 고객에 여러 행이 생기니까 customer_id만으로는 행을 고유하게 식별할 수 없다. 별도 대리 키가 필요한 이유다. 설계 세부 내용은 Gold 편에서 다룬다. 시점 조회는 이렇게 한다. -- 주문 시점의 고객 주소를 조인 SELECT o.order_id, o.order_date, c.city AS city_at_order_time FROM fct_orders o JOIN dim_customers c ON o.customer_id = c.customer_id AND o.order_date BETWEEN c.valid_from AND c.valid_to; 2025년 6월 주문은 “서울”, 2026년 3월 주문은 “부산”. 각 주문 시점의 실제 값이 나온다. DW 모델링 1편 에서 “SCD Type 2 스토리지 부담이 작아졌다"고 언급했다. 클라우드 Columnar Storage에서는 행이 늘어나는 비용이 온프레미스 대비 훨씬 작다. Type 2를 더 적극적으로 쓸 수 있는 환경이다. SCD Type 3 -이전 값을 컬럼으로 남긴다 이력 깊이가 1단계면 충분할 때 쓴다. 별도 컬럼에 직전 값을 저장한다. -- Type 3: 이전 값을 컬럼으로 -- customer_id | city | previous_city -- 1 | 부산 | 서울 구현은 단순하다. UPDATE dim_customers SET previous_city = city, city = '부산' WHERE customer_id = 1; 행 수가 늘어나지 않는다. 대신 두 단계 전 값은 없다. 서울 → 부산 → 대전으로 바뀌면 “서울"은 사라진다. 쓸 만한 사례가 있다. 조직 개편 전후 비교. “이 사원이 이번 개편 전에는 어느 부서였는가"만 알면 되는 경우. 직전 값 하나면 충분하고, 전체 이력은 필요 없다. 어떤 Type을 고를 것인가 기준 Type 1 Type 2 Type 3 이력 보존 없음 전체 직전 1단계 구현 복잡도 낮음 높음 중간 스토리지 변동 없음 행이 계속 늘어남 컬럼 추가 시점 분석 불가 가능 제한적 적합 속성 오타, 코드 설명 주소, 등급, 소속 조직 개편 전후 판단 기준은 간단하다. “과거 시점의 값으로 분석해야 하는가?” 그렇다면 Type 2. 아니라면 Type 1. Type 3은 직전 값만 필요한 특수한 경우에 한정된다. 하나의 테이블 안에서 컬럼별로 Type을 혼합할 수 있다. city는 Type 2로 이력을 쌓고, phone은 Type 1로 덮어쓴다. 전화번호의 과거 이력으로 분석할 일이 없으니까. 실습 데이터 준비 jaffle_shop의 raw_customers에는 주소 컬럼이 없다. SCD를 시연하려면 임의의 데이터를 생성해야 한다. import duckdb conn = duckdb.connect('warehouse.duckdb') # SCD 시연용 고객 데이터: city, membership_grade, updated_at 추가 conn.execute(""" CREATE SCHEMA IF NOT EXISTS bronze; CREATE OR REPLACE TABLE bronze.customers_v2 AS SELECT id AS customer_id, first_name, last_name, CASE WHEN id % 3 = 0 THEN '서울' WHEN id % 3 = 1 THEN '부산' ELSE '대전' END AS city, CASE WHEN id % 4 = 0 THEN 'Gold' WHEN id % 4 = 1 THEN 'Silver' WHEN id % 4 = 2 THEN 'Bronze' ELSE 'Standard' END AS membership_grade, TIMESTAMP '2025-01-15 09:00:00' AS updated_at FROM read_csv_auto( 'https://raw.githubusercontent.com/dbt-labs/jaffle_shop/main/seeds/raw_customers.csv' ); """) conn.execute("SELECT * FROM bronze.customers_v2 LIMIT 5").fetchdf() 변경 시뮬레이션 데이터도 만든다. 고객 몇 명이 이사하고, 등급이 올라간 상황을 시뮬레이션한다. # 변경분 데이터: 일부 고객이 이사했다 conn.execute(""" CREATE OR REPLACE TABLE bronze.customers_v2_updated AS SELECT customer_id, first_name, last_name, CASE WHEN customer_id IN (1, 3, 5) THEN '제주' ELSE city END AS city, CASE WHEN customer_id IN (2, 4) THEN 'Gold' ELSE membership_grade END AS membership_grade, CASE WHEN customer_id IN (1, 2, 3, 4, 5) THEN TIMESTAMP '2026-02-20 14:00:00' ELSE updated_at END AS updated_at FROM bronze.customers_v2; """) # 변경된 고객 확인 conn.execute(""" SELECT a.customer_id, a.city AS before_city, b.city AS after_city, a.membership_grade AS before_grade, b.membership_grade AS after_grade FROM bronze.customers_v2 a JOIN bronze.customers_v2_updated b ON a.customer_id = b.customer_id WHERE a.city != b.city OR a.membership_grade != b.membership_grade """).fetchdf() dbt snapshot으로 SCD Type 2를 자동화한다 snapshot이란 위에서 Type 2를 SQL로 직접 구현했다. 기존 행을 닫고, 새 행을 넣고, valid_from/valid_to를 관리하고. 테이블이 하나일 때는 할 만하다. 차원 테이블이 10개, 20개로 늘어나면 이 로직을 매번 직접 짜는 건 현실적이지 않다. dbt snapshot이 이걸 대신 해준다. snapshot 파일 하나를 정의하면 dbt가 소스 데이터의 변경을 감지하고 valid_from/valid_to 행을 알아서 관리한다. snapshot 파일 작성 import os os.makedirs('jaffle_shop/snapshots', exist_ok=True) %%writefile jaffle_shop/snapshots/snap_customers.sql {% snapshot snap_customers %} {{ config( target_schema='snapshots', unique_key='customer_id', strategy='timestamp', updated_at='updated_at' ) }} select * from bronze.customers_v2 {% endsnapshot %} strategy='timestamp' -updated_at 컬럼을 기준으로 변경 여부를 판단한다. updated_at이 이전 스냅샷 시점보다 새로우면 변경된 것으로 본다. unique_key='customer_id' -어떤 행이 같은 행인지 식별하는 키다. 이 키 기준으로 이전 값과 현재 값을 비교한다. snapshot 실행 from dbt.cli.main import dbtRunner # Colab의 ! 쉘 명령은 별도 프로세스를 띄운다. # DuckDB는 프로세스 간 동시 쓰기를 막는 파일 락을 건다. # dbtRunner로 같은 프로세스 안에서 실행하면 락 충돌이 없다. result = dbtRunner().invoke(['snapshot', '--project-dir', 'jaffle_shop', '--profiles-dir', 'jaffle_shop']) 첫 실행이다. 모든 행이 신규이므로 그대로 들어간다. dbt가 자동으로 dbt_valid_from, dbt_valid_to 컬럼을 추가한다. conn.execute("SELECT * FROM snapshots.snap_customers LIMIT 5").fetchdf() dbt_valid_to가 전부 NULL이다. 현재 유효한 행이라는 뜻이다. dbt snapshot은 9999-12-31 대신 NULL을 쓴다. 이제 변경 데이터를 투입하고 다시 실행한다. # 소스 테이블을 변경분으로 교체 conn.execute(""" CREATE OR REPLACE TABLE bronze.customers_v2 AS SELECT * FROM bronze.customers_v2_updated; """) conn.close() # snapshot 재실행 result = dbtRunner().invoke(['snapshot', '--project-dir', 'jaffle_shop', '--profiles-dir', 'jaffle_shop']) conn = duckdb.connect('warehouse.duckdb') # 이력이 생성된 고객 확인 conn.execute(""" SELECT customer_id, city, membership_grade, dbt_valid_from, dbt_valid_to FROM snapshots.snap_customers WHERE customer_id IN (1, 2, 3) ORDER BY customer_id, dbt_valid_from """).fetchdf() customer_id = 1인 고객에 행이 두 개 생겼다. 첫 번째 행의 dbt_valid_to가 채워졌고, 두 번째 행이 현재 유효한 행이다. SQL 한 줄 안 쓰고 Type 2가 구현됐다. check 전략 updated_at 컬럼이 없는 소스도 있다. 2편 에서 언급했듯, 데이터를 UPDATE하면서 updated_at을 안 바꾸는 시스템이 의외로 많다. 이런 경우 check 전략을 쓴다. 지정한 컬럼의 값이 바뀌었는지 직접 비교한다. %%writefile jaffle_shop/snapshots/snap_customers_check.sql {% snapshot snap_customers_check %} {{ config( target_schema='snapshots', unique_key='customer_id', strategy='check', check_cols=['city', 'membership_grade'] ) }} select customer_id, first_name, last_name, city, membership_grade from bronze.customers_v2 {% endsnapshot %} check_cols=['city', 'membership_grade'] -이 두 컬럼의 값이 이전과 다르면 변경으로 판단한다. updated_at이 필요 없다. 대신 매번 전체 행을 비교하므로 데이터가 크면 timestamp 전략보다 느리다. snapshot을 Silver/Gold와 연결한다 snapshot은 Bronze도 Silver도 아닌 별도 스키마(snapshots)에 저장된다. 메달리온 아키텍처에서 이 위치를 정리하면 이렇다. 소스 → [Bronze] → [Silver] → [Gold] ↑ Bronze → [Snapshot] ──┘ 1편 에서 정의한 레이어 구조에 snapshot이 추가된 형태다. snapshot은 Bronze 데이터를 직접 바라보고, Silver나 Gold 모델이 snapshot 결과를 참조한다. Silver 모델에서 snapshot을 참조하는 구조는 이렇다. %%writefile jaffle_shop/models/staging/stg_customers_hist.sql with source as ( select * from {{ ref('snap_customers') }} ), cleaned as ( select customer_id, first_name, last_name, city, membership_grade, dbt_valid_from AS valid_from, coalesce(dbt_valid_to, '9999-12-31'::timestamp) AS valid_to, dbt_valid_to IS NULL AS is_current from source ) select * from cleaned dbt의 dbt_valid_from/dbt_valid_to를 valid_from/valid_to로 이름을 바꾸고, NULL을 9999-12-31로 변환했다. Gold에서 BETWEEN 조인을 걸 때 편하다. Gold 팩트 테이블에서 시점 조인하면 이렇게 된다. -- Gold: 주문 시점의 고객 정보로 조인 select o.order_id, o.order_date, c.city AS city_at_order, c.membership_grade AS grade_at_order from stg_orders o join stg_customers_hist c on o.customer_id = c.customer_id and o.order_date >= c.valid_from and o.order_date < c.valid_to SCD 적용 패턴 정리 패턴 적용 대상 구현 방법 Type 1 (덮어쓰기) 오타, 코드 설명, 전화번호 단순 UPDATE Type 2 (이력 추가) 주소, 등급, 소속 부서 dbt snapshot (timestamp / check) Type 3 (이전 값 보존) 조직 개편 전후 비교 previous_ 컬럼 추가 혼합 하나의 테이블 내 컬럼별 구분 Type 1 + Type 2 병행 실무에서는 Type 2가 압도적이다. dbt snapshot이 처리해주니까 구현 부담도 크지 않다. Type 1은 이력이 필요 없는 속성에 한해서, Type 3은 직전 값만 필요한 드문 경우에 쓴다. 실무 참고: Airflow에서 dbt snapshot 실행 3편 에서 Airflow DAG로 dbt run → dbt test 순서를 잡았다. snapshot이 추가되면 순서가 바뀐다. from airflow import DAG from airflow.operators.bash import BashOperator from datetime import datetime with DAG( dag_id='medallion_with_snapshot', schedule='0 6 * * *', start_date=datetime(2026, 1, 1), catchup=False, ) as dag: # 1. snapshot 먼저 실행 -Bronze의 변경 이력을 캡처 run_snapshot = BashOperator( task_id='dbt_snapshot', bash_command='cd /opt/dbt/jaffle_shop && dbt snapshot', ) # 2. Silver 변환 -snapshot 결과를 참조하는 모델이 있으니까 run_staging = BashOperator( task_id='dbt_run_staging', bash_command='cd /opt/dbt/jaffle_shop && dbt run --select staging', ) # 3. Gold 변환 run_marts = BashOperator( task_id='dbt_run_marts', bash_command='cd /opt/dbt/jaffle_shop && dbt run --select marts', ) run_snapshot >> run_staging >> run_marts 핵심은 run_snapshot >> run_staging 순서다. Silver 모델 중 stg_customers_hist가 snap_customers를 참조한다. snapshot이 먼저 실행되어야 Silver가 최신 이력을 반영한다. snapshot을 Silver 뒤에 돌리면 이번 배치에서 감지된 변경이 다음 배치에서야 Silver에 반영된다. 하루 늦는다. 다음 글에서는 Gold 레이어를 다룬다. Silver에서 정제한 데이터와 snapshot 이력을 합쳐서 팩트·차원 테이블을 구성하는 과정이다. dbt marts 디렉토리가 본격적으로 등장한다. Google Colab에서 실습하기