programming study/B-Spring

Spring Boot 환경에서 Quartz Scheduler 사용하기

gu9gu 2022. 11. 14. 22:00

공부하게 된 이유

배치서버 작업 경험 중 배치서버가 이미 다 구현 된 상태이기 때문에 배치서버에 사용되는 Quartz 라이브러리를 잘 알지 못 해도 문제가 없었던 적이 있다. 배치작업 기능을 추가하는데 있어서 비지니스 로직을 추가하면 되는 상황이었다.

그렇지만 면접 준비를 하면서 작업 했던 환경에 대해서 설명을 요구할 수 있다고 생각해서 Quartz 라이브러리에 대해서 공부 해보기로 했다.

 

보통 대용량 데이터에 대한 작업을 순차적으로 처리하는데 사용되는 spring batch와 정해진 시간에 지정된 횟수만큼 반복적으로 특정 작업을 실행시키는 스케줄러 라이브러리인 quartz를 같이 사용한다고 한다. 그러나 spring boot 환경에서 spring batch 없이 quartz만 사용하여 batch server를 구현한 환경에서 작업했었기 때문에 quartz를 우선적으로 학습해보려고 한다. 작업했던 어플리케이션은 대용량 데이터 처리나 작업 기록 관리, 대시보드 등의 기능은 필요 없고 주기적으로 작업을 실행하는 scheduler의 기능만 필요해서 Quartz가 적합했던 것 같다.

 

아래는 Quartz에 대한 구글링, 유튜브 강의를 보고 정리한 내용 입니다.

 

참고

Spring Batch vs Quartz
Spring Batch는 트랜잭션 관리나 재시작 기능이 있는 job을 관리하는 프레임워크이고 스케줄러 기능을 제공하지 않습니다.
그리고 Quartz는 job을 일정 주기 마다 실행시키는 스케줄러 역할을 합니다.

 

본론

Quartz란?

java로 된 스케줄링 기능이 있는 라이브러리이다. spring에서 주로 사용되고 다른 곳에서도 사용될 수 있다고 한다.

 

 

왜 Quartz를 사용하는가?

스케줄러로는 Crontab, Quartz, Jenkins, Airflow, Teamcity, Spring Cloud Data Flow, Spring Scheduler 가 있는데 Quartz를 사용하는 이유는 다음과 같다.

  • java + 스프링 환경에서 사용할 수 있다. 스프링에서  라이브러리로 지원
  • 다중 서버 환경에서 Scheduler에 대한 Clustering 기능 지원
      Scheduler에 등록된 job, trigger 정보를 memory 또는 db에서 관리할 수 있는데 db에 저장하는 방식을 사용해서  한 서버에 문제가 생겼을 때 다른 서버가 db에 공유된 정보를 이용해서 처리하는 것을 말한다. 이렇게 하면 성능적 측면에서 부하가 분산 되어서 좋고 한 서버에 문제가 생겨도 job 실행에는 문제가 없게 할 수 있어서 좋다.
  • 메인 스레드를 막지 않고 비동기적 동작 가능
  • Misfire Instructions이 있어서 Scheduler 실패, thread pool에서 사용할 thread가 없는 경우에 대한 후처리 가능

Quartz의 기본 구성

  • org.quartz.Job : 스케줄링할 실제 작업을 가지는 객체이다. job을 implements 해서 동작시킬 작업을 만든다.
  • org.quartz.JobDetail : Job의 정보를 구성하는 객체이다.
  • org.quartz.Trigger : Job이 언제 시행될지를 구성하는 객체이다.
  • org.quartz.JobDataMap : Job에서 사용할 데이터를 전달하는 역할을 하는 객체이다. jobDetail에 담긴다.
  • org.quartz.Scheduler : JobDetail과 Trigger 정보를 이용해서 Job을 시스템에 등록하고, Trigger가 동작하면 지정된 Job을 실행시키는 역할을 하는 객체이다.
  • SchedulerFactory : Scheduler 인스턴스를 생성하는 역할을 하는 객체이다. 스프링 부트 환경에서 Scheduler는 자동 주입이 가능해서 SchedulerFactory는 사용되지 않는다.
  • quartz.properties : Quartz 스케줄러를 위한 configuration 을 담당하는 파일이다.  src/resources/ 에 위치하며 세부적인 사항은 Quartz Configuration Reference 문서를 참조하는 것을 추천한다. 하지만 quartz.properties 파일이 없어도 문제 없이 동작한다.
  • JobStore : 스케줄러에 등록된 Job의 정보와 실행이력이 저장되는 공간이다. 기본적으로 RAM에 저장되어 JVM 메모리공간에서 관리되지만, 원한다면 다른 RDB에서 관리할 수 있다.

