본문 바로가기
실습/리눅스 서버 + 스프링 부트

[Spring Boot] Quartz Scheduler

by 이민우 2025. 2. 20.
728x90
반응형

아래와 같은 요구사항이 있다고 가정해보자.

"5분에 한 번씩 A 로직이 실행됐으면 좋겠어요."


그러면 간단하게 @Scheduled를 사용하면 구현이 가능할 것이다.

 

@Scheduled(cron = "0 */5 * * * *") // 매 5분마다 실행
public void ALogic() {
	// A 로직
	log.info("@@ A Login Run");
}


위의 요구사항은 쉽다. "x분에 한 번" 이라는 요구사항이 명확하게 있으니까.

하지만 아래와 같은 요구사항이 있다고 가정하면 어떻게 될까?

"A 로직이 동적으로 실행됐으면 좋겠어요. 사용자가 1분에 한 번으로 다섯 개를 실행시킬 수도 있고, 10분에 한 개만 실행시킬 수도 있게요."


이러면 @Scheduled 만으로 구현이 까다로워질 것이다.


기본적으로 스케줄링을 DB에 등록해야하고, 이를 꺼내서 만약 조건에 맞다면 실행시켜야 하는 스케줄러가 1초에 한 번씩 돌아야 한다.

비단 이 문제 뿐 아니다.


사용자가 그렇게 많지 않아 단일 어플리케이션으로 만들어졌으면 상관이 없다.
하지만 만약 클러스터링 환경이면 어떻게 할 것인가?

다수의 사용자 Request를 받기 위해 같은 모듈 3개를 한 번에 띄웠다고 가정해보자.


@Scheduled는 클러스터링을 지원하지 않는다.
그렇기에 위 코드의 경우 5분마다 한 번씩 A 로직이 실행되는게 아니라, 각 모듈이 독립적으로 A 로직을 실행시켜 5분마다 세 번씩 실행되게 된다.

이 또한 @Scheduled를 사용해서 구현하면 꽤나 골치가 아플 것이다.
그러면 쉽게 하는 방법은 없을까?
없다면 포스팅을 하지도 않았을 것이다. 이러한 요구사항을 쉽게 구현하는 방법은 Quartz 라이브러리를 사용하는 것이다.

 


Quartz


Quartz는 강력한 스케줄링 기능을 제공하는 Java 라이브러리이다.
앞서 설명한 @Scheduled와는 아래의 차이점을 가진다.

  Quartz @Scheduled
트리거 방식 Cron, Simple, Calendar 트리거 Cron, Fixed Rate, Fixed Delay
분산 환경 지원 가능 (DB 저장 + 클러스터링 지원)  불가능
DB 연동 가능 (스케줄 상태 관리) 불가능
유연성 작업의 동적 등록, 수정, 삭제 가능 정적 선언 방식
작업 지속성 애플리케이션 재시작 후에도 유지 애플리케이션 종료 시 스케줄러 정보 소멸


  
앞서 한 설명을 보면 알 수 있듯, @Scheduled는 "간단한 스케줄링" 이나 "작은 프로젝트"에 적합하다.
하지만 간단하지 않고, 작지 않은 프로젝트에서는 아무래도 Quartz를 사용하는 것이 맞을 것이다.

Quartz를 사용함에 있어 얻을 수 있는 이점은 아래와 같다.

  1. DB에 스케줄 정보를 저장하여 서버가 재시작되어도 유지 가능
  2. 클러스터링을 지원하여 여러 노드에서 동일한 작업을 수행 가능
  3. 작업을 동적으로 추가/삭제/수정할 수 있음
  4. 다양한 트리거 옵션을 지원하여 세밀한 스케줄링이 가능

 

