Pingu
영차영차! Backend

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

2026년 3월 9일

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

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개입니다.

  1. Scheduler
    • 애플리케이션 코드가 직접 호출하는 API 진입점
  2. JobStore (JDBC JobStore)
    • Job/Trigger 상태를 DB(QRTZ_*)에 저장
  3. QuartzSchedulerThread
    • "지금 실행할 트리거"를 계속 획득하는 내부 루프
  4. ThreadPool
    • 획득한 트리거를 실제 worker 스레드에서 실행
  5. JobRunShell
    • Job 실행 전후 라이프사이클 처리

요약하면:

API 호출 -> DB 저장 -> 스케줄러 루프가 트리거 획득 -> 스레드에서 Job 실행

scheduleJob 내부 흐름

scheduleJob() 호출 시 Quartz가 하는 일은 생각보다 단순합니다.

  1. JobDetail 유효성 검사
  2. Trigger 유효성 검사
  3. JobStore 트랜잭션 시작
  4. QRTZ_JOB_DETAILS, QRTZ_TRIGGERS, QRTZ_SIMPLE_TRIGGERS/QRTZ_CRON_TRIGGERS 저장
  5. 커밋 후 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_STATE
  • NEXT_FIRE_TIME
  • MISFIRE_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입니다.

  1. scheduleJob() 시점에 next fire time 계산/저장
  2. scheduler thread가 가장 가까운 시각까지 대기
  3. 도래 시점에 트리거 획득 + ACQUIRED 상태 전이
  4. 실행 후 다음 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입니다.
이 스레드는 다음 루프를 반복합니다.

  1. 현재 시각 기준으로 실행 가능한 트리거 조회
  2. 획득(acquire)된 트리거를 ACQUIRED 상태로 마킹
  3. 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 디버깅은 보통 다음 순서로 보면 빨라집니다.

  1. QuartzSchedulerThread가 획득했는지
  2. JobStoreSupport에서 상태 전이가 성공했는지
  3. JobRunShell에서 execute 이후 어떤 instruction이 적용됐는지

Job 실행 경로: JobRunShell -> JobFactory -> execute

트리거가 획득되면 Quartz는 JobRunShell을 통해 Job을 실행합니다.

실행 순서:

  1. JobFactory로 Job 인스턴스 생성
  2. JobDataMap 병합
  3. job.execute(context) 호출
  4. 결과에 따라 재스케줄/완료 처리

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가 동시에 떠 있어도:

  1. 둘 다 같은 트리거를 보더라도
  2. 실제 획득 단계에서 하나만 ACQUIRED로 상태 전이
  3. 다른 노드는 획득 실패 후 다음 루프에서 다른 트리거 처리

여기에 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의 동적 스케줄링은 내부적으로 아래 시퀀스입니다.

  1. TaskService/TaskTopicRecommendService가 상태 전환
  2. QuartzAdapter에서 기존 Job 삭제 + 새 Trigger 등록
  3. JDBC JobStore가 QRTZ_*에 반영
  4. 시점 도래 시 TaskDeadlineJob/TaskTeacherRemindJob/TaskDueAtRemindJob 실행
  5. WebPushNotificationPublisher가 Pub/Sub 토픽에 발행
  6. notification-service가 최종 채널 발송

즉, "동적 스케줄링"은 코드 한 줄이 아니라:

상태 전환 규칙 + JobStore 영속화 + 획득 락 + 실행 조건 검증
이 네 가지가 함께 맞물려야 성립합니다.

운영하면서 꼭 보는 체크 포인트

  1. 트리거 상태 확인
    • QRTZ_TRIGGERS.TRIGGER_STATE, NEXT_FIRE_TIME
  2. 클러스터 헬스
    • 노드 check-in 정상 여부
  3. Job 실행 로그 상관관계
    • taskId/jobKey 기준으로 등록-실행-발행 추적
  4. misfire 지표
    • 비정상 증가 시 DB/스레드풀/인프라 병목 점검

마무리

Quartz의 동적 스케줄링은 마법이 아닙니다.
핵심은 "실행 계획을 DB에 저장하고, 클러스터 락으로 안전하게 획득해, 시점에 맞춰 실행한다"는 단단한 기본기에 있습니다.

Spring에서 Quartz를 쓸 때는 API만 보지 말고, 최소한 아래 3가지는 꼭 이해하고 가면 운영이 쉬워집니다.

  1. scheduleJob/rescheduleJob/deleteJob이 DB 상태를 어떻게 바꾸는지
  2. QuartzSchedulerThread가 트리거를 어떻게 획득하는지
  3. Cluster + misfire 정책이 장애 상황에서 어떻게 동작하는지

다음에는 실제 QRTZ_* 테이블을 직접 조회하면서, 각 상태 변화가 어떤 SQL로 반영되는지까지 이어서 정리해보겠습니다.

댓글

?