이상의 구성만으로 대략적인 Quartz의 Flow를 추정해보자면 다음과 같을 것입니다.

(1) quartz.properties 의 구성 사항을 적용
(2) SchedulerFactory를 이용하거나 스프링부트 환경에서 자동주입으로 Scheduler를 만듦

(3) Job을 implements 해서 동작시킬 작업객체를 만든다.

(4)  'job 구현 객체', job 동작 정보를 넣어서 JobDetailTrigger를 만든다.
(5) Scheduler에 JobDetail과 Trigger를 이용해 Job을 스케줄링 한다.
(6) 정해진 시간마다 Scheduler가 Job을 호출하여 시행한다.

 

 

실습

quartz 를 이용한 기본 기능 api 구현 ( 참고: https://www.youtube.com/watch?v=NoDmt7G4z6E )

[기능]

 - job 실행
 - 실행 중인 모든 job 반환
 - 실행 중인 job 중 timerId를 key 로 하는 job 반환
 - 실행 중인 job 중 timerId를 key 로 하는 job 삭제

[구성]

TimeInfo

public class TimerInfo implements Serializable {  // implements Serializable : 객체를 가지고 외부( 예를 들면 DB )와 통신하기 위해 직렬화 해준다
    private int totalFireCount; // timer가 몇번 실행될 것인지
    private int remainingFireCount; //남은 실행 횟수
    private boolean runForever; // 영원히 실행되게 할건지(영원히 실행되게 할 거라면 totalFierCount는 필요없음)
    private long repeatIntervalMs; // 몇 Ms 마다 반복할지
    private long initialOffsetMs; // 0으로 하면 즉시 실행 100으로 하면 100Ms 뒤 실행
    private String callbackData; // callbackData
    
   .
   . getter/setter
   .

 

HelloWorldJob : 남은 실행 횟수 log 남김

@Component
public class HelloWorldJob implements Job {  // org.quartz.Job을 implements
    private static final Logger LOG = LoggerFactory.getLogger(HelloWorldJob.class);

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
        TimerInfo info = (TimerInfo) jobDataMap.get(HelloWorldJob.class.getSimpleName());

        //LOG.info("Hello world job start");
        //LOG.info("callbackData : " + info.getCallbackData());
        LOG.info("Remaining fire count is '{}'", info.getRemainingFireCount());
    }
}

 

PlaygroundController : url 요청 처리, PlaygroundService 호출

PlaygroundService : TimerInfo job 설정 및 SchedulerService 호출

@Service
public class PlaygroundService {
    private final SchedulerService scheduler;

    @Autowired
    public PlaygroundService(final SchedulerService scheduler) {
        this.scheduler = scheduler;
    }

    public void runHelloWorldJob() {
        final TimerInfo info = new TimerInfo();
        info.setTotalFireCount(20);
        info.setRemainingFireCount(info.getTotalFireCount());
        info.setRepeatIntervalMs(2000);
        info.setInitialOffsetMs(1000);
        info.setCallbackData("My clallback data");

        scheduler.schedule(HelloWorldJob.class, info);
    }
 .
 .
 .
}

 

SchedulerService

  • 작업클래스와 시간정보로 schedule 을 실행한다.
  • 실행중인 모든 timer(스케줄러 작업)를 반환한다.
  • timerId 를 key 로 하는 실행 중인 timer(스케줄러 작업)를 반환한다.
  • timerId로 jobDataMap 찾아서 TimerInfo 를 반영한다.
  • timerId 로 timer(스케줄러 작업)를 scheduler 에서 삭제한다.
  • PostConstruct 를 이용해서 스프링이 실행될 때
    scheduler 를 실행시킨다.
    scheduler 에 triggerListener 를 등록한다.
  • PreDestroy 를 이용해서 스프링이 종료되기 전 scheduler 를 종료시킨다.
package com.example.studyquartz.timeservice;

import com.example.studyquartz.info.TimerInfo;
import com.example.studyquartz.util.TimerUtils;
import org.quartz.*;
import org.quartz.impl.matchers.GroupMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

@Service
public class SchedulerService {

    private static final Logger LOG = LoggerFactory.getLogger(SchedulerService.class);

    private final Scheduler scheduler;

    @Autowired
    public SchedulerService(Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    /**
     * 작업클래스와 시간정보로 schedule 을 실행한다.
     */
    public void schedule(Class jobClass, TimerInfo info) {
        final JobDetail jobDetail = TimerUtils.buildJobDetail(jobClass, info);
        final Trigger trigger = TimerUtils.buildTrigger(jobClass, info);

        try {
            scheduler.scheduleJob(jobDetail, trigger);
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
        }
    }

    /**
     * 실행중인 모든 timer(스케줄러 작업)를 반환한다.
     */
    public List<TimerInfo> getAllRunningTimers() {
        try {
            return scheduler.getJobKeys(GroupMatcher.anyGroup())
                    .stream()
                    .map(jobKey -> {
                        try {
                            JobDetail jobDetail = scheduler.getJobDetail(jobKey);
                            return (TimerInfo) jobDetail.getJobDataMap().get(jobKey.getName());
                        } catch (SchedulerException e) {
                            LOG.error(e.getMessage(), e);
                            return null;
                        }
                    })
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList());
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
            return Collections.emptyList();
        }

    }

    /**
     * timerId 를 key 로 하는 실행 중인 timer(스케줄러 작업)를 반환한다.
     */
    public TimerInfo getRunningTimers(String timerId) {
        try {
            final JobDetail jobDetail = scheduler.getJobDetail(new JobKey(timerId));

            if (jobDetail == null) {
                LOG.error("Failed to find timer with ID '{}'", timerId);
                return null;
            }

            return (TimerInfo) jobDetail.getJobDataMap().get(timerId);
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
            return null;
        }
    }

    /**
     * timerId로 jobDataMap 찾아서 TimerInfo 를 반영한다.
     */
    public void updateTimer(final String timerId, final TimerInfo info) {
        try {
            final JobDetail jobDetail = scheduler.getJobDetail(new JobKey(timerId));

            if (jobDetail == null) {
                LOG.error("Failed to find timer with ID '{}'", timerId);
                return ;
            }

            jobDetail.getJobDataMap().put(timerId, info);

            /*
             scheduler.addJob
              역할 : JobStore 에 jobDetail 반영하기 위함.
              사용하는 이유 : JobStore 을 DB 방식 jdbcJobStore(type 설정을 jdbc) 로 사용할 때는 변경된 jobDetail 을 유지시키기 위해 JobStore 에도 반영을 해야한다.
               참고로 말하자면 JobStore 을 RamJobStore (store-type 설정을 memory)로 사용할 때는 변경된 jobDetail 을 JobStore 에 반영하지 않아도 유지된다.

               JdbcJobStore 방식을 사용하고 JobStore 에 JobDetail 을 담아주면 job 이 동작하다가 서버가 중지되어도 DB 에서 정보를 유지하고 있는다.
               서버를 다시 시작하면 진행 중이던 job 이 다시 동작한다. 이 때 시간,반복횟수에 따라서 어떻게 동작할 지는 따로 설정할 수 있는 것 같다.
               아마도 Misfire Instructions 처리?

              # application.properties - spring.quartz.job-store-type=jdbc
             */
            scheduler.addJob(jobDetail, true, true);
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
        }
    }

    /**
     * timerId 로 timer(스케줄러 작업)를 scheduler 에서 삭제한다.
     */
    public boolean deleteTimer(final String timerId) {
        try {
            return scheduler.deleteJob(new JobKey(timerId));
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
            return false;
        }
    }

    /**
     * PostConstruct 를 이용해서 스프링이 실행될 때
     * scheduler 를 실행시킨다.
     * scheduler 에 triggerListener 를 등록한다.
     */
    @PostConstruct
    public void init() {
        try {
            scheduler.start();

            scheduler.getListenerManager().addTriggerListener(new SimpleTriggerListener(this));
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
        }
    }

    /**
     * PreDestroy 를 이용해서 스프링이 종료되기 전 scheduler 를 종료시킨다.
     */
    @PreDestroy
    public void close() {
        try {
            scheduler.shutdown();
        } catch (SchedulerException e) {
            LOG.error(e.getMessage(), e);
        }
    }
}


SimpleTriggerListener

  • implements TriggerListener로 트리거 리스너 이벤트 구현이 가능하다.
  • job 하나가 실행될 때 마다 remainingFireCount 를 1씩 감소시켜서 스케줄러에 반영한다.
package com.example.studyquartz.timeservice;

import com.example.studyquartz.info.TimerInfo;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.Trigger;
import org.quartz.TriggerListener;

public class SimpleTriggerListener implements TriggerListener {
    private final SchedulerService schedulerService;

    public SimpleTriggerListener(SchedulerService schedulerService) {
        this.schedulerService = schedulerService;
    }

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

    /**
     * Trigger가 실행된 상태
     * 리스너 중에서 가장 먼저 실행됨
     *
     * job 하나가 실행될 때 마다 remainingFireCount 를 1씩 감소시켜서 스케줄러에 반영한다.
     */
    @Override
    public void triggerFired(Trigger trigger, JobExecutionContext context) {
        final String timerId = trigger.getKey().getName();

        final JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
        final TimerInfo info = (TimerInfo) jobDataMap.get(timerId);

        if (!info.isRunForever()) {
            int remainingFireCount = info.getRemainingFireCount();
            if (remainingFireCount == 0 ) {
                return;
            }

            info.setRemainingFireCount(remainingFireCount - 1);
        }

        schedulerService.updateTimer(timerId, info);
    }

    /**
     * Trigger 중단 여부를 확인하는 메소드
     * Job을 수행하기 전 상태
     *
     */
    @Override
    public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
        return false;
    }

    /**
     * Trigger가 중단된 상태
     */
    @Override
    public void triggerMisfired(Trigger trigger) {

    }

    /**
     * Trigger가 완료된 상태
     */
    @Override
    public void triggerComplete(Trigger trigger, JobExecutionContext context, Trigger.CompletedExecutionInstruction triggerInstructionCode) {

    }
}

 