다만 당연히 이점만 있을 수는 없고, 아래와 같은 단점도 존재한다.

  1. 설정의 복잡성: 여러 설정 파일과 객체를 다루어야 하고, 트리거 및 잡 설정을 정교하게 다루어야 하기 때문에 간단한 작업을 처리하기엔 오히려 복잡할 수 있음
  2. 리소스 사용량: 내부적으로 데이터베이스와 연동하여 상태를 관리하기 때문에, 대규모 트리거나 작업 스케줄링이 빈번한 경우 데이터베이스 및 시스템 자원을 많이 소모할 수 있음
  3. 관리의 어려움: 클러스터링과 같은 기능을 활용할 때는 설정 및 관리가 까다로울 수 있음. 여러 서버 간의 동기화 문제나 스케줄 조정 등에서 관리 비용이 증가할 수 있음

 

그리고 Quartz에서는 다음의 용어가 자주 사용된다.

  • Scheduler : 전체 Quartz 스케줄링 시스템을 의미하며, Job과 Trigger를 관리
  • Job : 실행할 작업 (비즈니스 로직)
  • JobDetail : Job의 메타데이터 (Job 클래스, 이름, 그룹 등)
  • Trigger :  Job이 실행될 시점을 결정하는 스케줄러 (예: CronTrigger, SimpleTrigger)
  • JobStore :  Job과 Trigger 정보를 저장하는 저장소 (RAMJobStore, JDBC JobStore)
  • RAMJobStore :  메모리에 Job을 저장하는 방식 (애플리케이션 종료 시 데이터 소멸)
  • JDBC JobStore :  Job을 DB에 저장하여 애플리케이션 종료 후에도 유지 가능
  • Scheduler Instance :  Quartz 클러스터에서 실행 중인 개별 Quartz 인스턴스
  • Misfire :  Job이 예약된 시간에 실행되지 못했을 때 발생하는 상태
  • Listener :  특정 이벤트 (Job 실행, 완료 등)에 대한 처리를 담당하는 클래스


환경 설정


이제 얼추 개념을 알았으니 실습을 해보자.

Quartz가 DB에 스케줄링 데이터를 저장한다고 했는데, 이는 당연히 테이블이 있어야 하는 일이다.

*Ram에도 저장 가능하긴 하지만, 그러면 어플리케이션 종료 시 작업이 사라진다.

 

그리고 Quartz는 자동으로 테이블을 생성해주지 않기 때문에, 아래 테이블들을 수동으로 만들어줘야 한다.

* spring.quartz.jdbc.initialize-schema=ALWAYS 옵션을 application.properties에 넣으면 만들어진다는데, 이상하게 안된다.


현재를 기준으로 총 11개의 테이블이 있으며, 이 중 7개 테이블은 Quratz 동작에 필수이지만, 나머지 4개는 반드시 존재해야 하는 필수 테이블은 아니다.

* 필수 테이블은 이름 옆에 ★을 기재해놓겠다.

* 참고로 맨 앞의 QRTZ_는 구분을 위해 넣었을 뿐, 진짜 테이블명에 포함되는 것은 아니다.

QRTZ_JOB_DETAILS Job의 기본 정보 저장 (클래스명, 그룹, 실행 옵션 등)
QRTZ_TRIGGERS ★ Trigger의 기본 정보 저장 (다음 실행 시간, 상태, 우선순위 등)
QRTZ_CRON_TRIGGERS ★ Cron 기반의 Trigger 정보 저장
QRTZ_SIMPLE_TRIGGERS ★ SimpleTrigger (반복 실행) 정보 저장 (반복 횟수, 실행 간격 등)
QRTZ_BLOB_TRIGGERS  Blob 데이터로 저장된 트리거 정보
QRTZ_PAUSED_TRIGGER_GRPS  일시정지된 트리거 그룹 목록
QRTZ_FIRED_TRIGGERS ★ 현재 실행 중인 트리거 정보 (실행 시간, 상태 등)
QRTZ_SCHEDULER_STATE ★  클러스터 환경에서 Quartz 인스턴스 상태 저장
QRTZ_LOCKS ★  트랜잭션 동기화 및 동시 실행 방지
QRTZ_CALENDARS  Quartz 캘린더 정보 저장
QRTZ_SIMPROP_TRIGGERS  추가적인 심플 프로퍼티를 저장하는 테이블



