상황
현재 진행중인 프로젝트에서 사용자가 종료시간을 설정하여 과제를 생성할 수 있다.
해당 종료시간이 되면 과제는 자동으로 종료상태가 돼야하기 때문에 스케줄링 처리가 필요했다.
기존 방식
처음 구현한 방식은 @Scheduled 어노테이션을 활용하는 방법이었다.
구글링을 통해 찾아봐도 스프링에서 스케줄링 처리를 하는 방법은 모두 해당 어노테이션을 사용하는 방법으로 설명한다.
하지만 여기에는 문제가 있었다.
현재 요구사항은 사용자가 직접 종료시간을 설정해 과제를 생성하기 때문에, 스케줄링이 동작해야하는 시간이 고정되지 않았는데, @Scheduled로는 처리할 수 없었다.
간단하게 살펴보자면, 해당 어노테이션은 속성으로 스케줄링 시간을 설정할 수 있는데,
fixedRate, fixedDelay 속성은 ms단위의 시간을 받아 일정 주기마다 스케줄링을 처리한다.
cron속성은 표현식을 이용해, 특정 시간대, 혹은 특정 날짜마다 스케줄링이 동작하도록 설정할 수 있다.
이렇게 스케줄링이 동작해야하는 시간이 일정한 주기를 가지고있다면 충분히 해당 어노테이션으로 처리할 수 있지만, 동적으로 처리할 수는 없었다.
때문에 처음 해결했던 방법은 풀링방식처럼 1분마다 계속해서 처리해야할 작업이 있는지 검사하는 방식이었다.
@Scheduled(fixedRate = 60000)
public void ChallengeClosing() {
challengeService.ChallengeClosing();
}
위 코드로 스케줄링을 등록하면 1분마다 해당 메서드가 동작하게 된다.
처리해야하는 작업이 있는지 확인하려면 db에도 접근해야하는데, 필요없는 과정을 1분마다 계속해서 검사하는 방식은 너무 비효율적이었다.
개선 과정
- 개선 방법 찾기
그래서 계속해서 관련된 정보를 찾아보던중 TaskScheduler객체를 알게되었다.
어노테이션은 특정 동작들을 편하게 적용하기 위해서 사용해주는 일종의 표시일뿐, 그 자체가 구현은 아니다.
즉 @Scheduled가 어떤 옵션을 제공해주는지와 상관없이 내부적으로 Task를 등록하고 처리해주는 스케줄러 객체는 따로 있다는 것이다.
실제로 해당 어노테이션이 포함된 패키지를 뜯어보면 알수있다.
spring-context 패키지를 살펴보면 springframework.scheduling.annotation 패키지 속에 우리가 사용하는 Scheduled 어노테이션이 위치한걸 알 수 있다.
그럼 이제 주변 다른 파일들을 살펴보면 스케줄링을 처리해주는 녀석이 누구인지 유추할 수 있을 것이다.
먼저 해당 어노테이션 바로 아래의 파일을 보자.
'ScheduledAnnotationBeanPostProcessor' 라는 이름에서 유추해볼때,
@Scheduled 어노테이션이 붙은 Bean 객체를 찾아 사전 작업을 하는 녀석인 것을 알수있다.
(구체적인 동작 과정을 모두 분석하기에는.. 시간이 너무 오래걸릴것같으니 패스하겠다)
해당 객체 내부를 보면 'taskScheduler'라는 이름으로 스케줄러 빈 객체를 찾는것을 알 수 있다.
그리고 해당 클래스도 가볍게 살펴보면, Runnable타입의 task와 시간을 파라미터로 받아서 실행해주는 것을 알 수 있다.
자 여기까지 왔다면 이제 알 수 있을거다.
우리는 TaskScheduler객체와 schedule 메서드를 직접 사용해서 동적으로 원하는 시간에 스케줄링 하도록 구현할 수 있다.
- 구현 과정
이제 방법을 알았으니 구현하면 된다.
사용할 TaskScheduler를 직접 Bean 객체로 등록해주고, 스케줄러가 처리할 Task 클래스를 Runnable을 구현하는 형태로 만들어둔다.
그리고 스케줄러에 Task를 등록해주기 위한 service도 만들어주겠다.
- 스케줄러 빈 객체 등록
TaskScheduler의 구현클래스는 여러 종류가 있는데, 스프링에서 기본으로 사용하고있는 ThreadPoolTaskScheduler를 사용하겠다.
@Configuration
public class SchedulingConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10); // 동시에 실행할 스레드 수 설정
scheduler.setThreadNamePrefix("scheduled-task-"); //스레드 이름 접두사
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true); //작업 완료까지 스프링종료를 대기
scheduler.initialize();
return scheduler;
}
}
- Task 클래스 등록
@AllArgsConstructor
public class SchedulingTask<T> implements Runnable {
private final T target;
private final Consumer<T> task;
@Override
public void run() {
task.accept(target);
}
}
- service
@RequiredArgsConstructor
@Service
public class SchedulingService {
private final TaskScheduler taskScheduler;
public <T> void scheduleTask(T target, Consumer<T> action, LocalDateTime executionTime) {
SchedulingTask<T> task = new SchedulingTask<>(target, action);
Instant executionDate = executionTime.atZone(ZoneId.systemDefault()).toInstant();
taskScheduler.schedule(task, executionDate);
}
}
이렇게 모두 만들어두면 동적으로 스케줄링을 할 준비는 모두 마친것이다.
서비스의 scheduleTask 메서드를 사용해 작업을 등록하면 되는데, 등록할 대상 타겟과, 동작 메서드, 동작 시간을 파라미터로 주면 된다.
* 참고로 Consumer<T>는 자바의 함수형 인터페이스 중 하나로, 전달받은 함수를 수행하기만 하고 아무것도 반환하지 않는다. 스케줄러가 처리해야하는 동작을 함수 파라미터로 넘기기 위해 함수형 인터페이스를 사용한다.
필자의 경우, 과제를 생성할때 해당 과제 객체와 종료메서드, 종료 시간을 스케줄러에 등록해주는 방식으로 처리했다.
첫 번째 문제점과 해결
여기까지 했다면 일단 원하는대로 스케줄링 처리가 잘 된다.
하지만 문제가 있다.
스케줄러에 등록되는 작업들은 서버 메모리에 저장되기 때문에, 서버가 꺼지면 아직 처리하지 않은 작업들이 모두 사라진다.
이를 해결하기 위해서는 작업을 DB에 저장하는 등, 영속화를 하고 서버가 재시작되면 다시 등록하는 방법이 있다.
현재 상황에서는 과제 entity가 종료상태 필드를 가지니, 굳이 작업을 db에 저장하고 조회하기 보다는 과제를 조회하는 방식으로 처리했다.
서버가 실행되는 시점에 처리되지 않은 과제가 있는지 탐색하고, 상태를 바꿔주거나 스케줄러에 다시 등록하면 된다.
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void challengeClosingReload() {
List<Challenge> challenges = challengeRepository.findAllByEndStatusIsFalse();
challenges.forEach(challenge -> {
if (challenge.getEndTime().isBefore(LocalDateTime.now())) {
challengeClosing(challenge);
} else {
schedulingService.scheduleTask(challenge, this::challengeClosing,
challenge.getEndTime());
}
});
}
두 번째 문제점과 해결
이제 task가 누락되는 문제는 해결했다.
하지만 한가지 문제가 더 남았다. 분명 스케줄러에 정상적으로 등록되고, task가 제대로 동작하는데 과제의 종료상태가 변하지 않았다.
문제는 트랜젝션이 적용되지 않은 것이었다.
적용되지 않는 이유를 조사하니 @Transactional 어노테이션의 동작 방식 때문이었다.
첫번째 이유는 @Transactional이 적용된 스레드와 스케줄러가 작업을 처리하는 스레드가 달라 적용되지 않는 이유.
두번째는 @Transactional은 스프링이 생성하는 프록시객체를 통해 메서드를 호출할때 적용되는데, 현재 스케줄러 서비스의 구현방식을 보면 new 키워드를 사용해 직접 Runnable타입의 task를 만들고 등록한다.
이런 문제로 트랜젝션 처리가 되지않는다. 이를 해결하는 방법은 스케줄링을 처리할 때처럼, 어노테이션을 사용하지 않고 직접 트랜젝션 처리를 해주면 된다.
트랜젝션을 처리해주는 객체로는 TransactionTemplate가 있다.
복잡한 처리가 필요한게 아니라면 사용방법은 간단하다.
@Override
public void run() {
transactionTemplate.execute(status -> {
task.accept(target);
return null;
});
}
이렇게 작업을 실행하는 부분을 transactionTemplate의 execute메서드에 넣어주면 된다.
마무리
여기까지가 이번 프로젝트에서 동적으로 스케줄링 처리하기 위한 과정이었다.
스프링에서 어노테이션으로 편리하게 제공해주는 기술들이 많다보니 오히려 여기에 갇혀서 그 이상을 직접 구현할 생각을 쉽게 못했었는데, 내부적으로 어떤 클래스와 메서드를 사용해 동작하는지 살펴본 좋은 계기가 됐다.
앞으로는 더욱 유연한 사고로 직접 필요한 기능을 추가할 수 있겠다.
'Web > Spring' 카테고리의 다른 글
[Spring JPA] 테이블 공통 속성 분리하여 Entity 설계하기 (0) | 2024.03.18 |
---|---|
[Spring] queryDsl oneToMany조회시 N+1 문제. (0) | 2024.03.15 |
[Spring] MultipartFile이 포함된 DTO requestBody로 요청받기. swagger 요청 (0) | 2024.02.18 |
[Spring] 자바17+스프링3.x로 업그레이드하며 생긴 이슈. (Spring Security, Swagger) (0) | 2024.02.18 |
[Spring] yml 설정파일 보안정보 분리하기 + 배포환경과 로컬환경 설정정보 쉽게 바꾸기 (0) | 2023.12.15 |