포스트

Quartz Scheduler — Job·Trigger·State·Cluster 구조 이해하기

Quartz를 Job 실행 로직, Trigger 실행 시점, JobStore 상태 보존, Cluster 조율의 네 축으로 나눠 보고 Cron, Misfire, JDBCJobStore, @DisallowConcurrentExecution까지 연결해 이해한다.

Quartz Scheduler — Job·Trigger·State·Cluster 구조 이해하기

Quartz를 처음 보면 Job, JobDetail, Trigger, Scheduler, JobStore가 한꺼번에 등장해 역할이 섞이기 쉽다. 큰 그림은 네 층으로 나누면 단순하다.

1
2
3
4
5
6
7
8
9
10
11
무엇을 실행할까?
→ Job / JobDetail

언제 실행할까?
→ Trigger / Cron

실행 상태를 어디에 보존할까?
→ RAMJobStore / JDBCJobStore

여러 Scheduler가 어떻게 한 번만 실행하게 할까?
→ Cluster / Lock

즉 Quartz의 본질은 실행 로직과 실행 시점을 분리하고, 필요하면 그 스케줄 상태를 영속화·공유하는 Scheduler다.

전체 실행 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Job
└─ 실제 실행 코드

JobDetail
└─ Job의 식별자·설정·JobDataMap

Trigger
└─ 언제 Job을 fire할지 결정

Scheduler
└─ JobDetail + Trigger 등록·실행 관리

JobStore
└─ Job·Trigger·실행 상태 저장

실행 시점에는 다음처럼 이어진다.

1
2
3
4
5
6
7
8
9
Trigger fire 시각 도달
        ↓
Scheduler가 실행 가능 여부 판단
        ↓
JobDetail에서 Job 정보 조회
        ↓
Job 인스턴스 실행
        ↓
실행 상태 갱신

1. Job과 Trigger를 분리해서 본다

Job무엇을 할지, Trigger언제 할지를 담당한다.

1
2
3
4
5
6
7
8
public class QueryJob implements Job {
    @Override
    public void execute(JobExecutionContext context)
            throws JobExecutionException {
        String query = context.getMergedJobDataMap().getString("query");
        System.out.println("Executing query: " + query);
    }
}

JobDetail은 Job Class 자체가 아니라 Quartz가 관리할 실행 정의다.

1
2
3
4
JobDetail jobDetail = JobBuilder.newJob(QueryJob.class)
    .withIdentity("myJob", "group1")
    .usingJobData("query", "SELECT * FROM users")
    .build();

Trigger는 이 Job이 fire될 시간을 정의한다.

1
2
3
4
5
6
7
Trigger trigger = TriggerBuilder.newTrigger()
    .forJob(jobDetail)
    .withIdentity("myTrigger", "group1")
    .withSchedule(
        CronScheduleBuilder.cronSchedule("0 0/5 * * * ?")
    )
    .build();

마지막으로 Scheduler가 둘을 등록한다.

1
2
3
Scheduler scheduler = new StdSchedulerFactory().getScheduler();
scheduler.start();
scheduler.scheduleJob(jobDetail, trigger);

한 Job에 여러 Trigger를 연결할 수 있으므로 실행 로직을 복제하지 않고 “평일 오전 9시”와 “월말 23시”처럼 여러 스케줄을 붙일 수 있다.

1
2
3
4
Job A
├─ Trigger 1
├─ Trigger 2
└─ Trigger 3

2. Job을 멈추는 것과 Trigger를 멈추는 것은 다르다

Quartz 운영에서 중요한 경계다.

제어 대상의미
Trigger Pause특정 실행 시점만 멈춤
Job Pause해당 Job에 연결된 Trigger 전체 실행을 멈춤
Trigger Delete특정 스케줄 정의 제거
Job DeleteJob 정의와 연결 Trigger 제거

따라서 “오전 배치만 잠시 막고 저녁 배치는 살린다”면 Trigger 단위 제어가 맞고, 해당 업무 전체를 중지하려면 Job 단위 제어가 맞다.

