Springadvanced검토 2026.08

Quartz Scheduler: Trigger, Cluster와 복구

Job과 Trigger를 영속화하고 실행 시각, Misfire, Cluster Recovery와 중복 실행을 관리한다.

#Quartz#scheduler#trigger#misfire#cluster

Overview

Quartz는 **무엇을 실행할지(JobDetail)**와 **언제 실행할지(Trigger)**를 분리하고, Scheduler가 실행 Thread를 배정하는 Application Scheduler다. RAMJobStore는 Process 내부에서 단순하게 동작하고, JDBC JobStore는 Schedule과 상태를 DB에 보존해 재시작과 Cluster 조정을 지원한다.

한 문장 설명: 실행할 업무와 시간표를 등록하면, Scheduler가 시간이 된 Trigger를 한 실행 Node에 배정하는 Framework다.

핵심 용어

용어역할
Jobexecute(JobExecutionContext)를 구현한 실행 Logic
JobDetailJob Class, JobKey, JobDataMap을 담은 업무 정의
Trigger실행 시점과 반복 규칙, JobDetail 연결
SimpleTrigger특정 시각부터 고정 간격·횟수로 실행
CronTriggerCron 표현식과 TimeZone으로 달력 기반 실행
SchedulerTrigger를 획득하고 Thread Pool에 Job 실행 배정
JobStoreJobDetail, Trigger, Calendar와 실행 상태 저장
JDBC JobStoreQuartz Table과 DB Lock을 사용하는 영속 저장소
Misfire예정 Fire Time을 놓친 Trigger의 상태와 후속 정책
Recovery실행 중 Node 장애 후 복구 요청된 Job을 다시 실행하는 Mechanism

왜 필요한가

@Scheduled는 고정된 단순 주기 작업에 적합하지만 사용자가 만든 동적 예약, 미래 특정 시각 실행, 영속 Schedule, Pause/Resume, 여러 Node 조정이 필요하면 Quartz가 유리하다. 다만 “시간이 됐을 때 실행 요청”을 관리할 뿐 Business Exactly Once나 대량 데이터 Checkpoint까지 대신하지 않는다.

실행 흐름

flowchart LR; A[JobDetail] --> S[(JobStore)]; T[SimpleTrigger CronTrigger] --> S; S --> Q[Scheduler Thread]; Q --> L[Acquire Trigger + DB Lock]; L --> P[Worker Thread Pool]; P --> J[Job.execute]; J --> R[Result / Next Fire Time]
  1. Application이 고유한 JobKey와 TriggerKey로 JobDetail·Trigger를 등록한다.
  2. Scheduler Thread가 Misfire를 검사하고 실행 시각이 된 Trigger 후보를 찾는다.
  3. JDBC JobStore에서는 DB Transaction과 Lock으로 Trigger를 한 Node가 획득한다.
  4. Worker Thread가 Job Instance를 만들고 execute를 호출한다.
  5. 완료 후 Trigger 상태와 다음 Fire Time을 갱신한다.
  6. Job이 복구 요청 대상이면 장애 Instance 감지 후 다른 Node에서 Recovery 실행될 수 있다.

Job, JobDetail과 JobDataMap

Job Class는 실행 Logic이고 JobDetail은 그 Logic의 등록 정보다. 같은 Job Class라도 JobKey와 Data가 다른 여러 JobDetail을 만들 수 있다. JobDataMap에는 작은 식별자와 설정만 저장하고 큰 Payload·민감정보·직렬화에 취약한 복잡 객체를 넣지 않는다. 실행 시 최신 Business 데이터는 ID로 다시 조회하는 편이 Schema 변경과 보안에 안전하다.

SimpleTrigger와 CronTrigger

SimpleTrigger는 “10분 뒤 한 번”, “지금부터 5분마다 10회”처럼 간격 기반 실행에 적합하다. CronTrigger는 “매일 02:00”, “평일 오전” 같은 달력 규칙에 적합하다. Cron은 TimeZone과 DST 영향을 받으므로 서버 기본 TimeZone에 맡기지 않고 Business TimeZone을 명시한다.

Cron 표현식만 보고 “정확히 그 초에 반드시 실행”된다고 생각하면 안 된다. Thread Pool 포화, DB 장애, Application 중단으로 늦을 수 있고 그때 Misfire 정책이 적용된다.

RAMJobStore와 JDBC JobStore

구분RAMJobStoreJDBC JobStore
재시작 후 Schedule소실DB에서 복원
Cluster부적합동일 Table 공유로 조정
운영 복잡도낮음Schema, Lock, Pool, 정리 필요
성능Memory 접근DB 왕복·경합
적합한 경우임시·개발·재등록 가능동적 영속 예약·Cluster

Notion 실무 기록에는 자동 Schema 초기화 Script가 재시작 때 Table을 Drop해 Trigger가 사라진 문제가 있다. 운영에서는 Quartz Schema를 Migration으로 한 번 관리하고 initialize-schema=always 같은 파괴적 설정을 피한다. Application Schema와 분리한 DataSource를 쓸 때 Pool 한도와 Transaction 설정도 관리한다.

