모든 글
약 11분 분량 데이터 파이프라인이벤트·메시징

[DE Ch-04] 논리 날짜와 backfill: Airflow 3에서 {{ ds }}가 가리키는 날이 바뀌었다

목차

결론부터

  1. 배치 작업에는 실행 시각(run_after)과 처리할 데이터 구간(data interval) 두 시계가 있습니다. 재시도와 backfill이 안전하려면 태스크가 처리 범위를 데이터 구간으로 정해야 합니다.
  2. Airflow 3.0부터 cron 문자열 스케줄의 기본 timetable이 CronTriggerTimetable로 바뀌었습니다(create_cron_data_intervals=False). 이 timetable은 데이터 구간 개념이 없어서 data_interval_start와 data_interval_end가 둘 다 트리거 시각입니다. 매일 00시 run의 ds가 Airflow 2에서는 어제였고 Airflow 3 기본값에서는 오늘입니다.
  3. 재현해 보니 3.x 기본값의 data_interval_start로 날짜를 고르면 10/2 00:00 run이 아직 덜 찬 10/2 매출(8,000원)을 집계했습니다. 구간을 1일로 잡은 경우만 첫 실행과 재시도 모두 10/1 매출(42,000원)이 나왔습니다.
  4. 수동 트리거와 asset 트리거 run은 logical date가 없습니다(None). 이 run에서 data_interval_start를 꺼내면 KeyError가 나고 ds 같은 시간 변수도 없습니다.
  5. Airflow 3의 backfill은 스케줄러가 관리합니다. 이미 있는 run을 어떻게 할지 none, failed, completed 중에서 고릅니다. Argo CronWorkflow에는 논리 날짜와 backfill 기능이 없어 날짜를 파라미터로 넘기는 방식으로 만듭니다.

1. 실행 시각과 데이터 시각

일일 매출 집계는 “10월 1일 하루치 주문”을 처리합니다. 10월 1일 주문은 10월 1일이 끝나야 다 모이니, 실제 실행은 10월 2일 00시 이후입니다. 이 작업에는 시각이 둘 붙습니다.

시각뜻예
실행 시각(run_after)이 run을 언제 만들고 실행해도 되는지10/2 00:00
데이터 구간(data interval)이 run이 어느 구간의 데이터를 처리하는지10/1 00:00 ~ 10/2 00:00

Ch-03에서 태스크 안에서 now()로 “어제”를 계산하면 재시도할 때 다른 날을 처리한다고 했습니다. 실행 시각은 재시도마다 바뀌고 데이터 구간은 run에 고정되어 있으니, 처리 범위는 데이터 구간으로 정해야 합니다. 그런데 Airflow 3에서는 이 데이터 구간이 기본적으로 없어졌습니다.

2. Airflow 3에서 바뀐 기본 timetable

2-1. 두 종류의 timetable

Airflow 3.2.2 timetable 문서는 cron 스케줄을 해석하는 방식을 둘로 나눕니다.

CronDataIntervalTimetableCronTriggerTimetable
데이터 구간있다. 직전 cron 시각부터 이번 cron 시각까지없다. data_interval_start와 data_interval_end가 같다
실행 시각구간이 끝나는 cron 시각cron 시각
logical date구간 시작트리거 시각
매일 00시 스케줄, 10/2 00:00 실행 run의 ds2026-10-012026-10-02
Airflow 2 기본값이것
Airflow 3 기본값이것(create_cron_data_intervals=False)

문서는 두 timetable이 같은 시각에 run을 실행하고 run_id에 붙는 타임스탬프만 다르다고 적습니다. 실행 시각은 그대로인데 logical date와 그 파생값(ds, data_interval_*)이 하루 밀린 셈입니다. Astronomer의 업그레이드 문서도 logical date가 Airflow 2에서는 data_interval_start와 같았고 Airflow 3에서는 run_after와 같아졌다고 정리합니다.

