[Airflow 책 2장] Airflow DAG의 구조: DAG 파일 하나는 무엇으로 이루어져 있을까 (Airflow 3 반영)

1장을 정리하면서 DAG가 무엇인지는 감을 잡았는데, 막상 DAG 파일을 열어 보니 BashOperator, PythonOperator, task_id 같은 이름이 먼저 눈에 들어왔습니다. 오퍼레이터와 태스크는 같은 말인지, 파일을 만든 다음에는 무엇을 실행해야 화면이 뜨는지도 헷갈렸습니다. 《Apache Airflow 기반의 데이터 파이프라인》 2장은 DAG 하나를 처음부터 끝까지 만들어 돌려 보면서 이 질문들에 답하는 장입니다.

:pushpin: 이 글에서 알아갈 수 있는 것

  1. DAG 파일 하나는 어떤 부분으로 이루어져 있을까?
  2. 자주 섞어 쓰는 오퍼레이터와 태스크, 뭐가 다를까?
  3. Airflow를 띄우면 화면에서 무엇을 볼 수 있을까?
  4. 태스크가 실패하면 어디서부터 다시 돌릴까? Airflow는 무엇을 보고 실패라고 판단할까?
  5. 책 2장 예제는 Airflow 3에서 그대로 돌아갈까?

2장의 예제는 로켓 애호가 John의 이야기입니다. 여러 뉴스에 흩어진 로켓 발사 소식을 한곳에서 보고 싶은 John은, 우선 간단하게 곧 발사될 로켓의 사진부터 모으기로 합니다. 발사 정보를 받아 오고, 사진을 내려받고, 다 받았다고 알리는 세 단계입니다.


:rocket: 1. 무엇을 만들까: 로켓 발사 데이터 살펴보기

:open_book: 책 2.1절 · 리스트 2.1 · 그림 2.1~2.2

데이터는 Launch Library 2라는 오픈 API에서 가져옵니다. 누구나 쓸 수 있지만 요청 수 제한이 있다고 책에도 적혀 있습니다. 곧 있을 발사 목록은 주소 하나로 받을 수 있습니다.

curl -L "https://ll.thespacedevs.com/2.0.0/launch/upcoming"

응답은 JSON입니다. results 리스트 안에 발사가 한 건씩 들어 있고, 각 발사에는 이름(name), 발사 예정 시각(net), 로켓 사진 주소(image) 같은 값이 있습니다. 2장에서 쓰는 건 image뿐입니다.

{
  "results": [                         ← 발사 목록
    {                                  ← 발사 한 건
      "name": "Falcon 9 Block 5 | ...",
      "net": "...",                    ← 발사 예정 시각
      "image": "https://.../....jpg",  ← 로켓 사진 주소 (이것만 씁니다)
      ...
    },
    ...
  ]
}

책이 나온 지 꽤 됐지만, 이 주소는 지금(2026년 10월)도 같은 형식으로 응답하고 한 번에 발사 10건을 돌려줍니다.

John의 계획(그림 2.1)을 순서대로 적으면 이렇습니다.

① 곧 있을 발사 정보 받기     (API → /tmp/launches.json 파일)
② 사진 주소를 꺼내 사진 받기  (/tmp/launches.json → /tmp/images 폴더)
③ 다 받았다고 알리기

:brick: 2. 첫 번째 DAG: 세 부분으로 읽기

:open_book: 책 2.2절 · 그림 2.3~2.5 · 리스트 2.2~2.9

사실 이 정도는 셸 명령 몇 개를 이어 붙여 한 줄로도 됩니다. 책도 "Bash-fu를 이용하면 간단하게 한 줄로 해결할 수 있습니다. 그럼, 왜 여기서 Airflow가 필요할까요?"라고 묻습니다. 답은 1장과 같습니다. 큰 작업을 여러 태스크로 나눠 DAG로 엮으면 서로 관계없는 태스크는 동시에 돌릴 수 있고, 태스크마다 다른 기술(앞은 bash, 뒤는 파이썬)을 쓸 수 있습니다.

그래서 John의 계획은 태스크 세 개가 됩니다(그림 2.3).

download_launches ──→ get_pictures ──→ notify
 (발사 정보 받기)      (사진 내려받기)    (알림)

왜 하필 세 개일까?

하나로 합칠 수도, 다섯 개로 더 쪼갤 수도 있습니다. 책은 "태스크를 나누는 방법에는 정답이나 오답이 없습니다"라고 하면서, 판단 기준은 책 전체에서 사례로 보여 주겠다고 합니다. 2장 범위에서 제가 잡은 기준은 1장의 "실패한 곳부터 다시"입니다. 사진을 받다가 실패했을 때 API까지 다시 부를 필요는 없으니, API 호출과 사진 받기를 나눠 두면 실패한 쪽만 다시 돌리면 됩니다. 5절에서 실제로 그렇게 해 봅니다.

DAG 파일은 세 부분으로 되어 있습니다

리스트 2.2의 DAG 파일은 길어 보이지만, 뼈대만 남기면 세 부분입니다.

# 책 원본 (Airflow 2.0 기준)에서 뼈대만 추림. 주석은 정리하며 단 것

# ① DAG 객체: 모든 워크플로의 시작점
dag = DAG(
    dag_id="download_rocket_launches",            # 화면에 보이는 DAG 이름
    start_date=airflow.utils.dates.days_ago(14),  # 처음 실행할 수 있는 날짜
    schedule_interval=None,                       # 자동으로 돌리지 않음 (손으로 실행)
)

# ② 오퍼레이터로 태스크 만들기
download_launches = BashOperator(
    task_id="download_launches",
    bash_command="curl -o /tmp/launches.json -L 'https://ll.thespacedevs.com/2.0.0/launch/upcoming'",
    dag=dag,                                      # 이 태스크가 어느 DAG 소속인지
)
get_pictures = PythonOperator(task_id="get_pictures", python_callable=_get_pictures, dag=dag)
notify = BashOperator(
    task_id="notify",
    bash_command='echo "There are now $(ls /tmp/images/ | wc -l) images."',
    dag=dag,
)