3. RAMJobStore와 JDBCJobStore — 상태를 어디에 둘까

Quartz는 Job과 Trigger 상태를 어디에 저장할지 선택할 수 있다.

1
2
3
4
5
6
7
8
9
RAMJobStore
→ Memory
→ 프로세스 종료 시 상태 소실
→ 단일 인스턴스·개발 환경

JDBCJobStore
→ Database
→ 재시작 후에도 상태 유지
→ 운영·Cluster

RAMJobStore

  • 설정이 단순하고 빠르다.
  • Scheduler가 재시작되면 Job·Trigger 등록 상태가 사라진다.
  • 여러 노드가 상태를 공유할 수 없다.

JDBCJobStore

  • Job·Trigger·실행 상태를 DB에 저장한다.
  • 재시작 후 스케줄을 이어갈 수 있다.
  • 여러 Scheduler 인스턴스가 같은 DB를 보며 Cluster를 구성할 수 있다.

즉 Cluster가 필요하면 JDBCJobStore가 전제다.

4. 여러 노드에서는 누가 실행할지 조율해야 한다

Application을 두 대 띄우고 두 인스턴스 모두 Quartz Scheduler를 실행하면, 아무 조율이 없을 경우 같은 스케줄을 두 노드가 각각 실행할 수 있다.

Quartz Cluster는 공유 JDBCJobStore를 사용해 특정 Trigger를 어느 한 Scheduler만 획득하도록 조율한다.

1
2
3
4
5
6
7
Node A ─┐
        ├─ Shared Quartz DB
Node B ─┘
          ↓
     Trigger 획득 경쟁
          ↓
      한 노드만 실행

대표 설정은 다음과 같다.

1
2
3
org.quartz.jobStore.isClustered=true
org.quartz.scheduler.instanceId=AUTO
org.quartz.jobStore.clusterCheckinInterval=15000

핵심은 다음 세 가지다.

  • 모든 노드가 같은 Quartz DB를 본다.
  • instanceId는 노드마다 고유해야 한다.
  • 시스템 시계가 크게 어긋나지 않도록 NTP 등으로 맞춘다.

Quartz는 QRTZ_LOCKS, Trigger 상태, Scheduler Check-in 정보를 이용해 실행권을 조율한다.

5. 중복 실행 방지와 Cluster는 같은 문제가 아니다

Cluster는 한 Trigger Fire를 여러 노드가 동시에 가져가지 않도록 조율한다. 하지만 같은 Job의 이전 실행이 아직 끝나지 않았는데 다음 Trigger가 fire되는 문제는 별도다.

예를 들어 5분마다 실행되는 Job이 10분 걸리면:

1
2
3
10:00 실행 시작
10:05 다음 Trigger 도착
10:10 또 다음 Trigger 도착

이때 같은 JobDetail의 동시 실행을 막고 싶다면 Job Class에 @DisallowConcurrentExecution을 사용할 수 있다.

1
2
3
4
@DisallowConcurrentExecution
public class SettlementJob implements Job {
    // ...
}
1
2
3
4
5
Cluster
→ 여러 Scheduler 중 누가 Trigger를 실행할지

@DisallowConcurrentExecution
→ 같은 JobDetail 실행끼리 겹쳐도 되는지

둘은 다른 축이다.

6. Misfire — 실행 시각을 놓쳤을 때 무엇을 할까

Trigger가 예정 시각에 실행되지 못할 수 있다.

원인은 다양하다.

  • 서버가 내려가 있었음
  • Scheduler Thread Pool이 포화됨
  • DB 응답이 늦음
  • 이전 Job이 길게 실행 중
  • @DisallowConcurrentExecution으로 다음 실행이 대기함

Quartz는 일정 임계시간 이상 늦어진 Trigger를 Misfire로 판단하고, 놓친 실행을 어떻게 처리할지 정책을 적용한다.

Cron Trigger에서 자주 쓰는 선택은 다음과 같다.

