APScheduler 시리즈 1편: APScheduler CronTrigger에 timezone을 직접 써야 하는 이유

사고 개요

2026년 7월 11일, Lynceus 시스템의 주간 전략 최적화기(weekly optimizer)를 점검하다가 이상한 것을 발견했다.

로그를 보면 매주 일요일 새벽 2시에 weekly_optimizer_handler_called 로그가 찍혀 있다. 스케줄러는 정상 동작 중이다. 그런데 전략 파라미터 업데이트 이력이 없다. 6월 7일, 14일, 21일, 28일, 7월 5일 — 5번의 주간 실행 모두 마찬가지였다.

더 이상한 것은 에러 로그가 있다는 점이다. weekly_optimizer_handler_failed (ERROR). 매주 같은 시각, 같은 에러. 5주 동안.

왜 몰랐을까.


발견 과정

직접 발견이 아니었다. 전략 성과 데이터를 월별로 정리하다 “최근 파라미터 업데이트가 언제였지?“라는 질문에서 시작됐다. DB를 보니 6월 초 이후 optimization_runs 테이블에 새 레코드가 없었다.

그러면 optimizer가 실행됐을 때 무슨 일이 있었던 것인지 로그를 파봤다.

2026-06-07 02:00:01 INFO  weekly_optimizer_handler_called
2026-06-07 02:00:02 INFO  optimizer_session_start session_id=... n_experiments=50
2026-06-07 02:00:03 INFO  optimizer_session_done accepted=0 best_score=-999.0 elapsed_sec=0.8
2026-06-07 02:00:03 ERROR weekly_optimizer_handler_failed
  Traceback (most recent call last):
    ...
  AttributeError: 'OptimizerStatus' object has no attribute 'completed_experiments'

에러 로그가 있었다. 매주 있었다. 아무도 보지 않았을 뿐이다.


현상

weekly optimizer 핸들러는 다음 구조였다.

async def _weekly_optimizer_handler() -> None:
    # ...전제 조건 확인...

    try:
        optimizer = StrategyOptimizer(market_client, _session_factory)
        status = await optimizer.run_session(50, 100)

        if not status.best_params:
            logger.info("weekly_optimizer_no_best_params")
            return

        logger.info(
            "weekly_optimizer_session_completed",
            extra={
                "n_experiments": status.completed_experiments,  # <-- 존재하지 않는 속성
                "best_score": status.best_score,
            },
        )

        # ... _apply_best_params 호출 ...

    except Exception:
        logger.exception("weekly_optimizer_handler_failed")

OptimizerStatus 데이터클래스의 실제 속성명은 current_experiment였다. 어느 시점의 리팩토링에서 completed_experiments에서 current_experiment로 이름이 바뀌었는데, 이 호출 코드는 업데이트되지 않았다.

결과는 예측 가능하다: run_session()best_params가 있는 상태로 반환 → status.completed_experiments 접근 → AttributeError → outer except Exception: 이 잡음 → logger.exception(...) 로그.


원인: 두 층의 문제

이 사고는 두 가지 독립적인 문제가 겹쳐서 “조용한” 장기 실패를 만들었다.

1층: 내부에서 예외를 삼키는 run_session()

StrategyOptimizer.run_session()은 내부에서 발생하는 모든 예외를 catch한다.

async def run_session(self, n_experiments: int, days: int) -> OptimizerStatus:
    # ...
    try:
        # 50번 실험 루프
        for exp_no in range(1, n_experiments + 1):
            # ...
    except Exception as e:
        logger.error("optimizer_session_error", extra={"error": str(e)}, exc_info=True)
        self._status.last_error = str(e)   # 오류를 상태 객체에 담아 반환
    finally:
        self._status.is_running = False
        logger.info("optimizer_session_done", ...)

    return self._status  # 예외가 발생해도 항상 반환

예외가 발생하면 last_error 필드에 오류 메시지를 담고, optimizer_session_done 로그를 찍고, 상태 객체를 반환한다. 호출자 입장에서는 “뭔가 반환됐다” 이상의 정보를 모른다.

이 패턴 자체는 나쁘지 않다. API 서버가 status 객체를 폴링하는 상황이라면, 예외로 프로세스를 중단시키는 것보다 상태를 돌려주는 편이 낫다.