TimerUtils : jobClass와 timer 정보를 받아서 JobDetail, trigger를 만든다

package com.example.studyquartz.util;

import com.example.studyquartz.info.TimerInfo;
import org.quartz.*;

import java.util.Date;

public class TimerUtils {
    private TimerUtils() {}

    // jobClass와 timerInfo를 받아서 JobDetail를 만든다.
    // jobClass의 simpleName을 key로 사용한다.
    public static JobDetail buildJobDetail(final Class jobClass, final TimerInfo info) {
        final JobDataMap jobDataMap = new JobDataMap();
        jobDataMap.put(jobClass.getSimpleName(), info);

        return JobBuilder
                .newJob(jobClass)
                .withIdentity(jobClass.getSimpleName())
                .setJobData(jobDataMap)
                .build();
    }

    // jobClass와 timerInfo를 받아서 trigger를 만든다.
    public static Trigger buildTrigger(final Class jobClass, final TimerInfo info) {
        // 스케줄러 만들기
        // 1. 반복 간격 설정
        SimpleScheduleBuilder builder = SimpleScheduleBuilder.simpleSchedule().withIntervalInMilliseconds(info.getRepeatIntervalMs());

        // 2. 반복 횟수 설정
        if (info.isRunForever()) {
            builder = builder.repeatForever(); // 영원히 실행할 거면 repeatForever로 설정
        } else {
            builder = builder.withRepeatCount(info.getTotalFireCount() - 1); // 첫번째는 포함되지 않아서 5번 실행시키고 싶으면 4를 넣어줌
        }

        // 트리거에 작업스케줄러이름, 스케줄러, 시작시간을 설정
        return TriggerBuilder
                .newTrigger()
                .withIdentity(jobClass.getSimpleName())
                .withSchedule(builder)
                .startAt(new Date(System.currentTimeMillis() + info.getInitialOffsetMs()))
                .build();
    }

}

 

 