이 설정은 schedule에 cron 문자열("0 0 * * *", "@daily")을 넘긴 DAG에만 적용됩니다. timetable 객체를 직접 넘긴 DAG는 영향이 없습니다. 문서는 업그레이드 전에 이 값을 명시적으로 고정하라고 권합니다.

2-2. 재현: 날짜를 고르는 세 가지 방법

  • DuckDB에 10/1 주문 42,000원과, 아직 들어오는 중인 10/2 주문 8,000원을 넣습니다.
  • 매일 00시에 도는 일일 매출 run이 10/2 00:00에 실행되다 실패하고 10/3 09:00에 재시도된다고 칩니다.
  • 각 방법이 받는 data_interval_start는 문서의 timetable 정의를 흉내 냈습니다. Airflow를 직접 띄운 결과는 아닙니다.
  • 코드는 labs/de-study/ch04의 interval_vs_now.py입니다.
날짜를 고르는 방법첫 실행(10/2 00:00)재시도(10/3 09:00)
now() - 1일10/1, 42,000원10/2, 8,000원
3.x 기본 CronTrigger의 data_interval_start10/2, 8,000원10/2, 8,000원
1일 구간의 data_interval_start10/1, 42,000원10/1, 42,000원

두 번째 줄이 Airflow 2에서 3으로 올리면서 생길 수 있는 일입니다. 재시도해도 결과가 같으니 멱등하긴 합니다. 그런데 처음부터 틀린 날을 일관되게 처리합니다. 에러가 나지 않고 10/2 매출이 8,000원으로 나오는 것도 데이터가 덜 찼다는 걸 모르면 이상해 보이지 않습니다.

now() - 1일은 첫 실행에서 맞는 값을 냅니다. 테스트를 한 번 돌려 보는 것으로는 이 방법의 문제를 못 찾고 재시도나 backfill을 할 때 드러납니다.

2-3. 고치는 방법

구간을 다시 만들면 됩니다. 방법은 둘입니다.

from datetime import timedelta
from airflow.timetables.trigger import CronTriggerTimetable
@dag(
# 트리거 시각에 끝나는 1일 구간을 명시한다
schedule=CronTriggerTimetable("0 0 * * *", timezone="Asia/Seoul", interval=timedelta(days=1)),
...
)
  • CronTriggerTimetable에 interval을 주면 run마다 그 길이의 데이터 구간이 생기고 트리거 시각에 끝납니다.
  • 설정 [scheduler] create_cron_data_intervals = True로 cron 문자열 전체를 Airflow 2 방식으로 되돌릴 수도 있습니다. 문서는 trigger에서 data interval로 바꿀 때 기존 run이 있으면 스케줄 run 한 번이 건너뛰어진다고 경고합니다.

그리고 태스크에서는 ds 대신 data_interval_start, data_interval_end를 씁니다. 3.2.2 템플릿 문서는 logical date에 대해 “의미가 없는 식별용 값”이라고 적고 의미 있는 시각이 필요하면 data interval을 쓰라고 합니다. 같은 문서는 logical date와 ds가 DAG 안에서 유일하다고 가정하지 말고 run을 구분하려면 run_id를 쓰라고 합니다.

3. logical date가 없는 run

Airflow 3.0 릴리스 노트는 asset 이벤트로 트리거되거나 logical date 없이 REST API로 트리거된 run은 logical_date=None이 되고 데이터 구간도 없다고 적습니다. 이 run의 태스크에서 data_interval_start, data_interval_end, logical_date를 꺼내면 KeyError가 납니다. 템플릿 문서도 asset 트리거 DAG에는 logical_date, ds, ds_nodash 같은 시간 변수가 없다고 적습니다.

같은 DAG를 스케줄로도 돌리고 asset으로도 돌린다면 태스크가 두 경우를 다 처리해야 합니다. 릴리스 노트는 dag_run.logical_date가 있는지 먼저 확인하라고 권합니다. logical date 없는 run에서 {{ ds }}를 렌더링하면 3.2.2 소스 기준으로 그 키가 컨텍스트에 들어가지 않고 DAG의 기본 template_undefined가 StrictUndefined라 렌더링 오류가 날 것으로 보입니다. 직접 실행해 확인하지는 않았습니다.

