Quartz로 작업 예약하기

Bellmin·2025년 8월 3일

Econovation

목록 보기
2/3
post-thumbnail

Quartz란?

Quartz는 작업을 예약할 수 있도록 지원하는 라이브러리로 오픈소스이며 Java로 구현되어 있다.

스프링부트를 사용하면, spring-boot-starter-quartz를 활용할 수 있어서, 스프링에서 제공하는 기능을 조금 더 원활하게 사용할 수 있다.

Quartz의 내부 구조

쿼츠를 사용하기 전에 내부 구조를 먼저 살펴보자.

Job

실제로 수행될 비즈니스 로직을 담는 인터페이스이다.

public interface Job {
    void execute(JobExecutionContext var1) throws JobExecutionException;
}

JobDetail

Job의 세부 정보를 담는 클래스이다.
스케줄러를 통해서 Quartz에 작업의 세부 정보를 등록한다.

public interface JobDetail extends Serializable, Cloneable {
    JobKey getKey();

    String getDescription();

    Class<? extends Job> getJobClass();

    JobDataMap getJobDataMap();

    boolean isDurable();

    boolean isPersistJobDataAfterExecution();

    boolean isConcurrentExectionDisallowed();

	...
}

Trigger

언제 작업(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();

	...

Scheduler

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;
    
    ...

JobDataMap

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);
    }
    ...

JobStore

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에 저장하는 방식

    • 빠르다, 하지만 애플리케이션이 종료되면 작업 예약 정보가 사라진다.
  • 데이터베이스에 저장하는 방식

    • RAM에 저장하는 방식보다, 데이터를 조회하는 데 속도가 상대적으로 느리다.
    • 하지만, 애플리케이션이 종료되어도 작업 예약 정보가 사라지지 않는다.

내부 동작 그림

그림

위 그림과 같이, 스케줄러에 scheduleJob메소드를 호출하여, JobDetailTrigger를 넘겨주면 JobStore에 작업이 저장된다.

이후, Trigger에 설정된 시간에 맞게 작업이 실행된다.

활용하기

구현할 기능 : 사용자로부터 모집 시작 요청을 받으면 해당 작업을 등록한다. 취소 요청을 받으면 등록된 모든 작업을 취소한다.

커스텀 Job 정의

내가 수행할 작업의 비즈니스 로직을 가진 Job을 정의한다.
spring-boot-starter-quartz 의존성을 사용했으므로, QuartzJobBeanimplements 하여 구현한다.

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);
    }

   ...
}

Scheduler 정의

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를 종료,예약 취소 한다.

JobStore 설정

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을 지정한다. (스키마 정의 용도)

JobTrigger 정의

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의 차이 및 장단점 비교를 해보겠다.


참고자료

0개의 댓글