# ③ 의존성: 실행 순서
download_launches >> get_pictures >> notify
  1. DAG 객체

    워크플로는 이 객체에서 시작합니다. 태스크마다 붙은 dag=dag가 처음 보면 헷갈리는데, 왼쪽 dag는 오퍼레이터가 받는 인자 이름이고 오른쪽 dag는 위에서 만든 변수 이름입니다. "이 태스크는 저 DAG 객체 소속"이라고 적어 주는 것이고, 변수 이름이 우연히 dag라서 같은 글자가 두 번 나올 뿐입니다. 한 파일에 DAG를 둘 만들면 차이가 보입니다.

    daily = DAG(dag_id="daily_report", ...)
    weekly = DAG(dag_id="weekly_report", ...)
    
    a = BashOperator(task_id="a", bash_command="echo a", dag=daily)   # daily_report 소속
    b = BashOperator(task_id="b", bash_command="echo b", dag=weekly)  # weekly_report 소속
    

    3.3.2에서 이 코드를 불러오면 daily_report에는 a만, weekly_report에는 b만 들어갑니다. dag=를 빼먹은 태스크는 어느 DAG에도 들어가지 않아서 화면에 나오지 않습니다(다른 태스크와 >>로 이으면 그 태스크의 DAG로 따라 들어가긴 합니다). 서로 다른 DAG의 태스크끼리 a >> b로 이으면 “more than one Dag” 오류가 납니다. 태스크를 이을 수 있는 건 같은 DAG 안에서뿐입니다.

    태스크마다 소속을 적는 게 번거로워서, 요즘은 with DAG(...): 블록으로 묶어 씁니다.

    with DAG(dag_id="download_rocket_launches", ...):
        download_launches = BashOperator(task_id="download_launches", ...)
        get_pictures = PythonOperator(task_id="get_pictures", ...)
        notify = BashOperator(task_id="notify", ...)
    
        download_launches >> get_pictures >> notify
    

    with 블록 안에서 만든 태스크는 자동으로 그 DAG 소속이 됩니다. 들여쓰기 안에 있으면 "이 DAG 것"이라는 뜻이라 dag=dag를 적지 않습니다. 하는 일은 dag=dag와 같고 적는 방법만 다릅니다. 3.3.2에서 확인해 보면 몇 가지가 분명해집니다.

    • 태스크는 반드시 블록 안에서 만듭니다. 블록이 끝난 뒤(들여쓰기 밖)에 만든 태스크는 어느 DAG에도 들어가지 않습니다.
    • >>는 블록 밖에 적어도 동작합니다. 태스크의 소속이 이미 정해졌기 때문입니다. 그래도 DAG 하나가 한 덩어리로 읽히도록 보통 블록 안 맨 끝에 둡니다.
    • 책의 dag=dag 방식도 3.3.2에서 그대로 돌아갑니다. 지금 더 많이 쓰는 방식이 with일 뿐이고, 6절 실습 코드도 with로 썼습니다.

    schedule_interval=None은 자동으로 실행하지 않는다는 뜻이라, 처음에는 화면에서 손으로 실행합니다(4절에서 스케줄을 겁니다).

  2. 오퍼레이터로 만든 태스크

    BashOperator는 셸 명령을, PythonOperator는 파이썬 함수를 실행합니다. 모든 오퍼레이터에는 task_id가 필요하고, 이 이름이 화면에 그대로 보입니다.

  3. 의존성

    >>는 1장에서 본 그 화살표입니다. download_launches가 성공해야 get_pictures가, get_pictures가 성공해야 notify가 실행됩니다. 원래 파이썬에서 >>는 비트를 옮기는 연산자인데, Airflow가 의존성을 적는 기호로 다시 정의해 썼습니다.

오퍼레이터와 태스크는 뭐가 다를까? (2.2.1)

DAG 코드를 읽다 보면 오퍼레이터와 태스크가 섞여 나옵니다. 2.2.1절은 두 용어를 같은 의미로 쓴다고 먼저 밝혀 둡니다. 그래도 굳이 나누면 이렇습니다.

오퍼레이터 태스크
무엇인가 한 가지 작업을 하는 부품. BashOperator, PythonOperator, EmailOperator 등 DAG 안에서 그 오퍼레이터를 맡아 실행하는 단위
누가 신경 쓰나 사용자: “무엇을 할지” 정한다 Airflow: 시작·완료·실패 같은 상태를 챙긴다
2.2.1절의 비유 오퍼레이터의 “래퍼(wrapper)” 또는 “매니저(manager)”

예를 들어 BashOperator(bash_command="curl ...")를 쓸 때 우리가 정하는 건 "이 명령을 실행해라"까지입니다. 그 명령을 언제 시작할지, 끝났는지 실패했는지, 실패하면 다시 돌릴지는 적지 않습니다. 그 관리는 Airflow가 태스크 단위로 맡습니다(그림 2.4). DAG는 이 태스크들을 순서에 맞춰 시작시키는 지휘자입니다.

하나 더 알아 두면 화면을 볼 때 편합니다. 같은 태스크도 DAG가 실행될 때마다 한 번씩 새로 돕니다. 이렇게 "어느 실행의 어느 태스크"를 가리키는 하나하나를 태스크 인스턴스라고 부릅니다. 5절의 Clear 창에 나오는 것도 태스크 인스턴스 목록입니다.

파이썬 함수 실행하기 (2.2.2)

사진 내려받기는 JSON을 읽고, 주소를 하나씩 꺼내, 파일로 저장해야 해서 셸 한 줄보다 파이썬이 읽기 쉽습니다. 이때 쓰는 것이 PythonOperator이고, 지킬 것은 두 가지입니다.

  1. 실행할 파이썬 함수(_get_pictures)를 먼저 만든다.
  2. 오퍼레이터(get_pictures)를 만들고, python_callable에 그 함수 자체를 넘긴다.

