Spring

Spring Boot + Quartz 적용하기 - 2

댕발바닥 2024. 9. 16. 14:03

앞서 Quartz에 대해서 개념을 알아보았다

해당 포스팅은 Spring Boot  + Quartz를 활용하여 스케줄링을 구현해보겠습니다.

 

 

quartz 구성

 

 

1. Schduler

Job과 Trigger를 연결하여 Job을 실행시키는 역할을 수행하는 인터페이스입니다.



Spring + Quartz 사용으로 ApplicationContext에 의해 관리되는 SchedulerFactoryBean을 생성해주었다.

SchedulerFactoryBean에 JobFactory를 등록하여 Job도 컨텍스트로 관리될수 있게 설정된다.

 

package com.kr.quartz.config;

import com.kr.quartz.listener.MyJobListener;
import com.kr.quartz.listener.TriggerListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.scheduling.quartz.SpringBeanJobFactory;

@Slf4j
@Configuration
public class SchedulerConfig {


    @Bean
    public SchedulerFactoryBean schedulerFactoryBean(ApplicationContext applicationContext) {
        var schedulerFactoryBean = new SchedulerFactoryBean();

        var jobFactory = new SpringBeanJobFactory();

        schedulerFactoryBean.setJobFactory(jobFactory); // job factory 등록
        schedulerFactoryBean.setApplicationContext(applicationContext); // context 등록

        schedulerFactoryBean.setOverwriteExistingJobs(false); // job 중복 허용 (덮어쓰기)
        schedulerFactoryBean.setWaitForJobsToCompleteOnShutdown(true); // shutdown wait 속성

        schedulerFactoryBean.setGlobalTriggerListeners(new TriggerListener());
        schedulerFactoryBean.setGlobalJobListeners(new MyJobListener()); // job 리스너 등록

        return schedulerFactoryBean;
    }

}

 

 

2. Job

Quartz에서 실행할 작업을 정의하는 인터페이스입니다.

개발자는 자신이 실행하고자 하는 작업을 Job을 통하여 정의하며 정의된 Job은 Quartz의 설정 주기에 따라 실행이 됩니다.

 

package com.kr.quartz.jobs;

import lombok.extern.slf4j.Slf4j;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.springframework.stereotype.Component;

/**
 * Quartz 자체의 기본 인터페이스
 * Quartz 스케줄러에서 직접 인스턴스화하여 사용
 * 상태 저장 불가능 (매번 새로운 인스턴스 생성)
 *
 */
@Slf4j
@Component
public class MyJob implements Job { //

    @Override
    public void execute(JobExecutionContext jobExecutionContext)  {
      log.info("MyJob executing...");
      log.info("Job instance hash code: {}", this.hashCode());
    }

}

 

일반적인 Job 인터페이스를 생성하였습니다 이는 트리거에 맞춰 execute 가 실행이 됩니다.

 

package com.kr.quartz.jobs;

import com.kr.quartz.service.MyService;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;

/**
 * QuartzJobBean은 스프링에서 제공하는 Quartz 통합 지원 클래스
 * Spring Bean으로 관리되며, 의존성 주입 가능, 상태 저장 가능 (Spring의 싱글톤 Bean으로 관리)
 */
@Slf4j
@Component
@RequiredArgsConstructor
@DisallowConcurrentExecution // 동시 실행 방지
@NoArgsConstructor(access = AccessLevel.NONE)
public class MyQuartzJob extends QuartzJobBean {

    private final MyService myService;

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        log.info("MyQuartzJob executing...");
        myService.myMethod(); // 의존성 주입 확인
        log.info("MyQuartzJob instance hash code: {}", this.hashCode());

        try {
            log.info("Thread start");
            Thread.sleep(10000); // 동시 실행 방지 테스트
            log.info("Thread end");
        } catch (InterruptedException ignored) {}
    }

}

 

QuartzJobBean은 Spring에서 빈 주입이 가능하게 생성해주는 Job입니다. 내부 소스를 보게되면 Job 인터페이스를 구현하였으며 execute내부에서 excuteInternal을 호출합니다.

 

package com.kr.quartz.jobs;

import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
 * 리플렉션을 이용하여 targetMethod로 실행될수있게 설정
 */
@Slf4j
@NoArgsConstructor
public class InvokingJob {

    public void run() {
        log.info("InvokingJob executing...");
        log.info("Job instance hash code: {}", this.hashCode());
    }

}

 

일반적인 케이스가 아닌 Job을 invoking해서 타겟 메소드를 호출하게 하는 방식입니다.

 

위 3개의 개별적인 Job을 등록하였고 아래에서 해당 Job의 실행에 대해서 알아볼것 입니다.

 

 

3. JobListener

quartz에서 제공하는 인터페이스로 Job의 생명주기 동안 발생하는 이벤트를 처리합니다. 이 인터페이스를 구현함으로써 개발자는 Job이 시작되거나 완료될 때마다 특정 작업을 수행하도록 할 수 있습니다.

