LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 26분

dbt CI/CD 심화: Slim CI, state, 환경 분리, 아티팩트 관리

Slim CI가 해결하는 문제

dbt 프로젝트는 성장한다. 처음에는 모델 50개지만 6개월 후에는 300개, 1년 후에는 500개가 된다. 이 시점에서 "PR마다 전체 dbt 빌드를 돌린다"는 접근은 두 가지 문제를 만든다.

시간: BigQuery나 Snowflake에서 500개 모델을 실행하면 30~60분이 걸릴 수 있다. 리뷰 사이클이 느려지고 개발자 경험이 나빠진다.

비용: 클라우드 DWH는 쿼리 실행량에 따라 비용이 부과된다. PR 10개가 하루에 올라오면 전체 빌드를 10번 돌리는 셈이다. 월 수백 달러의 낭비가 발생한다.

Slim CI는 이 문제의 해결책이다. 변경된 모델과 그 영향을 받는 모델만 선택적으로 빌드하고 테스트한다. 나머지는 이미 프로덕션에서 검증된 결과를 그대로 참조한다.


manifest.json과 state 비교의 원리

Slim CI의 핵심은 두 manifest.json을 비교하는 것이다.

프로덕션 manifest target/manifest.json model_a (hash: abc123) model_b (hash: def456) model_c (hash: ghi789) model_d (hash: jkl012) PR 브랜치 manifest target/manifest.json model_a (hash: abc123) model_b (hash: xyz999) ← 변경! model_c (hash: ghi789) model_e (hash: mno345) ← 신규! dbt state 비교기 --state ./prod-manifest 해시 값 비교 의존성 그래프 추적 빌드 대상 선택 결과 model_b (변경), model_e (신규) + 이들의 다운스트림
dbt state 비교 원리

manifest.json이란

dbt 컴파일(또는 빌드) 시 target/manifest.json이 생성된다. 이 파일은 프로젝트의 모든 노드(모델, 테스트, 소스, 스냅샷, 매크로)에 대해 다음을 기록한다.

  • 컴파일된 SQL과 그 체크섬(hash)
  • 의존성 그래프 (upstream/downstream refs)
  • materialization, partition, cluster 등 설정
  • 테스트 정의와 소스 메타데이터

두 manifest.json의 해시 값을 비교하면 무엇이 바뀌었는지 정확히 알 수 있다.

state: 선택자 유형

state:modified        - SQL, 설정, 의존성 중 무언가 바뀐 모든 노드
state:modified.body   - SQL 본문(body)만 바뀐 노드
state:modified.configs - materialization 등 설정만 바뀐 노드
state:modified.macros  - 참조하는 매크로가 바뀐 노드
state:new              - 프로덕션 manifest에 없는 새 모델
state:modified+        - 변경·신규 모델 + 모든 다운스트림 의존 모델

실무에서 state:modified+를 가장 많이 쓰는 이유: 업스트림 모델이 변경되면 다운스트림 모델도 함께 테스트해야 예상치 못한 영향을 잡을 수 있기 때문이다.


--defer: 변경 안 된 모델을 다시 빌드하지 않는 방법

state:modified+로 빌드 범위를 줄였지만, 한 가지 문제가 남는다. 변경된 다운스트림 모델이 변경 안 된 업스트림 모델을 참조(ref())할 때, 그 업스트림 모델의 결과가 CI 환경에는 없다. 다시 빌드해야 할까?

--defer가 이 문제를 해결한다. 빌드 대상이 아닌 업스트림 모델을 다시 빌드하지 않고, 지정된 환경(보통 프로덕션)의 실제 테이블을 직접 참조한다.

dbt build --select state:modified+ --defer --state ./prod-manifest model_a 변경 없음 → 빌드 안 함 model_b ← 변경됨 CI 환경에서 빌드 실행 model_c (downstream) state:modified+로 포함됨 defer → prod 테이블 참조 프로덕션 DB prod.model_a (이미 존재) prod.model_c (이미 존재) CI 환경 DB (ci_pr_42) ci_pr_42.model_b (새로 빌드됨) ci_pr_42.model_c (새로 빌드됨) prod.model_a 직접 참조
--defer 작동 원리

--state와 --defer-state 분리 (dbt 1.6+)

# 기본: 하나의 manifest로 비교 + 참조 모두 처리
dbt build \
  --select state:modified+ \
  --defer \
  --state ./prod-manifest

# dbt 1.6+: 비교 기준과 deferral 참조를 분리
dbt build \
  --select state:modified+ \
  --state ./prod-manifest \        # 무엇이 변경됐는지 비교할 기준
  --defer \
  --defer-state ./staging-manifest # 변경 안 된 모델은 여기서 참조

언제 분리하는가: 스테이징 환경이 잘 유지되고 있어 실제 데이터로 CI 테스트를 하고 싶을 때. 변경 감지는 프로덕션 기준으로, 실제 참조는 스테이징 데이터로 분리할 수 있다.


manifest.json 아티팩트 관리 전략

Slim CI가 제대로 작동하려면 최신 프로덕션 manifest.json이 항상 CI에서 접근 가능해야 한다.