_get_pictures는 평범한 파이썬 함수이고, 하는 일은 세 단계입니다(예외 처리를 빼고 줄였습니다).

def _get_pictures():
    # 1. /tmp/images 폴더가 없으면 만든다
    pathlib.Path("/tmp/images").mkdir(parents=True, exist_ok=True)

    # 2. /tmp/launches.json을 읽어 발사마다 사진 주소(image)를 꺼낸다
    with open("/tmp/launches.json") as f:
        launches = json.load(f)
    image_urls = [launch["image"] for launch in launches["results"]]

    # 3. 주소마다 사진을 받아 /tmp/images에 저장한다
    for image_url in image_urls:
        response = requests.get(image_url)
        target_file = f"/tmp/images/{image_url.split('/')[-1]}"
        with open(target_file, "wb") as f:
            f.write(response.content)

get_pictures = PythonOperator(
    task_id="get_pictures",
    python_callable=_get_pictures,  # 괄호 없이 함수 이름만
    dag=dag,
)

파일 안에서도 이 순서여야 합니다. 파이썬은 파일을 위에서부터 읽기 때문에, 함수보다 오퍼레이터가 먼저 나오면 그 줄에서는 아직 _get_pictures라는 이름이 없습니다. 3.3.2에서 순서를 바꿔 보면 NameError: name '_get_pictures' is not defined 오류가 나고 DAG가 아예 올라오지 않습니다.

함수 이름 뒤에 괄호를 붙이지 않는 것도 중요합니다. python_callable=_get_pictures()라고 쓰면 1장에서 본 것처럼 파싱하는 순간 함수가 실행되고, 그 결과(None)가 넘어가서 오류가 납니다. Airflow 3.3.2에서 해 보면 함수 본문이 먼저 돈 다음 “python_callable param must be callable” 오류가 납니다.

get_pictures는 앞 태스크가 남긴 파일(/tmp/launches.json)을 읽습니다. 태스크끼리 파일로 결과를 넘기는 셈인데, 이게 되는 건 모든 태스크가 같은 컴퓨터에서 돌기 때문입니다. 워커가 여러 대라면 태스크마다 다른 컴퓨터에서 돌 수 있어서 /tmp를 함께 볼 수 없습니다. 그때는 공용 저장소를 쓰거나, 작은 값이라면 XCom(5.5절)으로 넘깁니다.

:light_bulb: [Airflow 2.0 이후 달라진 점] 오퍼레이터 import 경로와 @task

  • import 경로가 airflow.sdk와 airflow.providers로 나뉘었습니다. 둘은 하는 일이 다릅니다.

    • airflow.sdk(Task SDK)는 DAG를 쓰는 데 필요한 뼈대입니다. DAG, task, BaseOperator처럼 어떤 DAG든 쓰는 것들이 여기 모였습니다.
    • airflow.providers.*(프로바이더)는 오퍼레이터 묶음입니다. amazon, google, postgres, slack처럼 바깥 시스템별로 나뉘어 있고, 각각 따로 설치하고 버전도 따로 올라갑니다. BashOperator와 PythonOperator 같은 기본 오퍼레이터는 standard 프로바이더로 옮겨졌습니다(airflow.providers.standard.operators.bash, ...python). 3.3.2 공식 이미지에는 standard 프로바이더가 처음부터 깔려 있습니다.

    옛 airflow.operators.bash 경로도 3.3.2에서 아직 불러와지지만, ruff가 “앞으로 삭제될 예정”(AIR312)이라고 알려 줍니다.

  • 파이썬 함수는 @task로 더 짧게 쓸 수 있습니다. 5.6절에서 다루는 TaskFlow API입니다. 함수 위에 @task를 붙이면 PythonOperator로 감싸는 일을 Airflow가 대신 해 줍니다. task_id를 적지 않으면 함수 이름이 task_id가 되고, BashOperator와도 >>로 그대로 이을 수 있습니다.

from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG, task

with DAG(...):
    download_launches = BashOperator(...)

    @task
    def get_pictures():
        ...  # 사진 내려받기

    notify = BashOperator(...)

    download_launches >> get_pictures() >> notify

여기서는 get_pictures()처럼 괄호를 붙입니다. 앞에서 python_callable에는 괄호를 붙이면 안 된다고 했는데, @task가 붙은 함수는 괄호를 붙여 불러도 함수 본문이 돌지 않습니다. 대신 DAG에 태스크 하나를 만들어 돌려줍니다. 본문은 나중에 그 태스크가 실행될 때 돕니다.

3.3.2에서 이 DAG를 불러와 보면 get_pictures는 task_id가 get_pictures인 PythonOperator 계열 태스크(_PythonDecoratedOperator)로 잡힙니다. 쓰는 방법이 짧아졌을 뿐, "오퍼레이터를 감싼 태스크"라는 구조는 같습니다.


:spouting_whale: 3. Airflow 띄우고 화면 둘러보기

:open_book: 책 2.3절 · 리스트 2.10 · 그림 2.6~2.12

2.3절은 Airflow를 스케줄러, 웹 서버, 데이터베이스(메타스토어) 세 가지 핵심 컴포넌트로 소개하고, 띄우는 방법 두 가지를 보여 줍니다.

파이썬 환경에 설치하기 (2.3.1)

pip install apache-airflow로 설치합니다. 패키지 이름이 airflow가 아니라 apache-airflow라는 점에 주의하라고 합니다. Airflow가 2016년 아파치 재단에 들어가면서 이름이 바뀌었고, 옛 airflow 패키지는 올바른 이름을 알려 주는 용도로만 남아 있습니다. 프로젝트마다 가상 환경(pyenv, Conda, virtualenv 등)을 따로 두라는 조언도 있습니다.

설치한 뒤에는 다섯 단계를 거칩니다.