모든 테이블을 생성하면 아래와 같은 테이블들이 생성될 것이다.

https://dbdiagram.io/d

 

 

실습에 사용할 DB에 아래 쿼리를 이용해 각 테이블을 생성해준다.
참고로 나는 MariaDB를 사용했다.

# Job의 기본 정보를 저장 (클래스명, 그룹, 실행 옵션 등)
CREATE TABLE QRTZ_JOB_DETAILS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	JOB_NAME VARCHAR(200) NOT NULL,
	JOB_GROUP VARCHAR(200) NOT NULL,
	DESCRIPTION VARCHAR(250) NULL,
	JOB_CLASS_NAME VARCHAR(250) NOT NULL,
	IS_DURABLE VARCHAR(1) NOT NULL,
	IS_NONCONCURRENT VARCHAR(1) NOT NULL,
	IS_UPDATE_DATA VARCHAR(1) NOT NULL,
	REQUESTS_RECOVERY VARCHAR(1) NOT NULL,
	JOB_DATA BLOB NULL,
	PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
);

# Trigger의 기본 정보를 저장 (다음 실행 시간, 상태, 우선순위 등)
CREATE TABLE QRTZ_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_NAME VARCHAR(200) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	JOB_NAME VARCHAR(200) NOT NULL,
	JOB_GROUP VARCHAR(200) NOT NULL,
	DESCRIPTION VARCHAR(250) NULL,
	NEXT_FIRE_TIME BIGINT NULL,
	PREV_FIRE_TIME BIGINT NULL,
	PRIORITY INTEGER NULL,
	TRIGGER_STATE VARCHAR(16) NOT NULL,
	TRIGGER_TYPE VARCHAR(8) NOT NULL,
	START_TIME BIGINT NOT NULL,
	END_TIME BIGINT NULL,
	CALENDAR_NAME VARCHAR(200) NULL,
	MISFIRE_INSTR SMALLINT NULL,
	JOB_DATA BLOB NULL,
	PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
	FOREIGN KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)
	REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME,JOB_NAME,JOB_GROUP)
);

# Cron 표현식을 기반으로 실행되는 Trigger 저장
CREATE TABLE QRTZ_CRON_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_NAME VARCHAR(200) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	CRON_EXPRESSION VARCHAR(200) NOT NULL,
	TIME_ZONE_ID VARCHAR(80),
	PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
	FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
	REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

# SimpleTrigger 정보를 저장 (반복 횟수, 실행 간격 등)
CREATE TABLE QRTZ_SIMPLE_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_NAME VARCHAR(200) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	REPEAT_COUNT BIGINT NOT NULL,
	REPEAT_INTERVAL BIGINT NOT NULL,
	TIMES_TRIGGERED BIGINT NOT NULL,
	PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
	FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
	REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

# Blob 데이터를 저장하는 트리거 테이블
CREATE TABLE QRTZ_BLOB_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_NAME VARCHAR(200) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	BLOB_DATA BLOB NULL,
	PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
	FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
	REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

# 일시 중지된 트리거 그룹 정보 저장
CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP)
);

# 현재 실행 중인 트리거 정보 저장 (실행 시간, 상태 등)
CREATE TABLE QRTZ_FIRED_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	ENTRY_ID VARCHAR(95) NOT NULL,
	TRIGGER_NAME VARCHAR(200) NOT NULL,
	TRIGGER_GROUP VARCHAR(200) NOT NULL,
	INSTANCE_NAME VARCHAR(200) NOT NULL,
	FIRED_TIME BIGINT NOT NULL,
	SCHED_TIME BIGINT NOT NULL,
	PRIORITY INTEGER NOT NULL,
	STATE VARCHAR(16) NOT NULL,
	JOB_NAME VARCHAR(190) NULL,
	JOB_GROUP VARCHAR(190) NULL,
	IS_NONCONCURRENT VARCHAR(1) NULL,
	REQUESTS_RECOVERY VARCHAR(1) NULL,
	PRIMARY KEY (SCHED_NAME, ENTRY_ID)
);

