이번 시간에는 Docker로 띄운 Airflow에서 cron DAG를 직접 켜 보며, timetable과 타임존에 따라 run의 날짜(ds)가 어떻게 달라지는지 배워가는 시간입니다.
예를 들어서 서울 시간으로 매일 0시에 도는 Airflow DAG가 있다고 해볼게요. 10월 10일 0시에 돈 run(DAG를 한 번 실행한 단위)의 태스크 로그를 열어 보니, ds=2026-10-09가 찍혀 있어요. ds는 run의 날짜를 담는 템플릿 변수예요. 서울은 분명 10월 10일인데 날짜가 하루 이른 거죠. 왜 그럴까요?
"Airflow는 하루 늦게, 어제 날짜로 돈다"는 설명을 먼저 떠올리기 쉬워요. 그런데 그 설명은 data interval timetable을 쓸 때만 맞습니다.
timetable은 스케줄을 run으로 바꾸는 규칙이에요. data interval timetable은 하루 같은 구간(data interval)이 끝난 뒤에 그 구간의 run을 만들고, 구간의 시작을 run의 기준 시간인 logical date로 삼아요. ds는 이 logical date의 날짜 부분이고요.
기본 설정의 Airflow 3에서 cron 문자열은 trigger timetable인 CronTriggerTimetable이 돼요. trigger timetable은 cron 시간이 되면 바로 run을 만들고 그 시간을 logical date로 삼으며, 구간 길이는 0이에요. cron 문자열이 어느 timetable이 될지는 [scheduler] create_cron_data_intervals 설정값 하나로 정해지고, Airflow 3의 기본값은 False입니다.
위 로그의 DAG도 cron 문자열만 넣은 DAG였고, 날짜가 어긋난 이유는 타임존이었어요. 태스크를 재시도하거나 같은 구간을 backfill로 다시 처리해도 결과가 늘어나지 않게 하는 방법이 더 궁금하시면 Airflow 태스크의 멱등성을 다룬 글을 참고해 주세요.
실습 준비
이 글의 명령은 모두 실습 코드의 airflow-data-interval/ 디렉터리에서 실행합니다.
compose.yaml: PostgreSQL 17 메타데이터 DB와 Airflow 3.3.2 standalone 컨테이너dags/common.py: 모든 DAG에 붙이는 공통 태스크dags/tz.py: 타임존만 다른 DAG 세 개
compose.yaml의 컨테이너는 두 개예요. PostgreSQL은 Airflow 메타데이터 DB로만 쓰고, Airflow는 airflow standalone 명령으로 스케줄러, API 서버, DAG 프로세서, triggerer를 한 컨테이너에서 실행해요. standalone은 개발용 실행 방식이에요. Airflow API 서버는 호스트 7620 포트로 열어요.
모든 DAG는 dags/common.py의 공통 태스크를 probe()로 붙여요. 그중 echo는 BashOperator예요. Jinja 템플릿 변수 ds, logical_date, data_interval_start, data_interval_end와, logical date를 서울 시간으로 바꾼 값을 찍어요.
새 DAG는 일시 정지 상태로 등록되기 때문에([core] dags_are_paused_at_creation 기본값 True), 실습할 DAG만 unpause하면 됩니다.
DAG 파일의 start_date는 실습한 날인 2026년 10월 10일을 기준으로 적었어요. 따라 하실 때는 실행하는 날에 맞춰 날짜를 바꿔 주세요.
실습은 UTC 14시 무렵, 서울 시간으로 23시 무렵에 했어요. 두 타임존 모두 날짜가 10월 10일인 시간대라서 "오늘"이 하나로 정해져요. UTC 0시 바로 근처에 실행하면 그사이에 날짜가 바뀌어 run 개수가 달라질 수 있으니 피해 주세요.
이제 명령을 짧게 쓸 수 있는 셸 함수를 만들어 놓을게요. COMPOSE_PROJECT_NAME으로 compose 프로젝트 이름을 afint로 고정해요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=afint
af() { docker compose exec -T airflow airflow "$@"; }
meta() { docker compose exec -T postgres psql -U postgres -d airflow "$@"; }
runs() {
local ids; ids=$(printf "'%s'," "$@"); ids=${ids%,}
meta -c "SELECT dag_id, run_id, state, run_after, logical_date, data_interval_start AS start, data_interval_end AS \"end\"
FROM dag_run WHERE dag_id IN ($ids) ORDER BY dag_id, logical_date;"
}
tlog() {
docker compose exec -T airflow \
cat "/opt/airflow/logs/dag_id=$1/run_id=$2/task_id=$3/attempt=1.log" \
| grep -E '"logger":"(task.stdout|[^"]*SubprocessHook)"' \
| sed -E 's/^.*"event":"([^"]*)".*$/\1/' \
| grep -vE '^(Tmp dir|Running command|Output:|Command exited)'
}
waitruns() {
until [ "$(meta -tAc "SELECT count(*) FROM dag_run WHERE state IN ('queued', 'running')")" = 0 ]; do sleep 2; done
}
runs <dag_id>...: 메타데이터 DB의dag_run테이블에서 run마다 상태,run_after, logical date, data interval을 봐요.run_after는 스케줄러가 그 run을 만들어도 되는 가장 이른 시간이에요. psql은 컨테이너 기본 타임존인 UTC로 시간을 보여 줍니다.tlog <dag_id> <run_id> <task_id>: Airflow 3의 태스크 로그는 한 줄에 JSON 하나씩 기록돼요. 그중print출력과 Bash 명령의 출력 줄만 꺼냅니다.
컨테이너를 띄우고 API 서버가 응답할 때까지 기다린 뒤, 20초 더 둘게요.
docker compose up -d
until curl -sf localhost:7620/api/v2/monitor/health > /dev/null; do sleep 3; done
sleep 20
실습 1: 서울 타임존 DAG의 run 시간
dags/tz.py는 start_date가 10월 7일 0시이고 catchup=True인 DAG 세 개를 만들어요. catchup은 DAG를 켤 때 start_date부터 지금까지 지나간 스케줄을 모두 run으로 만들지 정하는 설정이에요. 타임존만 다르고, 세 DAG 모두 probe() 뒤에 dag.timezone과 그 타임존의 logical date를 찍는 dag_tz 태스크를 하나 더 붙여요.
tz_utc: UTC 0시 기준 data interval timetable이에요.tz_seoul: 서울 0시 기준 data interval timetable이에요. timetable과 start_date 모두 서울 타임존이에요.tz_seoul_string: cron 문자열만 넣고 타임존은 start_date에만 줬어요.
스케줄의 타임존은 다음 순서로 정해져요.
- timetable 객체를 직접 만들면
timezone인자로 준 값을 써요.CronDataIntervalTimetable과CronTriggerTimetable은 이 인자가 필수예요. - cron 문자열만 주면 DAG의 타임존으로 timetable을 만들어요. DAG의 타임존은 start_date의 타임존이에요(3.3.2 Task SDK
definitions/dag.py). - start_date에 타임존이 없으면
[core] default_timezone을 써요. 기본값은 utc예요.
세 DAG를 켜 볼게요.
for d in tz_utc tz_seoul tz_seoul_string; do af dags unpause $d > /dev/null; done
sleep 15; waitruns
runs tz_utc tz_seoul tz_seoul_string
dag_id | run_id | state | run_after | logical_date | start | end
-----------------+--------------------------------------+---------+------------------------+------------------------+------------------------+------------------------
tz_seoul | scheduled__2026-10-07T15:00:00+00:00 | success | 2026-10-07 15:00:00+00 | 2026-10-06 15:00:00+00 | 2026-10-06 15:00:00+00 | 2026-10-07 15:00:00+00
tz_seoul | scheduled__2026-10-08T15:00:00+00:00 | success | 2026-10-08 15:00:00+00 | 2026-10-07 15:00:00+00 | 2026-10-07 15:00:00+00 | 2026-10-08 15:00:00+00
tz_seoul | scheduled__2026-10-09T15:00:00+00:00 | success | 2026-10-09 15:00:00+00 | 2026-10-08 15:00:00+00 | 2026-10-08 15:00:00+00 | 2026-10-09 15:00:00+00
tz_seoul_string | scheduled__2026-10-06T15:00:00+00:00 | success | 2026-10-06 15:00:00+00 | 2026-10-06 15:00:00+00 | 2026-10-06 15:00:00+00 | 2026-10-06 15:00:00+00
tz_seoul_string | scheduled__2026-10-07T15:00:00+00:00 | success | 2026-10-07 15:00:00+00 | 2026-10-07 15:00:00+00 | 2026-10-07 15:00:00+00 | 2026-10-07 15:00:00+00
tz_seoul_string | scheduled__2026-10-08T15:00:00+00:00 | success | 2026-10-08 15:00:00+00 | 2026-10-08 15:00:00+00 | 2026-10-08 15:00:00+00 | 2026-10-08 15:00:00+00
tz_seoul_string | scheduled__2026-10-09T15:00:00+00:00 | success | 2026-10-09 15:00:00+00 | 2026-10-09 15:00:00+00 | 2026-10-09 15:00:00+00 | 2026-10-09 15:00:00+00
tz_utc | scheduled__2026-10-08T00:00:00+00:00 | success | 2026-10-08 00:00:00+00 | 2026-10-07 00:00:00+00 | 2026-10-07 00:00:00+00 | 2026-10-08 00:00:00+00
tz_utc | scheduled__2026-10-09T00:00:00+00:00 | success | 2026-10-09 00:00:00+00 | 2026-10-08 00:00:00+00 | 2026-10-08 00:00:00+00 | 2026-10-09 00:00:00+00
tz_utc | scheduled__2026-10-10T00:00:00+00:00 | success | 2026-10-10 00:00:00+00 | 2026-10-09 00:00:00+00 | 2026-10-09 00:00:00+00 | 2026-10-10 00:00:00+00
(10 rows)
tz_utc:run_after가 UTC 0시예요.tz_seoul:run_after가 UTC 15시, 즉 서울 0시예요. 같은 "매일 0시"인데tz_utc와 run을 만드는 시간이 9시간 달라요. 첫 구간의 시작은 서울 10월 7일 0시인 UTC 10월 6일 15시입니다.tz_seoul_string: 타임존을 start_date에만 줬는데도 UTC 15시, 서울 0시에 run이 있어요. trigger timetable이라 start_date인 서울 10월 7일 0시 run을 포함해 4개예요.
출력의 시간은 모두 +00으로 나와요. Airflow는 시간을 UTC로 저장하거든요.
실습 2: 서울 0시 run의 ds가 어제인 이유
템플릿도 UTC인지, tz_seoul_string의 마지막 run인 서울 10월 10일 0시 run의 로그를 볼게요.
tlog tz_seoul_string scheduled__2026-10-09T15:00:00+00:00 echo
tlog tz_seoul_string scheduled__2026-10-09T15:00:00+00:00 dag_tz
ds=2026-10-09 logical_date=2026-10-09 15:00:00+00:00 start=2026-10-09 15:00:00+00:00 end=2026-10-09 15:00:00+00:00 local=2026-10-10 00:00:00+09:00
tz=Asia/Seoul local=2026-10-10 00:00:00+09:00
글 첫머리의 로그가 바로 이거예요. 서울 10월 10일 0시 run인데 logical_date는 2026-10-09 15:00:00+00:00이고, ds는 그 UTC 날짜인 2026-10-09예요.
Time Zones 문서도 "Airflow returns time zone aware datetimes in templates, but does not convert them to local time so they remain in UTC"라고 설명해요. 서울 타임존을 줘도 바뀌는 것은 스케줄을 계산하는 기준뿐이고, 템플릿 날짜는 UTC 그대로인 거죠.
서울 0시부터 오전 9시 전까지는 UTC 날짜가 하루 전이에요. 그래서 logical date가 이 시간대에 있으면, ds는 logical date의 서울 날짜보다 하루 일러요.
같은 줄의 local은 logical_date.in_timezone('Asia/Seoul')로 바꾼 값이고, 서울 날짜 10월 10일이 제대로 나와요. dag_tz 태스크에서는 {{ dag.timezone }}이 Asia/Seoul로 찍혔으니, 타임존 이름을 직접 쓰지 않고 logical_date.in_timezone(dag.timezone)으로도 바꿀 수 있어요.
data interval timetable인 tz_seoul은 하루가 더 어긋나요.
tlog tz_seoul scheduled__2026-10-09T15:00:00+00:00 echo
tlog tz_seoul scheduled__2026-10-09T15:00:00+00:00 dag_tz
ds=2026-10-08 logical_date=2026-10-08 15:00:00+00:00 start=2026-10-08 15:00:00+00:00 end=2026-10-09 15:00:00+00:00 local=2026-10-09 00:00:00+09:00
tz=Asia/Seoul local=2026-10-09 00:00:00+09:00
이 run도 서울 10월 10일 0시에 만들어지는 run이에요. 처리하는 구간은 서울 10월 9일 하루이고, local도 10월 9일이에요. 그런데 ds는 2026-10-08입니다.
정리
범위: Airflow 3.3.2(apache/airflow:3.3.2-python3.12) standalone, LocalExecutor, 매일 0시 cron DAG, 실습일 2026년 10월 10일 UTC 14시 무렵.
- Airflow 3에서 cron 문자열은 기본으로
CronTriggerTimetable이 돼요. Airflow 2의 cron DAG를 옮길 때는[scheduler] create_cron_data_intervals값(Airflow 3 기본 False)부터 확인하세요. - 타임존을 start_date에만 준 cron 문자열 DAG도 서울 0시(UTC 15시)에 run을 만들었어요. start_date에 타임존이 없으면
default_timezone(기본 utc)을 써요. - 서울 10월 10일 0시 run의
logical_date는2026-10-09 15:00:00+00:00,ds는 2026-10-09였어요. 서울 날짜가 필요한 곳(파티션 이름, 파일 경로, 결과 테이블의 날짜 컬럼)에는ds를 쓰지 말고logical_date.in_timezone(dag.timezone)으로 바꾼 값을 쓰세요. - data interval timetable인
tz_seoul의 같은 서울 10월 10일 0시 run은ds가 2026-10-08이었어요. logical date가 구간 시작이라서 하루, 그 시작을 UTC로 표시해서 또 하루가 이른 거예요. 서울 기준 하루치는ds가 아니라data_interval_start,data_interval_end로 정해timestamptz컬럼과 비교하세요.