미래의 logical date로는 run을 트리거할 수 없게 된 것도 3.0의 변화입니다.

4. catchup: 놓친 구간을 자동으로 채우기

DAG를 켰을 때 start_date부터 지금까지 놓친 run을 자동으로 만드는 것이 catchup입니다. 3.0에서 기본값이 False로 바뀌었습니다.

start_date를 2026-01-01로 두고 2026-10-19 14:00에 DAG를 켰다면 catchup=True일 때 한꺼번에 만들어지는 run 수를 세어 봤습니다(catchup_count.py).

timetablerun 수logical date 범위
CronDataInterval291개01/01 ~ 10/18(구간이 끝난 것만)
CronTrigger292개01/01 ~ 10/19(트리거 시각이 지난 것)

291개 run이 동시에 생기면 Ch-06의 동시성 제한이 바로 의미를 갖습니다. max_active_runs 기본값 16만큼 동시에 돌면서 DB나 외부 API를 두드립니다. 오늘의집 Airflow 도입기(2021)도 과거 start_date가 자동 backfill로 이어지는 걸 짚고 catchup=False를 쓰라고 했습니다.

catchup=False일 때의 동작도 timetable마다 다릅니다. 문서의 예시는 1/31 15시에 DAG를 멈췄다가 2/2 15시에 다시 켠 경우입니다. CronTrigger는 2/1, 2/2 run을 건너뛰고 2/3 00시에 다음 run을 실행합니다. CronDataInterval은 2/1 run만 건너뛰고 2/2 run을 다시 켜자마자 실행합니다.

5. backfill: 지난 구간을 골라서 다시 돌리기

5-1. Airflow 3의 backfill

catchup이 “켤 때 자동으로”라면 backfill은 “범위를 골라서 직접”입니다. Airflow 3.0부터는 스케줄러가 backfill을 관리합니다(AIP-78). UI와 REST API로 시작하고 지켜볼 수 있습니다.

Terminal window
airflow backfill create --dag-id daily_sales \
--from-date 2026-09-01 --to-date 2026-09-30 \
--reprocess-behavior failed \
--max-active-runs 3 \
--dry-run # 만들 run 목록만 보여 준다
옵션뜻
--reprocess-behavior none(기본)그 logical date의 run이 이미 있으면 상태와 상관없이 만들지 않는다
--reprocess-behavior failed기존 run이 실패했을 때만 새로 만든다
--reprocess-behavior completed기존 run이 성공했든 실패했든 새로 만든다
공통최신 run이 running이나 queued면 어느 옵션이든 만들지 않는다
--max-active-runsbackfill에만 따로 적용된다. DAG의 max_active_runs와 별개
--run-backwards최근 날짜부터 거꾸로 돈다. depends_on_past 태스크가 있으면 쓸 수 없다

UI에서는 Trigger에서 Backfill을 고르고 reprocess 선택지가 “Missing Runs”, “Missing and Errored Runs”, “All Runs”로 보입니다.

공식 문서끼리 플래그 이름이 다른 곳이 있습니다. backfill 개념 문서의 예시는 --start-date, --end-date, --reprocessing-behavior를 쓰고 3.2.2 CLI 레퍼런스는 --from-date, --to-date, --reprocess-behavior입니다. 위 예시는 CLI 레퍼런스를 따랐습니다. FAQ에도 3.0에서 없어진 execution_date 설명이 아직 남아 있습니다.

backfill이 안전하려면 Ch-03의 멱등성이 전제입니다. completed로 이미 성공한 날을 다시 돌릴 때 append로 쌓는 태스크라면 매출이 두 배가 됩니다.

5-2. 사례: execution_date 제거의 비용

