Spring Quartz 내부 구현으로 이해하는 동적 스케줄링
Spring Quartz 내부 구현으로 이해하는 동적 스케줄링

quartz
들어가며
이전 글에서는 supporters-service에 Quartz를 적용해 예약 알림을 구현한 과정을 다뤘습니다.
이번에는 한 단계 더 들어가서, Quartz가 내부적으로 어떻게 동적 스케줄링을 처리하는지를 봅니다.
실무에서 가장 자주 하는 질문은 보통 이렇습니다.
scheduleJob()을 호출하면 내부에서 정확히 무슨 일이 일어나는가?- 이미 등록된 스케줄을
rescheduleJob()으로 바꾸면 어떤 데이터가 수정되는가? - 여러 인스턴스가 떠 있어도 왜 같은 Job이 중복 실행되지 않는가?
- 서버가 잠시 멈췄다가 살아나면 놓친 트리거는 어떻게 처리되는가?
이 글은 위 질문에 답하는 것이 목표입니다.
동적 스케줄링이란 무엇인가
Quartz에서 동적 스케줄링은 "애플리케이션 실행 중(runtime)에 스케줄을 등록/변경/취소하는 것"입니다.
- 등록:
scheduler.scheduleJob(jobDetail, trigger) - 변경:
scheduler.rescheduleJob(triggerKey, newTrigger) - 취소:
scheduler.unscheduleJob(triggerKey)또는scheduler.deleteJob(jobKey)
정적 크론 설정과 달리, 사용자 행동이나 도메인 상태 변화에 따라 스케줄이 계속 변합니다.
예를 들어 supporters-service에서는:
- 과제 상태가 바뀌면 기존 알림 Job 취소
- 새로운 deadline 기준으로 Job 재등록
- 완료/환불/재배정 시 관련 스케줄 정리
이게 곧 "동적 스케줄링"입니다.
큰 그림: Quartz 내부 컴포넌트
동적 스케줄링의 핵심 컴포넌트는 5개입니다.
Scheduler- 애플리케이션 코드가 직접 호출하는 API 진입점
JobStore(JDBC JobStore)- Job/Trigger 상태를 DB(
QRTZ_*)에 저장
- Job/Trigger 상태를 DB(
QuartzSchedulerThread- "지금 실행할 트리거"를 계속 획득하는 내부 루프
ThreadPool- 획득한 트리거를 실제 worker 스레드에서 실행
JobRunShell- Job 실행 전후 라이프사이클 처리
요약하면:
API 호출 -> DB 저장 -> 스케줄러 루프가 트리거 획득 -> 스레드에서 Job 실행
scheduleJob 내부 흐름
scheduleJob() 호출 시 Quartz가 하는 일은 생각보다 단순합니다.
JobDetail유효성 검사Trigger유효성 검사- JobStore 트랜잭션 시작
QRTZ_JOB_DETAILS,QRTZ_TRIGGERS,QRTZ_SIMPLE_TRIGGERS/QRTZ_CRON_TRIGGERS저장- 커밋 후 scheduler thread 깨우기(signal)
개념적으로는 이런 의사 코드와 비슷합니다.
Date scheduleJob(JobDetail jobDetail, Trigger trigger) { validate(jobDetail, trigger); jobStore.storeJobAndTrigger(jobDetail, trigger); notifySchedulerThread(trigger.getNextFireTime()); return trigger.getFirstFireTime(); }
핵심 포인트는 "바로 실행"이 아니라 "실행 계획을 저장"한다는 점입니다.
DB에는 무엇이 저장되나 (QRTZ_*)
JDBC JobStore를 쓰면 동적 스케줄링 상태는 DB가 진실 소스(source of truth)가 됩니다.
주요 테이블 역할:
QRTZ_JOB_DETAILS: Job 클래스/데이터QRTZ_TRIGGERS: 공통 트리거 메타데이터(상태, next_fire_time 등)QRTZ_SIMPLE_TRIGGERS: SimpleTrigger 상세값QRTZ_CRON_TRIGGERS: CronTrigger 상세값QRTZ_FIRED_TRIGGERS: 현재 실행 중인 트리거 추적QRTZ_LOCKS: 클러스터 노드 간 동시성 제어
실제 운영에서 "왜 실행이 안 됐지?"를 볼 때는 QRTZ_TRIGGERS의:
TRIGGER_STATENEXT_FIRE_TIMEMISFIRE_INSTR
이 3가지를 먼저 확인하면 디버깅이 빨라집니다.
Polling인가? Quartz 내부 루프인가?
여기서 자주 나오는 오해가 하나 있습니다.
"결국 DB를 조회한다면 polling 아닌가요?"
결론은 일반적인 애플리케이션 polling과는 다르다입니다.
차이점
- 애플리케이션 polling
- 짧은 주기로 계속 "실행할 것 있나?"를 반복 조회
- 주기를 짧게 하면 DB 부하가 급격히 증가
- Quartz 내부 루프
NEXT_FIRE_TIME기준으로 대기(sleep/wait)- 시각이 도래하거나 신호가 왔을 때
acquireNextTriggers수행 - 클러스터 락으로 획득된 트리거만 실행
즉, Quartz는 "1초마다 무조건 긁는 방식"이 아니라, 다음 실행 시각 중심으로 깨어나는 event-driven에 가까운 스케줄 루프입니다.
"그 시간에 실행해야 한다"를 어떻게 아나?
핵심은 QRTZ_TRIGGERS.NEXT_FIRE_TIME입니다.
scheduleJob()시점에 next fire time 계산/저장- scheduler thread가 가장 가까운 시각까지 대기
- 도래 시점에 트리거 획득 +
ACQUIRED상태 전이 - 실행 후 다음 fire time 재계산(또는 완료 처리)
지연이 발생한 경우에는 MISFIRE_INSTR 정책을 따라 실행/스킵/재정렬합니다.
운영 SQL: "왜 실행이 안 됐지?" 바로 확인하기
아래 쿼리는 PostgreSQL 기준입니다.
(QRTZ_ 접두사는 설정에 따라 다를 수 있습니다.)
1) 트리거 핵심 상태 한 번에 보기
SELECT sched_name, trigger_group, trigger_name, job_group, job_name, trigger_state, to_timestamp(next_fire_time / 1000.0) AS next_fire_at, to_timestamp(prev_fire_time / 1000.0) AS prev_fire_at, misfire_instr, priority FROM qrtz_triggers ORDER BY next_fire_time NULLS LAST LIMIT 200;
2) 특정 Job의 트리거 상세 보기
SELECT t.sched_name, t.trigger_group, t.trigger_name, t.job_group, t.job_name, t.trigger_state, to_timestamp(t.next_fire_time / 1000.0) AS next_fire_at, to_timestamp(t.prev_fire_time / 1000.0) AS prev_fire_at, t.misfire_instr, st.repeat_count, st.repeat_interval, st.times_triggered, ct.cron_expression FROM qrtz_triggers t LEFT JOIN qrtz_simple_triggers st ON st.sched_name = t.sched_name AND st.trigger_name = t.trigger_name AND st.trigger_group = t.trigger_group LEFT JOIN qrtz_cron_triggers ct ON ct.sched_name = t.sched_name AND ct.trigger_name = t.trigger_name AND ct.trigger_group = t.trigger_group WHERE t.job_name = 'YOUR_JOB_NAME' AND t.job_group = 'YOUR_JOB_GROUP';
3) 현재 실행 중인 트리거 확인
SELECT sched_name, entry_id, instance_name, trigger_group, trigger_name, fired_time, sched_time, state FROM qrtz_fired_triggers ORDER BY fired_time DESC LIMIT 100;
4) 클러스터 락/노드 상태 확인
-- 락 키 확인 SELECT sched_name, lock_name FROM qrtz_locks ORDER BY lock_name; -- 살아있는 scheduler 인스턴스(체크인) SELECT sched_name, instance_name, to_timestamp(last_checkin_time / 1000.0) AS last_checkin_at, checkin_interval FROM qrtz_scheduler_state ORDER BY last_checkin_time DESC;
5) "지금 시각 기준 overdue인데 WAITING" 빠르게 찾기
SELECT trigger_group, trigger_name, job_group, job_name, trigger_state, to_timestamp(next_fire_time / 1000.0) AS next_fire_at, misfire_instr FROM qrtz_triggers WHERE trigger_state = 'WAITING' AND next_fire_time IS NOT NULL AND next_fire_time < (extract(epoch FROM now()) * 1000)::bigint ORDER BY next_fire_time ASC;
이 결과가 많이 나오면 misfire 처리 지연, 스레드풀 부족, DB 부하, 클러스터 check-in 이슈를 우선 의심해볼 수 있습니다.
QuartzSchedulerThread: 트리거 획득 루프
Quartz의 심장은 QuartzSchedulerThread입니다.
이 스레드는 다음 루프를 반복합니다.
- 현재 시각 기준으로 실행 가능한 트리거 조회
- 획득(acquire)된 트리거를
ACQUIRED상태로 마킹 - worker 스레드에 실행 위임
개념적으로는 다음과 같습니다.
while (!halted) { List<OperableTrigger> triggers = jobStore.acquireNextTriggers(now + idleWaitTime, batchSize, timeWindow); for (trigger : triggers) { threadPool.runInThread(new JobRunShell(trigger)); } }
acquireNextTriggers가 동시성 핵심 지점입니다.
클러스터에서는 이 구간이 락(QRTZ_LOCKS)과 함께 동작해 중복 실행을 막습니다.
내부 코드 관점으로 보면 (Quartz 핵심 클래스)
개념 설명만으로는 감이 안 올 수 있어서, Quartz 내부 코드를 읽을 때 핵심 포인트를 추려보면 아래 3개로 정리됩니다.
1) QuartzSchedulerThread.run(): 언제 깨어나고 무엇을 가져오는가
스케줄러 메인 루프는 대략 다음 구조입니다(의사 코드).
while (!halted.get()) { // pause 상태면 대기 if (paused) { sigLock.wait(); continue; } long now = System.currentTimeMillis(); List<OperableTrigger> triggers = jobStore.acquireNextTriggers(now + idleWaitTime, maxCount, batchTimeWindow); if (triggers.isEmpty()) { // 다음 시그널까지 sleep/wait sigLock.wait(idleWaitTime); continue; } for (OperableTrigger trigger : triggers) { threadPool.runInThread(new WorkerThread(trigger)); } }
핵심은 "무한 polling"이 아니라, 시그널/시각 기준 대기 후 획득입니다.
2) JobStoreSupport.acquireNextTriggers(...): 왜 한 노드만 잡는가
JDBC JobStore의 핵심은 DB 락 안에서 획득/상태전이를 처리하는 부분입니다.
return executeInNonManagedTXLock(LOCK_TRIGGER_ACCESS, () -> { List<TriggerKey> keys = selectTriggerToAcquire(noLaterThan, maxCount, timeWindow); for (TriggerKey key : keys) { OperableTrigger t = retrieveTrigger(key); if (t == null) continue; if (applyMisfire(t)) continue; // misfire 처리 후 다시 판단 // WAITING -> ACQUIRED 상태 전이 시도 int rows = updateTriggerStateFromOtherState( key, STATE_ACQUIRED, STATE_WAITING); if (rows <= 0) continue; // 이미 다른 노드가 선점 insertFiredTrigger(t, STATE_ACQUIRED); acquired.add(t); } return acquired; });
여기서 LOCK_TRIGGER_ACCESS + 상태 전이 조건(WAITING -> ACQUIRED)이
클러스터 중복 실행 방지의 핵심입니다.
3) JobRunShell.run(): execute 전후 라이프사이클
트리거를 획득한 뒤에는 JobRunShell이 실제 실행 파이프라인을 관리합니다.
Job job = jobFactory.newJob(bundle, scheduler); JobExecutionContextImpl ctx = new JobExecutionContextImpl(...); try { job.execute(ctx); // 성공 처리: trigger completion, next fire time 갱신 complete(success); } catch (JobExecutionException e) { // refire, unschedule, set-all-complete 등 정책 반영 complete(withInstruction(e)); }
그래서 Quartz 디버깅은 보통 다음 순서로 보면 빨라집니다.
QuartzSchedulerThread가 획득했는지JobStoreSupport에서 상태 전이가 성공했는지JobRunShell에서 execute 이후 어떤 instruction이 적용됐는지
Job 실행 경로: JobRunShell -> JobFactory -> execute
트리거가 획득되면 Quartz는 JobRunShell을 통해 Job을 실행합니다.
실행 순서:
JobFactory로 Job 인스턴스 생성JobDataMap병합job.execute(context)호출- 결과에 따라 재스케줄/완료 처리
Spring에서는 SpringBeanJobFactory를 쓰면 DI가 필요한 Job도 정상 주입됩니다.
val jobFactory = SpringBeanJobFactory() jobFactory.setApplicationContext(applicationContext) factory.setJobFactory(jobFactory)
이 설정이 없으면 Job 내부 의존성이 null이거나 생성 실패할 수 있습니다.
rescheduleJob / deleteJob은 내부에서 뭘 바꿀까
rescheduleJob
- 기존 트리거를 해제
- 새 트리거 레코드로 교체
NEXT_FIRE_TIME재계산- scheduler thread 재신호
도메인 관점으로 보면 "기존 예약 취소 + 새 예약 등록"의 원자적 처리에 가깝습니다.
deleteJob
- 해당 Job의 트리거를 함께 제거(기본적으로 연관 정리)
- DB에서 JobDetail 삭제
실무에서는 "update 전에 delete + schedule" 패턴이 이해하기 쉽고 안정적인 경우가 많습니다.
supporters-service의 Quartz adapter도 이 패턴을 사용합니다.
클러스터에서 중복 실행이 막히는 이유
핵심은 "트리거 획득 시점의 DB 락 + 상태 전이"입니다.
클러스터 노드 A/B가 동시에 떠 있어도:
- 둘 다 같은 트리거를 보더라도
- 실제 획득 단계에서 하나만
ACQUIRED로 상태 전이 - 다른 노드는 획득 실패 후 다음 루프에서 다른 트리거 처리
여기에 QRTZ_FIRED_TRIGGERS와 check-in 메커니즘이 더해져, 죽은 노드 복구 시 orphan 실행도 정리됩니다.
실무 설정에서 자주 쓰는 값:
spring.quartz.job-store-type=jdbc spring.quartz.properties.org.quartz.jobStore.isClustered=true spring.quartz.properties.org.quartz.jobStore.acquireTriggersWithinLock=true spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
Misfire: "놓친 트리거"를 어떻게 처리하나
Misfire는 "트리거가 예정 시각에 실행되지 못한 상태"입니다.
대표 원인은 서버 다운, 장시간 GC pause, DB 지연 등입니다.
Quartz는 misfire threshold를 기준으로 판정하고, 트리거별 정책에 따라 처리합니다.
- 바로 한 번 실행 후 다음 주기 정렬
- 누락된 실행을 건너뛰고 다음 주기로 이동
- SimpleTrigger/CronTrigger 정책에 따른 분기
즉, Quartz는 단순히 "늦었으니 버림"이 아니라, 정책 기반으로 복구 동작을 수행합니다.
supporters-service에 대입해서 보면
supporters-service의 동적 스케줄링은 내부적으로 아래 시퀀스입니다.
TaskService/TaskTopicRecommendService가 상태 전환- QuartzAdapter에서 기존 Job 삭제 + 새 Trigger 등록
- JDBC JobStore가
QRTZ_*에 반영 - 시점 도래 시
TaskDeadlineJob/TaskTeacherRemindJob/TaskDueAtRemindJob실행 WebPushNotificationPublisher가 Pub/Sub 토픽에 발행- notification-service가 최종 채널 발송
즉, "동적 스케줄링"은 코드 한 줄이 아니라:
상태 전환 규칙 + JobStore 영속화 + 획득 락 + 실행 조건 검증
이 네 가지가 함께 맞물려야 성립합니다.
운영하면서 꼭 보는 체크 포인트
- 트리거 상태 확인
QRTZ_TRIGGERS.TRIGGER_STATE,NEXT_FIRE_TIME
- 클러스터 헬스
- 노드 check-in 정상 여부
- Job 실행 로그 상관관계
taskId/jobKey기준으로 등록-실행-발행 추적
- misfire 지표
- 비정상 증가 시 DB/스레드풀/인프라 병목 점검
마무리
Quartz의 동적 스케줄링은 마법이 아닙니다.
핵심은 "실행 계획을 DB에 저장하고, 클러스터 락으로 안전하게 획득해, 시점에 맞춰 실행한다"는 단단한 기본기에 있습니다.
Spring에서 Quartz를 쓸 때는 API만 보지 말고, 최소한 아래 3가지는 꼭 이해하고 가면 운영이 쉬워집니다.
scheduleJob/rescheduleJob/deleteJob이 DB 상태를 어떻게 바꾸는지QuartzSchedulerThread가 트리거를 어떻게 획득하는지- Cluster + misfire 정책이 장애 상황에서 어떻게 동작하는지
다음에는 실제 QRTZ_* 테이블을 직접 조회하면서, 각 상태 변화가 어떤 SQL로 반영되는지까지 이어서 정리해보겠습니다.