프로덕션 배포
main 브랜치 push
dbt build --target prod
성공 시에만 manifest 저장
아티팩트 저장소
S3 / GCS 버킷
또는 GitHub Artifact
target/manifest.json
PR CI 시작
manifest 다운로드
./prod-manifest/ 에 저장
Slim CI 실행
핵심 원칙: 실패한 프로덕션 빌드의 manifest는 절대 업로드하지 않는다.
manifest 아티팩트 관리 흐름

전략 1: S3/GCS 클라우드 스토리지 (OSS 권장)

# .github/workflows/dbt-prod.yml — 프로덕션 배포 후 manifest 저장
jobs:
  deploy:
    steps:
      - name: Run production dbt
        run: dbt build --target prod --profiles-dir .

      - name: Upload manifest to S3
        if: success()          # 성공 시에만 업로드
        run: |
          aws s3 cp target/manifest.json \
            s3://my-dbt-artifacts/prod/manifest.json \
            --metadata "git-sha=${{ github.sha }}"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# .github/workflows/dbt-ci.yml — PR 시 manifest 다운로드
jobs:
  slim-ci:
    steps:
      - name: Download production manifest
        run: |
          mkdir -p ./prod-manifest
          aws s3 cp s3://my-dbt-artifacts/prod/manifest.json \
            ./prod-manifest/manifest.json \
          || echo "No prod manifest found, running full build"

전략 2: GitHub Actions Artifact (단순한 경우)

# 프로덕션 워크플로우
- name: Save manifest artifact
  if: success()
  uses: actions/upload-artifact@v4
  with:
    name: dbt-prod-manifest
    path: target/manifest.json
    retention-days: 30

# CI 워크플로우
- name: Download manifest artifact
  uses: actions/download-artifact@v4
  with:
    name: dbt-prod-manifest
    path: ./prod-manifest

주의: GitHub Actions Artifact는 같은 레포지토리 내에서만 공유된다. 여러 레포지토리에서 manifest를 공유해야 한다면 S3/GCS를 사용한다.

manifest 없을 때 폴백 처리

최초 배포나 아티팩트가 없을 때를 대비한 로직이 필요하다.

if [ -f ./prod-manifest/manifest.json ]; then
  echo "Slim CI 실행"
  dbt build \
    --select state:modified+ \
    --defer \
    --state ./prod-manifest \
    --target ci
else
  echo "manifest 없음 — 전체 빌드 실행"
  dbt build --target ci
fi

환경 분리 심화

dev
개인 스키마 (dbt_alice)
로컬 빌드, 빠른 반복
소규모 샘플 데이터
dbt run --target dev
CI (PR)
PR별 격리 스키마 (ci_pr_42)
Slim CI — 변경분만 빌드
--defer로 prod 참조
PR 닫힐 때 스키마 삭제
staging
전체 빌드 (full build)
프로덕션 규모 데이터
통합 테스트 · QA 확인
--defer-state 활용 가능
prod
수동 승인 또는 자동 배포
성공 후 manifest 저장
배포 후 데이터 검증
롤백 플랜 준비
dbt 4단계 환경 분리 전략

CI 환경의 격리 설정

PR별로 별도 스키마를 생성하면 여러 PR이 동시에 실행될 때 서로 간섭하지 않는다.

# profiles.yml
my_project:
  target: dev
  outputs:
    dev:
      type: bigquery
      project: mycompany-dev
      dataset: "dbt_{{ env_var('DBT_USER', 'default') }}"
    ci:
      type: bigquery
      project: mycompany-ci
      dataset: "ci_pr_{{ env_var('PR_NUMBER', 'local') }}"
    staging:
      type: bigquery
      project: mycompany-staging
      dataset: dbt_staging
    prod:
      type: bigquery
      project: mycompany-prod
      dataset: dbt_prod

CI 스키마 자동 정리: PR이 닫힐 때 사용한 스키마를 삭제하지 않으면 스토리지 비용이 쌓인다.

# .github/workflows/cleanup-ci-schema.yml
on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Drop CI dataset
        run: |
          bq rm -r -f mycompany-ci:ci_pr_${{ github.event.pull_request.number }}

증분 모델의 CI/CD 처리

Slim CI에서 가장 까다로운 케이스는 Incremental 모델이다. CI 환경(ci_pr_42)에는 증분 모델의 이전 빌드 결과가 없다. 그러므로 is_incremental()false를 반환하고 전체 재빌드(full-refresh)가 일어난다. 대용량 테이블이라면 CI가 오래 걸리고 비용이 급증한다.

dbt clone으로 해결 (dbt 1.6+)

# 1. 먼저 변경된 증분 모델을 prod에서 CI 스키마로 복제
dbt clone \
  --select state:modified,resource_type:model \
  --target ci \
  --state ./prod-manifest \
  --profiles-dir .

# 2. 그 다음 Slim CI 실행 — 복제된 모델은 is_incremental=true로 동작
dbt build \
  --select state:modified+ \
  --defer \
  --state ./prod-manifest \
  --target ci \
  --profiles-dir .

