머신러닝 실전 워크플로 (11) - 오케스트레이션: Airflow
수집에서 학습, 배포까지를 사람 손 없이 순서대로, 실패하면 재시도까지 돌리는 것이 오케스트레이션입니다. cron으로는 왜 부족한지, Airflow의 DAG가 무엇을 보장하는지, 그리고 재시도를 안전하게 만드는 태스크 멱등성을 최소 DAG 예제로 정리한 11편입니다.
머신러닝 실전 워크플로 시리즈의 11편입니다. 10편의 “컨테이너와 배포: Docker”에 이어집니다.
이미지가 있어도 누군가 순서를 눌러야 한다
10편까지 오면서 각 단계를 컨테이너 이미지로 굳혔습니다. 어느 기계에서 돌려도 같은 환경에서 같은 코드가 실행됩니다. 그런데 이미지가 있다는 것과 파이프라인이 돈다는 것은 다릅니다. 수집 이미지를 실행하고, 끝나면 피처 이미지를, 그다음 학습과 등록으로. 이 순서를 지금은 사람이 터미널에서 하나씩 실행합니다.
문제는 이 순서가 한 번으로 끝나지 않는다는 점입니다. 새 데이터는 매달, 혹은 매일 들어오고, 그때마다 같은 순서를 다시 밟아야 합니다. 사람이 매번 정해진 시각에 앉아 명령 몇 개를 순서대로 치고, 중간에 하나가 실패하면 원인을 찾아 그 지점부터 다시 실행하는 일입니다. 이걸 기계에 넘기는 것이 오케스트레이션이고, 이번 편의 출발점입니다.
cron으로는 왜 부족한가
“정해진 시각에 명령 실행”이라면 cron이 이미 하는 일입니다. 매달 1일 새벽에 스크립트를 실행하도록 등록하면 됩니다. 그런데 파이프라인에는 cron이 모르는 것이 네 가지 있습니다.
- 의존성: 수집이 실패했는데 학습 시각이 되면 cron은 그냥 학습을 실행합니다. 어제 데이터로, 혹은 빈 데이터로. cron은 “언제”만 알고 “무엇 다음에 무엇”은 모릅니다.
- 부분 재시도: 다섯 단계 중 네 번째에서 실패하면, 고친 뒤 네 번째부터 이어서 돌리고 싶습니다. cron 스크립트는 대개 처음부터 다시입니다.
- 기록: 무엇이 언제 몇 초 걸려 성공했고 무엇이 왜 실패했는지를 cron은 남기지 않습니다. 로그를 직접 뒤져야 합니다.
- 백필: 지난 반년 치를 다시 처리하려면 cron으로는 손으로 여섯 번 돌립니다.
오케스트레이터는 이 네 가지를 채운 스케줄러입니다. 순서(의존성 그래프), 반복(스케줄), 실패 대응(재시도와 알림), 그리고 관찰(무엇이 언제 어떻게 됐는지)을 한 곳에서 관리합니다. 대표 도구로 Airflow를 봅니다. Prefect, Dagster, Argo Workflows 같은 대안이 있지만 푸는 문제는 같습니다. 도구를 외우기보다 “이 스케줄러가 cron 위에 무엇을 얹는가”를 보는 편이 오래 남습니다.
Airflow의 DAG: 태스크와 의존성
Airflow에서 파이프라인 하나는 DAG(directed acyclic graph, 방향성 비순환 그래프)로 표현합니다. 각 단계가 노드(태스크)이고, “이것 다음에 저것”이라는 순서가 방향을 가진 간선입니다.
이름의 두 성질이 그대로 쓸모입니다. 방향성은 간선에 방향이 있어 수집에서 학습으로는 가도 반대로는 못 간다는 뜻이고, 비순환은 그래프에 사이클이 없다는 뜻입니다. 사이클이 없어야 “무엇을 먼저 실행할 수 있는가”가 항상 정해집니다. A가 B를 기다리고 B가 다시 A를 기다리면 시작점이 없어 영원히 못 돕니다. DAG라는 제약이 곧 “실행 순서가 반드시 존재한다”는 보장입니다.
태스크가 실제로 무엇을 실행하는지는 오퍼레이터(operator)가 정합니다. 셸 명령을 실행하는 BashOperator, 파이썬 함수를 실행하는 PythonOperator, 컨테이너 이미지를 실행하는 DockerOperator 등이 있습니다. Airflow는 이 태스크들을 순서에 맞춰 실행하고, 실패하면 재시도하고, 결과를 기록합니다. 파이프라인의 로직 자체는 태스크 안에 있고, Airflow는 순서와 실패 대응, 기록만 맡습니다.
최소 DAG
수집, 피처, 학습 세 단계를 월 단위로 도는 최소 DAG입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator
default_args = {
"retries": 2, # 실패하면 태스크 단위로 두 번까지 재시도
"retry_delay": timedelta(minutes=5), # 재시도 사이 5분 대기
}
with DAG(
dag_id="taxi_demand_monthly",
schedule="@monthly", # 매달 한 번 실행
start_date=datetime(2025, 1, 1),
catchup=False, # 과거 구간을 한꺼번에 몰아 실행하지 않음
default_args=default_args,
max_active_runs=1, # 같은 테이블에 두 run이 동시에 쓰지 못하게
) as dag:
ingest = BashOperator(
task_id="ingest",
bash_command="python -m src.data.download --month ", # 실행 날짜를 인자로
)
features = BashOperator(
task_id="features",
bash_command="python -m src.data.aggregate --month ",
)
train = BashOperator(
task_id="train",
bash_command="python -m src.model.train --month ",
)
ingest >> features >> train # 의존성: 수집 다음 피처, 그다음 학습
읽을 부분은 세 곳입니다.
태스크의 명령이 손으로 치던 CLI 그대로입니다. BashOperator가 실행하는 것은 그동안 터미널에서 직접 돌리던 python -m ...과 같은 명령입니다. 덕분에 파이프라인 로직이 Airflow에 묶이지 않습니다. DAG가 깨져도 같은 명령을 손으로 실행할 수 있고, 각 모듈은 Airflow 없이 단독으로 테스트됩니다. 피처 파이프라인(5편)이나 학습을 이미 CLI로 만들어 두었다면 그대로 태스크가 됩니다.
>>가 의존성입니다. ingest >> features >> train은 “수집이 성공해야 피처가 시작되고, 피처가 성공해야 학습이 시작된다”는 방향 간선을 그립니다. 수집이 실패하면 뒤 태스크는 실행되지 않고 멈춥니다. cron이 못 하던 첫 번째 일입니다.
``는 이 run이 처리할 날짜입니다. Airflow가 실행 시점에 그 run의 논리 날짜로 치환하는 템플릿입니다. 명령은 “지금”이 아니라 “이 run이 맡은 구간”을 인자로 받습니다. 뒤에서 볼 멱등성과 백필이 이 한 조각 위에 섭니다.
스케줄과 재시도도 선언으로 끝납니다. schedule="@monthly"는 반복 주기를, retries와 retry_delay는 실패했을 때 몇 번, 얼마 간격으로 다시 시도할지를 정합니다. 일시적인 네트워크 장애나 잠깐 죽은 외부 API는 이 재시도만으로 대개 넘어갑니다. catchup=False는 start_date부터 지금까지의 빈 구간을 자동으로 채우지 않겠다는 뜻이고, 과거를 다시 처리하고 싶을 때는 구간을 지정해 백필합니다.
멱등성: 다시 돌려도 어긋나지 않기
재시도와 백필은 오케스트레이션의 핵심 이득입니다. 그런데 이 이득은 공짜가 아닙니다. 멱등성(idempotency), 즉 같은 태스크를 한 번 돌리든 세 번 돌리든 결과 상태가 같다는 성질이 전제되어야 합니다.
왜 전제가 되는지는 실패 순간을 그려 보면 분명합니다. 적재 태스크가 그 달 데이터의 절반을 넣다가 죽었다고 합시다. Airflow는 이 태스크를 재시도합니다. 그런데 태스크가 “데이터를 뒤에 덧붙이는” 방식이라면, 재시도는 이미 들어간 절반 위에 다시 전체를 붙입니다. 재시도가 데이터를 고치는 것이 아니라 망칩니다. 멱등하지 않은 태스크에서는 재시도 기능 자체가 위험이 됩니다.
멱등성을 확보하는 패턴은 몇 가지로 정리됩니다.
- 오브젝트 스토리지: 같은 키로 덮어쓰기. 두 번 올려도 파일은 하나로 남습니다.
- 데이터베이스 적재: 그 구간(예: 해당 월)을 먼저 삭제하고 삽입하거나, upsert(키가 있으면 갱신, 없으면 삽입)를 씁니다. 몇 번을 재시도해도 그 월의 행 수는 같습니다.
- 학습 산출물: run id나 날짜로 경로를 정해 늘 같은 자리에 덮어씁니다.
공통점은 태스크가 자기 구간을 통째로 책임진다는 점입니다. “그 달을 지우고 그 달을 다시 넣는다”로 설계하면, 몇 번을 돌려도 그 달의 최종 상태는 하나로 수렴합니다. 앞의 DAG에서 명령마다 붙인 --month 가 바로 이 “구간”을 태스크에 알려주는 장치였습니다. 실행 날짜가 인자로 고정되므로, 같은 run을 재시도하든 지난달을 백필하든 태스크가 손대는 범위가 정확히 그 달로 한정됩니다.
단순 append(뒤에 추가)는 멱등성을 깨는 가장 흔한 함정이다. 태스크가 재시도될 때마다 같은 행이 또 붙어 그 구간의 데이터가 두 배, 세 배가 된다. 에러가 나지 않아 한참 뒤 집계가 이상해서야 발견된다. 적재는 append 대신 “구간 삭제 후 삽입”이나 upsert로 짠다.
멱등성은 스케줄러 설정이 아니라 태스크를 짜는 방식에서 나옵니다. 자동화의 안전을 Airflow의 재시도 옵션이 주는 것 같지만, 실제로는 각 태스크가 멱등하게 설계되어 있어야 그 옵션이 안전해집니다. 오케스트레이터를 도입할 때 먼저 점검할 것은 DAG 문법이 아니라 “이 태스크를 지금 다시 돌리면 데이터가 어긋나는가”입니다.
NYC 택시에서는
이 흐름을 택시 데이터로 끝까지 구현한 사례가 Airflow 자동화, 모니터링, CI 편입니다. 수동으로 돌리던 수집, 집계, 적재를 월 단위 DAG로 묶는데, 태스크의 명령은 손으로 치던 CLI 그대로 두고 Airflow에는 순서와 재시도, 기록만 맡깁니다. MinIO 업로드는 같은 키 덮어쓰기, PostgreSQL 적재는 월 단위 삭제 후 삽입으로 모든 태스크를 멱등하게 만들었고, max_active_runs=1로 같은 테이블에 두 run이 동시에 쓰는 것을 막았습니다. 이번 편이 왜와 무엇을 정리했다면, 그 글은 같은 설계가 하나의 데이터에서 어떻게 굴러가는지를 보여줍니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 오케스트레이션 | 수집에서 학습, 배포까지를 순서대로, 재시도까지 사람 손 없이 굴리는 것 |
| cron의 한계 | 시각만 안다. 의존성, 부분 재시도, 기록, 백필을 못 한다 |
| DAG | 태스크를 노드로, 의존성을 방향 간선으로. 비순환이라 실행 순서가 항상 정해진다 |
| 오퍼레이터 | 태스크의 실행 방식. BashOperator는 손으로 치던 CLI를 그대로 담는다 |
| 스케줄과 catchup | @monthly로 반복하고, catchup으로 과거 구간을 채울지 정한다 |
| 재시도 | 실패한 태스크만 다시 실행. retries와 retry_delay로 일시적 장애를 흡수 |
| 멱등성 | 같은 태스크를 다시 돌려도 상태가 어긋나지 않는 성질. 재시도의 전제 |
| 멱등 패턴 | 덮어쓰기, 구간 삭제 후 삽입, upsert. append는 중복을 만든다 |
수집부터 학습까지가 이제 사람 손 없이 순서대로, 실패하면 재시도까지 돌아갑니다. 그런데 자동으로 잘 돈다고 해서 모델이 계속 맞는 것은 아닙니다. 데이터가 변하면 어제까지 맞던 모델이 오늘 틀리기 시작합니다. 마지막 편은 그 변화를 알아채고 재학습으로 잇는 모니터링과 드리프트입니다.