문제는 호출자가 last_error를 확인하지 않는다는 데 있다.

2층: last_error를 확인하지 않는 핸들러

status = await optimizer.run_session(50, 100)

# best_params 유무만 확인한다
if not status.best_params:
    logger.info("weekly_optimizer_no_best_params")
    return

# best_params가 있으면 "세션 완료"로 간주하고 진행
logger.info("weekly_optimizer_session_completed", ...)

run_session()이 내부 오류로 실험을 1건도 완료 못 했더라도, 이전 이력에서 로드한 best_params가 있다면 코드는 계속 진행한다. last_error가 있는지, 실험이 50회 완주됐는지 — 아무것도 확인하지 않는다.

이번 사례에서 run_session()은 다음 흐름으로 동작했다.

run_session() 내부:
  ① DB에서 기존 best_params 로드 → 성공
  ② 종목 목록 로드 → 성공
  ③ 실험 루프 실행
     exp_no=1: 실험 실행 → 성공 (또는 내부 오류로 루프 탈출)
  → status.best_params = (DB에서 로드한 기존 값) → non-None
  → 예외 발생 → last_error 설정 → 반환

핸들러 내부:
  status.best_params is not None → True → 계속 진행
  status.completed_experiments 접근 → AttributeError
  outer except Exception → logger.exception("weekly_optimizer_handler_failed")

만약 completed_experiments 버그가 없었다면, 실험 결과를 한 건도 완주하지 못했어도 “세션 완료"로 처리되어 기존 best_params로 _apply_best_params가 호출됐을 것이다. 기능은 동작하는 것처럼 보이지만 최적화는 전혀 이루어지지 않은 상태로.

왜 5주 동안 몰랐나

weekly_optimizer_handler_failed (ERROR) 로그는 매주 찍혔다. 그러나:

  • 새벽 2시 실행이라 실시간 모니터링 없음
  • Telegram 실패 알림 미구성 (성공 알림은 있었지만 실패 알림은 없었음)
  • “전략 파라미터가 업데이트되지 않는다"는 현상이 매매 성과에 즉시 반영되지 않음
  • APScheduler는 job 실패를 스케줄러 레벨에서 재시도하거나 경고하지 않음 — 핸들러가 이미 예외를 잡았으니 job 자체는 “정상 완료"로 처리됨

에러는 있었다. 알아차릴 메커니즘이 없었을 뿐이다.


해결

두 가지를 동시에 수정했다.

1. 미존재 속성 참조 수정

# 수정 전
"n_experiments": status.completed_experiments,

# 수정 후
"n_experiments": status.current_experiment,

2. last_error 및 미완주 세션 가드 추가

status = await optimizer.run_session(50, 100)

# 세션이 오류로 종료됐거나 실험을 완주하지 못한 경우
if status.last_error or status.current_experiment != status.total_experiments:
    logger.warning(
        "weekly_optimizer_session_incomplete",
        extra={
            "last_error": status.last_error,
            "current_experiment": status.current_experiment,
            "total_experiments": status.total_experiments,
        },
    )
    return   # 불완전한 세션의 결과는 적용하지 않는다

if not status.best_params:
    logger.info("weekly_optimizer_no_best_params")
    return

# 여기까지 왔으면 세션이 정상 완주됐다
logger.info("weekly_optimizer_session_completed", ...)

last_error가 있거나 current_experiment != total_experiments이면 세션이 완주되지 않은 것이다. 이 경우 WARNING 로그를 남기고 조기 리턴한다. 불완전한 세션의 결과로 파라미터를 업데이트하는 것은 의도된 동작이 아니다.


회귀 가드 테스트

불완전 세션이 파라미터 업데이트로 이어지지 않는다는 것을 테스트로 단언한다.

