스케줄 잡과 APScheduler
스케줄 잡과 APScheduler
정기 작업은 어느 백엔드든 등장합니다. 야간 집계·외부 데이터 수집·만료 토큰 정리. 작은 규모에서는 cron 이나 인프로세스 스케줄러로 충분하고, 커지면 분산 큐와 워커가 등장합니다.
1. APScheduler 에 대한 이야기
APScheduler 는 Alex Grönholm 이 시작한 Python 라이브러리로 2008 년경 첫 공개된 오래된 프로젝트입니다. 인프로세스 스케줄러로 cron 표현식·간격·날짜 트리거를 한 API 로 묶습니다.
| 트리거 | 의미 |
|---|---|
cron |
"매일 03:00" 같은 cron 식. |
interval |
"30 초마다" 같은 고정 간격. |
date |
특정 시각 한 번만. |
스케줄러 종류로 BlockingScheduler (메인 스레드 점유) · BackgroundScheduler (별도 스레드) · AsyncIOScheduler (이벤트 루프) 등이 있습니다. 잡 저장소 (jobstore) 로 메모리·SQLAlchemy·MongoDB·Redis 를 선택할 수 있어 재시작 후 잡 복원이 가능합니다.
2. 트리거와 잡
from apscheduler.schedulers.background import BackgroundScheduler
sched = BackgroundScheduler(timezone='Asia/Seoul')
@sched.scheduled_job('cron', hour=3, minute=0, id='daily-aggregate')
def daily_aggregate():
...
@sched.scheduled_job('interval', seconds=30, id='heartbeat')
def heartbeat():
...
sched.start()
id 를 명시하면 같은 잡의 중복 등록을 막을 수 있습니다 (replace_existing=True). 잡 저장소에 보존된 상태에서 코드가 변경되어도 같은 id 를 통해 갱신됩니다.
3. 단일 인스턴스 가정과 멱등성
APScheduler 는 같은 잡 저장소를 여러 프로세스가 공유하면 잡 분배를 자동으로 보장하지 않습니다 (별도 분산 락이 필요합니다). 단일 워커로 운영하는 것이 가장 단순한 가정입니다. 다중 워커가 필요하면 다음 중 하나를 선택합니다.
- 잡 저장소 + DB 행 락으로 한 번에 한 워커만 실행.
- Redis 기반 분산 락 (Redlock 같은 알고리즘).
- 운영상 "한 인스턴스에서만 스케줄러를 켠다" 는 컨벤션.
설계상 같은 잡이 두 번 실행될 가능성을 가정하고 본문은 멱등 하게 작성합니다 (같은 입력에 같은 결과 또는 안전한 무동작).
4. misfire 와 coalesce
스케줄된 시각에 워커가 죽어 있다 깨어났을 때 미실행 잡을 어떻게 다룰지의 정책입니다.
misfire_grace_time— 늦어도 이 시간 안에 실행되면 OK.coalesce— 누적된 실행을 한 번으로 합칠지.
기본값은 보수적이며 누적 실행이 시스템에 부담을 주지 않도록 명시 설정이 권장됩니다.
5. 다른 도구들
| 도구 | 첫 등장 | 모델 |
|---|---|---|
| cron (Unix) | 1975 | OS 수준 스케줄러. 가장 단순. |
| systemd timers | 2010s | systemd 의 cron 대체. 단일 호스트. |
| APScheduler | 2008 | Python 인프로세스. |
| Celery | 2009, Ask Solem | Python 분산 태스크 큐 (브로커: RabbitMQ/Redis). celery beat 가 스케줄. |
| RQ | 2012 | Python + Redis. Celery 보다 단순. |
| Sidekiq | 2012, Mike Perham | Ruby + Redis. 대규모 운영에서 표준급. |
| BullMQ | 2018 (전 Bull) | Node + Redis. |
| Quartz | 2001 | JVM 의 오래된 스케줄러. Spring 통합 표준. |
| Temporal | 2019 (Cadence 포크) | 워크플로 엔진. 상태·재시도·타이머가 1 급. |
| AWS EventBridge / GCP Cloud Scheduler | 2010s 후반 | 매니지드 cron. |
선택 기준의 한 축은 "실행이 멀티 호스트로 분산되어야 하는가" 입니다. 분산이 필요하면 큐 모델, 단일 호스트로 충분하면 인프로세스 스케줄러로도 무방합니다.
6. FastAPI 와의 결합
from contextlib import asynccontextmanager
from fastapi import FastAPI
from apscheduler.schedulers.asyncio import AsyncIOScheduler
sched = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
sched.start()
yield
sched.shutdown(wait=False)
app = FastAPI(lifespan=lifespan)
lifespan 이벤트로 스케줄러의 수명과 앱의 수명을 맞춥니다.
7. 잡 본문의 가드
def daily_aggregate():
if already_done(date.today()):
return
do_work()
mark_done(date.today())
이 형태가 멱등성의 출발점입니다. "같은 날짜로 두 번 깨어나도 한 번만 동작" 을 단순한 DB 플래그로 보장합니다.
8. 분산 락 (Redis)
여러 인스턴스가 같이 스케줄러를 돌릴 때 한 번에 한 잡만 실행되게 하려면 Redis SET key NX EX <ttl> 패턴이 흔합니다. Martin Kleppmann 의 글과 Redis 저자 antirez 의 응답으로 시작된 Redlock 논쟁이 잘 알려져 있습니다 (2016). 강일관성이 필수면 ZooKeeper · etcd · DB 행 락 같은 합의 기반 도구가 더 적합하다는 견해도 있습니다.
9. 자주 걸리는 자리
개발 환경의 자동 리로드 — uvicorn --reload 같은 모드는 워커를 두 개로 띄우는 효과를 낼 수 있어 잡이 두 번 등록됩니다. 개발 시 스케줄러를 끄거나 잡 저장소에 replace_existing=True 를 명시합니다.
시간대 (timezone) 설정 누락 — 스케줄 시각이 UTC 기준으로 해석되어 의도와 어긋납니다. 스케줄러 생성 시 timezone 을 명시합니다.
장기 실행 잡과 다음 트리거 충돌 — 직전 실행이 끝나기 전에 다음이 트리거됩니다. max_instances=1 · coalesce=True 같은 옵션을 검토합니다.
분산 락의 TTL 과 작업 시간 역전 — 락 TTL 보다 작업이 길어지면 다른 워커가 락을 가져가 두 번 실행될 수 있습니다. 작업 시간 분포를 측정하고 TTL 을 보수적으로 잡습니다.
하고픈 말
스케줄러는 큰 시스템이 등장하기 전 단계의 가장 효율적인 자리입니다. APScheduler 한 줄로 시작해 멱등 본문 + 단일 인스턴스 가정만 지키면 운영 부담이 매우 작습니다. 분산이 필요한 시점에는 Celery · Temporal 같은 도구로 옮겨가는 흐름이 자연스럽습니다.
Next
- typeorm-readonly
- crawler-ethics
APScheduler 공식 · APScheduler GitHub · Celery 공식 · Sidekiq 공식 · Quartz 공식 · Temporal 공식 · Redlock 논쟁 (Martin Kleppmann) 을 참고합니다.