Airflow 복습

Airflow 3 다시 보기

2.10.3으로 공부한 노트를 3.3.2(stable) 기준으로 다시 정리하고,
3.0부터 3.3까지 새로 들어온 기능을 함께 훑는다
개인 학습 노트 · 2026-10
원본: 노션 「Airflow」 노트(2.10.3 기준)
릴리스

3.x는 1년 반 동안 네 번 크게 바뀌었다

3.03.13.23.3 2025-04-222025-09-252026-04-072026-07-06 구조 개편사람과 함께 도는 워크플로대규모 데이터 인지상태 저장과 다언어 Task Execution API Task SDK API Server, React UI REST /api/v2 Dag bundle, DAG 버저닝 Asset, 이벤트 기반 스케줄링 scheduler가 관리하는 backfill Edge executor Human-in-the-Loop Deadline Alerts (SLA 대체) React 플러그인 UI 다국어 지원 Task SDK 분리 진행 Asset 파티셔닝 Multi-team (exp) Deadline SyncCallback (exp) async PythonOperator Task, Asset State Store Pluggable Retry Policy Java, Go Task SDK (exp) rerun 때 bundle 버전 선택 현재 stable 3.3.2
3.0이 구조를 바꿨고 3.1부터 3.3까지는 그 위에 기능을 쌓았다. 우리 레포는 아직 2.10.3이다. (exp)는 experimental.
1부 · 구조

워커는 이제
metadata DB에 직접 닿지 않는다

3.0의 큰 변화 세 가지는 모두 "워커가 무엇에 직접 닿을 수 있는가"를 다시 정한 결과다
1부 · 세 가지 변화

Airflow 3에서 가장 크게 바뀐 세 가지

01
Webserver가 API Server로
UI, REST API /api/v2, 워커용 Task Execution API를 서버 하나가 맡는다. FastAPI 기반이다.
airflow api-server
02
DAG Processor는 따로 띄운다
2.x에서는 scheduler 안의 subprocess였다. 3.x에서는 반드시 따로 띄우는 독립 컴포넌트다.
airflow dag-processor
03
워커의 DB 직접 접근 차단
상태 전이, heartbeat, XCom, Variable과 Connection 조회가 전부 API Server를 거친다. 워커 쪽 인터페이스는 airflow.sdk(Task SDK)다.
영향: task 코드에서 DB session 사용 불가
1부 · 아키텍처 2.x

2.x는 워커까지 모든 컴포넌트가 DB에 직접 붙었다

DAG filesdags/ 폴더 DagFileProcessorscheduler 안의 subprocess Scheduler+ Executor Metadata DB Workertask 코드 실행 Triggerer WebserverFlask, /api/v1 실행 요청 DB 직접 접속(워커 수만큼 커넥션)
점선 상자: 독립 프로세스가 아니라 scheduler가 띄우는 subprocess
1부 · 아키텍처 3.x

3.x는 워커와 DB 사이에 API Server가 들어간다

DAG filesDag bundle (git 등) DAG Processor파싱, DAG callback Scheduler+ Executor Metadata DB Workertask 코드 실행 API ServerExecution API, UI Triggerer UI, REST client브라우저, airflowctl serialized DAG 실행 요청 Task SDK상태, XCom, Connection /api/v2
DAG Processor와 Triggerer에서 도는 Dag 작성자 코드는 3.x에서도 DB에 접근할 수 있다(공식 security model).
1부 · 컴포넌트 ①

scheduler는 파싱을 놓고, 파싱과 API는 새 컴포넌트로 간다

Scheduler
airflow scheduler
  • serialized DAG를 보고 DagRun을 만든다
  • 의존성이 충족된 TI를 골라 Executor로 넘긴다
  • 3.x에서는 파싱을 하지 않는다
  • backfill도 scheduler가 관리한다
DAG Processor 독립
airflow dag-processor
  • DAG 파일을 파싱해 serialized DAG로 저장한다
  • --subdir 대신 Dag bundle을 읽는다
  • DAG-level callback이 도는 곳이다
