처음 공부한 내용이라 틀린 부분이 있을 수도 있습니다........
Github Code : batch quartz demo
1 도입기
기업 연계 프로젝트를 진행하면서 아래와 같은 기능이 필요하게 됨
- 5분 간격으로 상품 증분데이터를 받아와야 하는 기능
- 상품 데이터의 이상치 검사를 위해서 받은 상품 데이터들을 하나의 배치 ID로 매핑해서 이상치 검사 서버로 보냄
- 일정 주기로 동작(대충 스케줄링?)+ 데이터를 묶어서 처리(음...배치?)를 생각하게 됨
- 대용량 수준까지는 아니지만 반복적인 작업을 처리하고 새로운 상품 데이터의 처리라는 점에서 작업 관리가 중요하다고 생각함
Scheduling?
- 일정한 시간 간격으로 반복적인 작업을 수행하는 도구
- 특정 작업을 주기적 혹은 일정 시간이 지난 후에 작업을 수행할 수 있으며 효율적인 작업 관리 가능
- Java나 Spring에서는 다양한 스케줄링 클래스들이 존재
- Timer
- ScheduledExecitorService
Spring에서도 @Scheduled를 활용해서 스케줄링을 실행시킬 수 있다.
하지만 내가 최종적으로 원한 것은 작업의 상태를 DB나 특정 저장소에서 작업 스케줄을 관리하고 싶었다. 물론 @Scheduled를 사용해서도 시작전에 작업을 저장하고 할 수 있지만 짧은 개발 기간을 고려하면 시간을 너무 잡아먹을 거 같았다.
2 Quartz
Quartz의 주요 기능
기능 | 설명 |
작업 스케줄링 | 지정된 시간 간격 또는 특정 시간에 작업 예약 |
작업 실행 및 관리 | 작업 실행 횟수, 마지막 실행 시간, 다음 실행 시간 등 작업에 대한 메타데이터를 추적 |
작업 중단 및 재개 | 특정 이벤트나 조건에 따라 작업을 멈추고 필요에 따라 다시 시작 |
병렬 처리 | 멀티스레드 환경을 지원하여 동시에 여러 작업을 병렬로 실행 |
결과 처리 | 작업의 실행 이력을 관리하고, 이후 로직이나 오류 처리를 효율적 관리 |
쿼츠에서 주로 사용하는 클래스와 객체들은 아래와 같다.
클래스 및 인터페이스
용어 | 설명 |
Job | 실행할 작업을 정의하는 인터페이스 특정 작업 클래스를 정의하고 해당 인터페이스를 상속 받아 실행 내용을 정의. ex) class Customjob implements Job |
JobDetail | Job 인스턴스에 대한 메타데이터를 저장하는 클래스 |
Trigger | 작업의 실행 시점을 결정하는 스케줄링 조건을 정의하는 인터페이스 |
SimpleTrigger | 고정된 간격이나 지연 시간에 따라 작업을 실행시키는 Trigger |
CronTigger | Cron 표현식을 사용한 스케줄링 트리거 |
JobDataMap | Job 인스턴스가 실행할 때 사용할 수 있는 정보를 담을 수 있는 객체 JobDetail 생성시에 같이 생성하여 설정할 수 있다. |
Scheduler | JobDetil과 Trigger를 시스템에 등록하고 스케줄에 맞춰 Job을 실생시키는 객체 |
SchedulerFactory | Scheduler 인스턴스를 생성하는 팩토리 객체 |
Lisetenr | 스케줄러의 이벤트를 받을 수 있는 인터페이스이며 JobListener와 TriggerListener를 제공한다. |
대강적으로 큰그림을 보면 아래와 같은 구조로 구성된다고 생각하면 될거 같다.
Spring Boot Quartz
간단한 스케줄러를 하나 만들어보자 일단 의존성을 추가해준다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-quartz'
}
1. Job 정의
- PersistJobDataAfterExecution - 해당 Job이 실행되고 나서 JobDataMap 안의 값을 유지하게 해줌
- Job 인스턴스는 작업이 끝나면 인스턴스는 사라진다.
- 즉 해당 작업 안에서 JobDataMap의 변경 사항을 유지하도록 하는 역할
- Job을 수행하다가 실패했을 때 실패 전에 값을 변경했다면 반영됨
- DisallowConcurrentExecution - 동일한 Job이 동시에 여러 스레드에서 중복 실행되는 것을 방지
@Slf4j
@PersistJobDataAfterExecution
@DisallowConcurrentExecution
public class NewProductJob implements Job {
public static String FIRST_KEY = "job1";
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
JobDataMap map = jobExecutionContext.getJobDetail().getJobDataMap();
int tmpIdx = map.getInt(FIRST_KEY);
log.info("One : {} 번째 스케줄러 실행", tmpIdx);
map.put(FIRST_KEY, tmpIdx + 1);
try {
Thread.sleep(15000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("One : {} 번째 스케줄러 완료", tmpIdx);
}
}
로직을 간단히 설명하면
- JobDataMap에서 "job1"의 값을 읽어옴
- log 기록
- job1 값 증가
- sleep 15초
- sleep을 수행하는 이유는 밑에서 자세히 설명하겠슴다
참고 Job vs QuartzJobBean
spring quartz 라이브러리에는 QuartzJobBean이라는 추상 클래스가 존재한다. QuartzJobBean 내부적으로 Job을 상속받아서 구현해놓았는데 차이점은 아래와 같다.
Job을 사용해야 할 때:
- Spring 없이 Quartz를 사용하는 경우.
- Spring 환경이 필요 없는 간단한 Quartz Job을 작성할 때.
QuartzJobBean을 사용해야 할 때:
- Spring의 DI를 통해 빈을 주입받아야 하는 경우.
- Spring 관리 하에서 Quartz를 사용하는 경우.
2. JobDetail 및 Trigger 설정
@Slf4j
@Configuration
public class QuartzConfig {
@Bean
public JobDetail findNewProduct() {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put(NewProductJob.FIRST_KEY, 1);
return JobBuilder.newJob(NewProductJob.class)
.withIdentity("newProductJob")
.setJobData(jobDataMap)
.storeDurably()
.build();
}
@Bean
public Trigger findNewProductTrigger(JobDetail findNewProduct) {
return TriggerBuilder.newTrigger()
.withIdentity("newProductTrigger")
.forJob(findNewProduct)
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule("*/5 * * * * ?"))
.build();
}
}
- Bean으로 등록해서 하는 방식은 위 코드처럼 등록하면 된다
- 이때 storeDualy()은 트리거 없이도 스케줄러에 등록할 수 있도록 보장해주는 옵션이다
- Bean으로 JobDetail을 등록 할 때 해당 설정 없이 등록한다면 스케줄러에 등록될 때 Trigger 설정 없이 등록되어 아래와 같은 에러가 남
Jobs added with no trigger must be durable.
만약 해당 설정 없이 등록하고 싶다면 아래처럼 스케줄러를 DI 받아 직접 등록해주면 된다
@Slf4j
@Configuration
public class QuartzConfig {
private final Scheduler scheduler;
public QuartzConfig(Scheduler scheduler) {
this.scheduler = scheduler;
}
@PostConstruct
public void scheduleNewProductJob() throws SchedulerException {
// JobDetail 생성
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put(NewProductJob.FIRST_KEY, 1);
JobDetail jobDetail = JobBuilder.newJob(NewProductJob.class)
.withIdentity("newProductJob")
.setJobData(jobDataMap)
.build();
// Trigger 생성
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("newProductTrigger")
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule("*/5 * * * * ?"))
.forJob(jobDetail)
.build();
// Scheduler에 JobDetail과 Trigger 추가
scheduler.scheduleJob(jobDetail, trigger);
}
}
- Cron 스케줄러로 매 5초 단위로 Job 실행
3. sleep 15초를 수행한 이유
시작은 만약 1분 간격으로 상품 데이터를 읽어왔는데 1만건의 데이터를 읽어올 경우 로직을 수행하다 보면 작업이 밀리는 상황이 발생했을 때 어떻게 되는지 궁금했다.
- 우선 비슷한 상황을 가정해보기 위해 15초 sleep()을 걸고 스케줄러는 5초마다 실행
- 스케줄러의 스레드풀의 기본 값이 10이므로 작업이 밀려도 스레드 풀에 여유가 있다면 5초마다 작업 진행
- 즉 5초에 시작한 작업 밀림(스레드 1개 사용)
- 10초 정상 실행
- 15초 정상 실행
- 이런식으로 쭉 실행 됨
- 그렇다면 2초 간격으로 스케줄을 설정하고 10개의 스레드를 다 쓰고 있다면?
- JobStore에 저장된 작업 리스트에서 작업이 끝나면 바로 밀린 작업 시작
결과를 보면 중간에 "2번째 실행"이 처음으로 나오는 부분을 보면 작업이 끝나면 바로 밀린 작업을 실행하는 것을 볼 수 있다.
2024-10-31T14:40:38.005+09:00 INFO 2212 --- [eduler_Worker-1] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:40.000+09:00 INFO 2212 --- [eduler_Worker-2] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:42.010+09:00 INFO 2212 --- [eduler_Worker-3] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:44.004+09:00 INFO 2212 --- [eduler_Worker-4] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:46.000+09:00 INFO 2212 --- [eduler_Worker-5] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:48.007+09:00 INFO 2212 --- [eduler_Worker-6] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:50.001+09:00 INFO 2212 --- [eduler_Worker-7] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:52.001+09:00 INFO 2212 --- [eduler_Worker-8] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 실행
2024-10-31T14:40:53.005+09:00 INFO 2212 --- [eduler_Worker-1] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:40:54.002+09:00 INFO 2212 --- [eduler_Worker-9] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
2024-10-31T14:40:55.014+09:00 INFO 2212 --- [eduler_Worker-2] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:40:56.001+09:00 INFO 2212 --- [duler_Worker-10] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
2024-10-31T14:40:57.024+09:00 INFO 2212 --- [eduler_Worker-3] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:40:58.001+09:00 INFO 2212 --- [eduler_Worker-1] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
2024-10-31T14:40:59.006+09:00 INFO 2212 --- [eduler_Worker-4] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:41:00.000+09:00 INFO 2212 --- [eduler_Worker-2] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
2024-10-31T14:41:01.007+09:00 INFO 2212 --- [eduler_Worker-5] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:41:02.012+09:00 INFO 2212 --- [eduler_Worker-3] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
2024-10-31T14:41:03.009+09:00 INFO 2212 --- [eduler_Worker-6] c.s.s.g.schedule.job.NewProductJob : One : 1 번째 완료
2024-10-31T14:41:04.001+09:00 INFO 2212 --- [eduler_Worker-4] c.s.s.g.schedule.job.NewProductJob : One : 2 번째 실행
- 어...근데 만약 특정 작업은 순차적으로 진행되어야 한다면?
- 이럴 경우 DisallowConcurrentExecution 해당 설정을 통해 해당 작업이 진행되고 있다면 다른 스레드가 수행하지 못하도록 할 수 있다.
참고
https://advenoh.tistory.com/52
https://kouzie.github.io/springboot/Spring-Boot-Quartz/#spring-boot-quartz-%EC%84%A4%EC%A0%95
'개발 > spring boot' 카테고리의 다른 글
전략 패턴과 팩토리 메소드 패턴 리팩토링 (2) | 2025.01.03 |
---|---|
[Spring] Quartz 도입기 2 (0) | 2024.11.21 |
[Spring] IoC, DI (0) | 2024.04.24 |
[Spring]BeanFactory와 ApplicationContext (0) | 2023.08.09 |
[Spring]관심사의 분리 (0) | 2023.08.07 |