사고 개요

2026년 5월 4일, 매매 시스템 스케줄러가 KST 09:00에 실행되어야 할 작업을 UTC 09:00, 즉 KST 18:00에 실행했다. 그날 매매는 0건이었다.

에러 로그는 없었다. 서버는 정상 동작했다. 스케줄러도 설정대로 실행됐다. 문제는 “설정대로"의 의미가 개발자가 의도한 것과 달랐다는 점이다.


사고 발견 과정

문제는 당일 장 마감 후 거래 내역 집계 루틴에서 처음 발견됐다. 체결 건수가 0인 것이 이상해서 로그를 뒤졌다. 스케줄러 로그를 보면 job은 정상 실행됐고, 에러도 없었다. 그런데 실행 시각이 18:00:00 KST였다.

처음에는 증권사 API 문제를 의심했다. 장 마감 후 호출이니 당연히 응답 데이터가 없었고, 코드는 정상 흐름으로 종료했다. 에러가 없으니 로그만 보면 “정상 실행"처럼 보였다.

스케줄러 설정 코드를 다시 들여다봤을 때, CronTrigger(hour=9, minute=0) 라인에서 timezone=이 빠져 있다는 걸 발견했다. 그 순간 로컬 테스트 환경과 운영 서버의 시스템 시간대가 다르다는 사실이 떠올랐다.

로컬(Mac): TZ=Asia/Seoul (기본값) 운영 서버(컨테이너): TZ=UTC (도커 기본값)

로컬에서는 CronTrigger(hour=9)가 시스템 KST를 따라 KST 09:00에 실행됐다. 운영 서버에서는 같은 코드가 UTC 09:00, 즉 KST 18:00에 실행됐다. 코드 변경 없이, 환경만 달라졌을 뿐인데.


현상

APScheduler를 아래와 같이 설정했다.

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from zoneinfo import ZoneInfo

scheduler = AsyncIOScheduler(timezone=ZoneInfo("Asia/Seoul"))

scheduler.add_job(
    start_trading,
    CronTrigger(hour=9, minute=0),  # KST 09:00 의도
    id="morning_trade",
)

스케줄러 자체에 timezone=ZoneInfo("Asia/Seoul")을 전달했으니 모든 job이 KST 기준으로 실행될 것이라고 믿었다. 그러나 실제로는 UTC 09:00, 한국 시각으로 저녁 6시에 매매가 시작됐다.


원인

APScheduler 4.x에서 스케줄러 글로벌 timezone은 CronTrigger에 자동으로 전파되지 않는다.

AsyncIOScheduler(timezone=...) 설정은 스케줄러 내부 clock 기준과 일부 display 동작에 영향을 줄 뿐, 각 CronTrigger 인스턴스의 시간대 해석과는 별개다. CronTriggertimezone 인수를 생략하면 trigger는 시스템 기본 시간대(대부분의 서버 환경에서는 UTC)를 사용한다.

컨테이너 환경이나 클라우드 VM은 기본적으로 UTC로 설정된다. 로컬 개발 환경은 KST인 경우가 많다. 로컬에서는 정상으로 보이고 운영 배포 후에 처음 틀어지는 이유가 이것이다.

개발환경 (KST): CronTrigger(hour=9) → 시스템 KST 적용 → KST 09:00 실행 (정상)
운영환경 (UTC): CronTrigger(hour=9) → 시스템 UTC 적용 → UTC 09:00 = KST 18:00 실행 (오작동)

APScheduler 3.x에서는 스케줄러 글로벌 timezone이 trigger에 전파되는 경우가 있어서, 3.x에서 정상이던 코드가 4.x 마이그레이션 후 조용히 깨지는 사례도 보고된다.

환경변수 TZ 설정은 해결책이 아니다

서버에 TZ=Asia/Seoul을 환경변수로 주입하면 CronTrigger(hour=9)가 KST를 따라 정상 동작한다. 하지만 이 방법에는 중요한 함정이 있다.

첫째, 코드가 배포 환경의 설정에 암묵적으로 의존하게 된다. 컨테이너 재배포, 인프라 교체, 또는 다른 팀의 실수로 환경변수가 누락되는 순간 스케줄러는 다시 UTC로 돌아간다.

둘째, Python 자체가 프로세스 시작 후 os.environ["TZ"] 변경에 완전히 반응하지 않는 경우가 있다. time.tzset()을 명시적으로 호출하지 않으면 일부 내부 동작이 초기화된 시간대를 유지한다.

코드 안에 시간대를 명시하는 방법만이 환경에 독립적이고 의도를 명확히 전달한다.


해결

CronTrigger 인스턴스를 생성할 때 timezone= 인수를 직접 명시한다.

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from zoneinfo import ZoneInfo

KST = ZoneInfo("Asia/Seoul")

scheduler = AsyncIOScheduler(timezone=KST)

# 각 trigger에 timezone을 직접 명시한다
scheduler.add_job(
    start_trading,
    CronTrigger(hour=9, minute=0, timezone=KST),
    id="morning_trade",
)