@pytest.mark.asyncio
async def test_weekly_optimizer_handler_does_not_apply_incomplete_session(
    monkeypatch,
    caplog,
) -> None:
    """내부 오류로 실패한 세션은 완료 처리되거나 파라미터에 적용되지 않는다."""
    status = OptimizerStatus(
        current_experiment=5,   # 50회 중 5회만 완료
        total_experiments=50,
        best_params={"oversold": 35.0},
        last_error="AttributeError: 'OptimizerStatus' object has no attribute 'completed_experiments'",
    )

    # ... FakeStrategyOptimizer 패치 ...

    with caplog.at_level(logging.WARNING, logger="..."):
        await _weekly_optimizer_handler()

    # 세션 완료 로그가 찍히지 않아야 한다
    assert not any(
        record.message == "weekly_optimizer_session_completed"
        for record in caplog.records
    )
    # 미완주 경고 로그가 찍혀야 한다
    incomplete_record = next(
        record for record in caplog.records
        if record.message == "weekly_optimizer_session_incomplete"
    )
    assert incomplete_record.last_error == status.last_error
    # _apply_best_params는 호출되지 않아야 한다
    apply_best_params.assert_not_awaited()

일반화: 결과 객체를 통해 오류를 반환하는 패턴의 함정

run_session()처럼 내부 예외를 catch하고 오류를 상태 객체의 필드로 반환하는 패턴은 흔하다.

# 이런 패턴을 사용한다면
class TaskResult:
    success: bool
    data: Any
    error: str = ""

async def run_task() -> TaskResult:
    try:
        result = await do_something()
        return TaskResult(success=True, data=result)
    except Exception as e:
        return TaskResult(success=False, error=str(e))  # 예외 대신 오류 필드

호출자는 반드시 error 또는 success 필드를 확인해야 한다. 확인하지 않으면 실패한 작업이 성공한 것처럼 처리된다.

특히 배치 작업에서는 이 확인을 빠뜨리기 쉽다. 단일 요청-응답 사이클과 달리, 배치 작업의 실패는 사용자에게 즉시 노출되지 않는다.


배치 작업 설계 원칙

이 사고에서 도출한 규칙을 정리한다.

1. 결과 객체의 오류 필드를 항상 확인한다

status = await run_session(...)

# 확인 없이 진행하지 않는다
if status.last_error:
    logger.warning("session_failed", extra={"error": status.last_error})
    return  # 또는 raise

2. 완주 여부를 단언한다

예상 반복 횟수가 있다면, 실제로 그만큼 완료됐는지 확인한다.

if status.current_experiment != status.total_experiments:
    logger.warning(
        "session_incomplete",
        extra={
            "completed": status.current_experiment,
            "expected": status.total_experiments,
        },
    )
    return

3. 배치 작업 실패에 명시적 알림을 추가한다

로그 레벨을 ERROR로 올리는 것만으로는 부족하다. 사람이 개입해야 하는 실패라면 Telegram이든 PagerDuty든 명시적 채널을 통해 알려야 한다.

except Exception as exc:
    logger.exception("weekly_optimizer_handler_failed")
    try:
        await send_telegram_message(
            f"[경고] weekly optimizer 실패: {type(exc).__name__}: {exc}"
        )
    except Exception:
        pass  # 알림 실패가 원래 실패를 가리게 하지 않는다

4. 내부 예외를 삼키는 함수는 그 사실을 명시한다

async def run_session(self, ...) -> OptimizerStatus:
    """
    최적화 세션을 실행합니다.

    내부 예외는 catch되어 OptimizerStatus.last_error에 담깁니다.
    호출자는 반드시 last_error를 확인해야 합니다.
    """

docstring이 있었다면 호출자가 last_error 확인을 빠뜨리지 않았을 가능성이 높다.


정리

문제내용
직접 원인status.completed_experiments — 미존재 속성 참조
구조적 원인run_session()이 내부 예외를 last_error에 담아 정상 반환, 호출자가 확인 안 함
왜 5주 동안 몰랐나새벽 2시 실행 + Telegram 실패 알림 없음 + APScheduler는 핸들러 내 catch를 “정상 완료"로 봄
해결last_error 및 미완주 검사 추가, 미존재 속성 수정
방지결과 객체 오류 필드 확인, 완주 단언, 실패 알림 추가

배치 작업의 실패는 조용하다. 서버는 멀쩡히 돌고, 스케줄러는 다음 실행을 예약하고, 에러 로그는 아무도 보지 않는 파일에 쌓인다. 그 조용함을 깨뜨리는 것은 코드의 몫이다.