LLM WikiAccess-protected knowledge portal
← 스터디 홈
5편 · 약 22분

품질 테스트: source freshness, accepted values, relationship, custom test

테스트 없는 데이터 모델은 신뢰할 수 없다

dbt 모델이 아무리 잘 설계되어 있어도, "이 데이터가 정말 맞는가?"를 확인하지 않으면 분석가는 숫자를 믿지 못한다. 대시보드를 볼 때마다 "혹시 파이프라인이 멈춘 건 아닐까?", "이 고객 ID가 실제로 존재하는가?"를 의심해야 한다면 그 데이터 플랫폼은 신뢰를 잃은 것이다.

dbt는 SQL 변환과 테스트를 한 곳에서 관리할 수 있게 설계됐다. 테스트를 모델과 같은 프로젝트에 두면 변환 코드가 바뀔 때 테스트도 함께 리뷰된다. 이것이 dbt 테스트의 핵심 가치다.


1. dbt 테스트의 종류

dbt 테스트는 크게 두 범주로 나뉜다.

Generic Test (범용 테스트)
정의 방식: YAML에서 테스트명만 선언
재사용성: 여러 모델·컬럼에 반복 적용 가능
위치: tests/generic/ 또는 macros/
dbt 내장: unique, not_null, accepted_values, relationships
패키지: dbt-utils, dbt-expectations
Singular Test (단일 테스트)
정의 방식: tests/ 폴더 안 .sql 파일
재사용성: 특정 모델에만 적용
위치: tests/
반환 행 = 실패 케이스 (0행 반환 = 통과)
복잡한 비즈니스 규칙 검증에 적합
Source Freshness는 별도 명령(dbt source freshness)으로 실행 — dbt test / dbt build와는 독립
dbt 테스트 분류 체계

2. 내장 Generic Test 4종

2.1 unique

컬럼 값의 중복 여부를 검사한다. 기본 키나 자연 키에 반드시 설정한다.

# models/marts/finance/_schema.yml
models:
  - name: mart_orders
    columns:
      - name: order_id
        data_tests:
          - unique
          - not_null

uniquenot_null은 항상 함께 쓴다. 둘 중 하나라도 빠지면 기본 키 보장이 무너진다.

2.2 not_null

컬럼에 NULL이 없어야 함을 보장한다. FK 컬럼, 파티션 키, 집계 기준 컬럼에 설정한다.

      - name: customer_id
        data_tests:
          - not_null

2.3 accepted_values

컬럼 값이 허용된 목록 안에만 있는지 검사한다. 상태 코드, 등급, 채널 같은 열거형 컬럼에 쓴다.

      - name: order_status
        data_tests:
          - accepted_values:
              values: ['pending', 'paid', 'shipped', 'completed', 'cancelled']

quote: false 옵션을 쓰면 값을 따옴표 없이 비교한다. 숫자 코드 컬럼이나 Boolean에 유용하다.

      - name: is_active
        data_tests:
          - accepted_values:
              values: [true, false]
              quote: false

2.4 relationships (참조 무결성)

두 테이블 사이의 외래 키 관계를 검사한다. mart_orders.customer_iddim_customers에 실제로 존재하는지 확인하는 것이 전형적인 사용 사례다.

      - name: customer_id
        data_tests:
          - relationships:
              to: ref('dim_customers')
              field: customer_id

주의: 소스 테이블에서도 relationships 테스트를 걸 수 있지만, 소스 시스템 간 FK가 깨진 데이터가 흔하다. 처음에는 where 절로 NULL이나 알려진 예외를 필터링하는 것이 현실적이다.

      - name: product_id
        data_tests:
          - relationships:
              to: ref('dim_products')
              field: product_id
              config:
                where: "product_id is not null and product_id != '-1'"

3. Source Freshness — 소스 데이터 최신성 검사

3.1 왜 필요한가

파이프라인이 멈춰도 dbt run은 성공한다. 기존 테이블은 그대로 있고, 거기서 조인·집계를 하는 모델들도 문제없이 실행된다. 분석가가 대시보드를 열어보고 숫자가 이상하다는 것을 느끼기 전까지는 아무것도 터지지 않는다.

Source Freshness는 이 사각지대를 막는다. 소스 테이블의 마지막 적재 시각을 확인하고, 임계값을 초과하면 경고 또는 오류를 발생시킨다.

3.2 설정 방법

# models/staging/orders/_sources.yml
sources:
  - name: raw
    database: analytics
    schema: raw_data
    freshness:
      warn_after: {count: 6, period: hour}     # 소스 전체 기본값
      error_after: {count: 12, period: hour}
    loaded_at_field: _etl_loaded_at              # 적재 타임스탬프 컬럼

    tables:
      - name: orders
        # 개별 테이블은 소스 기본값 상속

      - name: inventory
        freshness:
          warn_after: {count: 1, period: day}   # 테이블별 오버라이드
          error_after: {count: 2, period: day}
        loaded_at_field: updated_at              # 테이블별 오버라이드

      - name: audit_log
        freshness: null                          # 이 테이블은 freshness 검사 제외