API Server 신규
airflow api-server
  • REST /api/v2, UI, Task Execution API
  • 기본 auth manager는 Simple Auth
  • helm chart에서는 webserver가 apiServer로 바뀐다
1부 · 컴포넌트 ②

Executor와 Triggerer는 그대로, 워커는 API로만 말한다

Executor
  • scheduler 프로세스 안에서 동작한다(그대로)
  • multi-executor로 task마다 executor를 지정한다
  • Sequential, CeleryKubernetes, LocalKubernetes executor는 제거됐다
Worker
Task SDK
  • task 코드를 실행한다
  • API Server와만 통신한다
  • airflow.settings.Session을 쓸 수 없다
Triggerer
airflow triggerer
  • deferred task의 async trigger를 실행한다(그대로)
  • Variable과 Connection은 API를 거쳐 읽는다
1부 · Task SDK (AIP-72, AIP-108)

DAG 작성자 인터페이스가 airflow.sdk로 떨어져 나왔다

워커가 API로만 통신하게 되면서 task를 실행하는 쪽이 Airflow core와 분리됐다. 이 분리 덕분에 다른 언어로 task를 쓸 수도 있게 됐다.
3.0부터 3.2까지: 분리
  • 3.0: Task Execution API와 Task SDK 도입
  • 3.1: 직렬화 개선, 예전 import 경로에 경고
  • 3.2: 예외, serde, lineage도 SDK로 이동
  • SDK만 따로 업그레이드할 수 있게 됐다
3.3: Java와 Go exp
  • 오케스트레이션(DAG 정의)은 Python 그대로
  • task 본문만 Java나 Go로 쓰고 일반 워커에서 실행한다
  • XCom, Connection, Variable은 SDK client로 읽는다
  • 언어별 SDK는 pip 패키지와 별도로 배포된다
1부 · Task SDK

Python은 task 모양만 선언하고, queue로 언어를 고른다

# Python 쪽은 task 모양만 선언하고, queue로 언어별 coordinator에 연결한다
@task.stub(queue="golang")
def go_transform(): ...

@task.stub(queue="java")
def java_load(): ...

# airflow.cfg
[sdk]
queue_to_coordinator = {"golang": "go", "java": "jdk-17"}
dag_id와 task_id는 Python stub과 Go, Java 코드에서 똑같아야 한다.
2부 · 코드와 데이터가 들어오는 길

DAG 코드는 bundle로 들어오고,
run은 데이터로도 깨운다

Dag bundle과 버저닝, 그리고 Asset
2부 · Dag bundle과 DAG 버저닝 (AIP-66)

run 하나는 끝까지 같은 코드로 돈다

2.x에서는 run 도중 DAG 파일이 바뀌면 남은 task가 새 코드로 돌 수 있었다. 3.x는 버저닝되는 bundle이면 run이 시작한 버전을 끝까지 쓴다.
main v1 (a1b2c3) v2 머지 (task 이름 변경) run 10-18 extract v1 transform v1 load v1 run 도중 v2가 머지돼도 v1을 끝까지 쓴다 run 10-19 extract v2 clean v2 UI에서 run마다 그때의 DAG 구조를 볼 수 있다
2부 · Dag bundle

버전이 고정되는 건 GitDagBundle뿐이다

bundle 종류
  • LocalDagBundle: 기존 dags 폴더 방식, 버저닝 안 됨
  • GitDagBundle: git에서 직접 읽는다, 버저닝 됨
  • S3DagBundle: S3에서 읽는다, 버저닝 안 됨
  • [dag_processor] dag_bundle_config_list로 여러 개를 등록한다
clear, rerun, backfill이 쓰는 버전 (3.3)
  • 기본값: clear와 rerun은 원래 버전, backfill은 최신 버전
  • DAG의 rerun_with_latest_version이나 [core] 설정으로 바꾼다
  • backfill은 3.0부터 scheduler가 관리하고 UI에서 만든다
지금처럼 git-sync 사이드카로 폴더에 동기화하면 LocalDagBundle이라 버전이 고정되지 않는다.
2부 · Asset (AIP-74, 75, 76)

시간이 아니라 데이터 갱신으로 DAG를 돌린다