Misfire

Misfire는 Trigger가 실행돼야 했던 시각을 Scheduler 중단·Thread 부족·DB 지연 등으로 놓친 상태다. 정책은 업무 의미에 따라 다르다.

  • 즉시 한 번 실행: 놓친 정산을 가능한 빨리 보완해야 할 때
  • 다음 정상 시각으로 이동: 오래된 알림을 몰아서 보내면 안 될 때
  • 놓친 횟수 보전: 정말 각 회차가 의미 있을 때만 신중하게 검토

Misfire Threshold보다 짧은 지연은 Misfire로 판단되지 않을 수 있다. 모든 Trigger에 Smart Policy를 무심코 맡기지 말고 업무별로 “놓치면 보충할까, 버릴까?”를 정한다.

Recovery와 Exactly Once

Recovery는 실행 중 Scheduler Node가 죽었을 때 Cluster가 Check-in 실패를 감지하고 requestRecovery(true)인 Job을 다시 실행할 수 있는 Mechanism이다. Misfire가 시각을 놓친 문제라면 Recovery는 실행 중 Node가 죽은 문제다.

Recovery는 Exactly Once를 보장하지 않는다. 외부 API 성공 후 Quartz 완료 상태 기록 전에 Process가 죽으면 다른 Node가 다시 호출할 수 있다. JobExecutionContext의 isRecovering()으로 복구 실행인지 알 수 있어도 Side Effect 중복을 자동 제거하지 않는다. Business ID, 처리 Table, Unique Constraint, Idempotency Key를 사용한다.

Cluster와 Trigger 획득

Quartz Cluster는 여러 Scheduler가 동일 JDBC JobStore와 Table Prefix를 공유하고 각 Instance가 Cluster Check-in을 기록한다. Trigger 획득은 DB Lock으로 조정되며 한 Trigger를 보통 한 Node가 실행하도록 한다. 다음 조건이 중요하다.

  • Instance ID가 Node마다 고유한가?
  • DB Clock과 Application Clock 차이가 허용 범위인가?
  • Check-in Interval과 장애 감지 시간이 업무 RTO에 맞는가?
  • Scheduler Thread Pool과 DB Connection Pool이 충분한가?
  • 모든 Node가 같은 Job Class와 호환되는 Version을 가지고 있는가?

Rolling Deployment 중 Job Class나 JobData 직렬화가 호환되지 않으면 어떤 Node가 Trigger를 얻는지에 따라 실패할 수 있다.

동시 실행 제어

Job 실행 시간이 Trigger 주기보다 길면 같은 JobDetail 실행이 겹칠 수 있다. @DisallowConcurrentExecution은 같은 JobKey의 동시 실행을 막는 데 사용한다. Job Class 전체나 다른 JobKey까지 전역 직렬화하는 의미는 아니다.

막힌 다음 Trigger는 지연 또는 Misfire가 될 수 있으므로 Annotation만 붙이고 끝내지 않는다. 긴 Job은 주기, Queueing, Timeout, 취소, 다음 실행 정책을 함께 설계한다. 여러 Business Key 중 같은 고객만 직렬화해야 한다면 Quartz 전역 직렬화보다 DB 불변식이나 Key 기반 조정이 더 적합할 수 있다.

Example

@DisallowConcurrentExecution
class BillingReminderJob implements Job {
    @Override
    public void execute(JobExecutionContext context) {
        String billingDate = context.getMergedJobDataMap().getString("billingDate");
        // billingDate를 idempotency key로 사용해 실제 업무 서비스 호출
    }
}
 
JobDetail job = newJob(BillingReminderJob.class)
    .withIdentity("billing-reminder", "billing")
    .usingJobData("billingDate", "2026-08-12")
    .requestRecovery(true)
    .storeDurably()
    .build();
 
CronTrigger trigger = newTrigger()
    .withIdentity("daily-billing", "billing")
    .forJob(job)
    .withSchedule(cronSchedule("0 0 2 * * ?")
        .inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
        .withMisfireHandlingInstructionDoNothing())
    .build();
 
scheduler.scheduleJob(job, trigger);

동적 예약 등록 API는 같은 Business 예약에 안정적인 JobKey/TriggerKey를 사용한다. UUID를 무조건 새로 만들면 중복 등록을 감지하지 못한다. 등록, 변경(rescheduleJob), 해제(unscheduleJob/deleteJob)의 멱등 규칙을 API에 포함한다.

Spring과 Job Instance 생성

Quartz가 Job 객체를 직접 만들면 일반 Spring Bean처럼 의존성이 자동 주입되지 않을 수 있다. SpringBeanJobFactory 또는 SchedulerFactoryBean 설정으로 Autowiring을 연결한다. 그래도 Job은 얇은 Adapter로 두고 실제 Business Logic은 Spring Service에 위임하면 Test와 Transaction 경계가 명확하다.

Spring Batch와 조합

Quartz는 Spring Batch Job 내부의 Reader/Writer를 직접 대신하지 않는다. Trigger가 울리면 Adapter Job이 JobLauncher.run(batchJob, jobParameters)을 호출하는 구조가 일반적이다.

