[Airflow] start_date, logical date, data interval 개념 정리
1. 개요
Airflow DAG을 처음 만들면 실행 시각이 예상과 다르게 나오는 경우가 많다. 매일 12시에 돌도록 schedule="0 12 * * *"을 걸었는데 첫 실행은 하루 뒤에 일어나고, 10월 3일에 실행된 DAG Run의 ds 값은 10월 2일이다. 매시 정각 스케줄을 5시 34분에 등록했더니 6시가 아니라 7시에 처음 실행되거나, 반대로 등록하자마자 4시~5시 구간이 바로 실행되기도 한다.
이런 차이는 Airflow가 실행 시각이 아니라 처리할 데이터 구간을 기준으로 스케줄을 계산하기 때문에 생긴다. 이 글에서는 다음 개념을 순서대로 정리한다.
- DAG Run과 schedule
- data interval
- logical date
- start_date와 첫 실행 시각
- catchup, 수동 실행, timetable 종류에 따른 차이
- 시간대
본문은 Airflow 2.4~2.10 기준으로 설명하고, Airflow 3.x에서 달라진 부분은 8장에 따로 모았다.
2. DAG Run과 schedule
2.1 DAG Run
DAG은 작업과 의존 관계를 정의한 것이고, DAG Run은 그 정의가 한 번 실행된 인스턴스다. 같은 DAG이라도 처리하는 시점마다 별도의 DAG Run이 생기고 Task Instance, XCom, 로그가 모두 DAG Run 단위로 나뉜다.
DAG Run은 생성 방식에 따라 run_type이 다르다.
| run_type | 생성 주체 | 예 |
|---|---|---|
scheduled |
스케줄러가 schedule에 따라 생성 | 매일 12시 실행 |
manual |
UI·CLI·REST API에서 직접 트리거 | Trigger DAG 버튼 |
backfill |
backfill 명령 | 과거 구간 재처리 |
dataset_triggered |
Dataset 갱신 이벤트 | 상위 DAG 적재 완료 후 실행 |
이 글에서 다루는 data interval 계산은 주로 scheduled DAG Run에 해당한다. 수동 실행은 7장에서 따로 설명한다.
2.2 schedule 표기
schedule은 DAG Run을 언제 만들지 정한다. Airflow 2.4부터 schedule_interval과 timetable 파라미터가 schedule 하나로 통합됐고, 기존 schedule_interval은 2.x에서 deprecated 경고와 함께 계속 동작한다.
| 표기 | 예 | 내부 timetable |
|---|---|---|
| cron 문자열 | "0 12 * * *" |
CronDataIntervalTimetable |
| preset | "@daily", "@hourly" |
CronDataIntervalTimetable |
| timedelta | timedelta(hours=1) |
DeltaDataIntervalTimetable |
| None | None |
NullTimetable (스케줄 없음) |
"@once" |
"@once" |
OnceTimetable |
| Timetable 객체 | CronTriggerTimetable("0 12 * * *", timezone="Asia/Seoul") |
지정한 클래스 |
| Dataset 목록 | [Dataset("s3://bucket/orders")] |
DatasetTriggeredTimetable |
cron 문자열과 timedelta를 쓰면 2.x에서는 모두 이름에 DataInterval이 붙은 timetable로 변환된다. 이 두 timetable이 data interval 방식으로 DAG Run을 만든다.
from datetime import timedelta
import pendulum
from airflow import DAG
with DAG(
dag_id="daily_sales_summary",
start_date=pendulum.datetime(2026, 10, 1, tz="UTC"),
schedule="0 12 * * *",
catchup=False,
):
...
3. Data Interval
data interval은 DAG Run 하나가 처리하는 데이터의 시간 범위다. data_interval_start부터 data_interval_end 직전까지의 반열린 구간이고, 스케줄된 DAG Run은 이 구간이 끝난 뒤에 실행된다.
schedule="0 12 * * *"이면 구간은 12시부터 다음 날 12시까지다. 10월 2일 12시~10월 3일 12시 구간의 데이터는 10월 3일 12시가 지나야 모두 쌓이므로 그 시각에 DAG Run이 실행된다. 일 단위 집계라면 하루치 데이터가 모두 들어온 뒤에 집계하는 것이 자연스럽고, Airflow는 이 순서를 스케줄 계산에 그대로 반영했다.
그래서 data interval은 날짜보다 데이터 범위로 이해하는 편이 정확하다. DAG Run은 “10월 3일에 실행된 작업”이라기보다 “10월 2일 12시~10월 3일 12시 데이터를 처리하는 작업”이다. 같은 구간을 다시 실행해도 같은 범위를 처리하므로 재실행과 backfill 결과가 실행 시점에 따라 달라지지 않는다.
작업 코드에서는 실행 시각(datetime.now()) 대신 data interval 경계로 조회 범위를 잡는다.
SELECT order_id, amount
FROM sales.orders
WHERE created_at >= '{{ data_interval_start }}'
AND created_at < '{{ data_interval_end }}';
이렇게 쓰면 스케줄러 지연으로 DAG Run이 몇 분 늦게 시작되거나 며칠 뒤에 재실행해도 조회 범위가 바뀌지 않는다.
4. Logical Date
4.1 logical date와 data interval의 관계
logical date는 DAG Run을 식별하는 기준 시각이다. data interval 방식 timetable에서는 data_interval_start와 같은 값이다. 그림 1의 Run 2는 10월 3일 12시에 실행되지만 logical date는 10월 2일 12시다.
Airflow 2.2 이전에는 같은 값을 execution_date라고 불렀다. 이름 때문에 실행 시각으로 오해하는 경우가 많아 2.2에서 logical_date로 바꾸고 data interval 개념을 함께 도입했다. 2.x에서는 execution_date도 deprecated 상태로 남아 있다.
logical date는 DAG Run의 식별자 역할도 한다. 하나의 DAG 안에서 같은 logical date를 가진 DAG Run은 하나만 존재할 수 있고, 기본 run_id도 scheduled__2026-10-02T12:00:00+00:00처럼 logical date로 만들어진다.
4.2 템플릿 변수
그림 1의 Run 2에서 작업이 받는 주요 템플릿 변수는 다음과 같다.
| 변수 | 값 | 설명 |
|---|---|---|
logical_date |
2026-10-02 12:00:00+00:00 |
구간 시작 시각 |
data_interval_start |
2026-10-02 12:00:00+00:00 |
구간 시작 시각 |
data_interval_end |
2026-10-03 12:00:00+00:00 |
구간 끝 시각 |
ds |
2026-10-02 |
logical date의 날짜 |
ds_nodash |
20261002 |
logical date의 날짜(구분자 없음) |
prev_data_interval_start_success |
직전 성공 DAG Run의 구간 시작 | 첫 실행이면 None |
dag_run.start_date |
2026-10-03 12:00 이후 |
DAG Run이 실제로 시작된 시각 |
ds가 실행일보다 하루 이른 날짜로 나오는 것은 이 관계 때문이다. 일 단위 파티션을 ds로 지정하면 처리 대상 날짜의 파티션에 정확히 기록된다. 반대로 실행일 기준 경로가 필요하면 data_interval_end를 쓴다.
dag_run.start_date는 DAG의 start_date 인자와 이름만 같고 의미가 다르다. 앞의 것은 DAG Run이 실제로 시작된 시각, 뒤의 것은 다음 장에서 설명할 스케줄 계산의 기준점이다.
5. start_date
5.1 첫 번째 data interval의 시작점
start_date는 DAG이 처음 실행되는 시각이 아니라 첫 번째 data interval이 시작될 수 있는 가장 이른 시각이다. 첫 구간이 끝나야 첫 DAG Run이 실행되므로 실제 첫 실행 시각은 start_date와 schedule을 함께 봐야 계산된다.
start_date=2026-10-01 12:00, schedule="0 12 * * *"이면 첫 구간은 10월 1일 12시~10월 2일 12시이고 첫 실행은 10월 2일 12시다. start_date에서 한 구간을 온전히 지나야 첫 DAG Run이 생긴다.
5.2 cron과 timedelta의 정렬 차이
start_date가 schedule 경계와 맞지 않을 때 두 timetable은 다르게 동작한다.
CronDataIntervalTimetable은 start_date 이후의 첫 cron 시각으로 구간 시작을 맞춘다. start_date가 cron 시각과 정확히 같으면 그 시각부터 시작한다.DeltaDataIntervalTimetable은 정렬 없이 start_date부터 timedelta만큼 구간을 자른다.
start_date를 10월 1일 05:34로 두고 1시간 주기를 걸면 차이가 바로 드러난다.
cron 스케줄은 첫 구간이 06:00에 시작하므로 첫 실행이 07:00까지 밀린다. timedelta 스케줄은 이후 실행도 06:34, 07:34처럼 34분에 고정된다. 정각 실행이 필요하면 cron을 쓰고, start_date도 정각으로 맞추는 것이 계산하기 쉽다.
5.3 start_date를 고정값으로 두는 이유
start_date에 datetime.now()처럼 DAG 파일을 파싱할 때마다 바뀌는 값을 넣으면 안 된다. 스케줄러는 DAG 파일을 주기적으로 다시 파싱하는데, 파싱할 때마다 start_date가 현재 시각으로 밀리면 첫 구간이 계속 미래에 있게 되어 DAG Run이 생기지 않거나 예상과 다른 시각에 생긴다.
서비스에서 사용자가 스케줄을 등록할 때 DAG 파일을 생성하는 구조라면, 등록 시각을 계산해 DAG 파일에 리터럴 값으로 기록한다. 파일 안에서 now()를 호출하지 않는다.
6. catchup과 첫 실행 시각
6.1 catchup
catchup은 start_date부터 현재까지 실행되지 않은 구간을 모두 채울지 정한다.
catchup=True: start_date 이후 완료된 모든 구간에 대해 DAG Run을 만든다. start_date가 30일 전인 일 단위 DAG을 활성화하면 DAG Run 30개가 한꺼번에 생긴다(max_active_runs만큼씩 실행).catchup=False: 가장 최근에 완료된 구간 하나만 실행하고 그 이전 구간은 건너뛴다.
catchup=False에서 놓치기 쉬운 부분은 “가장 최근에 완료된 구간 하나”가 활성화 직후 바로 실행된다는 점이다. start_date가 과거인 매시 정각 DAG을 05:34에 활성화하면 04:00~05:00 구간이 05:34에 즉시 실행되고, 이어서 06:00에 05:00~06:00 구간이 실행된다. 2.x 기본값은 catchup=True이고 [scheduler] catchup_by_default로 바꿀 수 있다.
6.2 05:34에 매시 정각 스케줄을 등록한 경우
서비스에서 05:34에 매시 정각(0 * * * *) 스케줄을 등록하고, 첫 실행은 06:00이기를 기대하는 상황을 보면 설정에 따라 결과가 셋으로 나뉜다.
- start_date가 과거, catchup=False: 가장 최근에 완료된 04:00~05:00 구간이 등록 직후 바로 실행된다.
- start_date를 등록 시각(05:34)으로, cron data interval: 5.2의 정렬 규칙으로 첫 구간이 06:00~07:00이 되어 07:00에 처음 실행된다.
- start_date를 등록 시각으로,
CronTriggerTimetable: cron 시각에 바로 실행되므로 06:00에 처음 실행된다.
06:00 첫 실행을 원하면 두 가지 방법이 있다.
- 처리 구간 개념이 필요 없는 작업(리프레시, 알림, 외부 API 호출)이면
CronTriggerTimetable을 쓰고 start_date에 등록 시각을 넣는다. - data interval 기반으로 유지하려면 start_date를 등록 시각이 속한 정각(05:00)으로 내림한다. 첫 구간이 05:00~06:00이 되어 06:00에 실행된다. 이 경우 첫 DAG Run은 05:00~06:00 데이터를 처리한다는 점을 함께 고려한다.
import pendulum
from airflow import DAG
from airflow.timetables.trigger import CronTriggerTimetable
# 등록 시각 05:34 → DAG 파일 생성 시 리터럴로 기록
with DAG(
dag_id="refresh_sales_summary",
start_date=pendulum.datetime(2026, 10, 19, 5, 34, tz="Asia/Seoul"),
schedule=CronTriggerTimetable("0 * * * *", timezone="Asia/Seoul"),
catchup=False,
):
...
7. 수동 실행과 외부 트리거
UI의 Trigger DAG 버튼이나 REST API로 실행한 DAG Run은 schedule과 관계없이 즉시 실행된다. 2.x에서 수동 DAG Run의 logical date는 트리거한 시각이고, data interval은 timetable의 infer_manual_data_interval이 트리거 시각을 기준으로 추정한다. CronDataIntervalTimetable은 트리거 시각 직전에 완료된 구간을 data interval로 넣는다.
같은 logical date를 가진 DAG Run은 하나만 존재할 수 있으므로, 이미 스케줄로 실행된 logical date를 지정해 수동 트리거하면 충돌로 실패한다. 특정 구간을 다시 처리하려면 해당 DAG Run을 Clear하거나 backfill을 쓴다.
수동 실행에서도 작업 코드가 data_interval_start/data_interval_end로 범위를 잡고 있다면 처리 범위는 timetable이 추정한 구간이 된다. 수동 실행 때만 다른 범위를 처리해야 하면 params나 dag_run.conf로 범위를 넘겨 받는 방식이 명확하다.
8. Airflow 3.x에서 달라진 부분
Airflow 3.0에서 스케줄링 기본값과 일부 파라미터가 바뀌었다.
| 항목 | 2.x | 3.x |
|---|---|---|
| 스케줄 파라미터 | schedule (schedule_interval deprecated) |
schedule만 사용 (schedule_interval, timetable 제거) |
execution_date |
deprecated, 사용 가능 | 제거, logical_date 사용 |
| cron 문자열 기본 timetable | CronDataIntervalTimetable |
CronTriggerTimetable |
catchup 기본값 |
True |
False |
| 수동 실행 logical date | 트리거 시각 | 지정하지 않으면 비어 있을 수 있음 |
가장 영향이 큰 변경은 cron 문자열의 기본 timetable이다. 3.x에서는 [scheduler] create_cron_data_intervals 기본값이 False라서 schedule="0 12 * * *"이 CronTriggerTimetable로 해석된다. 이때 DAG Run은 cron 시각에 바로 실행되고 logical date도 실행 시각이 된다. 2.x에서 만든 DAG을 3.x로 옮기면 같은 cron 문자열이라도 ds가 하루 뒤로 바뀌므로, 날짜 파티션을 ds로 지정한 작업은 결과 경로가 달라진다. 2.x 방식이 필요하면 설정값을 True로 두거나 CronDataIntervalTimetable을 명시한다.
9. 시간대
Airflow는 DAG Run의 시각을 UTC로 저장하고, 템플릿 변수도 UTC 기준의 timezone-aware 값으로 넘긴다. 시간대와 관련해 다음을 확인한다.
- start_date에 시간대 없는
datetime을 넣으면[core] default_timezone(기본utc)으로 해석된다. - cron 식은 DAG의 시간대로 계산된다. DAG의 시간대는 start_date의 시간대를 따르므로
pendulum.datetime(..., tz="Asia/Seoul")로 지정하면"0 9 * * *"이 한국 시각 09시가 된다. - 템플릿 변수를 한국 시각 문자열로 쓰려면 변환이 필요하다. Python 코드에서는
airflow.utils.timezone.make_naive(value, timezone)로 지정 시간대 기준의 naive datetime을 만들 수 있다.
import pendulum
from airflow.utils.timezone import make_naive
KST = pendulum.timezone("Asia/Seoul")
def load(data_interval_start, data_interval_end, **_):
start = make_naive(data_interval_start, KST) # 2026-10-19 05:00:00 (tzinfo 없음)
end = make_naive(data_interval_end, KST)
...
매시 정각 스케줄은 UTC와 KST의 차이가 정수 시간이라 영향이 없지만, 일 단위 이상 스케줄에서 시간대를 지정하지 않으면 실행 시각과 ds가 9시간 어긋난다.
10. 정리
- DAG Run은 data interval 하나를 처리하는 실행 단위이고, data interval 방식 timetable에서는 구간이 끝난 뒤에 실행된다.
- logical date는 구간의 시작 시각이라 실행 시각보다 한 주기 이르다.
ds가 실행일 전날로 나오는 이유다. - start_date는 첫 구간이 시작될 수 있는 가장 이른 시각이다. cron 스케줄은 다음 cron 시각으로 맞춰서, timedelta 스케줄은 start_date 그대로 구간을 시작한다.
catchup=False는 가장 최근 완료 구간을 활성화 직후 실행한다. 05:34에 등록한 매시 정각 스케줄을 06:00에 처음 실행하려면CronTriggerTimetable을 쓰거나 start_date를 05:00으로 내린다.- Airflow 3.x에서는 cron 문자열의 기본 timetable이
CronTriggerTimetable로 바뀌어 logical date가 실행 시각과 같아진다.
Leave a comment