scheduler.add_job(
    end_trading,
    CronTrigger(hour=15, minute=30, timezone=KST),
    id="afternoon_close",
)

스케줄러에 글로벌 timezone을 설정하더라도, 각 CronTrigger에 명시적으로 timezone= 인수를 전달해야 의도한 시간대로 실행된다.


확장: 함께 잡아야 할 datetime 패턴

timezone 관련 사고는 스케줄러에만 국한되지 않는다. 다음 두 가지 패턴이 같은 맥락에서 자주 문제를 일으킨다.

datetime.now() 사용 금지

# 잘못된 패턴 — 시스템 timezone에 의존
from datetime import datetime
now = datetime.now()  # 컨테이너에서는 UTC 반환
# 올바른 패턴 — timezone-aware KST
from datetime import datetime
from zoneinfo import ZoneInfo

KST = ZoneInfo("Asia/Seoul")
now = datetime.now(KST)

datetime.now() 는 timezone-naive 객체를 반환하며, 시스템 시간대를 그대로 따른다. 컨테이너나 클라우드 VM에서는 UTC가 기본이므로 9시간 오차가 발생한다. datetime.utcnow()는 UTC를 명시하지만 마찬가지로 timezone-naive이므로 KST와의 비교 시 조용한 오차를 만든다.

시간 비교 시 naive/aware 혼합 금지

# 이 코드는 TypeError 또는 silent 오차를 만든다
deadline = datetime(2026, 5, 12, 9, 0)           # naive
now = datetime.now(ZoneInfo("Asia/Seoul"))         # aware

if now > deadline:  # TypeError: can't compare offset-naive and offset-aware
    ...

모든 datetime 객체는 생성 시점부터 timezone-aware로 만들어야 한다. 중간에 섞이면 명시적 에러가 나거나, 조용히 잘못된 비교 결과를 낸다.


IntervalTrigger도 동일하다

CronTrigger뿐 아니라 IntervalTrigger에서도 같은 주의가 필요하다. 특히 start_time이나 end_time에 naive datetime을 넣으면 의도치 않은 실행 범위가 생긴다.

from datetime import datetime
from apscheduler.triggers.interval import IntervalTrigger
from zoneinfo import ZoneInfo

KST = ZoneInfo("Asia/Seoul")

# 잘못된 패턴 — start_time이 naive
scheduler.add_job(
    check_market,
    IntervalTrigger(
        minutes=5,
        start_time=datetime(2026, 5, 12, 9, 0),   # naive → UTC로 해석될 수 있음
    ),
    id="market_check",
)

# 올바른 패턴 — start_time을 aware로 명시
scheduler.add_job(
    check_market,
    IntervalTrigger(
        minutes=5,
        start_time=datetime(2026, 5, 12, 9, 0, tzinfo=KST),
        timezone=KST,
    ),
    id="market_check",
)

시간대가 명시된 인수는 CronTriggertimezone=만이 아니다. 시간 값을 직접 받는 모든 인수(start_time, end_time, run_date 등)에 timezone-aware datetime을 전달해야 한다.


회귀 가드 테스트

이 사고가 재발하지 않도록 단언(assertion) 기반 테스트를 함께 작성해두는 것이 좋다.

import pytest
from zoneinfo import ZoneInfo
from your_app.scheduler import build_scheduler

def test_all_job_triggers_use_kst():
    """등록된 모든 CronTrigger의 timezone이 Asia/Seoul인지 검증한다."""
    scheduler = build_scheduler()
    kst = ZoneInfo("Asia/Seoul")

    for job in scheduler.get_jobs():
        trigger = job.trigger
        assert hasattr(trigger, "timezone"), f"{job.id}: trigger에 timezone 속성 없음"
        assert trigger.timezone == kst, (
            f"{job.id}: 예상 Asia/Seoul, 실제 {trigger.timezone}"
        )

스케줄러 설정 코드가 변경될 때마다 이 테스트가 잡아준다.


정리

항목잘못된 패턴올바른 패턴
스케줄러 timezoneAsyncIOScheduler(timezone=KST)만 설정trigger에도 timezone=KST 직접 명시
현재 시각datetime.now()datetime.now(ZoneInfo("Asia/Seoul"))
UTC 금지datetime.utcnow()동일, 사용 금지
naive/aware 혼합한쪽만 aware로 생성모든 datetime을 처음부터 aware로 생성

핵심 규칙은 하나다: timezone은 묵시적으로 전파되지 않는다. 코드에 명시적으로 써야 한다.

운영 환경은 UTC가 기본이고, 개발 환경은 KST인 경우가 많다. 그 차이가 조용한 9시간 오차를 만들고, 그 오차는 종종 매매 0건처럼 눈에 띄는 사고로 이어진다.

스케줄러 설정을 작성할 때 trigger마다 timezone=ZoneInfo("Asia/Seoul")을 입력하는 한 줄이, 운영 장애를 막는 가장 저렴한 방어선이다.