3.3 loaded_at_field 선택 기준

컬럼권장 여부이유
_etl_loaded_at / _loaded_at권장ELT 도구가 적재 시각을 기록 — 가장 정확
updated_at주의소스 시스템 갱신 시각. ETL 도달 시각과 다를 수 있음
created_at비권장append-only 테이블에만 유효. 갱신된 행을 놓침

dbt는 freshness 검사를 항상 UTC 기준으로 수행한다. 소스 컬럼이 로컬 시각을 저장하면 변환이 필요하다.

3.4 실행 방법

# freshness 검사만 실행
dbt source freshness

# 특정 소스만 검사
dbt source freshness --select source:raw

# 주의: dbt build / dbt test 로는 freshness가 실행되지 않는다

3.5 freshness 결과 해석

Pass 마지막 적재 warn_after 이내 Warn warn_after 초과, error_after 이내 Error error_after 초과 파이프라인 중단 Pass (skip) freshness: null 설정 없음 지연 증가 임계 초과 ✓ 정상 운영 ⚠ 조사 시작 ✗ 즉시 대응 검사 제외
Source Freshness 상태 전이

4. 테스트 설정: severity와 where

4.1 severity — 경고 vs 오류

기본값은 error다. 데이터 품질 문제가 바로 파이프라인을 중단시키지 않아야 하는 경우 warn으로 낮출 수 있다.

      - name: email
        data_tests:
          - not_null:
              config:
                severity: warn   # 실패해도 파이프라인 계속 진행

신규 소스 연동 초기에는 warn으로 시작해서 실제 데이터 품질을 파악한 후 error로 올리는 것이 안전하다.

4.2 where — 조건부 검사

특정 행에만 테스트를 적용할 수 있다. NULL을 허용해야 하는 레거시 컬럼이나, 특정 상태에서만 의미 있는 컬럼에 유용하다.

      - name: shipped_at
        data_tests:
          - not_null:
              config:
                where: "order_status = 'shipped'"

5. Custom Generic Test

내장 4종으로 커버되지 않는 비즈니스 규칙은 직접 만들 수 있다.

5.1 정의 방법

tests/generic/ 또는 macros/ 폴더에 test_ 접두어로 시작하는 Jinja 블록을 작성한다.

-- tests/generic/test_amount_is_positive.sql
{% test amount_is_positive(model, column_name) %}

select *
from {{ model }}
where {{ column_name }} < 0

{% endtest %}

0 행 반환 = 통과, 1행 이상 반환 = 실패다. YAML에서 컬럼명 없이 모델 전체를 대상으로 하려면 column_name 인자를 생략한다.

# schema.yml에서 사용
      - name: amount
        data_tests:
          - amount_is_positive

5.2 파라미터가 있는 Custom Test

-- tests/generic/test_column_sum_equals.sql
{% test column_sum_equals(model, column_name, expected_total) %}

select abs(sum({{ column_name }}) - {{ expected_total }}) as diff
from {{ model }}
having diff > 0.01

{% endtest %}
      - name: allocated_budget
        data_tests:
          - column_sum_equals:
              expected_total: 1000000

6. Singular Test — 복잡한 비즈니스 규칙

재사용이 필요 없는 복잡한 규칙은 tests/ 폴더에 SQL 파일로 작성한다. 파일명이 테스트명이 된다.

-- tests/assert_order_revenue_matches_line_items.sql
-- 주문 헤더의 total_amount가 주문 명세 합계와 일치하는지 확인
select
    o.order_id,
    o.total_amount as header_amount,
    sum(li.unit_price * li.quantity) as line_item_total
from {{ ref('mart_orders') }} o
join {{ ref('mart_order_line_items') }} li using (order_id)
group by 1, 2
having abs(header_amount - line_item_total) > 0.01

이 쿼리가 행을 반환하면 테스트는 실패한다. Singular Test는 여러 테이블을 함께 검증하거나, 집계 결과를 비교하거나, 복잡한 비즈니스 규칙을 표현할 때 적합하다.


7. 테스트 패키지: dbt-utils와 dbt-expectations

7.1 dbt-utils

dbt Labs가 유지 관리하는 공식 확장 패키지다. 내장 4종 외에 유용한 테스트들이 포함되어 있다.

# packages.yml
packages:
  - package: dbt-labs/dbt_utils
    version: ">=1.0.0"

자주 쓰이는 테스트:

      data_tests:
        # 두 컬럼의 조합이 유일해야 함
        - dbt_utils.unique_combination_of_columns:
            combination_of_columns: [order_id, product_id]

        # 행 수가 다른 모델보다 적어야 함 (mart가 소스보다 적을 때 정상)
        - dbt_utils.fewer_rows_than:
            compare_model: ref('stg_orders')

        # SQL 표현식이 참이어야 함
        - dbt_utils.expression_is_true:
            expression: "paid_amount <= total_amount"