# 클러스터링된 Quartz 인스턴스의 상태 저장
CREATE TABLE QRTZ_SCHEDULER_STATE (
	SCHED_NAME VARCHAR(120) NOT NULL,
	INSTANCE_NAME VARCHAR(200) NOT NULL,
	LAST_CHECKIN_TIME BIGINT NOT NULL,
	CHECKIN_INTERVAL BIGINT NOT NULL,
	PRIMARY KEY (SCHED_NAME, INSTANCE_NAME)
);

# 트랜잭션 동기화 및 동시 실행 방지
CREATE TABLE QRTZ_LOCKS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	LOCK_NAME VARCHAR(40) NOT NULL,
	PRIMARY KEY (SCHED_NAME,LOCK_NAME)
);

# Quartz 캘린더 정보 저장
CREATE TABLE QRTZ_CALENDARS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	CALENDAR_NAME VARCHAR(190) NOT NULL,
	CALENDAR BLOB NOT NULL,
	PRIMARY KEY (SCHED_NAME,CALENDAR_NAME)
);

# 추가적인 심플 프로퍼티를 저장하는 테이블
CREATE TABLE QRTZ_SIMPROP_TRIGGERS (
	SCHED_NAME VARCHAR(120) NOT NULL,
	TRIGGER_NAME VARCHAR(190) NOT NULL,
	TRIGGER_GROUP VARCHAR(190) NOT NULL,
	STR_PROP_1 VARCHAR(512) NULL,
	STR_PROP_2 VARCHAR(512) NULL,
	STR_PROP_3 VARCHAR(512) NULL,
	INT_PROP_1 INT NULL,
	INT_PROP_2 INT NULL,
	LONG_PROP_1 BIGINT NULL,
	LONG_PROP_2 BIGINT NULL,
	DEC_PROP_1 NUMERIC(13,4) NULL,
	DEC_PROP_2 NUMERIC(13,4) NULL,
	BOOL_PROP_1 VARCHAR(1) NULL,
	BOOL_PROP_2 VARCHAR(1) NULL,
	PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
	FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
	REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);



DB에 테이블 생성이 끝났다면 프로젝트를 만들 차례이다. 아래 버전으로 프로젝트를 생성했다.

  • Spring 3.4.3
  • Java 23


프로젝트를 만들 때 Quartz를 포함하는 Dependency를 설정하자.
그리고 위 설명에서 볼 수 있듯, Quartz는 DB에 스케줄링 데이터를 저장하기에 저장될 DB의 드라이버도 추가해준다.

 

다음으로 위에서 추가되지 않은 Dependency를 추가한다. 자동으로 추가할 수 있는 목록에 존재하지 않아 해당 Dependency를 아래 URL에서 최신 버전으로 가져와 pom.xml에 수동으로 추가한다.

https://mvnrepository.com/artifact/c3p0/c3p0

 

나는 0.9.2 버전으로 추가해놓았다.

 

참고로 위 과정을 거치지 않으면 실행 시 아래 에러가 발생할 수 있으므로 반드시 추가해주자.

Caused by: org.quartz.SchedulerException: ConnectionProvider class 'org.quartz.utils.C3p0PoolingConnectionProvider' could not be instantiated.
at org.quartz.impl.StdSchedulerFactory.instantiate(StdSchedulerFactory.java:1044) ~[quartz-2.3.2.jar:na]
... 52 common frames omitted

 

이제 pom.xml을 저장하고 Spring이 자동으로 디펜던시를 추가해주었다면, 

application.properties 내에 간단한 설정을 추가한다.

 

별도로 Quartz.properties 파일을 만드는 방법도 있지만 그냥 application.properties에 전부 기재해버렸다.

# Spring Boot Quartz 설정
spring.quartz.job-store-type=jdbc
#spring.quartz.jdbc.initialize-schema=ALWAYS