메서드 반환 값 설명
getName() String JobListener의 이름을 반환합니다.
jobToBeExecuted() void Job이 실행되기 직전에 호출되는 메서드로, Job의 실행 준비 상황을 확인하거나 필요한 작업을 수행하는 데 사용할 수 있습니다.
jobExecutionVetoed() void 다른 JobListener가 Job의 실행을 방해(veto)했을 때 호출되는 메서드입니다.
jobWasExecuted() void Job의 실행이 완료된 후에 호출되는 메서드로, Job의 실행 결과를 로깅하거나 후속 작업을 수행하는 데 사용할 수 있습니다.

 

package com.kr.quartz.listener;

import lombok.extern.slf4j.Slf4j;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.JobListener;
import org.springframework.stereotype.Component;

/**
 * 로깅용으로 사용하는 job 리스너
 */
@Slf4j
public class MyJobListener implements JobListener {

    @Override
    public String getName() {
        return getClass().getSimpleName();
    }

    @Override
    public void jobToBeExecuted(JobExecutionContext jobExecutionContext) {
        log.info("jobToBeExecuted"); // job 실행 시점
    }

    @Override
    public void jobExecutionVetoed(JobExecutionContext jobExecutionContext) {
        log.info("jobExecutionVetoed"); // 트리거 veto 실행시
    }

    @Override
    public void jobWasExecuted(JobExecutionContext jobExecutionContext, JobExecutionException e) {
        log.info("jobWasExecuted"); // job 종료 시점
    }
}

 

위에 글로벌 설정으로 SchedulerFactoryBean에 등록한것을 볼 수 있습니다. job 전/후에 동작을 살펴볼 수있으며 트리거 중지에 이벤트가 전파됩니다.

 

3. TriggerListener

quartz에서 제공하는 인터페이스로 트리거의 생명주기 동안 발생하는 이벤트를 처리합니다. 이 인터페이스를 구현함으로써 개발자는 트리거가 시작되거나 완료될 때, 중지 등의 특정 작업을 수행하도록 할 수 있습니다.

 

package com.kr.quartz.listener;

import lombok.extern.slf4j.Slf4j;
import org.quartz.JobExecutionContext;
import org.quartz.Trigger;
import org.quartz.TriggerListener;

@Slf4j
public class MyTriggerListener implements TriggerListener {

    @Override
    public String getName() {
        return getClass().getSimpleName();
    }

    @Override
    public void triggerFired(Trigger trigger, JobExecutionContext jobExecutionContext) {
        log.info("triggerFired"); // 트리거 수행시점
    }

    @Override
    public boolean vetoJobExecution(Trigger trigger, JobExecutionContext jobExecutionContext) {
        // 트리거 중지 여부
        return false;
    }

    @Override
    public void triggerMisfired(Trigger trigger) {
        log.info("triggerMisfired"); // 트리거 수행 실패
    }

    @Override
    public void triggerComplete(Trigger trigger, JobExecutionContext jobExecutionContext, Trigger.CompletedExecutionInstruction completedExecutionInstruction) {
        log.info("triggerComplete"); // 트리거 수행 완료
    }
}

 

 

4. 스케줄링 구현

 

package com.kr.quartz.triggers;

import com.kr.quartz.jobs.MyJob;
import com.kr.quartz.jobs.MyQuartzJob;
import lombok.extern.slf4j.Slf4j;
import org.quartz.JobBuilder;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.TriggerBuilder;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MyJobTrigger {

    public MyJobTrigger(SchedulerFactoryBean schedulerFactoryBean) throws SchedulerException {

        var jobDetail = JobBuilder
                .newJob(MyJob.class)
                .withIdentity("myJob", "group1")
                .withDescription("my job description")
                .requestRecovery(true)
                .storeDurably(true)
                .usingJobData("key", "val")
                .build();

        var trigger = TriggerBuilder.newTrigger()
                .withIdentity("myJobTrigger", "group1")
                .startNow()
                .withSchedule(
                        SimpleScheduleBuilder.simpleSchedule()
                                .withIntervalInSeconds(5)
                                .repeatForever()
                )
                .build();

        schedulerFactoryBean.getScheduler().scheduleJob(jobDetail, trigger);
    }

}

 

구현한 Job을 이용하여 JobDetail을 생성해주었으며 SimpleSchedule을 등록하였습니다.

 

MyJob

 

package com.kr.quartz.triggers;

import com.kr.quartz.jobs.MyJob;
import com.kr.quartz.jobs.MyQuartzJob;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MyQuartzJobTrigger {

    public MyQuartzJobTrigger(SchedulerFactoryBean schedulerFactoryBean) throws SchedulerException {

        var jobDetail = JobBuilder
                .newJob(MyQuartzJob.class)
                .withIdentity("myQuartzJob", "group1")
                .withDescription("my job description")
                .requestRecovery(true)
                .storeDurably(true)
                .usingJobData("key", "val")
                .build();

        var trigger = TriggerBuilder.newTrigger()
                .withIdentity("myQuartzTrigger", "group1")
                .startNow()
                .withSchedule(
                        CronScheduleBuilder.cronSchedule("0/5 * * * * ?")
                )
                .build();

        schedulerFactoryBean.getScheduler().scheduleJob(jobDetail, trigger);
    }

}

 

