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 및 미완주 검사 추가, 미존재 속성 수정 |
| 방지 | 결과 객체 오류 필드 확인, 완주 단언, 실패 알림 추가 |
배치 작업의 실패는 조용하다. 서버는 멀쩡히 돌고, 스케줄러는 다음 실행을 예약하고, 에러 로그는 아무도 보지 않는 파일에 쌓인다. 그 조용함을 깨뜨리는 것은 코드의 몫이다.