# 기본 Quartz 설정
## 클러스터링을 위한 스케줄러 이름 설정
spring.quartz.properties.org.quartz.scheduler.instanceName=ClusteredScheduler
## 노드가 자동으로 고유한 인스턴스 ID를 가지도록 설정 (클러스터링 시 AUTO 권장)
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO

# Job Store 설정 (JDBC 사용하도록 설정)
## Job 저장 방식 (RAMJobStore 또는 JobStoreTX)
spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
## 사용할 DB 종류 지정 - 작업, 트리거 저장 및 관리를 위함.
## MariaDB를 사용하고 싶은데, MariaDB와 MYSql을 지원하지 않아 디폴트 값 사용.
# spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
## 클러스터링 활성화 여부
spring.quartz.properties.org.quartz.jobStore.isClustered=true
## 클러스터링 상태 체크 간격 (ms 단위 입력)
spring.quartz.properties.org.quartz.jobStore.clusterCheckinInterval=20000
## 지연된 Job 처리 기준 (ms)
spring.quartz.properties.org.quartz.jobStore.misfireThreshold=60000
## Quartz 테이블 접두사
spring.quartz.properties.org.quartz.jobStore.tablePrefix=QRTZ_

# 데이터 소스 설정
## 아래 dataSource 밑 이름 지정
spring.quartz.properties.org.quartz.jobStore.dataSource=DS
## dataSource 지정 (위에서 DS라고 붙였으니, DS 설정)
spring.quartz.properties.org.quartz.dataSource.DS.driver=org.mariadb.jdbc.Driver
spring.quartz.properties.org.quartz.dataSource.DS.URL=jdbc:mariadb://127.0.0.1:3306/testdb
spring.quartz.properties.org.quartz.dataSource.DS.user=root
spring.quartz.properties.org.quartz.dataSource.DS.password=0000
spring.quartz.properties.org.quartz.dataSource.DS.maxConnections=5

# Thread Pool 설정
## 사용할 스레드 풀 클래스
spring.quartz.properties.org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
## 사용할 스레드 개수
spring.quartz.properties.org.quartz.threadPool.threadCount=10

 

개발

 

이제 설정이 끝났으니 실제로 개발을 해보자.

 

우선 위에서 언급한 요구사항대로, 동적으로 스케줄러를 등록/삭제하는 로직을 만들 예정이다.

참고로 DB에 Insert된 스케줄링은 프로그램 실행 시 자동으로 메모리에 로드되며 스케줄링이 등록된다.

* 물론 무조건 불러오는 건 아니고, 아래 조건이 필요하다.
- 타겟 DB에 스케줄링 정보가 입력되어 있어야 함. (아래 서비스단 코드 참고)
- JDBC JobStore를 사용해야 함. (위 quartz.properties 참고)

하지만 이미 돌아가고 있는 프로그램이라면 단순히 DB에 Insert하는 것만으로는 스케줄링이 등록되지 않으며 등록하는 로직이 필요하다. (아래 서비스단 코드 참고)

 

우선 실제로 작업을 수행할 Job클래스를 개발하겠다.

만드는 방법은 간단한데, Job을 상속받은 후 비즈니스 로직을 execute 메서드 안에 기재하기만 하면 된다.

 

ALogicJob.java

package com.example.demo;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class ALogicJob implements Job {

	@Override
	public void execute(JobExecutionContext context) {
		// A 로직
		String jobName = context.getJobDetail().getKey().getName();
		log.info("@@ {} Runned A Login Run", jobName);
	}
}

 

이제 그 후 방금 생성한 AJob을 핸들링하며 Job을 등록/수정/삭제/조회하는 기능을 제공하는 서비스 클래스를 작성한다.

 

ALogicService.java

package com.example.demo;

import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;

import java.util.Set;

@Slf4j
@Service
public class ALogicService {

	@Autowired
	private Scheduler scheduler;