구현한 QuartzJobBean을 이용하여 JobDetail을 생성해주었으며 cronSchedule을 등록하였습니다.

 

QuartzJobBean

위 로그를 보면 여러 내용을 확인 할 수 있습니다. cron으로 5초단위를 실행하였으나 Job 내부소스를 보게되면 ThreadSleep 10초를 주었습니다. 일반적으로는 5초마다 새로운 스레드에 의해 실행되어야하나 아래 어노테이션으로 동시 실행을 방지했습니다.

@DisallowConcurrentExecution

 

해당 어노테이션에 따라 종료되지 않는 스케줄러에 의해 트리거 misfired를 확인 할 수있습니다.

 

 

package com.kr.quartz.triggers;

import com.kr.quartz.jobs.InvokingJob;
import com.kr.quartz.jobs.MyQuartzJob;
import lombok.extern.slf4j.Slf4j;
import org.quartz.JobBuilder;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.TriggerBuilder;
import org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class InvokingJobTrigger {

    public InvokingJobTrigger(SchedulerFactoryBean schedulerFactoryBean) throws SchedulerException, ClassNotFoundException, NoSuchMethodException {

        var jobDetail = new MethodInvokingJobDetailFactoryBean();
        jobDetail.setTargetObject(new InvokingJob());
        jobDetail.setTargetMethod("run"); // 타겟 메소드
        jobDetail.setName("invokingJob");
        jobDetail.setGroup("invokingJobGroup");
        jobDetail.afterPropertiesSet();

        var trigger = TriggerBuilder.newTrigger()
                .withIdentity("invokingJobTrigger", "group1")
                .startNow()
                .withSchedule(
                        SimpleScheduleBuilder.simpleSchedule()
                                .withIntervalInSeconds(5)
                                .repeatForever()
                )
                .build();

        schedulerFactoryBean.getScheduler().scheduleJob(jobDetail.getObject(), trigger);
    }

}

 

MethodInvokingJobDetail

 

해당 Job은 일반적인  Job과 다르게 Method ivoking을 통하여 설정된 메소드를 실행 되는것을 볼수 있습니다.

 

내부 소스를 보면 afterPropertiesSet에 의하여 JobDetail이 생성됩니다. Job을보게되면 MethodInvokingJob 내부 Job 클래스로 생성하게됩니다.

 

MethodInvokingJob을 살펴보면 invoke 메소드를 호출하게 되어있으며 우리가 설정한 타겟 메소드를 리플렉션으로 실행하는것을 확인 할 수 있습니다.

 

 

이후 getObject를 이용하여 JobDetail이 반환됩니다.

 

위 경우는 일반적으로 사용하는것보다 레거시하게 구성된 클래스드를 적용할 수있게 지원해주는 Job으로 보입니다.

 

 

5. Job 실행 흐름 확인해보기

 

SchedulerFactoryBean

 

1. InitializingBean 

Quartz 스케줄러는 스프링의 컨테이너의 빈 LifeCycle 관리에 의해서 scheduler관련 설정이 초기화, 시작, 종료가 된다InitializingBean 인터페이스의 의해서 afterPropertiesSet 메소드가 호출 됩니다.

 

SchedulerFactoryBean

 

SchedulerFactory를 생성하고 해당prepareScheduler를 이용하여 내부적으로 스케줄러를 생성합니다.

prepareScheduler
createScheduler

2. SmartLifeCycle 

 

SmartLifeCycle 인터페이스에 의해서 start 메소드가 실행되며 startScheduler 메소드로 실행을 위임합니다.

실제 QuartzScheduler 관련 쓰레드를 시작해서 등록된 Job들을 실행합니다.

start
startScheduler

 

별개로 톰캣 shutdown 될 때 stop이 실행됩니다.

stop

 

3. StdSchedulerFactory

 

위에서 살펴보았듯이 createScheduler에 일반적으로 StdSchedulerFactory가 인자로 가게됩니다. 이후 스케줄러를 호출하는 내용을 확인 할 수 있습니다.

 

 

내부 소스를 확인해보면 instantiate를 호출하는것을 볼수있다. 소스를 따라가보면 QuartzScheduler를 호출하는것을 볼수 있다.

 

 

 

4. QuartzScheduler

생성자를 확인해보면 QuartzSchedulerThread를 생성하여 실행되는것을 볼수 있습니다.

 

 

5. QuartzSchedulerThread

 

스케줄러가 halted 상태가 아닌경우는 계속 Loop가 수행되는것을 볼수 있습니다.

 

수행할 트리거를 조회 한 후 JonRunShell를 생성합니다. 해당 runner를 이용하서 runInThread에서 스레드를 실행하게 됩니다.

 

 

스레스 실행되면 Job을 생성하여 excute하는것을 볼수있다.

 

굉장히 복잡한 구조로 되어있는 Quartz 내부를 확인해 보았다.