Airflow 3에서 execution_date가 컨텍스트와 API에서 빠지고 logical_date로 바뀌었습니다. 대규모 DAG 환경에서 3.0으로 옮긴 경험을 정리한 글(nanta, 2026)은 이 변화의 영향이 가장 컸다며 1차 252개, 2차 35개, 3차 21개 파일을 고쳤다고 적었습니다. next_execution_date는 data_interval_end로, prev_execution_date_success는 prev_data_interval_start_success로 바꿨습니다.

6. 시간대

Airflow는 기본적으로 UTC로 동작합니다. 오늘의집 도입기는 start_date에 pendulum.timezone("Asia/Seoul")을 넣어 KST 기준으로 스케줄을 짰다고 썼습니다. 2-3절의 예처럼 CronTriggerTimetable에 timezone을 주는 방법도 있습니다.

시간대를 쓰면 일광 절약 시간(DST)이 있는 지역에서는 하루에 실행이 0번이나 2번 생길 수 있습니다. Argo CronWorkflow 문서가 이 경우를 명시합니다. 한국은 DST가 없지만 해외 데이터를 그 지역 시간으로 자르는 작업이라면 확인해야 합니다.

7. Argo CronWorkflow는 날짜를 파라미터로 넘긴다

Argo CronWorkflow는 쿠버네티스 CronJob을 본떠 만들었고 Airflow의 logical date나 data interval에 해당하는 개념이 문서에 없습니다.

필드기본값뜻
schedules(필수)cron 식 목록. 단수형 schedule은 v4.0에서 없어졌다
timezone컨트롤러 기계의 시간대IANA 이름
concurrencyPolicyAllowAllow는 겹쳐도 실행, Forbid는 이전 것이 돌고 있으면 건너뜀, Replace는 이전 것을 지우고 새로 실행
startingDeadlineSeconds0놓친 실행을 몇 초 안에 따라잡을지. 여러 번 놓쳐도 한 번만 실행한다
successfulJobsHistoryLimit, failedJobsHistoryLimit3, 1남길 Workflow 수

실행 시각은 workflow.scheduledTime 변수로 받을 수 있습니다. 처리할 날짜는 이 값에서 직접 계산하거나 파라미터로 넘깁니다.

backfill도 내장 기능이 없습니다. 공식 cron-backfill 예시는 이렇게 만듭니다.

  1. 실제 일을 하는 WorkflowTemplate을 만들고 date 파라미터를 받게 합니다(기본값 “yesterday”).
  2. CronWorkflow는 이 템플릿을 매일 부릅니다.
  3. backfill이 필요하면 별도 Workflow에서 withSequence로 날짜 목록을 만들어 날짜마다 템플릿을 부릅니다. Workflow 이름에 날짜를 넣어 같은 날짜가 두 번 만들어지지 않게 합니다.

Airflow가 구간 개념을 오케스트레이터 안에 둔다면, Argo는 그걸 작업 쪽 파라미터로 내보낸 셈입니다. 어느 쪽이든 처리 범위를 실행 시각과 분리해서 넘겨받는다는 원칙은 같습니다.

8. 중국과 국내 빅테크 사례

8-1. 카카오뱅크: 날짜를 인자로 넘기고 시간대를 바꾼다

카카오뱅크의 2024년 글 “Airflow로 펀드 배치 시스템 완벽 구축하기”는 2절의 원칙을 그대로 적었습니다. 업무를 재처리하려고 다음 영업일에 다시 돌려도 전날 실행과 같은 결과가 나오도록 날짜 파라미터를 넘깁니다. 실제 실행 일자가 필요할 때는 {{ data_interval_end }}를 쓰고 astimezone으로 Asia/Seoul로 바꿉니다. 6절의 시간대 문제를 태스크에서 명시적으로 처리하는 방식입니다.

8-2. Alibaba DataWorks: 끝나야 하는 시각에서 거꾸로 계산하는 기준선