	private static final String jobGroupName = "a-logic-jobs";
	private static final String triggerGroupName = "a-logic-triggers";
	private static final String triggerSuffix = "-trigger";

	// Job 등록 메서드
	public void addJob(String jobName, String cron) throws SchedulerException {
		// 작업에 대한 기본 정보
		JobDetail jobDetail = 
				// 어떤 Job을 구동할지 결정
				JobBuilder.newJob(ALogicJob.class)
				// Job의 개별 이름과 그룹명 지정
				.withIdentity(jobName, jobGroupName)
				// 트리거가 없더라도 작업을 계속 저장할 것인가?
				// false일 경우 트리거가 없으면 작업을 자동으로 DB에서도 삭제함.
				// true일 경우 트리거가 없으면 작업을 DB에서는 삭제하지 않음. (추후 트리거 추가 혹은 수동으로 재설정 가능)
				.storeDurably(true)
				.build();

		Trigger trigger = 
				TriggerBuilder.newTrigger()
				// 트리거의 이름과 그룹을 지정
				.withIdentity(jobName + triggerSuffix, triggerGroupName)
				// 실행주기 설정
				// 일단 Cron으로 했는데, 만약 Cron 외 다른 걸 써야한다면 아래와 같이 쓰면 됨.
				/*
				* 간격 기반 : SimpleScheduleBuilder
				* 날짜 기반 : CalendarIntervalScheduleBuilder
				* 단일 실행 : withSchedule이 아닌 startAt 메서드에 SimpleTrigger 사용
				*/
				.withSchedule(CronScheduleBuilder.cronSchedule(cron))
				.build();
                
		// 아래 메서드 실행 시점에 DB에도 JobDetail과 Trigger가 자동으로 등록됨
		scheduler.scheduleJob(jobDetail, trigger);
		
		log.info("@@ {} Job Registerd.", jobName);
	}

	// Job 삭제 메서드
	public Boolean deleteJob(String jobName) throws SchedulerException {
		JobKey jobKey = new JobKey(jobName, jobGroupName);
		
		Boolean rslt = scheduler.deleteJob(jobKey);
		
		if(rslt) {
			// 삭제됨
			log.info("@@ {} Job Deleted.", jobName);
		}
		else {
			log.info("@@ {} Job Not Exists.", jobName);
		}
		
		return rslt;
	}

	// 트리거 삭제 메서드
	public Boolean deleteTrigger(String jobName) throws SchedulerException {
		// 트리거의 고유 키를 생성 (트리거 이름과 그룹명을 사용)
		TriggerKey triggerKey = new TriggerKey(jobName + triggerSuffix, triggerGroupName);
		// 트리거 삭제
		Boolean rslt = scheduler.unscheduleJob(triggerKey);

		if(rslt) {
			// 삭제됨
			log.info("@@ {} Job Uncheduled.", jobName);
		}
		else {
			log.info("@@ {} Job Not Exists.", jobName);
		}
		
		return rslt;
	}

	// Job 수정 메서드 (1) : PUT 방식
	public Boolean updateJobPut(String jobName, String newJobName, String cron) throws SchedulerException {
		// 기존 Job을 삭제
		Boolean rslt = deleteJob(jobName);
		
		if(!rslt) {
			// 존재하지 않는 JOB
			log.info("@@ {} Job Not Exists.", jobName);
			return rslt;
		}

		// 새로운 Cron 표현식을 가진 Job을 등록
		addJob(newJobName, cron);
		
		log.info("@@ {} Job Updated To {}.", jobName, newJobName);
		
		return rslt;
	}