QuartzConfiguration : DataSource 스프링 빈을 수동으로 만들어준다.

  의문점! @EnableAutoConfiguration, @QuartzDataSource 없어도 정상 동작함.

               reference https://github.com/spring-projects/spring-framework/issues/27709

package com.example.studyquartz;

import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.quartz.QuartzDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;


@Configuration
//@EnableAutoConfiguration
public class QuartzConfiguration {

    /*
    https://docs.spring.io/spring-boot/docs/2.1.1.RELEASE/reference/html/boot-features-quartz.html
    https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.quartz
    https://wannaqueen.gitbook.io/spring5/spring-boot/undefined-1/41.-quartz-scheduler

        -> 스프링부트에서 quartz 의 jobStore 을 사용하기 위한 설정 : @Bean과 @QuartzDataSource를 같이 사용하여 datasource 빈 생성 메소드를 만들어준다.

        -> @EnableAutoConfiguration, @QuartzDataSource 를 제거해도 정상 동작함.(버전 업 되면서 뭔가 수정 된 듯?)

        예상.
        1. @EnableAutoConfiguration 이 없어도 dataSource 객체는 spring bean으로 등록 됨
        2. @QuartzDataSource 가 없고 spring bean 이름이 quartzDataSource가 아니여도 어떤 내부 동작에 의해 Quartz JobStore가 dataSource 스프링 빈을 찾아 db 연결을 함
    */
    @Bean(name = "aa")
    //@QuartzDataSource
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource aa() {
    /*
    https://escapefromcoding.tistory.com/m/711
        -> 스프링 부트 환경에서 db와 연결하기 위한 dataSource 빈을 등록해준다.
     */
        return DataSourceBuilder.create().build();
    }
}