airflow db init                                   # 1. 메타스토어(DB) 초기화
airflow users create --username admin --password admin \
  --firstname Anonymous --lastname Admin \
  --role Admin --email admin@example.org          # 2. 로그인할 사용자 만들기
cp download_rocket_launches.py ~/airflow/dags/    # 3. DAG 파일을 DAG 폴더에 복사
airflow webserver                                 # 4. 웹 서버 시작
airflow scheduler                                 # 5. 스케줄러 시작 (다른 터미널에서)

웹 서버와 스케줄러는 계속 떠 있어야 하는 프로세스라 터미널을 두 개 열거나 백그라운드로 돌립니다. 그다음 http://localhost:8080에서 admin/admin으로 로그인합니다.

도커로 띄우기 (2.3.2)

도커 컨테이너는 파이썬 패키지뿐 아니라 운영체제 수준의 의존성까지 격리해 줍니다. 리스트 2.10은 apache/airflow:2.0.0 이미지 하나로 위 단계를 모두 실행합니다. DB 초기화와 사용자 생성을 한 뒤, 웹 서버와 스케줄러를 한 컨테이너 안에서 같이 띄우는 방식입니다. 노트에서도 "하나의 도커 컨테이너 안에 여러 프로세스를 실행하는 것은 문제가 있다"는 점을 인정하고, 예제를 간단히 하려는 데모 환경이라고 밝힙니다.

:light_bulb: [Airflow 2.0 이후 달라진 점] 띄우는 명령이 바뀌었습니다

다섯 단계 중 그대로인 건 DAG 파일 복사와 airflow scheduler뿐입니다. 3.3.2 컨테이너에서 이 명령을 그대로 쳐 보면 airflow db init은 “invalid choice”, airflow webserver는 “has been removed. Please use airflow api-server” 오류가 납니다. 1장에서 본 구조 변화(웹 서버 → API 서버, DAG 프로세서 분리)가 명령에도 그대로 드러납니다.

책 (Airflow 2.0) Airflow 3.3.2
pip install apache-airflow 버전과 constraints 파일을 함께 지정해 설치 (아래 명령). Python 3.10 이상
airflow db init 없어짐 → airflow db migrate
airflow users create ... 기본 인증 관리자(Simple auth manager)는 설정에 적힌 사용자 목록을 씁니다. 기본값은 admin 사용자 하나이고 비밀번호는 자동으로 만들어집니다. users create는 FAB 인증 관리자용 명령이라 FAB 프로바이더가 있을 때만 보입니다
airflow webserver 없어짐 → airflow api-server
airflow scheduler 그대로. 대신 DAG 파일을 파싱하는 airflow dag-processor도 따로 띄웁니다 (기다리는 태스크를 맡는 airflow triggerer도)
위를 한 번에 airflow standalone: API 서버·스케줄러·DAG 프로세서·트리거러를 한 번에 띄우는 실습용 명령
apache/airflow:2.0.0-python3.8 이미지 apache/airflow:3.3.2 (기본 Python 3.13)
# 마지막 3.12 자리는 내 파이썬 버전에 맞춰 바꿉니다 (3.10~3.14)
pip install "apache-airflow==3.3.2" \
  --constraint "https://raw.githubusercontent.com/apache/airflow/constraints-3.3.2/constraints-3.12.txt"

이름이 비슷하지만 트리거러(triggerer) 는 화면의 트리거(Trigger) 버튼과 다른 것입니다. 트리거 버튼은 DAG를 지금 실행하라고 누르는 버튼이고, 트리거러는 "파일이 생길 때까지"처럼 오래 기다리는 태스크를 대신 지켜보는 백그라운드 프로세스입니다. 한국어 화면에서도 둘을 "트리거"와 "트리거러"로 나눠 씁니다.

airflow standalone은 처음 띄울 때 admin 비밀번호를 만들어 터미널에 보여 주고, simple_auth_manager_passwords.json.generated 파일에도 저장합니다. 이 시리즈의 실습 저장소는 로그인을 꺼 둔 설정(simple_auth_manager_all_admins)이라 바로 들어갑니다.

화면 둘러보기 (2.3.3)

2.3.3절의 순서를 따라가면 이렇습니다.

  1. 로그인하면 첫 화면에 DAG 목록이 나옵니다. DAG 폴더에 넣은 download_rocket_launches 하나가 보입니다(그림 2.7).
  2. DAG 이름을 누르면 그래프 뷰가 열립니다. 스크립트에서 읽어 낸 태스크와 화살표가 그대로 그려지고, 오퍼레이터 종류와 상태 색 범례도 보입니다. 이 화면을 "워크플로를 개발하는 동안 가장 많이 사용되는 화면 중 하나"라고 소개합니다(그림 2.8).
  3. DAG를 실행하려면 먼저 이름 옆 토글로 켜고(On), 재생 버튼(▷)을 눌러 트리거합니다. 태스크가 순서대로 색을 바꾸며 돕니다(그림 2.9).
  4. 끝난 notify 태스크를 눌러 Log를 열면, 긴 로그 속에서 There are now N images.라는 출력을 찾을 수 있습니다(그림 2.10~2.11). 알림은 이메일이나 슬랙 대신 이미지 개수 출력으로 단순화했습니다.
  5. 사진은 내 컴퓨터가 아니라 컨테이너 안의 /tmp/images에 저장됩니다. 그래서 docker exec -it airflow /bin/bash로 컨테이너에 들어가 확인합니다(그림 2.12).

:light_bulb: [Airflow 2.0 이후 달라진 점] 화면

  • 화면이 새로 만들어졌습니다. 메뉴 위치와 모양은 책의 스크린샷과 다르지만, DAG 목록 → DAG 화면 → 태스크 → 로그로 들어가는 흐름은 같습니다. DAG를 실행하는 버튼은 오른쪽 위의 Trigger입니다.
  • DAG 화면 왼쪽에는 그리드 뷰가 기본으로 열립니다. 날짜별 실행을 격자로 보여 주는, 트리 뷰가 있던 자리입니다(2.3부터 그리드 뷰). 바로 옆 버튼으로 그래프 뷰로 바꿉니다.
  • 한국어 화면을 지원합니다. 브라우저 언어가 한국어면 처음부터 한국어로 열리고, 왼쪽 아래 사용자 메뉴의 언어 선택(Select Language)에서 바꿀 수도 있습니다. 한국어 화면에서는 Trigger가 “트리거”, Clear가 “지우기”, upstream_failed가 "업스트림 실패"로 나옵니다.