	// Job 수정 메서드 (2) : Cron만 Patch 방식
	public Boolean updateJobPatch(String jobName, String cron) throws SchedulerException {
		// 트리거의 고유 키를 생성 (트리거 이름과 그룹명을 사용)
		TriggerKey triggerKey = new TriggerKey(jobName + triggerSuffix, triggerGroupName);

		// 기존 트리거를 찾고 삭제
		scheduler.unscheduleJob(triggerKey);

		JobKey key = new JobKey(jobName, jobGroupName);
		
		if(!scheduler.getJobKeys(GroupMatcher.jobGroupEquals(jobGroupName)).contains(key)) {
			// JOB이 존재하지 않음.
			log.info("@@ {} Job Not Exists.", jobName);
			return false;
		}
        
		// 새로운 cron 표현식을 가진 트리거 생성
		Trigger newTrigger = 
				TriggerBuilder.newTrigger()
				.withIdentity(jobName + triggerSuffix, triggerGroupName)
				.withSchedule(CronScheduleBuilder.cronSchedule(cron))
				// 기존에 등록된 JOB에 Trigger만 등록하는 것이므로, 어떤 Job에 등록하는 건지 지정
				.forJob(key)
				.build();

		// 새로운 트리거를 다시 스케줄에 등록
		scheduler.scheduleJob(newTrigger);
		
		log.info("@@ {} Job Updated.", jobName);
		
		return true;
	}

	// 등록된 모든 Job의 리스트 조회 메서드
	public Set<JobKey> getAllJobs() throws SchedulerException {
		// 그룹 내 JobKey를 조회
		return scheduler.getJobKeys(GroupMatcher.jobGroupEquals(jobGroupName));
	}
}

 

동적으로 생성/수정/삭제가 되는지 확인하기 위해 오늘은 Test 코드 대신 Controller를 짜고, 직접 Postman으로 API를 찔러보며 스케줄러가 동적으로 바뀌는지 확인한다.

 

ALogicController.java

package com.example.demo;

import org.quartz.JobKey;
import org.quartz.SchedulerException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

import java.util.Set;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/jobs")
public class ALogicController {

	@Autowired
	private ALogicService aLogicService;

