Spring Boot - 스케줄러 사용해보기 1. FixedDelay vs FixedRate

2020. 2. 23. 21:48Programming/Java

들어가며

최근에 있었던 일입니다. 제가 만든 컴포넌트를 코드 리뷰를 통해 팀원들과 공유하는 자리를 가졌었는데, 스케줄링 된 작업에 대해서 이런 질문이 들어왔었습니다.

어라.. 저 작업이 0.5초마다 실행되게 설정되어 있는데요, 만일 작업의 수행속도가 0.5초를 넘기면 어떻게 되나요? 다음 작업이 무시되나요? 아니면 스레드가 하나 더 생겨서 어떻게든 0.5초마다 계속 실행되도록 하나요? 아니면 밀린 만큼 연달아 실행하나요?

이 질문은 중요했습니다. 왜냐하면 해당 작업은 무조건 순차적으로, 동시가 아닌 딱 1번씩만 실행되어야만 하는 작업이었기 때문이죠. 저는 스프링의 스케줄링에 대해 대략적인 조사를 해 보았을 때의 기억에 의존해서, "제가 알기로 기본 설정이 싱글 스레드이기 때문에, 수행 시간이 밀린다고 멀티 스레딩이 되진 않을 것으로 압니다... 그렇지만! 한 번 더 조사해보도록 하겠습니다" 라고 대답했습니다.

따라서 이번 포스트는 제가 공부한 것들을 기록해두는 용도이며, Spring Boot의 Scheduling에 대해 살짝 더 자세한(?) 정보(특히 FixedRate와 FixedDelay)를 알고자 하는 분께 도움이 될 것이라 생각합니다.

Spring과 Scheduling

Spring은 기업용 어플리케이션 개발을 위한 종합 도구 세트입니다. 그 증거 중 하나로, Spring에서는 주기적으로 일을 수행하고자 할 때 사용가능한 "스케줄링"을 자체적으로 지원을 하는데요. 이는 장기간 미접속 회원의 휴면 상태 처리, 오랜 기간 사용되지 않은 데이터 삭제 등 사람이 직접 개입하지 않고 주기적으로 처리해줘야만 하는 일을 수행하는 데 큰 도움을 줍니다.

본래 리눅스에서는 Cron이라고 불리는 스케줄러가 따로 들어가 있으므로, Cron을 통해 주기적인 업무 처리를 할 수도 있습니다만, 이는 명령 수행의 단위가 쉘 스크립트 단위이므로, 단순히 자바 어플리케이션 내부에서 쉽게 해결될 수 있는 로직이라면 다소 귀찮은 과정이 될 수 있습니다. (스프링 내부의 특정 메소드를 호출하면 될 일인데, 이걸 jar 파일로 따로 빼서 주기적으로 실행시키게 한다면... 그럼 변경사항 생길 때마다 이걸 다시 빌드해서...)

따라서, 서버에서 주기적으로 어떤 일을 수행해야하고, 그 일이 자바 코드로 돌아가는 것이 더 적합하다면, 스프링의 스케줄러를 쓰는 것이 더 좋은 선택입니다.

스케줄러 사용

fixedDelay를 사용

Spring Boot Aplication에서 스케줄러를 사용하려면, @EnableScheduling이라는 Annotation이 필요합니다.

그 후에, 주기적으로 실행하고 싶은 메소드에 @Scheduled Annotation만 붙여주면, 그 메소드는 주기적으로 실행되게 됩니다.

실행 결과. ms 단위에서 약간의 차이는 있지만, 1초 단위로 계속 출력되고 있음을 확인할 수 있습니다.


참고사항

  1. 여기에서는 출력 시의 시간, 해당 메소드를 실행하는 스레드 정보 등을 확인하기 위해 Slf4j 라는 로깅 라이브러리를 사용했습니다. 이 또한 스프링에서 기본적으로 제공하고 있으며, Lombok을 사용할 때에, @Sl4fj 라는 Annotation을 붙여서 클래스 내부에 선언문 없이 log라는 이름의 로거 객체를 만들 수 있습니다.
  2. @Scheduled Annotation은 Spring Bean으로 등록되어 있는 객체의 메소드에 붙어있을 때만 유효합니다.

위의 Service는, 단순히 1초에 한번씩 로그를 찍는 메소드를 가지고 있습니다. 실제 로그를 보면, 정말로 1초에 한번씩 실행되었음을 확인할 수 있습니다. 이는 Scheduled에 같이 들어간 fixedDelay 프로퍼티가 1000으로 설정되어 있기 때문입니다. 눈치 채셨겠지만 이 프로퍼티는 ms단위이므로, 500을 넣으면 0.5초, 3000을 넣으면 3초에 한번씩 실행하도록 설정할 수 있습니다.

