
Quartz는 작업을 예약할 수 있도록 지원하는 라이브러리로 오픈소스이며 Java로 구현되어 있다.
스프링부트를 사용하면, spring-boot-starter-quartz를 활용할 수 있어서, 스프링에서 제공하는 기능을 조금 더 원활하게 사용할 수 있다.
쿼츠를 사용하기 전에 내부 구조를 먼저 살펴보자.
실제로 수행될 비즈니스 로직을 담는 인터페이스이다.
public interface Job {
void execute(JobExecutionContext var1) throws JobExecutionException;
}
Job의 세부 정보를 담는 클래스이다.
스케줄러를 통해서 Quartz에 작업의 세부 정보를 등록한다.
public interface JobDetail extends Serializable, Cloneable {
JobKey getKey();
String getDescription();
Class<? extends Job> getJobClass();
JobDataMap getJobDataMap();
boolean isDurable();
boolean isPersistJobDataAfterExecution();
boolean isConcurrentExectionDisallowed();
...
}
언제 작업(Job)을 수행할지를 정의하는 인터페이스
public interface Trigger extends Serializable, Cloneable, Comparable<Trigger> {
long serialVersionUID = -3904243490805975570L;
int MISFIRE_INSTRUCTION_SMART_POLICY = 0;
int MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY = -1;
int DEFAULT_PRIORITY = 5;
TriggerKey getKey();
JobKey getJobKey();
String getDescription();
String getCalendarName();
JobDataMap getJobDataMap();
int getPriority();
boolean mayFireAgain();
Date getStartTime();
Date getEndTime();
...
JobDetail + Trigger 를 통해서 작업을 등록,관리, 실행하는 중앙 컴포넌트
public interface Scheduler {
String DEFAULT_GROUP = "DEFAULT";
String DEFAULT_RECOVERY_GROUP = "RECOVERING_JOBS";
String DEFAULT_FAIL_OVER_GROUP = "FAILED_OVER_JOBS";
String FAILED_JOB_ORIGINAL_TRIGGER_NAME = "QRTZ_FAILED_JOB_ORIG_TRIGGER_NAME";
String FAILED_JOB_ORIGINAL_TRIGGER_GROUP = "QRTZ_FAILED_JOB_ORIG_TRIGGER_GROUP";
String FAILED_JOB_ORIGINAL_TRIGGER_FIRETIME_IN_MILLISECONDS = "QRTZ_FAILED_JOB_ORIG_TRIGGER_FIRETIME_IN_MILLISECONDS_AS_STRING";
String FAILED_JOB_ORIGINAL_TRIGGER_SCHEDULED_FIRETIME_IN_MILLISECONDS = "QRTZ_FAILED_JOB_ORIG_TRIGGER_SCHEDULED_FIRETIME_IN_MILLISECONDS_AS_STRING";
String getSchedulerName() throws SchedulerException;
String getSchedulerInstanceId() throws SchedulerException;
SchedulerContext getContext() throws SchedulerException;
void start() throws SchedulerException;
void startDelayed(int var1) throws SchedulerException;
boolean isStarted() throws SchedulerException;
void standby() throws SchedulerException;
...
JobDetail 또는 Trigger에 전달할 파라미터
키-값 형태로 데이터를 저장할 수 있다.
public class JobDataMap extends StringKeyDirtyFlagMap implements Serializable {
private static final long serialVersionUID = -6939901990106713909L;
public JobDataMap() {
super(15);
}
public JobDataMap(Map<?, ?> map) {
this();
Map<String, Object> mapTyped = map;
this.putAll(mapTyped);
this.clearDirtyFlag();
}
public void putAsString(String key, boolean value) {
String strValue = Boolean.valueOf(value).toString();
super.put(key, strValue);
}
...
JobDetail, Trigger, 스케줄링 메타데이터를 저장하는 저장소
public interface JobStore {
void initialize(ClassLoadHelper var1, SchedulerSignaler var2) throws SchedulerConfigException;
void schedulerStarted() throws SchedulerException;
void schedulerPaused();
void schedulerResumed();
void shutdown();
...
RAM에 저장하는 방식과, 데이터베이스에 저장하는 방식으로 나뉜다.
RAM에 저장하는 방식
데이터베이스에 저장하는 방식

위 그림과 같이, 스케줄러에 scheduleJob메소드를 호출하여, JobDetail 과 Trigger를 넘겨주면 JobStore에 작업이 저장된다.
이후, Trigger에 설정된 시간에 맞게 작업이 실행된다.
구현할 기능 : 사용자로부터 모집 시작 요청을 받으면 해당 작업을 등록한다. 취소 요청을 받으면 등록된 모든 작업을 취소한다.
내가 수행할 작업의 비즈니스 로직을 가진 Job을 정의한다.
spring-boot-starter-quartz 의존성을 사용했으므로, QuartzJobBean을 implements 하여 구현한다.
package com.econovation.recruit.api.recruitment.quartz;
...
// 수행할 작업을 의미하는 클래스
@Component
@RequiredArgsConstructor
public class RecruitmentJob extends QuartzJobBean {
private final RecruitmentPort repository;
private final LatestRecruitmentVo recruitmentVo;
private static final String RECRUITMENT_ID = "recruitmentId";
private static final String OP = "operation";
private static final String START_POST_FIX = "_START";
private static final String END_POST_FIX = "_END";
@Override
public void executeInternal(JobExecutionContext context) throws JobExecutionException {
JobDataMap data = context.getMergedJobDataMap();
RecruitmentStates targetState;
Recruitment target =
repository
.findById(data.getLong(RECRUITMENT_ID))
.orElseThrow(() -> RecruitmentNotFoundException.EXCEPTION);
if (data.getString(OP).equals("start")) targetState = RecruitmentStates.RECRUITING;
else if (data.getString(OP).equals("end")) targetState = RecruitmentStates.END;
else throw new IllegalStateException("job op가 잘못 설정되었습니다.");
repository.save(target.updateStates(targetState));
recruitmentVo.refreshRecruitment(target);
}
...
}
Quartz 스케줄러는 Java 코드, application.yml 등으로 커스텀하여 사용할 수 있다.
ThreadPool 크기, DataSource 등을 커스텀할 수 있다.
내가 구현하고자 하는 작업은 그렇게 복잡한 작업이 아니므로, 별도의 설정을 하지 않고, 기본적으로 생성해주는 스케줄러를 사용한다.
package com.econovation.recruit.api.recruitment.quartz;
...
@Slf4j
@Component
@RequiredArgsConstructor
public class RecruitmentScheduler {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private final Scheduler scheduler;
public void reserveStart(Recruitment target) {
try {
JobDetail jobDetail = RecruitmentJob.getStartJob(target.getId(), target.getYear());
scheduler.scheduleJob(
jobDetail, RecruitmentTrigger.get(jobDetail.getKey(), startAt(target)));
} catch (SchedulerException e) {
throw new QuartzException(e);
}
}
public void reserveEnd(Recruitment target) {
try {
JobDetail jobDetail = RecruitmentJob.getEndJob(target.getId(), target.getYear());
scheduler.scheduleJob(
jobDetail, RecruitmentTrigger.get(jobDetail.getKey(), endAt(target)));
} catch (SchedulerException e) {
throw new QuartzException(e);
}
}
public void cancelJob(Long recruitmentId) {
try {
scheduler.deleteJob(RecruitmentJob.startJobKey(recruitmentId));
scheduler.deleteJob(RecruitmentJob.endJobKey(recruitmentId));
} catch (SchedulerException e) {
throw new QuartzException(e);
}
}
private ZonedDateTime startAt(Recruitment recruitment) {
return ZonedDateTime.of(recruitment.getStartAt(), KST);
}
private ZonedDateTime endAt(Recruitment recruitment) {
return ZonedDateTime.of(recruitment.getEndAt(), KST);
}
}
RecruitmentScheduler는 Quartz의 스케줄러를 상속 받는 클래스가 아닌, Quartz 스케줄러를 합성하여 사용한다.
reservedStart, reservedEnd 메소드를 통해 시작할 Recruitment를 전달받아 작업을 등록하고
cancelJob 메소드를 통해서, recruitment를 종료,예약 취소 한다.
Quartz는 두 가지의 JobStore가 존재하는데 application.yml에서 설정할 수 있다.
내가 구현하고자 하는 기능은, 애플리케이션이 종료되어도 사용자가 예약한 작업 정보를 그대로 유지하고 있어야 하므로
job-store-type: jdbc 로 설정했다.
spring:
quartz:
jdbc:
initialize-schema: always
schema: classpath:quartz-init.sql
job-store-type: jdbc
overwrite-existing-jobs: true
JDBC JobStore를 사용하기 위해서는, 별도의 테이블이 추가로 만들어져야 한다.
그렇기 때문에 위 설정에서 initialize-schema : always , schema : classpath:quartz-init.sql 로 설정하여 저장할 Job에 대한 스키마를 구성한다.
initialize-schema : always : 애플리케이션이 시작될 때마다, 스키마를 자동으로 초기화 한다.schema : classpath:quartz-init.sql : 애플리케이션이 시작될 때, 실행될 SQL을 지정한다. (스키마 정의 용도)RecruitmentTrigger라는 트리거 클래스를 정의한 후에, 내부에 static 메소드로 수행할 작업의 Key와 실행시킬 시간을 전달 받아서 Trigger 를 반환하도록 했다.
package com.econovation.recruit.api.recruitment.quartz;
public class RecruitmentTrigger {
public static Trigger get(JobKey jobKey, ZonedDateTime startAt) {
return TriggerBuilder.newTrigger()
.forJob(jobKey)
.startAt(Date.from(startAt.toInstant()))
.build();
}
}
위 코드를 해석해보자면, "전달 받은 작업(Key) 을 startAt에 실행시켜라" 라는 의미가 된다.
원래 이 기능을 구현하기 위해서 Spring Scheduler가 제공하는 TaskScheduler를 사용하려 했으나,
애플리케이션이 종료되면 예약된 작업이 사라지는 단점이 있어, 정교한 작업과 영속성을 보장할 수 있는 Quartz를 사용했다.
Spring Batch와 같은 배치 처리에 특화된 프레임워크도 존재하지만 내가 구현할 기능은 그렇게 무거운 작업이 아닐 뿐더러, 많아야 요청하는 사용자가 한 명인 기능이다.
추가로 기회가 된다면, Spring Batch, Spring Scheduler, Quartz의 차이 및 장단점 비교를 해보겠다.