[Spring] @Scheduled를 통한 스케쥴러 구현 시 주의할 점
개요
Spring에서는 @Scheduled 어노테이션을 통해 간편하게 스케쥴러 기능을 구현할 수 있다. 주기적으로 특정 로직을 수행하거나, 특정 시간에 작업을 예약해서 기능을 실행시킬 수 있다.
정말 편리한 기능이지만, 여러가지 부딪힌 이슈들이 있기에 공유해보려 한다.
설정
설정 클래스에 @EnableScheduling 어노테이션을 사용하여, @Scheduled 어노테이션을 사용할 수 있다.
@Configuration
// 스케쥴링 기능 활성화
@EnableScheduling
public class SchedulerConfig {
}
@Scheduled을 통한 스케쥴링 작업 실행
일정 주기로 작업을 실행하고 싶다면 @Scheduled 어노테이션에 fixedDelay 옵션을 통해 주기를 입력해주면 된다.
@Component
class ScheduledTasks {
// 1초마다 작업 실행
@Scheduled(fixedDelay = 1000)
fun task1() {
log.info { "Task 1 실행" }
}
}
cron 식을 통해 특정 시간에 작업을 예약하여 실행할 수도 있다.
@Component
class ScheduledTasks {
//1초마다 작업 실행
@Scheduled(cron = "* * * * * *")
fun task1() {
log.info { "Task 1 실행" }
}
//매 분 0초마다 작업 실행
@Scheduled(cron = "0 * * * * *")
fun task2() {
log.info { "Task 2 실행" }
}
}
cron 식의 문법은 앞에서부터 다음과 같이 설정하면 된다고 한다. 헷갈릴 때, 어노테이션에 설명을 참고하거나 검색하여 시간대를 설정하면 될 것 같다.

한계 - 모든 스케쥴링 작업을 단일 스레드로 실행
@Component
class ScheduledTasks {
//5초마다 작업 실행
@Scheduled(fixedDelay = 1000)
fun task1() {
Thread.sleep(5000L)
log.info { "Task 1 실행: ${Thread.currentThread().name} 실행 시간: ${System.currentTimeMillis()}" }
}
//5초마다 작업 실행
@Scheduled(fixedDelay = 1000)
fun task2() {
log.info { "Task 2 실행: ${Thread.currentThread().name} 실행 시간: ${System.currentTimeMillis()}" }
}
}
하나의 작업이 5초 동안 blocking이 발생한다고 가정하자.
다음과 같은 코드를 실행할 때 log는 아래와 같다.

Task2는 1초마다 실행될 수 있지만, Task1과 묶여서 5초마다 함께 실행되는 것을 볼 수 있다. 왜냐면 @Scheduled은 별도의 설정이 없다면, 단일 스레드에서 모든 스케쥴링 작업을 실행하기 때문이다. 만약 Task2가 실시간성이 중요한 작업일 경우 치명적일 것이다.
스케쥴러 전용 스레드 풀을 설정하여 병렬로 처리하기
스케쥴러 전용 스레드 풀을 설정하면 각 스케쥴러 작업들을 병렬적으로 처리할 수 있다.
@Configuration
@EnableScheduling
class ScheduledConfig {
companion object {
const val POOL_SIZE = 5
}
//스케쥴러 설정
@Bean
fun taskScheduler(): TaskScheduler {
val scheduler = ThreadPoolTaskScheduler()
//스레드 풀 사이즈 설정
scheduler.setPoolSize(POOL_SIZE)
//스레드 풀 이름 설정
scheduler.setThreadNamePrefix("scheduler-thread-")
//스케쥴러 전역 예외 처리
scheduler.setErrorHandler({ throwable: Throwable? ->
log.error { "스케줄 작업 에러 발생: ${throwable!!.message}, $throwable " }
})
return executor
}
}
ThreadPoolTaskScheduler를 Bean으로 설정하고, 스레드 풀 사이즈와 스레드 풀 이름을 커스텀하게 설정할 수 있다.
Spring에서는 Bean으로 등록된 TaskScheduler를 스케쥴링 작업에 설정하여 스케쥴링 작업을 병렬적으로 처리한다.
또한 @Scheduled 메서드에 대한 전역 예외 처리도 설정할 수 있다. 스케쥴러 작업에 대한 예외 발생 시, 메신저 웹훅으로 전달할 수 있도록 설정하면 좋을 것이다.

로그를 통해 알 수 있듯 Task2는 Task1의 작업과 관계없이 1초마다 병렬적으로 실행되는 것을 볼 수 있다.
스레드 풀을 Virtual Thread로 설정
만약에 모든 작업들이 I/O 블로킹으로 이루어져 있다면, Java 21에서 지원하는 Virtual Thread를 통해 작업을 병렬적으로 진행하여 자원 및 성능을 아낄 수 있을 것이다.
@Configuration
@EnableScheduling
class ScheduledConfig {
//스케쥴러 설정
@Bean
fun taskScheduler(): TaskScheduler {
val scheduler = ThreadPoolTaskScheduler()
//virtual thread 설정
scheduler.setVirtualThreads(true)
scheduler.setThreadNamePrefix("scheduler-thread-")
scheduler.setErrorHandler({ throwable: Throwable? ->
log.error { "스케줄 작업 에러 발생: ${throwable!!.message}, $throwable " }
})
return executor
}
}
다음과 같이 setVirtualThreads(true)를 통해 스케쥴러 작업을 Virtual Thread로 실행할 수 있을 것이다.
이 때, Virtual Thread는 task마다 스레드를 실행하는 경량스레드이기에, 스레드 풀 사이즈를 설정할 필요는 없다.
Virtual Thread 개념 및 사용 시 주의사항은 여기를 참고하자
주의 - Timezone 설정
스케쥴러가 매일 정각에 실행하도록 설정하였는데, 원하는 시간에 작업이 실행되지 않는 이슈가 있었다. 해당 작업은 정각이 아닌 오전 9시에 실행되고 있었다. Spring의 타임존은 JVM을 따르고, JVM 타임존은 UTC이다. 스케쥴러는 JVM Timezone을 기준으로 동작하고 있었던 것이다.
한국 시간을 기준으로 스케쥴러 작업을 진행하고 싶다면, 아래와 같이 timezone 설정을 별도로 해주자
@Component
class ScheduledTasks {
//한국 시간 기준 매일 정각 실행
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
fun task1() {
log.info { "Task 2 실행: ${Thread.currentThread().name} 실행 시간: ${System.currentTimeMillis()}"
}
}
스케쥴링 스레드 풀 사이즈 산정
여러 개의 스케쥴링 작업들 중, 동시에 실행되는 스케쥴링 작업의 최대 수를 설정하면 좋을 것이다.
예를 들어 10개의 스케쥴링 작업이 있다. 그 중 4개의 작업이 매일 정각에 동시에 실행되고, 그것이 동시에 실행되는 스케쥴링 작업 수의 최대라고 가정하자. 그렇다면 스레드 풀 사이즈는 4개 혹은 넉넉하게 5~6개로 설정하는 것이 좋을 것이다.
스케쥴링 작업이 10개라고 해서 모든 작업이 동시에 실행되는 것이 아니기에, 스레드 풀을 10개로 설정해서 자원을 낭비할 필요는 없다.