fixedRate를 사용

이번에는 fixedDelay 대신에, fixedRate를 사용해보도록 하겠습니다.

이것도 똑같이 1초에 한번씩 실행됩니다...만 뭔가 느낌이 묘하게 다르지 않나요?

fixedDelay를 fixedRate로 바꿨습니다. 실행 결과를 보면 똑같이 1초에 한번씩 실행되는 것을 확인할 수 있지만, 뭔가 느낌이 이상합니다.

감이 좋은 분들은 눈치챘을 수 있겠지만, fixedRate 로 설정한 쪽의 ms는 큰 차이 없이 균일하게 가고 있다는 것을 확인할 수 있습니다. fixedDelay를 사용했을 때는 변화폭이 컸는데 말이죠. 어떤 차이일까요?

이유는, fixedDelay는 해당 작업이 끝난 시점부터 시간을 세고, fixedRate는 해당 작업의 시작 시점부터 시간을 세기 때문입니다.

FixedDelay vs FixedRate

두 방식의 차이를 알아보기 위해서, 메소드 안에 sleep()을 넣어서, 메소드의 수행시간이 조금 더 길어지게 조정해보겠습니다.


참고사항

  1. FixedDelayJob과 FixedRateJob 양 쪽에 스케줄링이 걸려있으면 정확한 비교가 안 됩니다. FixedDelayJob에 스케줄링이 되어있을 경우엔, FixedRateJob의 @Scheduled Annotation을 지워야만 합니다. 자세한 이유는 후술하겠습니다.

fixedDelayJob의 수행 결과. 로그가 사실상 1.5초에 한번 찍히고 있습니다.
fixedRateJob의 수행 결과. 1초에 한번씩 찍히고 있습니다.

Thread.sleep(500)을 통해 로그를 찍을 때마다 0.5초의 텀을 두도록 했습니다. 그 결과, fixedDelayJob은 1.5초(작업 실행시간 0.5초 + 인터벌 1초)에 한 번씩 로그를 찍으며, fixedRateJob은 무조건 1초에 한번씩 로그를 찍고 있음을 확인할 수 있습니다.

fixedRate보다 작업 수행 시간이 더 길면?

fixedDelay는 작업 종료 때부터 시간을 재므로, sleep()을 아무리 걸어도 어차피 종료 이후부터 n초를 기다려 실행할 것이란 것은 자명합니다. 그렇다면, fixedRate라면 어떨까요? 작업 수행 시간이 fixedRate보다 길면, 어떤 결과가 나올까요?

이를 위해 sleep의 시간을 늘려보겠습니다.


참고사항

  1. initialDelay는 Scheduled된 작업이 수행되기 전에, 최초에 쉬게 될 시간을 지정하는 프로퍼티입니다. 위 코드의 경우에는, Spring Application의 정상 실행 이후 1초를 기다렸다가 fixedRateJob()을 수행합니다.

그렇게 시간을 잘 지키던 fixedRateJob이 1초마다 실행되지 않고, 2.5초마다 실행되고 있습니다.

결과는 이렇습니다. 시간을 유심히 보시면 알겠지만, fixedRate = 1000으로 설정되어 있음에도 불구하고, 2.5초에 한번씩 실행되고 있다는 것을 확인할 수 있습니다. 그 이유는 Thread.sleep(2500) 때문인데요, 작업 수행 시간이 fixedRate를 넘겨버렸으므로 작업이 끝나자마자 다음 n초대를 기다리지 않고, 바로 다음 작업을 실행한 것입니다. 즉, fixedRate일 경우에 작업의 수행시간이 길어진다면, 주기적인 실행을 보장하지 못합니다.

스케줄러 사용하기 - Thread Pool

Spring의 스케줄러의 기본 설정은 한계점이 있습니다. 바로 싱글 스레드로 돈다는 점인데요, 그렇기 때문에 만일 @Scheduled 작업이 여러개가 있다면, 이런 상황이 발생할 수도 있습니다.

불쌍한 fixedDelayJob. 이번엔 얘마저도 제대로 동작하지 못하고 있습니다.

fixedRateJob이 이전과 똑같이 수행시간이 rate보다 길게 잡혀있습니다. 이렇게 될 경우, 싱글 스레드로 도는 특성상 다른 Scheduled 작업들도 정상적으로 수행되지 않습니다. fixedDelayJob은 1.5초마다 실행되어야 정상일텐데, 로그 상에서는 무려 5초 뒤에나 실행이 되었습니다. 즉, 어느 것 하나 정시에 작동하지 않았습니다.