2.x의 Dataset이 Asset이 됐다. 3.0은 외부 이벤트로 asset을 갱신하는 길을 열었다.
3.0: @asset과 이벤트 기반 스케줄링
  • airflow.datasets.Dataset은 airflow.sdk.Asset으로
  • @asset: 함수 하나가 asset을 만드는 DAG가 된다
  • AssetWatcher: 메시지 큐 같은 외부 이벤트로 asset을 갱신한다
  • watcher의 trigger는 BaseEventTrigger를 상속해야 한다
주의: 무한 스케줄링
"파일이 있다", "작업이 성공했다"처럼 한 번 참이면 계속 참인 조건으로 watcher를 걸면 DAG가 반복해서 돈다. 예로 S3KeyTrigger가 있다.
2부 · Asset 파티셔닝 (3.2)

같은 파티션이 모두 찼을 때만 downstream이 돈다

000102030405 team_a_statsteam_b_statscombine downstream 파티션 도착 아직 없음 downstream 실행 upstream은 CronPartitionTimetable로 시간 파티션을 쓰고, downstream은 PartitionedAssetTimetable(assets=a & b)로 같은 파티션 키가 둘 다 찼을 때만 돈다. 3.3에서 FanOutMapper, WaitForAll, MinimumCount(n) 같은 정책이 추가됐다.
3부 · 스케줄과 실행

같은 cron이어도
run이 받는 날짜가 다르다

Deferrable operator, data_interval, 수동 run, Executor
3부 · Deferrable Operator

기다리는 동안에는 워커 슬롯을 반납한다

operator 전체가 triggerer에서 도는 게 아니다. 시작과 재개는 워커에서, 기다림만 triggerer에서 처리한다.
Worker slotTriggerer execute() 슬롯 반납, 그동안 다른 task가 쓴다 resume async trigger 폴링 self.defer() TriggerEvent 시간 running: 워커 슬롯 점유 deferred: triggerer에서 대기
3부 · data_interval (복습 1순위)

같은 cron인데 data_interval의 모양이 다르다

schedule="0 */2 * * *", 2026-10-18 00:00에 도는 run. 3.x 기본값 create_cron_data_intervals = False라서 cron 문자열에 CronTriggerTimetable이 붙는다.
2.x CronDataIntervalTimetable data_interval: 22:00부터 00:00까지 logical_date 3.x 기본 CronTriggerTimetable logical_date data_interval은 폭 0인 점 10/17 20:0022:0010/18 00:0002:00 10/17 20:0022:0010/18 00:0002:00 run 실행 run 실행
3부 · data_interval

3.x 기본값에서는 ds가 하루 뒤로 밀린다

값2.x3.x 기본
data_interval_start2026-10-17 22:002026-10-18 00:00
data_interval_end2026-10-18 00:002026-10-18 00:00
logical_date2026-10-17 22:002026-10-18 00:00
ds2026-10-172026-10-18
실제 실행2026-10-18 00:002026-10-18 00:00
2.x: run은 구간이 끝난 뒤 00:00에 돌고 logical_date는 구간 시작이다.
3.x: logical_date가 트리거 시각과 같다. "전날 데이터"를 ds로 계산하던 로직은 어긋난다.
구간이 필요하면 업그레이드 전에
create_cron_data_intervals = True를 업그레이드 전에 설정하거나 CronDataIntervalTimetable을 명시한다. 3.x run이 쌓인 뒤에 바꾸면 스케줄 run 하나가 skip된다.
'@daily'도 같은 규칙
preset도 문자열이라 같은 분기를 탄다. timedelta는 DeltaTriggerTimetable이 된다.
근거: task-sdk definitions/dag.py의 _create_timetable. ds는 UTC logical_date를 strftime한 값이다.
3부 · logical_date

수동 run에서는 logical_date를 직접 읽는다

바뀐 점
  • REST API로 trigger하면 logical_date 기본값이 None이다
  • 수동 run의 data_interval은 timetable과 trigger 경로에 따라 달라진다
  • execution_date, next_ds, prev_ds, tomorrow_ds, yesterday_ds는 context에서 빠졌다
