스케줄러 Quartz를 사용하는 방법을 알아 보려고 합니다. 사람들이 배치와 스케줄러를 많이 혼동 하시는데 다른 개념이라
고 보시면됩니다. 차이점은 아래 주소를 통해 확인해주세요
스케줄러 Quartz를 사용하는 이유는 무엇일까요? 간단하게 스프링에서 제공해주는 @Scheduled를 통해서 사용 할 수 있는데 말이죠. 저는 아래에 내용인거 같습니다.
장점
- In-memory Job Scheduler
- DB를 이용한 Clustering
만약 여러개의 배치서버가 도는 경우 한쪽서버에서 만 특정Job이 수행되도록 해줘야 합니다. 물론 개발자가 이러한 기능을 개발 할 수 있겠지만 Quartz는 이러한 기능을 DB를 통한 Clustering 방식으로 손쉽게 사용할 수 있도록 지원 해주고 있습니다. 또한 필요에 따르면 DB를 이용하지 않고 Memory에서 가능하도록 하는 기능도 제공해주고 있습니다. 그 만큼 실제 개발가 개발하려면 오랜시간이 걸리는 기능 들을 Quartz는 정말 적은 시간으로 구성 가능하게 만드는 것을 도와주고 있기 때문에 많은 개발자들이 사용하고 있는 거 같습니다.
Quartz 구성 요소
Scheduler
스케줄을 관리 하는 객체입니다.
JobDetail
Job을 실행시키기 위한 필요 정보를 담고 있습니다. JobDetail을 만들기 위해 필요한 것은 Job의 이름, Job의 파라마티터인 JobDataMap, Job을 언제 동작시킬지에 대한 정보 Trigger가 필요합니다.
Trigger
Job을 언제 동작시킬지, 몇번 반복시킬지에 대한 정보를 담고 있습니다. 날짜 정보는 흔히 사용하는 Cron 형식으로 구성 가능합니다.
JobDataMap
Job을 동작시키는데 필요한 Parameter를 담고 있습니다. 흔히 날짜, 횟수에 대한 정보를 담아서 동작시킵니다.
JobListener
Job이 실행될때의 이벤트 정보를 담고 있습니다. Job이 실행 되기전 , 중단, 실행 완료 후 각종 이벤트를 넣어 줄 수 있습니다.
TriggerListener
Trigger가 실행될때의 이벤트 정보를 담고 있습니다.
Quartz 예제
프로젝트 구조는 아래의 형태로 구성 될것입니다.
Quartz를 이용하기 위해서는 Quartz에서 사용하는 table이 필요합니다. 자동으로 Schema를 생성해주는 설정이 있지만 저는 수동으로 Schema를 구성하는 방법을 선택하려고 합니다. Schema 생성 SQL 은 아래의 주소를 참고 해주세요
SQL을 실행하시면 아래의 Schema가 생성되는 것을 확인 할 수 있습니다.
그러면 이제 Quartz 사용하기 위한 Dependency와 yml 파일을 설정해주세요. 저는 Jpa를 사용하기 때문에 Jpa를 넣었지만 Mybatis를 사용거나 다른 DB를 사용하시는 분들은 그에 맞게 환경을 구성해주세요.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter'
implementation "org.springframework.boot:spring-boot-starter-quartz"
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
application.yml
spring:
jpa:
hibernate:
ddl-auto: create
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
use-new-id-generator-mappings: false
show-sql: true
properties:
hibernate.format_sql: true
datasource:
url: jdbc:mysql://localhost:3306/batch?serverTimezone=UTC&characterEncoding=UTF-8
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
quartz.properties
org.quartz.scheduler.instanceName=pooney
org.quartz.scheduler.instanceId=AUTO
org.quartz.scheduler.rmi.export=false
org.quartz.scheduler.rmi.proxy=false
org.quartz.threadPool.class=org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount=3
org.quartz.context.key.QuartzTopic=QuartzPorperties
org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.tablePrefix=QRTZ_
org.quartz.jobStore.isClustered=true
org.quartz.jobStore.dataSource = pooney
org.quartz.dataSource.pooney.provider=hikaricp
org.quartz.dataSource.pooney.driver = com.mysql.cj.jdbc.Driver
org.quartz.dataSource.pooney.URL = jdbc:mysql://localhost:3306/batch?serverTimezone=UTC&characterEncoding=UTF-8
org.quartz.dataSource.pooney.user = root
org.quartz.dataSource.pooney.password = 1234
org.quartz.dataSource.pooney.maxConnections = 30
Quartz 설정 정보는 Quartz Doc에 잘 설명되어 있으니 참고해주시면 됩니다.
http://www.quartz-scheduler.org/documentation/
quartz.properties 에서 신경 써주셔야 하는 부분은 isClustered, dataSource 입니다. 이름에서 알 수 있 듯이 isClustered부분은 클러스터링을 담당합니다. 필요에 따라 설정을 변경하시면 됩니다. 그렇다면 dataSource부분의 신경써야 org.quartz.dataSource.{pooney} 입니다. 이부분은 아래와 같이 구성요소가 비슷해 실수 하는 경우가 많습니다.
하지만 Quartz.2.3 부터 설정이 변경되어 빨간색으로 칠해진 부분이 동일하게 설정되어야 합니다. 아니면 아래와 같은 에러를 만날 수 있으니 주의해주세요
org.quartz.jobStore.dataSource = pooney
org.quartz.dataSource.pooney
아래는 Quartz.2.3에 Config 변경 내용입니다.
http://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/ConfigDataSources.html
AutoWiringSpringBeanJobFactory
public class AutoWiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware {
private transient AutowireCapableBeanFactory beanFactory;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
beanFactory = applicationContext.getAutowireCapableBeanFactory();
}
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
final Object job = super.createJobInstance(bundle);
beanFactory.autowireBean(job);
return job;
}
}
Job Class에서는 Dependency를 해주지 않습니다. 하지만 대부분의 기능은 Dependency를 통해서 어떤 특정 기능을 수행하는데 AutoWiringSpringBeanJobFactory는 JobClass에서 Dependency가 가능하도록 도와 주는 기능을 담당합니다.
QuartzJobListner
@Slf4j
public class QuartzJobListner implements JobListener {
@Override
public String getName() {
return this.getClass().getName();
}
@Override
public void jobToBeExecuted(JobExecutionContext context) {
log.info("Job 수행 되기 전");
}
@Override
public void jobExecutionVetoed(JobExecutionContext context) {
log.info("Job 중단");
}
@Override
public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
log.info("Job 수행 완료 후");
}
}
JobListener는 Job 실행 전후에 event를 걸어주는 역할을 담당합니다.
QuartzTriggerListener
@Slf4j
public class QuartzTriggerListener implements TriggerListener {
@Override
public String getName() {
return this.getClass().getName();
}
@Override
public void triggerFired(Trigger trigger, JobExecutionContext context) {
log.info("Trigger 실행");
}
/**
* @Content : 결과가 true이면 JobListener jobExecutionVetoed(JOB중단) 실행
*/
@Override
public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
log.info("Trigger 상태 체크");
JobDataMap map = context.getJobDetail().getJobDataMap();
int executeCount = 1;
if (map.containsKey("executeCount")) {
executeCount = (int) map.get("executeCount");
}
return executeCount >= 2;
}
@Override
public void triggerMisfired(Trigger trigger) {
}
@Override
public void triggerComplete(Trigger trigger, JobExecutionContext context, Trigger.CompletedExecutionInstruction triggerInstructionCode) {
log.info("Trigger 성공");
}
}
TriggerListener는 Trigger 실행 전후에 event를 걸어주는 역할을 담당합니다.
QuartzConfig
@Configuration
@RequiredArgsConstructor
@Slf4j
public class QuartzConfig {
private final DataSource dataSource;
private final ApplicationContext applicationContext;
private final PlatformTransactionManager platformTransactionManager;
@Bean
public SchedulerFactoryBean schedulerFactoryBean() {
SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
AutoWiringSpringBeanJobFactory autoWiringSpringBeanJobFactory = new AutoWiringSpringBeanJobFactory();
autoWiringSpringBeanJobFactory.setApplicationContext(applicationContext);
schedulerFactoryBean.setJobFactory(autoWiringSpringBeanJobFactory);
schedulerFactoryBean.setDataSource(dataSource);
schedulerFactoryBean.setOverwriteExistingJobs(true);
schedulerFactoryBean.setAutoStartup(true);
schedulerFactoryBean.setTransactionManager(platformTransactionManager);
schedulerFactoryBean.setQuartzProperties(quartzProperties());
return schedulerFactoryBean;
}
private Properties quartzProperties() {
PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean();
propertiesFactoryBean.setLocation(new ClassPathResource("quartz.properties"));
Properties properties = null;
try {
propertiesFactoryBean.afterPropertiesSet();
properties = propertiesFactoryBean.getObject();
} catch (IOException e) {
log.error("quartzProperties parse error : {}", e);
}
return properties;
}
}
QuartzJob
@Slf4j
@Component
@PersistJobDataAfterExecution
@DisallowConcurrentExecution
// @RequiredArgsConstructor 사용 못함
public class QuartzJob implements Job {
// private final MarketRepository MarketRepository;
@Autowired
private MarketRepository marketRepository;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
log.info("Quartz Job Executed");
JobDataMap dataMap = context.getJobDetail().getJobDataMap();
log.info("dataMap date : {}", dataMap.get("date"));
log.info("dataMap executeCount : {}", dataMap.get("executeCount"));
//JobDataMap를 통해 Job의 실행 횟수를 받아서 횟수 + 1을한다.
int cnt = (int) dataMap.get("executeCount");
dataMap.put("executeCount", ++cnt);
//Market 테이블에 pooney_현재시간 데이터를 insert 한다.
Market market = new Market();
market.setName(String.format("pooney_%s", dataMap.get("date")));
market.setPrice(3000);
marketRepository.save(market);
}
}
Lombok에서 제공하는 @RequiredArgsConstructor를 통해 Dependency를 받는 경우 많은데요, 이경우 위에서 설명한것 같이 Dependency를 받지 못합니다. 때문에 위에서 AutoWiringSpringBeanJobFactory를 설정했기 때문에 @Autowired를 사용할 수 있어 Dependency를 받아 사용 가능합니다.
@PersistJobDataAfterExecution는 Job이 동작중에 JobDatMap을 변경 할때 사용합니다.
QuartzService
@Slf4j
@Configuration
@RequiredArgsConstructor
public class QuartzService {
private final Scheduler scheduler;
@PostConstruct
public void init() {
try {
//스케줄러 초기화 -> DB도 CLAER
scheduler.clear();
//Job 리스너 등록
scheduler.getListenerManager().addJobListener(new QuartzJobListner());
//Trigger 리스너 등록
scheduler.getListenerManager().addTriggerListener(new QuartzTriggerListener());
//Job에 필요한 Parameter 생성
Map paramsMap = new HashMap<>();
//Job의 실행횟수 및 실행시간
paramsMap.put("executeCount", 1);
paramsMap.put("date", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
//Job 생성 및 Scheduler에 등록
addJob(QuartzJob.class, "QuartzJob", "Quartz Job 입니다", paramsMap, "0/5 * * * * ?");
} catch (Exception e){
log.error("addJob error : {}", e);
}
}
//Job 추가
public <T extends Job> void addJob(Class<? extends Job> job ,String name, String dsec, Map paramsMap, String cron) throws SchedulerException {
JobDetail jobDetail = buildJobDetail(job,name,dsec,paramsMap);
Trigger trigger = buildCronTrigger(cron);
if(scheduler.checkExists(jobDetail.getKey())) scheduler.deleteJob(jobDetail.getKey());
scheduler.scheduleJob(jobDetail,trigger);
}
//JobDetail 생성
public <T extends Job> JobDetail buildJobDetail(Class<? extends Job> job, String name, String desc, Map paramsMap) {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.putAll(paramsMap);
return JobBuilder
.newJob(job)
.withIdentity(name)
.withDescription(desc)
.usingJobData(jobDataMap)
.build();
}
//Trigger 생성
private Trigger buildCronTrigger(String cronExp) {
return TriggerBuilder.newTrigger()
.withSchedule(CronScheduleBuilder.cronSchedule(cronExp))
.build();
}
}
전체적인 Quartz가 동작하는 Flow는 아래와 같습니다.
- scheduler 초기화 -> DB 내용도 초기화가 이루어집니다.
- Job,Trigger Listener 등록
- paramsMap을 통해 Job에 넘길 카운트 횟수 , Job이 동작하는 시간 생성
- QuartzJob class와 동작시킬 Job의이름, Job의 설명, paramsMap, 동작시킬 시간을 addJob에게 넘겨 Job을 생성
- schedulerFactoryBean.setAutoStartup(true)를 통해서 어플리케이션 동작시 자동으로 Quartz가 작동 하여 정해진 시간에 Job을 실행.
- Job이 실행될때 TriggerListener가 동작하여 vetoJobExecution를 통해 Job의 실행 상태를 체크한다. 상태체크는 해당 Job이 2번이상 실행되면 false를 return 하고 JobListener의 jobExecutionVetoed가 동작하여 더이상 Job 이 동작하지 않는다.
결과는 아래와 같이 리스너와 함께 Job이 두번째 동작할때 "Job 중단" 로그를 출력하고 Job이 실행되지 않는 것을 확인 할 수 있습니다.
DB를 확인 해보겠습니다
Market Table에는 정상적으로 "pooney_현재시간" 레코드가 추가 된 것과 함께 어떤 Job이 등록되었는지, 해당 Job의 Cron은 어떻게 되지는지, 언제 동작했는지 , 이전 Job이 종료된 사간은 언제인지등 다양한 정보가 보여지는 것을 확인 할 수 있습니다.
Market.table
qrtz_cron_triggers
qrtz_fired_triggers
qrtz_job_details
qrtz_locks
qrtz_triggers
이렇게 스케줄러 Quartz를 알아 보았습니다. 다음에는 Quartz + Spring Batch를 결합하는 방법을 소개해 드리겠습니다.감사합니다.
'Spring boot > spring-batch' 카테고리의 다른 글
Spring Batch - Rest API JOB 구성 (0) | 2023.08.27 |
---|---|
Spring boot - Spring Batch + Quartz 실행 (3) | 2022.07.01 |
Spring boot - Spring Batch란? (0) | 2022.06.20 |