Quartz Trigger
  → Quartz Adapter Job
    → Spring Batch JobLauncher
      → JobInstance / JobExecution
        → Step / Chunk / JobRepository

Quartz JobKey와 Spring Batch JobParameters의 중복 의미를 맞춘다. 같은 Trigger가 Recovery로 다시 실행될 때 현재 시각을 새 Parameter로 넣으면 새 JobInstance가 생겨 중복 처리될 수 있다. Business Date나 Schedule ID를 식별 Parameter로 사용한다.

실무에서 발생하는 문제

  • 운영 기동 시 자동 DDL이 Quartz Table과 등록 Trigger를 삭제한다.
  • Thread Pool보다 긴 Job이 많아져 Fire Time을 놓치고 Misfire가 폭증한다.
  • Cluster Check-in 실패를 Application 장애로 오인하거나 Network 지연을 실제 장애로 판단한다.
  • Recovery Job이 외부 결제·Email을 중복 실행한다.
  • 무작위 JobKey로 동일 예약이 여러 개 등록된다.
  • @DisallowConcurrentExecution 범위를 전역 Lock으로 오해한다.
  • JobDataMap에 큰 객체·민감정보를 저장해 DB와 직렬화 문제를 만든다.
  • Application Connection Pool을 Quartz와 공유해 Business Query와 Scheduler가 서로 고갈시킨다.
  • 단순 Cron 작업까지 Application 내부 Quartz로 넣어 배포와 Schedule Lifecycle을 결합한다.

Trade-off와 흔한 오해

Quartz는 동적 영속 예약과 Application 통합에 강하지만 Scheduler DB와 Cluster 운영 비용이 생긴다. 단순한 고정 Cron은 Kubernetes CronJob, Cloud Scheduler, EventBridge 같은 Platform Scheduler가 더 단순할 수 있다. 장시간 대량 처리와 Checkpoint가 핵심이면 Spring Batch를 함께 사용한다.

  • Quartz Cluster가 업무 Exactly Once를 보장하지 않는다.
  • Misfire와 Recovery는 같은 개념이 아니다.
  • Cron은 실행 요청 시각이지 완료 시각 보장이 아니다.
  • @DisallowConcurrentExecution은 같은 JobKey 범위다.
  • JDBC JobStore를 쓰기만 하면 Application Version 호환 문제가 사라지지 않는다.
  • Scheduler Thread 수를 늘리면 DB·외부 API Capacity도 함께 늘어나는 것이 아니다.

Production Considerations

Trigger 예정 시각 대비 실제 시작 지연, Misfire 수, 현재 실행 수, 실행 시간, 실패·Recovery, Thread Pool Active/Queue, JDBC JobStore Query·Lock Wait, Connection Pool Waiting, Cluster Check-in을 관찰한다. JobKey·TriggerKey와 Business ID를 Log/Trace에 함께 남긴다. 운영자는 Pause/Resume, 재실행, 강제 종료, Trigger 변경, 장애 Node 복구 절차를 Runbook으로 가져야 한다.

다른 사람에게 설명한다면

30초: “Quartz는 JobDetail과 Trigger를 분리해 실행 시점을 관리하는 Scheduler입니다. JDBC JobStore를 공유하면 여러 Node가 Trigger 획득을 조정하고 장애 후 Recovery할 수 있지만 Exactly Once는 아니므로 Job은 멱등해야 합니다. Misfire는 실행 시각을 놓친 것이고 Recovery는 실행 중 Node 장애입니다.”

2분: JobDetail·SimpleTrigger/CronTrigger·Scheduler·JobStore 흐름을 설명하고, JDBC Cluster Check-in과 DB Lock, Misfire 정책, requestRecovery, @DisallowConcurrentExecution, Spring Batch JobLauncher 연동까지 연결한다.

Interview Questions / Follow-up Questions

  1. Job과 JobDetail, Trigger는 어떻게 다른가?
  2. SimpleTrigger와 CronTrigger는 언제 선택하는가?
  3. RAMJobStore와 JDBC JobStore의 Trade-off는?
  4. Misfire와 Recovery의 차이는?
  5. requestRecovery가 Exactly Once를 보장하지 않는 이유는?
  6. @DisallowConcurrentExecution의 정확한 범위는?
  7. Quartz Cluster가 Trigger를 어떻게 한 Node에 배정하는가?
  8. Scheduler Thread Pool이 포화되면 어떤 현상이 생기는가?
  9. JobDataMap에 Entity 전체를 저장하면 왜 위험한가?
  10. Quartz와 Spring Batch, Platform Cron을 어떻게 선택하는가?
  11. 재시작 시 Trigger가 사라지는 Schema 설정 문제를 어떻게 예방하는가?
  12. Quartz가 Batch Job을 재호출할 때 중복 JobInstance를 어떻게 막는가?

Spring Batch, 멱등성·중복·순서, Connection Pool을 함께 본다.

SOURCE REFERENCES

이 문서의 근거

본문은 Dev Atlas 안에서 완결되며, 검증이 필요할 때만 원문을 확인할 수 있습니다.

원문 출처 보기 3