3.3.2 실습 컨테이너에서 같은 순서로 찍어 봤습니다. DAG 화면에서 그래프 버튼을 누르면 태스크 세 개가 오퍼레이터 이름과 함께 그려집니다. 오른쪽에는 최근 실행 기록이 나옵니다.

오른쪽 위 트리거를 누르면 바로 실행되지 않고 창이 하나 뜹니다. 책에는 없던 화면입니다. 한 번만 돌릴지(단일 실행), 지난 기간을 채울지(백필)를 고르고 논리적 날짜를 정합니다. 그대로 "트리거"를 누르면 지금 날짜로 한 번 실행됩니다.

실행이 끝나고 notify 태스크를 눌러 로그를 열면, 책처럼 이미지 개수가 찍혀 있습니다.


:alarm_clock: 4. 스케줄 걸기: @daily 한 줄

:open_book: 책 2.4절 · 리스트 2.11 · 그림 2.13~2.14

지금까지는 John이 직접 트리거해야 했습니다. 컴퓨터가 잘하는 반복 작업을 사람이 매일 할 이유는 없습니다. 바꿀 곳은 한 줄입니다.

dag = DAG(
    dag_id="download_rocket_launches",
    start_date=airflow.utils.dates.days_ago(14),
    schedule_interval="@daily",   # None → "@daily": 매일 자정에 한 번
)

이렇게 바꾸고 DAG를 켜면 트리 뷰(그림 2.13)에는 실행이 14개 생깁니다. 오늘 한 번 돌 줄 알았는데 왜 14개일까요? 2.4절의 설명은 이렇습니다. 시작 날짜가 14일 전이니 하루 간격으로 나누면 칸이 14개이고, “이 14개 간격의 시작 및 종료 시간이 모두 현재 시간보다 과거에 있기 때문에” Airflow가 그 칸을 모두 실행한다는 것입니다. 1장에서 본 "날짜 칸마다 실행이 하나씩"이 그대로 화면에 나타난 셈입니다. 이렇게 지난 칸을 한꺼번에 채우는 동작을 catchup이라고 부르는데, 2.4절은 이 말을 아직 쓰지 않고 자세한 내용을 3장으로 넘깁니다.

트리 뷰는 같은 DAG를 시간 축으로 펼친 화면입니다. 열 하나가 실행 하나이고, 행이 태스크입니다(그림 2.14). 그래프 뷰는 "구조"를, 트리 뷰는 "날짜별 상태"를 보여 줍니다.

Airflow 3에는 트리 뷰가 없고, 같은 자리를 그리드 뷰가 맡습니다(2.3부터). 읽는 법은 같습니다. 열 하나가 실행 하나이고, 행이 태스크입니다. 아래는 실습 컨테이너의 그리드 뷰로, 컨테이너를 켜 둔 날의 @daily 실행과 손으로 트리거한 실행이 열로 쌓여 있습니다. 칸 위 막대는 실행마다 걸린 시간입니다.

:light_bulb: [Airflow 2.0 이후 달라진 점] 같은 설정인데 실행 개수가 다릅니다

schedule_interval은 schedule로 바뀌었고, days_ago()는 없어져서 고정된 날짜를 씁니다. 더 큰 차이는 catchup 기본값입니다. Airflow 3에서는 catchup 기본값이 False라서, 책처럼 시작 날짜를 과거로 두고 켜도 가장 최근 칸 하나만 실행합니다. 3.3.2에서 시작 날짜를 10월 4일 0시(한국 시간)로 두고 10월 18일 낮에 켜서 세어 봤습니다.

10/4 시작 · @daily · 10/18에 켰을 때 생긴 실행 붙은 날짜
책과 같은 방식 (구간 방식 + catchup 켬) 14개 10/4 ~ 10/17
Airflow 3 @daily + catchup=True 15개 10/4 ~ 10/18
Airflow 3 기본값 (catchup 끔) 1개 10/18

14개와 15개의 차이는 1장의 @daily 이야기와 같습니다. 책의 방식은 하루 구간이 끝나야 그 칸을 실행하므로, 아직 끝나지 않은 10/18 칸은 빠집니다. Airflow 3은 예정 시각(10/18 0시)이 지났으면 그 실행을 만듭니다. 과거 칸을 채우고 싶다면 DAG에 catchup=True를 주거나, 필요한 기간만 백필하면 됩니다(3.5절). 참고로 schedule을 아예 적지 않으면 기본값은 None이라 손으로 트리거할 때만 돕니다.


:repeat_button: 5. 실패하면: 실패한 곳부터 다시

:open_book: 책 2.5절 · 그림 2.15~2.20

지금까지는 모든 칸이 초록색이었습니다. 2.5절은 사진을 받는 도중 네트워크가 잠깐 끊겼다고 가정하고 실패한 모습을 보여 줍니다.

  • 사진을 받지 못한 get_pictures는 그래프 뷰와 트리 뷰에서 빨간색(failed)이 됩니다.
  • notify는 get_pictures가 성공해야 실행되므로 아예 실행되지 않고 주황색(upstream_failed)으로 표시됩니다. 기본적으로는 앞 태스크가 모두 성공해야 다음 태스크가 실행되기 때문입니다.

get_pictures의 로그를 열면 원인이 보입니다(그림 2.16).

urllib3.exceptions.NewConnectionError: <urllib3.connection.HTTPSConnection object at 0x7f37963ce3a0>:
Failed to establish a new connection: [Errno -2] Name or service not known

