APScheduler 예약이 재시작마다 날아갈 때, DB를 진실의 원천으로

APScheduler 예약이 재시작마다

이 글에서는 APScheduler 예약이 재시작마다 날아가는 근본 원인과, 잡스토어를 바꾸는 것만으로는 왜 부족한지, 그리고 DB를 진실의 원천(source of truth)으로 두는 틱 루프 구조로 이 문제를 근본적으로 우회하는 방법을 확인하실 수 있습니다. 핵심 답부터 말씀드리면, APScheduler의 잡 저장소를 영속화하는 것보다 “예약 정보는 DB에만 두고, APScheduler는 주기적으로 DB를 훑는 틱(tick) 하나만 담당하게” 구조를 바꾸는 편이 재시작에 훨씬 강합니다. 이 방식은 실제로 예약 발행형 블로그 자동화 서비스에서 흔히 쓰이는 패턴입니다.

APScheduler 예약이 재시작마다 날아가는 이유

APScheduler를 처음 붙일 때 대부분 BackgroundScheduler()를 기본값 그대로 씁니다. 기본 잡스토어는 MemoryJobStore인데, 이름 그대로 프로세스 메모리에만 잡(Job) 객체를 들고 있습니다. 그래서 add_job()으로 등록한 예약은 서버 프로세스가 살아있는 동안만 유효하고, 배포·재기동·에러로 인한 프로세스 재시작이 일어나는 순간 통째로 사라집니다.

문제는 이게 버그가 아니라 설계라는 점입니다. APScheduler 공식 사용자 가이드에서도 잡스토어별로 영속성 여부가 다르다는 점을 명확히 구분해 설명하고 있습니다. 노트북 서버처럼 git pull 배포마다 프로세스를 재기동하는 환경이라면, 메모리 잡스토어를 쓰는 순간 예약은 “1분마다 리셋되는 시한부 데이터”가 되는 셈입니다.

SQLAlchemyJobStore를 쓰면 정말 해결될까요?

SQLAlchemyJobStore(url="sqlite:///jobs.sqlite")로 바꾸면 잡이 테이블에 저장되고, 재시작 후에도 스케줄러가 이전 잡을 다시 로드합니다. 여기까지는 맞습니다. 다만 실무에서는 몇 가지 제약이 바로 발목을 잡습니다.

  • 잡 함수는 반드시 모듈 경로로 참조 가능해야 합니다. 람다, 클로저, 클래스 내부 익명 함수는 pickle로 직렬화되지 않아서 재시작 후 복원에 실패합니다.
  • misfire_grace_time을 넘긴 잡은 조용히 버려집니다. 서버가 5분 멈춰 있었고 grace time이 그보다 짧으면, 그 사이 실행됐어야 할 작업은 알림 없이 스킵됩니다.
  • 여러 워커 프로세스에서 같은 DB를 바라보면 같은 잡이 중복 실행될 수 있습니다. APScheduler 자체에는 분산 락이 없습니다.

즉 SQLAlchemyJobStore는 “잡 정의”는 지켜주지만, “이 시간에 이 작업이 반드시, 한 번만 실행됐다”는 보장까지는 해주지 않습니다. 이 부분은 APScheduler 공식 문서의 오작동(misfire) 처리 설명에서도 실행 보장이 아니라 최선 노력(best-effort) 수준이라고 명시하고 있습니다.

서버 데이터베이스에서 예약 작업을 관리하는 백엔드 시스템 구조 이미지

DB를 진실의 원천으로 두면 구조가 달라집니다

발상을 뒤집어 보면 훨씬 단순해집니다. “예약 60건을 APScheduler 잡으로 각각 등록”하는 대신, 예약 정보는 그냥 평범한 DB 테이블 한 줄로 관리하고, APScheduler에는 잡을 딱 하나만 등록합니다. 30초나 1분마다 깨어나서 “지금 시각 기준으로 실행 대상인 행이 있는지” DB를 조회하는 틱 잡입니다.

이 구조에서 APScheduler가 재시작 때마다 잊어버려도 상관없는 이유는, 애초에 기억해야 할 정보가 “틱 잡을 30초마다 돌려라”라는 규칙 하나뿐이기 때문입니다. 이 규칙은 코드 안에 scheduler.add_job(tick, "interval", seconds=30, id="tick_loop", replace_existing=True)로 매번 재등록하면 되므로, 메모리 잡스토어를 그대로 써도 됩니다. 진짜 예약 데이터 60건, 600건은 DB 테이블에 안전하게 남아 있고, 틱이 매번 그 시점의 최신 상태를 다시 읽어 판단합니다.

이 접근은 참고 자료에서도 “앱 재시작 시 스케줄이 날아갑니다, DB에 저장하고 시작 시…”라고 짧게 언급된 방향과 같은 결인데, 여기서는 그걸 “잡을 영속화”하는 대신 “잡의 존재 자체를 줄이고 폴링으로 대체”하는 쪽으로 더 밀어붙인 형태입니다.

틱 루프, 이렇게 구현해 보세요

아래는 APScheduler 3.x와 SQLAlchemy(2.x 스타일 ORM)로 작성한 최소 예시입니다. posts.db는 로컬 SQLite지만, 운영 환경에서는 서비스가 이미 쓰고 있는 PostgreSQL·MySQL로 바꿔도 구조는 동일합니다.