7.2 dbt-expectations

Great Expectations 스타일의 테스트 62종을 제공한다. 수치 범위, 분포, 패턴 검사에 강하다.

packages:
  - package: metaplane/dbt_expectations
    version: ">=0.10.0"
      data_tests:
        # 컬럼 값의 범위 검사
        - dbt_expectations.expect_column_values_to_be_between:
            min_value: 0
            max_value: 100

        # 문자열 패턴 검사 (이메일 형식)
        - dbt_expectations.expect_column_values_to_match_regex:
            regex: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}{{BODY}}quot;

        # 컬럼의 중앙값이 범위 안에 있어야 함
        - dbt_expectations.expect_column_median_to_be_between:
            min_value: 50
            max_value: 200

8. 테스트 전략: 무엇을, 얼마나

모든 컬럼에 모든 테스트를 걸면 실행 시간이 길어지고 테스트 실패가 너무 많아진다. 중요도에 따라 계층화한다.

필수 (모든 모델)
기본 키: unique + not_null
소스 freshness: 모든 원천 테이블에 warn_after / error_after
↓ 중요도 낮아짐
권장 (Staging / Intermediate)
FK 컬럼: relationships
열거형: accepted_values
NOT NULL 중요 컬럼
선택적 (Mart / 비즈니스 규칙)
집계 합계 검증: singular test
수치 범위: dbt-expectations
복합 유일성: dbt_utils.unique_combination_of_columns
운영 모니터링 (anomaly detection)
Elementary Data, Monte Carlo 등 외부 관측 도구로 보완
테스트 우선순위 계층

실전 설정 예시 (우선순위 적용)

models:
  - name: mart_orders
    data_tests:
      # 모델 레벨: 복합 키 유일성
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns: [order_id, order_date]
    columns:
      - name: order_id
        data_tests:
          - unique
          - not_null
      - name: customer_id
        data_tests:
          - not_null
          - relationships:
              to: ref('dim_customers')
              field: customer_id
      - name: order_status
        data_tests:
          - accepted_values:
              values: ['pending', 'paid', 'shipped', 'completed', 'cancelled']
      - name: paid_amount
        data_tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= 0"

9. 테스트 실행과 CI 통합

# 전체 테스트
dbt test

# 특정 모델 테스트
dbt test --select mart_orders

# 특정 테스트 유형만
dbt test --select test_type:generic

# 빌드 + 테스트 (dbt build = run + test + snapshot)
dbt build --select mart_orders+

CI 파이프라인에서는 변경된 모델과 그 하위 모델만 선택해서 테스트한다. 전체 테스트 실행은 시간이 너무 오래 걸린다.

# GitHub Actions에서 PR 대상 변경 모델과 하위 모델만 테스트
dbt build --select state:modified+ --defer --state ./prod_artifacts

--defer --state는 변경되지 않은 upstream 모델은 프로덕션 결과물을 재사용하게 한다.


마무리

dbt 테스트는 "데이터가 변환 전제를 만족하는가"를 코드로 표현하는 방법이다. Source Freshness로 적재 지연을 탐지하고, accepted_values와 relationships로 도메인 규칙을 강제하고, custom test로 비즈니스 로직을 검증한다. 중요도에 따라 테스트를 계층화하면 테스트 피로 없이 핵심 품질 보장이 가능하다.

다음 편에서는 이 모델들 간의 lineage를 활용해 변경이 어디까지 영향을 미치는지 분석하고, 다운스트림 대시보드를 보호하는 실전 전략을 다룬다.

References

  • dbt Labs, "Add data tests to your DAG": https://docs.getdbt.com/docs/build/data-tests
  • dbt Labs, "Writing custom generic data tests": https://docs.getdbt.com/best-practices/writing-custom-generic-tests
  • dbt Labs, "freshness reference": https://docs.getdbt.com/reference/resource-properties/freshness
  • dbt Labs, "Add sources to your DAG": https://docs.getdbt.com/docs/build/sources
  • dbt Labs, "About dbt source command": https://docs.getdbt.com/reference/commands/source
  • Paradime, "dbt Source Freshness: Best Practices for Analytics Engineers": https://www.paradime.io/guides/blog-dbt-source-freshness-best-practices
  • Datafold, "How to use dbt source freshness tests to detect stale data": https://www.datafold.com/blog/dbt-source-freshness/
  • Datacoves, "dbt Testing: A Complete Guide": https://datacoves.com/post/dbt-test-options
  • Elementary Data, "dbt tests: How to write fewer and better data tests": https://www.elementary-data.com/post/dbt-tests
  • Datafold, "7 dbt testing best practices": https://www.datafold.com/blog/7-dbt-testing-best-practices/