파이썬의 HTTP 클라이언트가 연결을 시도했지만 실패했다는 뜻입니다. 방화벽에 막혔거나 인터넷이 끊긴 경우입니다.

지우기(Clear)로 실패한 곳부터 다시

문제를 고쳤다면(예: 인터넷 케이블을 다시 꽂았다면) 다시 돌립니다. 이때 DAG 전체를 처음부터 돌릴 필요가 없다는 게 핵심입니다. 노트에 적힌 대로 "이전에 성공한 태스크를 재시작할 필요없이 실패한 지점부터 다시 시작할 수 있다는 것"이 Airflow의 장점입니다.

  1. 트리 뷰에서 실패한 get_pictures 칸을 누르고, 팝업에서 Clear를 누릅니다(그림 2.17).
  2. 지울 태스크 인스턴스 목록이 나옵니다. get_pictures [failed]와 그 뒤의 notify [upstream_failed] 두 개이고, 이미 성공한 download_launches는 목록에 없습니다(그림 2.18).
  3. OK를 누르면 두 태스크의 상태가 초기화되고 Airflow가 자동으로 다시 실행합니다. 연결이 돌아왔다면 모두 초록색이 됩니다(그림 2.19~2.20).

Clear는 1장에서 본 것처럼 기록을 지우는 게 아니라 상태를 되돌리는 것입니다. 번역서는 그림 설명에서 Clear를 "삭제"로 옮겼는데, 실제로는 "다시 실행하도록 초기화"에 가깝습니다.

2.5절 끝에는, 어떤 실패를 허용할지처럼 실패를 다루는 기준을 워크플로의 모든 수준에서 정할 수 있다는 이야기도 있습니다. 본문은 4장을 가리키지만, 목차상으로는 트리거 규칙(5.4절)과 실패 알림(12.5절)에서 다룹니다.

그런데 책 코드라면 get_pictures가 정말 실패할까?

실습하다가 하나 이상한 점을 발견했습니다. 책의 _get_pictures는 사진을 받다가 연결 오류(ConnectionError)가 나면 except로 잡아 "Could not connect to …"를 출력하고 다음 사진으로 넘어갑니다. 예외를 잡아서 출력만 하면 함수는 정상적으로 끝나고, Airflow 입장에서는 성공한 태스크입니다.

책과 같은 방식으로 예외를 잡는 실습 DAG(6절)로 확인해 봤습니다. 3.3.2에서 사진 서버 주소만 막아 두고 get_pictures를 다시 돌렸습니다. 로그에는 "Could not connect to …"가 10번 찍혔고 받은 사진은 하나도 없었지만, 태스크는 초록색(성공)으로 끝났습니다. 뒤따른 notify는 "There are now 9 images."라고 알렸는데, 이전 실행에서 받아 둔 사진이 /tmp/images에 남아 있었기 때문입니다. 그림 2.16처럼 빨간색이 되려면 오류가 함수 밖으로 나가야 합니다.

재시도와 Clear는 Airflow가 실패를 알아야 쓸 수 있습니다. 예외를 삼키면 실패해야 할 태스크가 성공으로 기록되니, 태스크는 실패해야 할 때 제대로 실패하도록 짜야 합니다.

Airflow는 무엇을 보고 실패라고 판단할까?

그렇다면 Airflow는 무엇을 보고 성공과 실패를 나눌까요? 403이나 500 같은 HTTP 상태 코드를 보는 게 아닙니다. Airflow가 보는 건 태스크가 어떻게 끝났는지 하나입니다.

  • PythonOperator: 함수 밖으로 예외가 나오면 실패, 아무 일 없이 끝나면 성공입니다. 무엇을 반환하는지(False라도)는 상관없습니다.
  • BashOperator: 명령의 종료 코드가 0이 아니면 실패입니다. 종료 코드 99는 예외로 "건너뜀(skipped)"이 됩니다. 명령을 ;로 여러 개 이었다면 마지막 명령의 종료 코드만 봅니다.

3.3.2에서 경우마다 태스크를 하나씩 만들어 돌려 봤습니다.

태스크 안에서 한 일 결과
requests.get()으로 받았는데 응답이 404 성공
같은 요청 뒤에 response.raise_for_status() 호출 실패 (HTTPError)
예외를 except로 잡고 출력만 함 성공
예외를 잡아 출력한 다음 raise로 다시 던짐 실패
함수가 False를 반환 성공
셸 명령 exit 1 실패
셸 명령 exit 99 건너뜀
셸 명령 false; echo done (앞 명령은 실패, 마지막 명령은 성공) 성공

굵게 표시한 "성공"이 함정입니다. requests는 404나 500을 받아도 예외를 내지 않습니다. 그래서 응답 코드를 확인하려면 raise_for_status()처럼 직접 예외를 내야 합니다. 예외를 잡아서 로그를 남기고 싶다면, 남긴 다음 raise로 다시 던지면 됩니다. 로그도 남고 Airflow도 실패를 압니다.

try:
    response = requests.get(image_url, timeout=30)
    response.raise_for_status()      # 404·500이면 여기서 HTTPError
except requests.exceptions.RequestException as e:
    print(f"Could not download {image_url}: {e}")
    raise                            # 다시 던져야 태스크가 실패로 기록됨

:light_bulb: [Airflow 2.0 이후 달라진 점] 지우기 창

Airflow 3에서는 그리드 뷰나 그래프 뷰에서 태스크를 누르고 Clear Task Instance(한국어 화면은 “태스크 인스턴스 지우기”) 버튼을 누릅니다. 단축키는 Shift+C입니다. 창에서 함께 지울 범위를 고를 수 있습니다. 다운스트림(Downstream)은 뒤따르는 태스크까지, 업스트림(Upstream)은 앞 태스크까지, 과거·미래(Past·Future)는 다른 날짜의 같은 태스크까지 지우고, "실패한 태스크만 지우기(Clear only failed tasks)"도 고를 수 있습니다. 책처럼 실패한 태스크와 그 뒤를 함께 다시 돌리는 다운스트림은 처음부터 선택되어 있습니다.