이를 방지하기 위해서는, 스케줄러가 멀티 스레딩으로 작동하게 만들어 줄 필요가 있습니다. 기본 스케줄러는 pool size가 1인 ThreadPool에서 작동하는데, 우리는 Scheduling 설정을 커스터마이징해서 이 Thread Pool을 늘릴 수 있습니다.

적어도 이제 fixedDelay는 정상적으로 작동하게 됐습니다.

SchedulingConfigurer는, @EnableScheduling이 걸린 @Configuration에 사용될 목적으로 만들어진 인터페이스입니다. 이 인터페이스는 TaskScheduler를 등록하기 위한 용도로 주로 쓰이고, 이 등록은 configureTasks(ScheduledTaskRegistrar taskRegistrar)라는 메소드를 통해 이루어집니다.

ScheduledTaskRegistrar는 Registrar(등록자)라는 이름에서 알 수 있듯이, 스케줄된 작업(cron, fixedDelay, fixedRate의 추가 및 제거 등...)을 전반적으로 관리해줍니다. (자세한 사항은 링크를 참조해주세요) 그 중에 setTaskScheduler(TaskScheduler taskScheduler)는 스케줄된 Task들을 실행해줄 TaskScheduler를 선택할 수 있게 해줍니다. 이를 통해 우리는 직접 만든 ThreadPoolTaskScheduler를 주입시킬 수 있습니다.

실행 결과를 확인해 보면, 이전에 scheduling-1 로만 표시되던 스레드가 hello-n으로 바뀐 것을 확인할 수 있습니다. 설정한 대로, 10의 size를 가지는 thread pool에서 작업들이 수행되게 된 것입니다. 그 결과, fixedDelayJob은 정상적으로 1.5초마다 실행됩니다.


Reference : How to Schedule Tasks with Spring Boot

 

How to Schedule Tasks with Spring Boot

In this article, you'll learn how to schedule tasks in Spring Boot using the @Scheduled annotation. You'll also configure a custom thread pool for executing all the scheduled tasks.

www.callicoder.com

스케줄러 사용하기 - Async

이제 딱 한가지 문제만 남았습니다. ThreadPool을 사용했음에도 불구, fixedRateJob의 문제만큼은 해결이 되지 않았습니다. 결국 ThreadPool을 사용하더라도 Thread 하나에 Task 하나가 할당될 뿐, 같은 Task가 동시에 여러 Thread로 실행되지는 않는다는 의미입니다. 이 문제는 어떻게 해결할 수 있을까요? 바로 비동기적으로 스케줄링을 수행할 수 있게 해주는 @Async annotation을 사용하면 됩니다.

Async annotation을 사용하기 위해서는, SpringBootApplication에서 혹은 Configuration에서 @EnableAsync를 넣어줘야 합니다.

그 후, fixedRateJob에 @Async를 넣어보도록 하겠습니다.

fixedRate가 이제서야 정상 작동합니다.

실행 결과를 보면 fixedRateJob이 이제서야 정상작동하는 것을 알 수 있습니다. Async로 선언한 이후로, fixedRateJob이 "task-n"이라는 별도의 스레드에서 생성되고 있으며, 앞의 fixedRate가 아직 끝나지 않았더라도 새로운 task thread에 fixedRate를 넣어 실행시키고 있다는 것까지 확인할 수 있습니다.

특히, 해당 task가 어떤 thread에서 처리되는지 제대로 확인하기 위해, 실행될 때마다 지역변수 j에 전역변수 i의 값을 복사하고, i는 올리되 로그에는 j를 찍도록 했는데요, 이를 통해 출력되는 메시지가 몇번째 실행된 Task였는지를 확인할 수 있습니다. 이를 통해 알 수 있는 부분은, 한번 실행된 task는 종료 때까지 쭉 같은 스레드를 사용하고 있다는 것을 확인할 수 있습니다. 비동기라서 중간에 다른 스레드가 납치(?)해가지 않을까 싶었는데, 그러지 않네요.

Async 스케줄링은 위에서 설명한 ThreadPool 스케줄링만큼이나 설명할게 많은 방식입니다. 제대로 사용하기 위해서는 이 또한 커스텀 Configuration을 사용해야만 하는데요, 관련 글은 다음에 시간이 날 경우에 쓰도록 하겠습니다.