zero-copy clone: BigQuery와 Snowflake에서 CLONE 문은 실제 데이터를 복사하지 않고 메타데이터만 복사한다. dbt clone은 지원 플랫폼에서 이 기능을 자동으로 활용하므로 비용과 시간이 거의 들지 않는다.

주의: Redshift나 Databricks에서는 실제 데이터 복사가 발생할 수 있다. 플랫폼별 동작을 확인해야 한다.


완성된 GitHub Actions 워크플로우

다음은 프로덕션 배포와 Slim CI를 통합한 실전 워크플로우다.

프로덕션 배포 워크플로우 (main 머지 시):

# .github/workflows/dbt-prod.yml
name: dbt Production Deploy

on:
  push:
    branches: [main]
    paths: ['dbt/**']

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dbt
        run: pip install dbt-bigquery==1.8.*

      - name: Run production build
        run: dbt build --target prod --profiles-dir .
        env:
          DBT_GOOGLE_BIGQUERY_KEYFILE: ${{ secrets.GCP_SA_KEY }}

      - name: Upload manifest to S3
        if: success()
        run: |
          aws s3 cp target/manifest.json \
            s3://my-dbt-artifacts/prod/manifest.json \
            --metadata "sha=${{ github.sha }},ts=$(date -u +%Y%m%dT%H%M%S)"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

PR Slim CI 워크플로우:

# .github/workflows/dbt-ci.yml
name: dbt Slim CI

on:
  pull_request:
    paths: ['dbt/**']

env:
  PR_NUMBER: ${{ github.event.pull_request.number }}

jobs:
  slim-ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dbt
        run: pip install dbt-bigquery==1.8.*

      - name: Download production manifest
        run: |
          mkdir -p ./prod-manifest
          aws s3 cp s3://my-dbt-artifacts/prod/manifest.json \
            ./prod-manifest/manifest.json \
          || echo "No manifest found"
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: dbt compile (문법 검사)
        run: dbt compile --target ci --profiles-dir .
        env:
          DBT_GOOGLE_BIGQUERY_KEYFILE: ${{ secrets.GCP_SA_KEY }}

      - name: Clone incremental models from prod
        if: hashFiles('prod-manifest/manifest.json') != ''
        run: |
          dbt clone \
            --select state:modified,resource_type:model \
            --target ci \
            --state ./prod-manifest \
            --profiles-dir .
        env:
          DBT_GOOGLE_BIGQUERY_KEYFILE: ${{ secrets.GCP_SA_KEY }}

      - name: Slim CI build and test
        run: |
          if [ -f ./prod-manifest/manifest.json ]; then
            dbt build \
              --select state:modified+ \
              --defer \
              --state ./prod-manifest \
              --target ci \
              --profiles-dir .
          else
            dbt build --target ci --profiles-dir .
          fi
        env:
          DBT_GOOGLE_BIGQUERY_KEYFILE: ${{ secrets.GCP_SA_KEY }}

  cleanup:
    runs-on: ubuntu-latest
    if: github.event.action == 'closed'
    steps:
      - name: Drop CI dataset
        run: |
          bq rm -r -f mycompany-ci:ci_pr_${{ github.event.pull_request.number }}
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCP_SA_KEY }}

흔한 실수와 해결책

실수 1: 실패한 빌드의 manifest 업로드

if: success() 없이 무조건 manifest를 업로드하면, 실패한 빌드의 상태가 prod manifest로 기록된다. 다음 PR의 state 비교가 틀린 baseline을 사용하게 된다.

해결: 업로드 스텝에 항상 if: success() 추가.

실수 2: state:modified (+ 없이) 사용

state:modified는 변경된 모델만 빌드한다. 그 모델의 다운스트림이 영향을 받아도 테스트하지 않는다. state:modified+가 대부분의 경우 올바른 선택이다.

단, 프로젝트가 매우 크면 +가 선택하는 모델이 너무 많아질 수 있다. 이 경우 state:modified,1+state:modified (1단계 다운스트림만)으로 범위를 좁힐 수 있다.

실수 3: 소스 변경 미감지

state:modified는 모델과 매크로 변경만 감지한다. 소스 데이터베이스의 스키마가 바뀌어도 CI에서 자동으로 잡히지 않는다. 소스 freshness 테스트를 별도로 추가해야 한다.

# schema.yml
sources:
  - name: raw_orders
    freshness:
      warn_after: {count: 6, period: hour}
      error_after: {count: 24, period: hour}
    loaded_at_field: _loaded_at

실수 4: PR 스키마 미정리

PR이 닫혀도 CI 스키마가 남아 비용이 쌓인다. pull_request 이벤트의 types: [closed]를 활용해 정리 워크플로우를 추가한다.


Open Questions

  • 대규모 프로젝트(1,000+ 모델)에서 state:modified+의 선택 범위가 과도하게 넓어지는 문제를 다루는 표준 전략이 아직 완전히 정립되지 않았다.
  • dbt clone은 BigQuery, Snowflake에서 zero-copy로 동작하지만, 다른 플랫폼에서의 동작 보장이 일관적이지 않다. 플랫폼별 동작 확인이 필요하다.

References