3.3.2에서 직접 실패를 만들어 지우기까지 해 봤습니다. 네트워크를 끊는 대신 /tmp/images 폴더 자리에 같은 이름의 빈 파일을 두었더니, get_pictures가 폴더를 만들다 FileExistsError로 실패했습니다. 책과 같은 모양이 나옵니다. download_launches는 초록(성공), get_pictures는 빨강(실패), notify는 주황(업스트림 실패)입니다.

get_pictures를 누르고 지우기 버튼을 누르면 아래 창이 뜹니다. 다운스트림이 기본으로 선택되어 있어서 목록에는 get_pictures와 notify 두 개만 있고, 이미 성공한 download_launches는 없습니다. 폴더를 원래대로 돌려놓고 확인을 누르자 두 태스크만 다시 돌아 모두 초록이 됐습니다. get_pictures는 두 번째 시도로 기록됐고, download_launches는 첫 번째 시도 그대로였습니다.


:test_tube: 6. 실습: 책 2장 예제, Airflow 3에서 그대로 돌아갈까?

:open_book: 저자 저장소 chapter02 · 리스트 2.2 · 리스트 2.11

책의 예제 코드는 저자 저장소(BasPH/data-pipelines-with-apache-airflow)의 chapter02/dags/download_rocket_launches.py입니다. 2절에서 본 리스트 2.2에 4절의 @daily까지 반영된 파일입니다. 앞부분은 이렇습니다.

# 책 원본 (Airflow 2.0 기준) 중 일부
import airflow
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator

dag = DAG(
    dag_id="download_rocket_launches",
    description="Download rocket pictures of recently launched rockets.",
    start_date=airflow.utils.dates.days_ago(14),
    schedule_interval="@daily",
)

Airflow 3.3.2 공식 Docker 이미지에서 불러오면 두 군데에서 오류가 납니다. 나머지 두 군데는 아직 돌지만 바꾸길 권하는 곳입니다.

책 원본 Airflow 3.3.2에서 이렇게 고침
airflow.utils.dates.days_ago(14) AttributeError (함수가 삭제됨) pendulum.datetime(2026, 10, 1, tz="Asia/Seoul")처럼 고정된 날짜
schedule_interval="@daily" TypeError (인자가 삭제됨) schedule="@daily"
from airflow.operators.bash import BashOperator, from airflow.operators.python import PythonOperator 아직 동작함 (AIR312: 앞으로 삭제 예정) airflow.providers.standard.operators.bash, airflow.providers.standard.operators.python
from airflow import DAG 아직 동작함 (AIR311) from airflow.sdk import DAG

1장 예제의 DummyOperator는 이름 자체가 없어져서 바로 오류가 났지만, BashOperator와 PythonOperator는 옛 경로로도 아직 불러와집니다. 그래도 언젠가 없어질 경로이니 지금 바꿔 두는 게 좋습니다. 1장에서 소개한 ruff로 책 원본을 검사하면 표의 네 가지가 모두 잡힙니다. ruff는 import 줄이 아니라 오퍼레이터를 쓴 곳마다 알려 주기 때문에, BashOperator 두 번과 PythonOperator 한 번이 AIR312 3개로 나와서 모두 6개(AIR301 2개, AIR311 1개, AIR312 3개)입니다.

uvx ruff@latest check --select AIR3 dags/

다 고친 Airflow 3 버전입니다.

import json
import pathlib

import pendulum

from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.operators.python import PythonOperator
from airflow.sdk import DAG

LAUNCHES_URL = "https://ll.thespacedevs.com/2.0.0/launch/upcoming"
LAUNCHES_FILE = "/tmp/launches.json"
IMAGES_DIR = "/tmp/images"

def _get_pictures():
    # 파싱 때마다 불러오지 않도록 태스크 안에서 import (1장 '파싱과 실행')
    import requests

    pathlib.Path(IMAGES_DIR).mkdir(parents=True, exist_ok=True)
    launches = json.loads(pathlib.Path(LAUNCHES_FILE).read_text())

    for launch in launches["results"]:
        image_url = launch.get("image")
        if not image_url:
            print(f"No image for {launch['name']}")
            continue
        target_file = f"{IMAGES_DIR}/{image_url.split('/')[-1]}"
        try:
            response = requests.get(image_url, timeout=30)
        except requests.exceptions.ConnectionError:
            print(f"Could not connect to {image_url}.")
            continue
        pathlib.Path(target_file).write_bytes(response.content)
        print(f"Downloaded {image_url} to {target_file}")

with DAG(
    dag_id="download_rocket_launches",
    description="Download rocket pictures of recently launched rockets.",
    start_date=pendulum.datetime(2026, 10, 1, tz="Asia/Seoul"),
    schedule="@daily",
):
    download_launches = BashOperator(
        task_id="download_launches",
        bash_command=f"curl -o {LAUNCHES_FILE} -L '{LAUNCHES_URL}'",
    )

    get_pictures = PythonOperator(
        task_id="get_pictures",
        python_callable=_get_pictures,
    )

    notify = BashOperator(
        task_id="notify",
        bash_command=f'echo "There are now $(ls {IMAGES_DIR}/ | wc -l) images."',
    )

    download_launches >> get_pictures >> notify