from datetime import datetime, timezone
from sqlalchemy import create_engine, Column, Integer, String, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
from apscheduler.schedulers.background import BackgroundScheduler

Base = declarative_base()

class ScheduledPost(Base):
    __tablename__ = "scheduled_posts"
    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    run_at = Column(DateTime, nullable=False)
    status = Column(String, default="pending")  # pending, done, failed

engine = create_engine("sqlite:///posts.db")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)

def publish(post):
    print(f"[{datetime.now()}] 발행: {post.title}")

def tick():
    session = Session()
    now = datetime.now(timezone.utc)
    due_posts = (
        session.query(ScheduledPost)
        .filter(ScheduledPost.status == "pending", ScheduledPost.run_at <= now)
        .all()
    )
    for post in due_posts:
        try:
            publish(post)
            post.status = "done"
        except Exception:
            post.status = "failed"
    session.commit()
    session.close()

scheduler = BackgroundScheduler()
scheduler.add_job(tick, "interval", seconds=30, id="tick_loop", replace_existing=True)
scheduler.start()

이 코드를 실행 중인 프로세스를 강제로 죽였다가 다시 띄워도, scheduled_posts 테이블에 남아 있는 pending 행은 다음 틱에서 그대로 다시 잡힙니다. 출력은 이런 식으로 찍힙니다.

[2026-08-15 09:00:12] 발행: 8월 마케팅 트렌드 정리
[2026-08-15 09:00:12] 발행: 여름 다이어트 식단 후기

파이썬 코드로 자동화 스케줄을 작성하는 개발자 화면

핵심은 run_at <= now 조건입니다. 정확한 시각 일치가 아니라 "이미 지난 시각"을 기준으로 잡기 때문에, 서버가 몇 분 멈춰 있다가 다시 켜져도 밀린 예약이 소리 없이 사라지지 않고 다음 틱에서 잡힙니다. 다만 publish()가 재시도 시 같은 글을 두 번 발행하지 않도록 멱등성을 갖추는 것이 전제입니다. 멱등성 자체는 위키백과의 멱등법칙 문서에서 다루는 일반 개념을 그대로 적용하면 됩니다.

세 방식을 정리하면 다음과 같습니다.

구분 MemoryJobStore SQLAlchemyJobStore DB 틱 루프
재시작 시 예약 유지 안 됨 됨 (단, 함수는 모듈 참조만) 됨 (틱 등록만 재실행)
밀린 작업 처리 사라짐 grace time 내에서만 실행 다음 틱에서 그대로 재확인
다중 프로세스 중복 실행 위험 해당 없음(단일 프로세스 전제) 있음, 별도 락 필요 status 컬럼으로 제어 가능
구현 난이도 매우 낮음 중간 낮음~중간

이 설계가 안 맞는 경우도 있습니다

틱 루프가 만능은 아닙니다. 초 단위보다 세밀한 정밀도가 필요한 스케줄이라면 틱 간격 자체가 오차 범위가 되어버립니다. 30초 간격 틱으로는 "정확히 9시 0분 0초에 실행"을 보장할 수 없고, 실제 실행 시각은 최대 30초까지 밀릴 수 있습니다.

예약 건수가 수만 건 이상으로 커지면 매 틱마다 run_at <= now 조건으로 테이블 전체를 훑는 비용도 무시할 수 없습니다. 이 경우 run_at 컬럼에 인덱스를 걸고, status = "pending" 조건을 함께 걸어 스캔 범위를 줄이는 정도의 최적화가 필요합니다. 그리고 워커 프로세스를 여러 개 띄우는 구조로 확장할 계획이라면, status 컬럼 업데이트만으로는 두 워커가 동시에 같은 행을 집어갈 수 있어서 SELECT ... FOR UPDATE SKIP LOCKED 같은 락 처리가 추가로 필요해집니다. 지금처럼 단일 노트북 서버에서 단일 프로세스로 돌리는 규모라면 이 문제는 당장 신경 쓰지 않아도 됩니다.

틱 주기는 얼마로 잡아야 하나요?

예약 발행처럼 "몇 분 정도 오차는 괜찮은" 작업이라면 30초~60초 간격이면 충분합니다. 틱 자체는 가벼운 쿼리 하나만 날리므로 이 정도 주기로는 DB 부하가 거의 늘지 않습니다. 반대로 결제 만료 처리처럼 정확한 타이밍이 중요한 작업이 섞여 있다면, 그 작업만 별도로 더 짧은 틱(5~10초)을 도는 잡으로 분리하는 편이 안전합니다.

정리하면, APScheduler 예약이 재시작마다 날아가는 문제는 잡스토어를 영속화하는 것으로도 어느 정도 막을 수 있지만, 재시작이 잦은 배포 환경에서는 "예약 자체를 DB 행으로 두고 APScheduler는 틱 하나만 담당"하는 구조가 더 단순하고 재시작에도 강합니다. 지금 서버에 SQLite나 파일 기반 DB가 이미 있다면, scheduled_posts 같은 테이블 하나 추가하고 틱 잡을 등록하는 데 30분이면 충분합니다. 먼저 기존 예약을 이 테이블로 옮기고, 그다음 misfire 처리가 실제로 얼마나 안전하게 도는지 로그로 며칠 지켜보시는 걸 추천드립니다.

Hugging Face 모델이 매번 다시 다운로드되는 이유와 캐시·오프라인 설정

참고: APScheduler 예약이 재시작마다 — 위키백과

Leave a Comment