초기 구현: APScheduler in FastAPI
처음에는 FastAPI 앱 내에 APScheduler를 내장해 APNs 알림 스케줄링을 처리했습니다.
# ❌ 초기 구현 — FastAPI startup에서 APScheduler 시작
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = AsyncIOScheduler()
scheduler.add_job(send_pending_notifications, "interval", minutes=1)
scheduler.start()
yield
scheduler.shutdown()
왜 분리했는가
세 가지 이유로 독립 서비스로 분리했습니다.
1. Kubernetes 수평 확장 문제
API 서버를 replica 2개로 확장하면 APScheduler가 두 인스턴스에서 동시에 실행됩니다. 같은 알림이 2번 전송되는 중복 실행 버그가 발생했습니다.
2. 메모리/CPU 격리
AI 브리핑 생성(OpenAI API 호출)이 스케줄러에 포함되면 API 서버의 응답 지연이 증가했습니다. 스케줄러를 별도 Pod로 분리해 리소스를 격리했습니다.
3. 독립 배포
알림 로직 버그 수정 시 API 서버 전체를 재배포할 필요 없이 스케줄러만 재배포할 수 있습니다.
독립 Scheduler 서비스 구조
# scheduler/main.py
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from scheduler.core.job_queue_processor import JobQueueProcessor
async def main():
processor = JobQueueProcessor()
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# 1분마다 pending job 처리
scheduler.add_job(
processor.process_pending_jobs,
"interval",
minutes=1,
max_instances=1 # 중복 실행 방지
)
# 매일 오전 6시 AI 브리핑 생성
scheduler.add_job(
processor.trigger_daily_briefings,
"cron",
hour=6, minute=0
)
scheduler.start()
print("[Scheduler] 시작됨")
try:
await asyncio.Event().wait()
finally:
scheduler.shutdown()
if __name__ == "__main__":
asyncio.run(main())
Kubernetes Deployment — replica 1 고정
# k8s/scheduler-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ivent-scheduler
spec:
replicas: 1 # 반드시 1 — 중복 실행 방지
selector:
matchLabels:
app: ivent-scheduler
template:
spec:
containers:
- name: scheduler
image: ivent-scheduler:latest
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: ivent-secrets
key: database-url
API 서버와 통신: DB Job Queue
API 서버는 DB에 Job을 INSERT하고, Scheduler는 주기적으로 DB를 폴링해 Job을 처리합니다. HTTP 직접 통신 대신 DB를 중간 버퍼로 사용해 느슨한 결합을 달성했습니다.
교훈
Kubernetes 환경에서 스케줄러는 반드시 독립 서비스로 분리하고 replica=1을 고정해야 합니다. APScheduler의 max_instances=1은 같은 프로세스 내 중복만 방지하고, 다중 Pod 중복은 막지 못합니다.