코드 읽는 법

  • with DAG(...): 블록: 블록 안에서 만든 태스크는 자동으로 이 DAG 소속이 되어서, 책처럼 태스크마다 dag=dag를 적지 않아도 됩니다. 1장 예제와 같은 방식입니다.
  • import requests를 함수 안으로: 1장에서 본 것처럼 파일 맨 위의 import는 파싱할 때마다 실행됩니다. requests는 get_pictures만 쓰니 그 함수 안에서 불러왔습니다.
  • 사진 주소가 없는 발사는 건너뜀: API가 사진 주소 자리에 null을 줄 수도 있습니다. 원본은 잘못된 주소를 예외(MissingSchema)로 잡았는데, 여기서는 미리 확인하고 넘어갑니다.
  • timeout=30: requests는 시간 제한을 주지 않으면 응답이 올 때까지 계속 기다립니다. 서버가 응답하지 않을 때 태스크가 멈춰 있지 않도록 30초 제한을 뒀습니다.
  • 연결 오류는 책처럼 출력만 하고 넘어감: 5절에서 본 대로 이 방식이면 사진을 하나도 못 받아도 태스크는 성공합니다. 책과 같은 동작을 보려고 그대로 두었습니다.

직접 돌려 보기

1장과 같은 저장소 vividbaek/airflow-book-study에 이 DAG를 추가했습니다. 이미 받아 두었다면 git pull만 하면 됩니다.

git clone https://github.com/vividbaek/airflow-book-study.git
cd airflow-book-study
docker compose up

http://localhost:8080에 들어가면 download_rocket_launches DAG가 켜진 상태로 올라와 바로 한 번 실행됩니다. 받은 사진은 컨테이너 안에 있으니, 책의 docker exec 대신 저장소 폴더에서 이렇게 확인합니다.

docker compose exec airflow ls /tmp/images

실제로 돌린 결과

  • 첫 실행: 아무것도 누르지 않았는데 실행이 하나 생겼고(붙은 날짜는 한국 시간 10월 18일 0시), 세 태스크가 26초쯤 만에 모두 성공했습니다. download_launches 약 5초, get_pictures 약 19초, notify 1초 미만입니다.
  • 10장을 받았는데 9장: get_pictures 로그에는 "Downloaded …"가 10번 찍혔는데, notify는 "There are now 9 images."라고 알렸습니다. 스타링크 발사 두 건이 같은 사진 파일을 쓰고 있어서, 같은 이름의 파일을 두 번 받아 덮어썼기 때문입니다.
  • 일부러 실패시키기: 컨테이너 안에서 API 주소를 막고 DAG를 한 번 더 실행했습니다. download_launches는 curl이 연결에 실패해(종료 코드 7) 빨간색이 됐고, get_pictures와 notify는 실행되지 않고 "업스트림 실패"가 됐습니다. 책은 get_pictures에서 실패했지만, 실패한 태스크 뒤가 줄줄이 멈추는 모습은 같습니다.
  • 지우기로 다시: 주소 막은 것을 풀고 download_launches를 다운스트림까지 지웠더니, 세 태스크가 순서대로 다시 돌아 모두 성공했습니다. download_launches의 로그에는 실패한 첫 시도(attempt 1)가 그대로 남고, 다시 돈 기록이 attempt 2로 쌓였습니다.
  • 실패해야 할 때 성공하는 경우: 5절에서 쓴 것처럼 사진 서버만 막고 get_pictures를 다시 돌리면, 사진을 하나도 못 받았는데도 성공으로 끝납니다.

책 예제 57줄에서 오류가 나는 곳은 두 군데이고, 나머지는 바꾸길 권하는 곳입니다. 1장보다 깨지는 곳은 적지만, catchup 기본값처럼 코드는 그대로 돌면서 동작만 달라지는 부분이 있어서 직접 돌려 보며 확인하는 게 중요합니다.


:chequered_flag: 7. 2장을 마치며

처음에 던진 다섯 가지 질문에 답해 보면 이렇습니다.

  1. DAG 파일은 DAG 객체, 오퍼레이터로 만든 태스크, >>로 적은 의존성 세 부분입니다.
  2. 오퍼레이터는 "무엇을 할지"를 정하는 부품이고, 태스크는 그 오퍼레이터를 맡아 실행과 상태를 챙기는 Airflow 쪽 단위입니다. 실행마다 생기는 하나하나는 태스크 인스턴스라고 부릅니다.
  3. 화면에서는 그래프 뷰로 구조를, 트리 뷰(지금의 그리드 뷰)로 날짜별 상태를, 로그로 태스크 출력을 봅니다. Airflow 3에서는 띄우는 명령이 api-server, dag-processor 등으로 바뀌었고, 실습은 airflow standalone 하나로 됩니다.
  4. 실패하면 실패한 태스크와 그 뒤 태스크만 Clear해서 다시 돌립니다. 단, Airflow는 HTTP 상태 코드가 아니라 파이썬은 예외, 셸은 종료 코드를 보고 실패를 판단합니다. 예외를 잡아 출력만 하면 성공으로 기록되니, 태스크가 제대로 실패하도록 짜야 합니다.
  5. 책 2장 예제는 days_ago와 schedule_interval 두 곳에서 깨지고, 오퍼레이터 import는 아직 돌지만 바꿔 두는 게 좋습니다. 같은 설정이어도 catchup 기본값 때문에 실행 개수가 14개에서 1개로 달라집니다.

DAG 파일은 "무엇을 할지(오퍼레이터)"와 "어떤 순서로 할지(의존성)"를 적은 설계도이고, 그 일을 언제 실행하고 실패하면 어떻게 다시 돌릴지는 Airflow가 태스크 단위로 챙깁니다. 그래서 태스크를 어디서 나누고, 실패를 어떻게 드러낼지가 DAG 설계의 시작입니다.

다음 3장(Airflow의 스케줄링)에서는 2.4절에서 미뤄 둔 스케줄 간격과 실행 날짜, 백필을 본격적으로 다룹니다. 1장에서 본 Airflow 3의 @daily 날짜 변화가 가장 크게 영향을 주는 장입니다.


이 글은 Apache Airflow 한국 사용자 모임 운영진으로 활동하며 《Apache Airflow 기반의 데이터 파이프라인》을 학습하고, Airflow 3에서 달라진 내용을 더해 정리한 기록입니다. 글을 읽으시며 의견이나 함께 나누고 싶은 인사이트가 있다면 언제든 댓글로 남겨주세요!

4개의 좋아요