불규칙적 상황? 동적 스케줄링?
개발을 진행하다 보면 특정 시점에 기능이 동작해야 하는 경우가 존재한다.
trigger가 존재한다면 단순하게 trigger 메서드에서 target 메서드를 실행하면 되지만, trigger가 시간인 경우 어떻게 처리할 수 있을까?
예를 들어 '예약 서비스'에서 사용자가 특정 시간에 예약을 했을 때, 예약 시간 10분 전에 알림을 보내는 기능을 생각해 보자.
사용자의 예약 시간은 정기적인 것이 아니라 랜덤 하기 때문에 'cron 식'을 사용한 단순 scheduler'를 사용한다면 상당히 비효율적일 것이다.
사용자 예약 시간을 다음처럼 가정해 보자
예약 시간-> 10:30, 12:00, 17:00
이 경우 cron 식을 사용해서 처리하려면 30분 단위로 계속해서 예약이 존재하는지 확인하고, 존재하는 경우 대상에게 알림을 보내는 기능을 구현해야 한다.
cron 식 -> @Scheduled(cron = "0 30 * * * * ")
예약 타임은 세 개 밖에 없지만 24시간 동안 30분마다 알림을 조회해야 하므로, 총 48번(2/h*24h)의 메서드가 실행되기에 리소스가 낭비된다.
그런데 여기서 조금 더 생각해 보면, 사실 우리는 '예약 시간'에 알림을 보내는 게 아니라 '예약 시간 10분 전'에 알림을 보내야 한다.
그렇다면 알림이 발행되는 시점은 '10:20, 11:50, 16:50'이 된다.
정기적으로 스케쥴러가 돌아가는 cron 식에서는 위의 경우를 만족하려면 10분 단위로 스케쥴러를 돌릴 수밖에 없다.
cron 식 -> @Scheduled(cron = "0 10 * * * * ")
우리가 원하는 건 하루에 세 타임에만 알림을 보내는 메서드를 실행하는 것인데, cron 식을 사용한 스케쥴링을 사용하면 하루에 144번(6/h*24h)의 알림 조회 메서드가 동작하게 되는 것이다.
따라서 이러한 불규칙적인 시간에 대한 스케줄링을 위해 task scheduler를 사용하여 동적 스케줄링을 구현해 보자
Task Scheduler
Task Scheduler란?
간단하게 TaskScheduler는 'Runnable을 상속하고 있는 task'를 'trigger'가 실행되거나 '특정 시간'에 실행되도록 등록하는 역할을 한다고 생각하면 된다.
이는 @Scheduled를 사용해서 정적 스케줄링을 사용하든, TaskScheduler를 사용하여 동적 스케줄링을 사용하든 모두 동일한데, 두 경우 모두 TaskScheduler 구현체의 'schedule' 메서드를 사용해서 스케줄을 등록하게 된다.
@Scheduled(cron 식)의 경우 schedule(task, CronTrigger)를 호출하게 되고,
우리가 동적으로 스케줄을 등록하려는 경우는 schedule(task, startTime)을 호출하게 된다.
동적 스케쥴러를 구현하기 위한 'Instant startTime'을 파라미터로 받는 메서드의 설명을 보면, 지정된 실행 시간에 주어진 Runnable을 예약한다는 것을 알 수 있다.
이를 통해 우리는 사용자가 '특정 시간에 예약'을 진행할 때, 그 시간을 파라미터로 넘겨주기만 하면 간단하게 동적 스케줄링을 구현할 수 있다.
영속성 처리
Scheduler에서 무슨 영속성 문제냐? 라고 생각할 수 있겠지만.. 스케줄러에 등록되는 task들은 서버 메모리에 등록되기 때문에 서버가 재실행되면 등록된 작업들이 모두 날아가는 문제가 발생한다.
즉, 모종의 에러나 배포등의 이슈로 서버가 재실행된다면 내가 등록한 작업들이 모두 날아가서, 예약한 작업이 실행되지 못한다는 의미다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
간단하다.
그냥 서버 재실행시에 내가 task로 등록하기 원하는 데이터를 db에서 조회해서 다시 등록해 주면 된다.
이를 위해 db에 따로 task 테이블을 만들 수도 있겠지만, 이보다는 존재하는 데이터 중에서 특정 조건을 만족하는 데이터를 찾아와서 등록하는 편이 구현도 간편하고 데이터 정합성 측면에서도 좋다고 생각한다.
정리해 보자면, 서버 실행 시 컨텍스트 로드가 끝난 후 db를 조회하여 조건을 만족하는 데이터들을 찾아온 후 task를 다시 서버 메모리에 등록해 주면 된다.
이를 위해 우리는 '@EventListener + ApplicationReadyEvent'를 사용할 것이다.
구현
Class 정의
: TaskScheduler를 사용한 동적 스케줄링을 구현하기 위해 다음 클래스들이 필요하다
- SchedulerConfig
- 우리가 사용할 TaskScheduler를 빈으로 등록하고 커스텀 설정을 하기 위해 사용된다
- TaskScheduler의 구현체는 interface 설명에서 나온 것처럼 기본 구현체인 ThreadPoolTaskScheduler를 사용한다
- SchedulingTask
- 'Runabble' 객체를 상속하는 task로, 메모리에 등록된 task의 작업을 수행시키는 역할을 한다
- 즉, 실제로 우리가 수행시킬 task라고 보면 된다
- SchedulingRepository
- 등록된 task를 취소하기 위해 메모리상에 저장, 취소하는 로직을 구현하는 Repository 클래스
- SchedulingService
- 스케줄링 등록, 취소 등의 작업을 수행하는 비즈니스 로직이 구현된 Service 클래스
- TaskSchedulerEventHandler
- 'task 등록' 및 '서버 실행 시 task 재등록' 로직을 비동기로 실행하기 위한 EventListener를 구현한 클래스
상황
- 'StartDate'와 'EndDate', 'ExamState'를 필드로 가지는 'Exam' Entity가 존재
- 'Exam'이 생성되면 'ExamState'가 'PREPARING' 상태로 생성된다
- 이후 현재 시각이 'StartDate'가 되면 'ExamState'가 'PROGRESS' 상태로, 'EndDate'가 되면 'END' 상태로 변경되어야 한다
1. SchedulerConfig
기본적인 설정을 해주고 ThreadPoolTaskScheduler를 Bean으로 등록한다
2. SchedulingTask
실제 수행될 작업을 받아서 실행시키는 클래스다.
target은 대상이 될 Entity를, task는 target에 적용될 method라 생각하면 된다.
여기서 중요한 점은 'TransactionTemplate'을 사용해서 명시적으로 트랜잭션 처리를 했다는 점이다.
SchedulingService에서 나오겠지만 task를 생성할 때 'new' 키워드를 사용하여 task를 생성하고 곧바로 이 task를 사용하는데, 이 경우 생성된 'task'는 스프링 컨테이너에 의해 관리되는 Bean으로 등록된 객체가 아니기에 스프링 AOP 기반 프록시 패턴으로 작용하는 @Transactional이 적용되지 못한다.
또한 task가 실제로 실행되는 시점은 등록 시점이 아닌 스케줄러가 동작하는 시점이기 때문에, Service가 실행될 때의 스레드가 아니라 스케줄러의 별도 스레드에서 실행된다. 따라서 원래 Service의 트랜잭션과 task가 실행될 때의 트랜잭션이 분리된다.
이러한 문제들에 의해 @Transactional이 적용되지 않기에, 결과적으로 메서드를 호출했을 때 JPA의 Dirty Checking이 동작하지 않아서 엔티티의 변경 사항이 commit 되지 않는 문제가 발생한다.
이를 해결하기 위해 모든 메서드에 'save'를 명시적으로 호출하여 commit을 할 수도 있지만 비효율적이기에, 'TransactionTemplate'을 사용해 명시적으로 트랜잭션을 설정해 주는 방식을 사용하여 어떤 스레드에서 실행되더라도 트랜잭션이 적용되도록 구현하였다.
3. SchedulingRepository
충돌 방지를 위해 ConcurrentHashMap을 사용하여 repository를 구현하였고, 'ScheduledFuture' 타입의 task를 저장하도록 구현하였다.
이를 통해 서버 메모리에 등록되어 있는 task들을 조회할 수 있고, task의 'cancel(true)'를 통해 취소할 수 있다.
4. SchedulingService
Task를 등록하는 메서드와, 등록된 Task를 삭제하는 메서드를 가지고 있다.
1. scheduleTask -> (대상 Entity, 대상에게 수행할 작업, 실행시간)을 받아 task를 등록하는 메서드이다.
SchedulingTask를 새로 만들고, 우리가 Config에서 Bean으로 등록했던 TaskScheduler의 'schedule(Runnable task, Instant startTime)'에 'SchedulingTask'와 'executionTime'을 넘겨 실행시킴으로써 task를 실제로 등록한다.
이후 schedulingRepository에 task를 등록하고 생성된 ScheduledFuture 객체를 저장한다
2. cancelTask -> 대상 Entity의 id를 받아 등록된 task를 취소하는 메서드이다.
repository의 cancel 메서드를 실행하면, target의 id로 저장된 ScheduledFuter를 조회한 후, 등록을 취소한다.
5. TaskSchedulerEventHandler
1. examStateTaskSchedule -> (Exam 엔티티, ExamState, excuteTime, 기타 정보)를 받아서 SchedulingService의 'scheduleTask' 메서드를 호출하여 실질적으로 task를 등록하는 역할을 하는 메서드
2. examTaskHandler -> TaskEvent를 받아 '1. examStateTaskSchedule'을 호출하는 역할
3. examTaskHandler -> ApplicationReadyEvent를 사용하여, 서버 실행 시에 db에서 조건을 만족하는 Exam Entity를 조회한 후, '1. examStateTaskSchedule'을 호출하여 task를 재등록하는 역할
4. updateExamDateTaskHandler -> 등록된 task에서 시간이 변경되었을 때, 등록된 task를 삭제하고 변경된 사항에 맞춰서 task를 재등록하는 역할을 하는 메서드
이상으로 동적 스케줄링에 대해 알아봤다.
지금까지는 당연하게 @Scheduled를 사용했는데, 얕게나마 내부 동작 방식을 알고 내가 커스텀해서 쓸 수 있다는 점을 알았으니 앞으로 유용하게 사용해 봐야겠다.
Reference
- [Spring] TaskScheduler로 동적 스케줄링 처리하기 (고정되지 않은 시간): https://devsungwon.tistory.com/entry/Spring-TaskScheduler%EB%A1%9C-%EB%8F%99%EC%A0%81-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%A7%81-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-%EA%B3%A0%EC%A0%95%EB%90%98%EC%A7%80-%EC%95%8A%EC%9D%80-%EC%8B%9C%EA%B0%84
[Spring] TaskScheduler로 동적 스케줄링 처리하기 (고정되지 않은 시간)
상황현재 진행중인 프로젝트에서 사용자가 종료시간을 설정하여 과제를 생성할 수 있다.해당 종료시간이 되면 과제는 자동으로 종료상태가 돼야하기 때문에 스케줄링 처리가 필요했다. 기존
devsungwon.tistory.com