	// Job 등록
	@PostMapping
	public ResponseEntity<String> addJob(@RequestBody Map<String, String> body) {
		String jobName = body.get("jobName");
		String cron = body.get("cron");
		
		if(jobName == null || "".equals(jobName) || cron == null || "".equals(cron)) {
			log.error("@@ Required Parameter Not Exist: {}", body);
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Required Parameter Not Exist: " + body);
		}
		
		try {
			aLogicService.addJob(jobName, cron);
			return ResponseEntity.ok("Job " + jobName + " Successfully Registered.");
		} catch (SchedulerException e) {
			log.error("@@ Error Registering Job {}: {}", jobName, e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed To Register Job: " + e.getMessage());
		}
	}

	// Job 삭제
	@DeleteMapping("/{jobName}")
	public ResponseEntity<String> deleteJob(@PathVariable String jobName) {
		try {
			if(!aLogicService.deleteJob(jobName)) {
				// 존재하지 않는 Job
				return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Not Exist Job Name");
			}
			return ResponseEntity.ok("Job " + jobName + " Successfully Deleted.");
		} catch (SchedulerException e) {
			log.error("@@ Error Deleting Job {}: {}", jobName, e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed To Delete Job: " + e.getMessage());
		}
	}

	// 트리거 삭제
	@DeleteMapping("/{jobName}/trigger")
	public ResponseEntity<String> deleteTrigger(@PathVariable String jobName) {
		try {
			if(!aLogicService.deleteTrigger(jobName)) {
				// 존재하지 않는 Job
				return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Not Exist Job Name");
			}
			return ResponseEntity.ok("Trigger For Job " + jobName + " Successfully Deleted.");
		} catch (SchedulerException e) {
			log.error("@@ Error Deleting Trigger for job {}: {}", jobName, e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed To Delete Trigger: " + e.getMessage());
		}
	}

	// Job 수정 (PUT 방식)
	@PutMapping("/{jobName}")
	public ResponseEntity<String> updateJobPut(@PathVariable String jobName, @RequestBody Map<String, String> body) {
		
		String newJobName = body.get("jobName");
		String cron = body.get("cron");
		
		if(cron == null || "".equals(cron)) {
			log.error("@@ Required Parameter Not Exist: {}", body);
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Required Parameter Not Exist: " + body);
		}
		
		try {
			if(!aLogicService.updateJobPut(jobName, newJobName, cron)) {
				// 존재하지 않는 Job
				return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Not Exist Job Name");
			}
			return ResponseEntity.ok("Job " + jobName + " Successfully Updated (PUT).");
		} catch (SchedulerException e) {
			log.error("@@ Error Updating Job {}: {}", jobName, e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed To Update Job: " + e.getMessage());
		}
	}

	// Job 수정 (PATCH 방식 - Cron만 수정)
	@PatchMapping("/{jobName}")
	public ResponseEntity<String> updateJobPatch(@PathVariable String jobName, @RequestBody Map<String, String> body) {
		
		String cron = body.get("cron");
		
		if(cron == null || "".equals(cron)) {
			log.error("@@ Required Parameter Not Exist: {}", body);
			return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Required Parameter Not Exist: " + body);
		}
		
		try {
			if(!aLogicService.updateJobPatch(jobName, cron)) {
				// 존재하지 않는 Job
				return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Not Exist Job Name");
			}
			return ResponseEntity.ok("Job " + jobName + " Successfully Updated (PATCH).");
		} catch (SchedulerException e) {
			log.error("@@ Error Updating Job {}: {}", jobName, e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed To Update Job: " + e.getMessage());
		}
	}

	// 모든 Job 리스트 조회
	@GetMapping
	public ResponseEntity<Set<JobKey>> getAllJobs() {
		try {
			Set<JobKey> jobKeys = aLogicService.getAllJobs();
			return ResponseEntity.ok(jobKeys);
		} catch (SchedulerException e) {
			log.error("@@ Error Retrieving Job List: {}", e.getMessage());
			return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
		}
	}
}

 

테스트

 

컨트롤러까지 작성이 완료됐으니, 이제 직접 API를 통해 정상 동작 여부를 확인해보자.

 

원래대로라면 Postman으로 했겠지만 지금 사용중인 컴퓨터에 Postman이 깔려있지 않은 관계로, (사양이 너무 낮아 꼭 필요한 거 말고는 깔고 싶지가 않다...) cmd에서 명령어를 통해 우선 스케줄링을 등록해보았다.

 

등록 API 호출

5초에 한 번 동작하는 스케줄링 등록 
curl -X POST http://localhost:8080/jobs -H "Content-Type: application/json" -d "{\"jobName\":\"5SecJob\",\"cron\":\"*/5 * * * * ?\"}"

3초에 한 번 동작하는 스케줄링 등록
curl -X POST http://localhost:8080/jobs -H "Content-Type: application/json" -d "{\"jobName\":\"3SecJob\",\"cron\":\"*/3 * * * * ?\"}"

 

 

정상적으로 등록이 완료되었고, 이는 프로그램을 껐다 켜도 유지가된다.

 

이제 방금 생성한 작업 중 3SecJob을 삭제해보자.

삭제 API 호출

curl -X DELETE http://localhost:8080/jobs/3SecJob

 

정상적으로 삭제가 되었고, 5SecJob만 실행이 되고있음을 확인할 수 있다.

 

마지막으로 나머지 API들도 테스트해보며, 정상 동작을 확인하고 포스팅을 마친다.

트리거 삭제 API
curl -X DELETE http://localhost:8080/jobs/5SecJob/trigger

Job 수정 API (PATCH / 위에서 삭제한 Trigger 1초로 재등록)
curl -X PATCH http://localhost:8080/jobs/5SecJob -H "Content-Type: application/json" -d "{\"cron\":\"*/1 * * * * ?\"}"

Job 수정 API (PUT / 이름 변경 및 5초 원복)
curl -X PUT http://localhost:8080/jobs/5SecJob -H "Content-Type: application/json" -d "{\"jobName\":\"testJob\", \"cron\":\"*/5 * * * * ?\"}"

Job 조회 API
curl -X GET http://localhost:8080/jobs
728x90
반응형