점검할 곳
  • 수동 trigger 때 data_interval_start를 "요청 날짜"로 쓰던 DAG
  • TriggerDagRunOperator로 하위 DAG를 띄우는 흐름
  • DT Platform Manual Trigger처럼 API로 run을 만드는 기능
3부 · logical_date

요청 날짜와 구간은 context에서 따로 꺼낸다

# 사용자가 지정한 날짜가 필요할 때
from airflow.sdk import get_current_context, task

@task
def process():
    ctx = get_current_context()
    target = ctx["logical_date"]          # 수동 run의 요청 날짜
    window = (ctx["data_interval_start"], ctx["data_interval_end"])
    # 구간 의미가 필요할 때만 data_interval을 쓴다
3부 · Executor

Celery, Kubernetes, 그리고 Edge

Celery와 Kubernetes는 개념이 2.x와 같고, 워커가 DB 대신 API Server와 통신한다는 점만 다르다. API로만 통신하니 원격지 워커(Edge)도 가능해졌다.
CeleryExecutor
  • broker에 넣으면 상시 worker가 가져간다
  • 시작 지연이 낮고 자원 경합이 생길 수 있다
  • k8s에서는 HPA나 KEDA로 늘린다
언제: task가 꾸준히 많고 latency가 중요할 때
KubernetesExecutor
  • TI마다 pod을 만들고 끝나면 지운다
  • 자원이 격리되는 대신 pod 기동 지연이 있다
  • pod이 API Server에 닿아야 한다(네트워크, JWT)
언제: 리소스 편차가 크거나 bursty할 때
EdgeExecutor 3.0
  • 다른 네트워크나 지역의 워커가 HTTP로 일을 가져간다
  • DB 접속 없이 API만 있으면 된다
  • edge3 provider로 제공된다
언제: 데이터가 있는 원격지에서 실행할 때
3부 · Executor

CeleryKubernetesExecutor 대신 multi-executor를 쓴다

# CeleryKubernetesExecutor는 제거됐다. 여러 executor를 나열하고 task마다 고른다
[core]
executor = CeleryExecutor,KubernetesExecutor   # 첫 번째가 기본값

heavy = PythonOperator(
    task_id="heavy",
    python_callable=run,
    executor="KubernetesExecutor",
)
3.2 multi-team(experimental)에서는 팀마다 다른 executor, connection, pool을 쓸 수 있다.
4부 · 알림과 새 기능

끝나지 않은 run도 잡고,
사람을 기다리고, 상태를 기억한다

Callback, Deadline Alerts, HITL, State Store, DB 접근, 운영
4부 · Callback

콜백이 언제, 어디서 도는가

콜백은 워커 실행으로 상태가 바뀔 때만 불린다. CLI나 UI로 상태를 바꾸면 불리지 않는다.
Task-level: operator나 default_args 실행 위치 Worker, 에러는 task 로그 scheduled queued on_execute_callback running success failed up_for_retry skipped on_success_callbackon_failure_callbackon_retry_callbackon_skipped_callback DAG-level: DAG(...) 실행 위치 DAG Processor, 에러는 dag processor 로그 DagRun running success failed on_success_callbackon_failure_callback
콜백은 리스트로도 지정한다(on_failure_callback=[f1, f2]). 3.2부터 DAG 콜백 context의 TI는 DAG 상태와 관련된 task로 골라진다.
4부 · SLA에서 Deadline Alerts로 (AIP-86)

SLA는 끝난 task만 판정해서 막힌 run을 못 잡았다

SLA 3.0에서 제거
  • task가 끝나야만 판정한다. 막혀 있는 task는 miss를 못 잡는다
  • SLA는 task 속성인데 callback은 DAG 속성이다
  • 판정할 때마다 DAG를 다시 파싱한다
Deadline Alerts 3.1부터
  • 기준 시각(큐 진입, logical_date, 고정 시각)에 허용 시간을 더해 판정한다
  • 3.1: triggerer에서 도는 AsyncCallback
  • 3.2: 워커에서 도는 SyncCallback, DAG당 여러 개
  • 3.3: Browse 메뉴에 Deadlines 페이지
4부 · Deadline Alerts