quartz.sql

https://github.com/liliumbosniacum/timerservice/blob/master/src/main/resources/sql/quartz.sql
  DB 방식으로 JobStore 를 사용하기 위한 DDL

 

application.properties

    job-store-type을 jdbc 또는 memory로 설정할 수 있다. 

# JobStore 를 DB 방식으로 사용하면 Scheduler 에 대한 Clustering 가능하다.
# JobStore 를 DB 방식으로 사용하기 위한 설정
using.spring.schedulerFactory=true
spring.quartz.job-store-type=jdbc
spring.datasource.jdbc-url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/quartz_schema
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=

 

build.gradle

  • quartz라이브러리를 사용하기 위해 추가
        implementation 'org.springframework.boot:spring-boot-starter-quartz'
  • web어노테이션을 사용하기 위해 추가
        implementation 'org.springframework.boot:spring-boot-starter-web'
  • mysql 연결하기 위해 추가
        implementation 'org.springframework.boot:spring-boot-starter-jdbc:2.7.5'
        runtimeOnly 'mysql:mysql-connector-java'
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.5'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-quartz'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc:2.7.5'
    runtimeOnly 'mysql:mysql-connector-java'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

 

 

 

참고

Quartz 홈페이지 : http://www.quartz-scheduler.org/

https://velog.io/@park2348190/Spring-Boot-%ED%99%98%EA%B2%BD%EC%9D%98-Quartz-Scheduler-%ED%99%9C%EC%9A%A9

https://sabarada.tistory.com/113

스캐줄러 종류 https://dahye-jeong.gitbook.io/spring/spring/2020-03-23-batch/2021-11-24-batch-scheduler

https://velog.io/@smallcherry/%EB%B0%B0%EC%B9%98%EC%99%80-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC

https://medium.com/used-developer/quartz-scheduler-%ED%81%B4%EB%9F%AC%EC%8A%A4%ED%84%B0%EB%A7%81-%EA%B5%AC%EC%84%B1-baad3a764ee0

JSP Web 프로그래밍 - Quartz + Spring Batch 조합하기 (stechstar.com)