Alibaba의 DataWorks는 태스크 하나가 아니라 혈연으로 이어진 사슬 전체를 감시 단위로 삼는 “기준선(基线)“을 둡니다. 2023년 발표 정리 “阿里巴巴数据稳定性治理实践”에 따르면 기준선에는 산출 마감 시각, 경보 여유, 경보 방식, 수신자가 들어 있고 실행 전에 미리 경보하고 실행 뒤에 즉시 경보합니다. 공식 문서는 경보 시각을 “약속 시각 - 경보 여유”로 두고 과거 평균 실행 시간을 거꾸로 더해 가며 각 태스크가 늦어도 언제 시작하고 끝나야 하는지 계산한다고 적습니다. 실행이 30분을 넘고 과거 평균보다 15분 넘게 길면 느려진 태스크로 판정합니다.

1절에서 실행 시각과 데이터 시각을 나눴다면, 기준선은 여기에 “결과가 나와야 하는 시각”을 하나 더 둡니다. backfill로 지난 구간을 다시 돌릴 때 오늘 마감에 걸린 사슬과 자원을 다투지 않게 하는 근거가 됩니다.

8-3. backfill이 얼마나 큰 일인가

  • Meituan(2017): 두 데이터 플랫폼을 합칠 때 옮길 태스크가 7,000개를 넘었다가 8,000개를 넘었고 의존 깊이가 평균 10단계, 깊은 것은 수십 단계였습니다. 태스크 하나를 옮기는 데 데이터 복사 1~4일, 코드 수정과 시험과 이중 실행에 3~5일이 걸렸습니다. 만들어지는 데이터의 약 50%가 과거 데이터를 덮어쓰는 것이라고 적었습니다. 재계산이 일상의 절반이라는 뜻이고 5절의 backfill이 멱등해야 하는 이유입니다.
  • 有赞(2021): 장애 동안 빠진 실행(예: 7시, 8시)은 복구 뒤 catchup으로 자동으로 채우고 여러 DAG에 걸친 재계산은 하류 인스턴스를 지운(clear) 뒤 catchup으로 다시 채운다고 설명했습니다.

9. 범위와 한계

  • 2-2절은 timetable 정의를 흉내 낸 시뮬레이션입니다. Airflow 3.2를 직접 띄워 ds 값을 찍어 보지는 않았습니다. 직접 확인하려면 Ch-02 실습 환경에서 @daily DAG로 {{ ds }}와 {{ data_interval_start }}를 출력해 보면 됩니다.
  • 3절의 {{ ds }} 렌더링 오류는 소스를 읽고 내린 추론입니다.
  • 4절의 catchup=False 동작은 문서 예시를 옮겼고 직접 재현하지 않았습니다.
  • create_delta_data_intervals 설정과 timedelta 스케줄의 관계는 문서와 소스가 맞지 않아 다루지 않았습니다.

10. 헷갈렸던 것

Q. 그럼 Airflow 3에서 ds는 쓰면 안 되나요?

timetable이 데이터 구간을 갖고 있으면 ds는 구간 시작 날짜라 그대로 쓸 수 있습니다. 문제는 기본값인 CronTrigger에서 ds가 트리거 날짜가 된다는 점입니다. 어느 timetable인지 코드만 보고 알기 어렵다면 data_interval_start, data_interval_end를 쓰고 구간을 명시하는 쪽이 읽는 사람에게도 분명합니다.

Q. catchup과 backfill은 같은 건가요?

둘 다 지난 구간의 run을 만듭니다. catchup은 DAG를 켤 때 스케줄러가 start_date부터 자동으로 만들고 backfill은 사람이 범위와 재처리 방식을 골라 만듭니다. 3.0부터 catchup 기본값이 꺼져 있어서 지난 구간은 backfill로 채우는 쪽이 기본 흐름이 됐습니다.

Q. Argo concurrencyPolicy: Forbid면 중복 실행이 없나요?

이전 Workflow가 돌고 있으면 이번 스케줄을 건너뜁니다. 건너뛴 날짜의 데이터는 처리되지 않으니 나중에 따로 돌려야 합니다. Ch-06에서 본 것처럼 겹침을 막는 장치와 같은 일을 한 번만 하게 하는 장치는 다릅니다.

참고 자료

공식 문서와 소스

사례

이 시리즈

중국과 국내 빅테크 사례

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.