우리 DAG의 sla_miss_callback은 이렇게 옮긴다

from airflow.sdk.definitions.deadline import DeadlineAlert, DeadlineReference, AsyncCallback

with DAG(
    "datamodeling_temp_layer_daily_kr",
    deadline=DeadlineAlert(
        reference=DeadlineReference.DAGRUN_QUEUED_AT,   # 큐에 들어간 시각부터
        interval=timedelta(hours=2),                    # 2시간 안에 안 끝나면
        callback=AsyncCallback(SlackWebhookNotifier, kwargs={...}),
    ),
): ...
기존 sla_miss_callback=SLAMissNotifier()의 자리. 3.2 기능은 experimental이라 버전별 문서를 같이 본다.
4부 · 3.1부터 3.3까지의 새 기능

task가 사람을 기다리고, 상태를 기억한다

Human-in-the-Loop 3.1
  • HITLOperator가 deferred 상태로 멈추고 UI에 입력 폼을 띄운다
  • 권한 있는 사용자가 params와 XCom을 보고 결정한다
  • 3.3: awaiting_input 상태 추가
예: 모델 결과 검수, 적재 전 승인
State Store 3.3
  • retry와 run을 넘어 남는 key-value
  • task_state_store: task instance 범위
  • asset_state_store: asset 범위, 공유 watermark
  • Spark 재시도 때 기존 job에 다시 붙는다(durable=True)
예: 증분 적재 cursor 저장
그 밖에
  • 3.3 Pluggable Retry Policy: 예외 종류별 재시도와 backoff
  • 3.2 async def를 PythonOperator와 @task에
  • 3.1 trigger rule ALL_DONE_MIN_ONE_SUCCESS
  • 3.3 @result와 /wait API로 run 결과 받기 exp
Pluggable Retry Policy는 공식 블로그에 API 예시가 없어 개념만 적었다.
4부 · State Store

증분 적재 cursor를 run 밖에 남긴다

@task
def extract(**context):
    state = context["task_state_store"]
    cursor = state.get("last_cursor", default=0)   # 지난 run이 남긴 값
    rows = fetch_records(since=cursor)
    state.set("last_cursor", rows[-1]["id"])
    return rows
4부 · DB Connection

외부 DB와 metadata DB는 붙는 길이 다르다

무엇을어떻게판정
외부 DB (BigQuery, MySQL 등)Connection(conn_id) + Hook허용
Airflow metadata 조회Airflow Python Client, /api/v2권장
Airflow metadata 조회PostgresHook으로 metadata DB 직접비권장 우회
Airflow metadata 조회airflow.settings.Session, ORM 모델차단
콜백은 상태 변화에 반응해 알리는 용도다. 안에서 무거운 query나 대량 insert, update를 하면 장애 지점과 운영 부담이 커진다. dag_run을 BQ로 싱크하는 notifier처럼 metadata를 읽는 코드는 3.x로 옮길 때 점검한다.
참고: data-infrastructure 레포의 alpha airflow rds-proxy.tf (Andy 공유)
4부 · UI와 운영

UI는 React로, timer 메트릭은 Histogram으로 바뀐다

UI
  • React로 새로 쓴 UI, Grid 안에 Gantt
  • 3.1: UI 17개 언어 지원, 즐겨찾기 DAG
  • 3.2: UI에서 XCom 추가, 수정, 삭제
  • 3.3: DagRun 일괄 mark, TI 일괄 clear
API, CLI, 플러그인
  • REST /api/v2, 원격 CLI airflowctl 분리
  • Flask 플러그인 대신 React 앱, FastAPI 앱, 미들웨어
  • 기존 FAB 뷰는 FAB provider로 호환된다
메트릭 주의 3.3
  • timer 메트릭이 OTel Gauge에서 Histogram으로
  • dag_processing.last_run.seconds_ago가 tag 방식으로
  • 기존 대시보드와 알림 쿼리를 고쳐야 한다
5부 · 우리 DAG 옮기기

우리 DAG를
3.x로 옮기면

datamodeling_temp_layer_daily_kr 기준
5부 · datamodeling_temp_layer_daily_kr