정책동작
FireAndProceed놓친 실행을 1회 즉시 수행 후 정상 스케줄 복귀
DoNothing놓친 실행은 버리고 다음 정상 시각 대기
IgnoreMisfires가능한 놓친 실행을 따라잡도록 처리
1
2
CronScheduleBuilder.cronSchedule("0 0/5 * * * ?")
    .withMisfireHandlingInstructionFireAndProceed();

어떤 정책을 택할지는 업무 의미로 결정한다.

1
2
3
4
5
정산·회계 집계
→ 놓친 실행을 반드시 채워야 하나?

알림·주기적 Polling
→ 과거 실행을 몰아서 해도 의미가 있나?

즉 Misfire는 기술 설정이 아니라 놓친 시간을 업무적으로 어떻게 해석할지의 문제다.

7. Quartz DB 테이블은 네 그룹으로 보면 된다

JDBCJobStore의 표준 테이블을 이름 그대로 외울 필요는 없다. 역할별로 묶으면 이해하기 쉽다.

Job 정의

  • QRTZ_JOB_DETAILS

Trigger 정의

  • QRTZ_TRIGGERS
  • QRTZ_CRON_TRIGGERS
  • QRTZ_SIMPLE_TRIGGERS
  • QRTZ_SIMPROP_TRIGGERS
  • QRTZ_BLOB_TRIGGERS
  • QRTZ_CALENDARS

실행 상태

  • QRTZ_FIRED_TRIGGERS
  • QRTZ_PAUSED_TRIGGER_GRPS

Cluster 조율

  • QRTZ_SCHEDULER_STATE
  • QRTZ_LOCKS

이렇게 보면 DB Schema가 Quartz 내부 구조를 그대로 반영한다.

1
2
3
4
5
Job / Trigger 정의
        ↓
실행 상태
        ↓
Cluster Coordination

8. Trigger State는 내부 실행 상태다

Quartz API에서 Trigger 상태를 조회하면 NORMAL, PAUSED, BLOCKED, COMPLETE, ERROR, NONE 같은 상태를 볼 수 있다.

특히 BLOCKED@DisallowConcurrentExecution이 걸린 Job의 다른 실행이 진행 중이라 기다리는 상황에서 나타날 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
NORMAL
→ 실행 가능

PAUSED
→ 운영자가 일시 중지

BLOCKED
→ 동시 실행 제한 때문에 대기

COMPLETE
→ 더 이상 Fire Time이 없음

ERROR
→ Trigger 처리 오류

이 상태는 업무 Job 자체의 성공/실패 상태와 동일한 개념은 아니다. Scheduler가 Trigger를 현재 어떻게 다루고 있는지를 나타낸다.

Quartz와 Spring Batch의 경계

둘은 자주 같이 언급되지만 역할이 다르다.

1
2
3
4
5
6
7
Quartz
→ 언제 실행할까?
→ Scheduler

Spring Batch
→ 대량 작업을 어떻게 단계·상태·재시작 단위로 처리할까?
→ Batch Processing Framework

따라서 복잡한 대량 정산 Job은 Spring Batch로 구현하고, 실행 시점은 Quartz가 Trigger하는 조합도 가능하다.

반대로 단순히 “5분마다 API 호출” 정도라면 Spring Batch 없이 Quartz나 Spring Scheduling만으로 충분할 수 있다.

정리

Quartz를 구성요소 이름으로 외우지 말고 네 질문으로 보면 된다.

1
2
3
4
5
6
7
8
9
10
11
무엇을 실행?
→ Job / JobDetail

언제 실행?
→ Trigger

상태를 어디에 보존?
→ JobStore

여러 노드는 어떻게 조율?
→ JDBCJobStore + Cluster

그 위에 운영 정책이 얹힌다.

1
2
3
4
5
6
7
8
실행이 겹쳐도 되나?
→ @DisallowConcurrentExecution

놓친 실행을 어떻게 할까?
→ Misfire Policy

특정 스케줄만 멈출까?
→ Trigger 제어

이 구조를 잡으면 Quartz의 Cron, DB Schema, Cluster 설정은 서로 떨어진 기능이 아니라 하나의 Scheduler 상태 모델로 연결된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.