바뀌는 곳은 import, schedule, SLA 세 군데다

- from airflow.models import DAG
+ from airflow.sdk import DAG

  with DAG(
      dag_id=DAG_ID,
-     schedule_interval='@daily',
+     schedule='@daily',
      start_date=datetime.datetime(2024, 4, 7, 0, tzinfo=KST),
+     catchup=False,                      # 3.x 기본값도 False지만 명시한다
      default_args=default_args,          # FailureNotifier, ResolveSlackAlertNotifier 그대로
      max_active_runs=1,
      max_active_tasks=20,
-     sla_miss_callback=SLAMissNotifier(),
+     # Deadline Alerts로 이전 (27장)
      on_success_callback=dag_run_sync_to_bq_notifier,
      on_failure_callback=dag_run_sync_to_bq_notifier,
  )
커스텀 Notifier는 airflow.sdk.BaseNotifier를 상속하게 바꾼다. '@daily'에서 ds를 쓰는 로직은 19장 기준으로 확인한다.
5부 · 체크리스트 ①

import 경로는 대부분 airflow.sdk로 모인다

2.x import3.x
airflow.models.dag.DAGairflow.sdk.DAG
airflow.decorators.taskairflow.sdk.task
airflow.models.variable.Variableairflow.sdk.Variable
airflow.hooks.base.BaseHookairflow.sdk.BaseHook
airflow.notifications.basenotifier.BaseNotifierairflow.sdk.BaseNotifier
airflow.datasets.Datasetairflow.sdk.Asset
BashOperator, PythonOperatorapache-airflow-providers-standard
5부 · 체크리스트 ②

나머지는 기본값 변경과 ruff로 확인한다

바뀐 기본값과 제거된 것
  • schedule_interval, timetable 인자는 schedule 하나로
  • schedule 기본값은 None, catchup 기본값은 False
  • xcom_pull()은 task_ids가 없으면 현재 task만 본다
  • SubDAG는 TaskGroup이나 Asset으로
  • REST /api/v1은 /api/v2로
# 자동 점검
ruff check dags/ --select AIR301
ruff check dags/ --select AIR301 --fix

# 설정 파일 갱신
airflow config update
정리

다섯 가지만 기억하면 된다

1워커는 metadata DB에 직접 닿지 않는다. 모든 런타임 상호작용은 API Server의 Task Execution API를 거친다.
2DAG Processor는 따로 띄우고, DAG 파일은 Dag bundle로 들어온다. git bundle이면 run마다 버전이 고정된다.
3cron 문자열의 기본 timetable이 CronTriggerTimetable이라 data_interval과 ds가 달라진다.
4시간 대신 Asset으로 돌릴 수 있고, 3.2부터는 파티션 단위로 깨운다.
5SLA는 Deadline Alerts로, 사람 승인은 HITL로, 증분 cursor는 State Store로 처리한다.
출처

공식 문서와 릴리스 블로그를 기준으로 했다

Upgrading to Airflow 3airflow.apache.org/docs/apache-airflow/stable/installation/upgrading_to_airflow3.html
Release Notes (3.3.2)airflow.apache.org/docs/apache-airflow/stable/release_notes.html
Apache Airflow 3.1.0 블로그airflow.apache.org/blog/airflow-3.1.0/
Apache Airflow 3.2.0 블로그airflow.apache.org/blog/airflow-3.2.0/
Apache Airflow 3.3.0 블로그airflow.apache.org/blog/airflow-3.3.0/
Dag Bundles…/administration-and-deployment/dag-bundles.html
Event-driven scheduling…/authoring-and-scheduling/event-scheduling.html
Callbacks…/administration-and-deployment/logging-monitoring/callbacks.html
timetable 기본값은 apache/airflow 소스 task-sdk/src/airflow/sdk/definitions/dag.py에서 직접 확인했다.
마무리

3.x의 변화는 대부분
"워커는 API로만 말한다"에서 출발한다

"DB를 떼어 내자 버저닝, 다언어 SDK, 원격 워커가 따라왔다."
같은 내용을 글로 풀어 쓴 버전은 studies/topics/airflow에 있다.