2023-12-05 작성

스프링부트 Scheduler 정해진 시간마다 동작 시키는법

이번 프로젝트에서 정해진 시간마다 특정 동작을 수행해야 하는 기능이 있었다. Spring Boot에서 제공하는 Scheduler 기능을 이용해서 매우 간단하게 구현할 수 있다.
이번 포스팅에서는 특정 시간에 스케줄러를 통해 특정 동작을 구현하는 다양한 예제를 살펴볼 것이다.
(쪼금 스압 주의)

1. 스케줄러 활성화

Scheduler는 기본적으로 Spring Boot에 포함되어 있어서 의존성을 별도로 추가할 필요는 없다.
메인 Application 클래스에 @EnableScheduling를 추가하여 Scheduler를 활성화한다.

@SpringBootApplication
@EnableScheduling // 스케줄러 활성화
public class SchedulerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SchedulerApplication.class, args);
    }
}

2. 스케줄러 대상에 적용하기

스케줄러를 사용하기 위해서는 스케줄러를 적용할 대상 클래스에 @Component를 추가하고, 주기적으로 실행하고 싶은 메서드에 @Scheduled를 추가하면 된다.

@Component
public class Scheduler {

	@Scheduled
	public void sample() {
	}
}


만약 스케줄러 적용할 클래스가 @Controller, @RestController, @Service와 같이 스프링 빈에 등록된 클래스일 경우, 어노테이션 내부에 이미 @Component이 포함되어 있기 때문에 @Component를 추가하지 않아도 된다.

스케줄러 메서드는 아래와 같은 규칙을 지켜야 하며

  • 스케줄러 메서드는 void 리턴 타입이어야 함
  • 스케줄러 메서드는 매개변수 사용 불가

@Scheduled 속성을 설정할 수 있다. 순서대로 하나씩 알아보자.

  • fixedDelay : 메소드의 실행이 끝난 시간을 기준으로, 설정된 밀리세컨드 간격마다 실행
  • fixedRate : 메소드의 실행이 시작하는 시간을 기준으로, 설정된 밀리세컨드 간격마다 실행
  • initialDelay : 설정된 밀리세컨드 시간 후부터 fixedDelay 간격마다 실행
  • cron : Cron 표현식을 사용하여 설정한 시간에 실행

예제 1) fixedDelay

이전 작업의 종료 시점으로부터 정의된 ms 시간만큼 지나면 작업을 실행한다. 즉, 작업 수행시간을 포함해서 작업을 마친 후부터 주기 타이머가 돌아 메서드를 호출하므로 Delay 될 수 있다. 

@Scheduled(fixedDelay = 1000) // 1초마다 실행
public void fixedDelayJob() throws InterruptedException {
	log.info("fixedDelay START");
	Thread.sleep(500); // 0.5초씩 지연
}


위 예제를 실행하면 1.5초에 한번씩 실행되고 있다. fixedDelay 프로퍼티에 1000(1초)로 설정했지만 sleep(0.5초) 지연시켰기 때문이다.

fixedDelay는 1.5초마다 실행되고 있다.

예제 2) fixedRate

이전 작업의 시작 시점으로부터 정의된 ms 시간만큼 지난 후 작업을 실행한다. 즉, 작업 수행시간과 상관없이 일정 주기마다 메서드를 호출한다.

@Scheduled(fixedRate = 1000) // 1초마다 실행
public void fixedRateJob() throws InterruptedException {
	log.info("fixedRate START");
	Thread.sleep(500); // 0.5초씩 지연
}

반면 fixedRate는 sleep(0.5초)로 지연시키든 말든 1초마다 실행되고 있다.

fixedRate는 1초마다 실행되고 있다.

 

fixedDelay vs. fixedRate

이 둘의 차이점은 무엇일까? fixedDelay는 해당 작업이 끝난 시점부터 시간을 세고, fixedRate는 해당 작업의 시작 시점부터 시간을 센다. 

만약  작업 수행시간이 설정한 ms보다 길면 어떤 결과가 나올까? fixedDelay는 작업 종료 때부터 시간을 재므로 sleep을 아무리 걸어도 종료 이후부터 n초를 기다려 실행할 것이다.

그렇다면 fixedRate의 경우 어떨까? 만약 fixedRate의 작업 수행시간이 fixedRate보다 길면 어떤 결과가 나올까?

@Scheduled(fixedRate = 1000) // 1초마다 실행
public void fixedRateJob() throws InterruptedException {
    log.info("fixedRate START");
    Thread.sleep(3000); // 3초씩 지연
    log.info("fixedRate END");
}


fixedRate 값이 1초로 설정되어 있음에도 3초에 한번씩 실행되고 있다. 

강제로 지연시키자 바로 다음 작업을 실행하는 fixedRate


sleep으로 3초씩 강제로 지연시킴으로써 작업 수행시간이 fixedRate를 넘겨버렸으므로 작업이 끝나자마자 다음 n초대를 기다리지 않고, 바로 다음 작업을 실행한다. 즉, fixedRate은 작업의 수행시간이 길어진다면, 주기적인 실행을 보장하지 못하는 점을 주의해야 한다.

예제 3) initialDelay

스케줄러에서 메서드가 등록되자마자 수행하는 것이 아닌, 초기 지연시간을 설정하는 것이다. 다음 예제는 3초의 대기(initialDelay) 후에 5초(fixedRate)마다 문자열을 출력하게 된다. 

@Scheduled(initialDelay = 3000, fixedRate = 5000) // 3초 대기 후에 5초마다 실행
public void run() {
	log.info("Hello World!");
}

예제 4) cron

Cron 표현식을 사용하여 작업을 예약할 수 있다. 

@Scheduled(cron = "*/10 * * * * *") // 10초마다 실행
public void run() {
	log.info("Hello World!");
}

 
첫 번째 *부터 초(0-59) 분(0-59) 시간(0-23) 일(1-31) 월(1-12) 요일(0-6) (0: 일, 1: 월, 2:화, 3:수, 4:목, 5:금, 6:토) 로 표현할 수 있다. 스프링의 @Scheduled cron은 6자리 설정만 허용하며 연도 설정을 할 수 없다.
 
cron 사용 예시

// 매일 오후 18시에 실행
@Scheduled(cron = "0 0 18 * * *")
public void run1() {
	log.info("Hello World!");
}

// 매달 10일,20일 14시에 실행
@Scheduled(cron = "0 0 14 10,20 * ?")
public void run2() {
	log.info("Hello World!");
}

// 매달 마지막날 22시에 실행
@Scheduled(cron = "0 0 22 L * ?")
public void run3() {
	log.info("Hello World!");
}

// 1시간 마다 실행 ex) 01:00, 02:00, 03:00 ...
@Scheduled(cron = "0 0 0/1 * * *")
public void run4() {
	log.info("Hello World!");
}

// 매일 9시00분-9시55분, 18시00분-18시55분 사이에 5분 간격으로 실행
@Scheduled(cron = "0 0/5 9,18 * * *")
public void run5() {
	log.info("Hello World!");
}

// 매일 9시00분-18시55분 사이에 5분 간격으로 실행
@Scheduled(cron = "0 0/5 9-18 * * *")
public void run6() {
	log.info("Hello World!");
}

// 매달 1일 10시30분에 실행
@Scheduled(cron = "0 30 10 1 * *")
public void run7() {
	log.info("Hello World!");
}

// 매년 3월내 월-금 10시30분에 실행
@Scheduled(cron = "0 30 10 ? 3 1-5")
public void run8() {
	log.info("Hello World!");
}

// 매달 마지막 토요일 10시30분에 실행
@Scheduled(cron = "0 30 10 ? * 6L")
public void run9() {
	log.info("Hello World!");
}

 
아래부터는 읽어보면 좋을 내용들을 추가했습니다 :)

(참고) cron 설정 파일 분리하기

Cron 표현식이 많을 경우 별도 설정파일로 분리해서 관리하는 것이 편할 수 있다. 예를 들어 src/main/resources 패키지에서 config 폴더 추가하여 schedule.properties 파일을 만들어서 아래처럼 관리할 수도 있다.

schedule.sample.cron=* 10 * * * *

스케줄러 클래스

@Component
@PropertySource(value = { "classpath:config/schedule.properties" })
@Slf4j
public class Scheduler {

	@Scheduled(cron = "${schedule.sample.cron}") // 설정파일에서 가져오도록 변경
	public void run() {
		log.info("START");
	}
}

(참고) 스케줄러 사용하기 - Thread Pool

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

@Scheduled(initialDelay = 1000, fixedRate = 1000) // 1초 대기 후에 1초마다 실행
public void fixedRateJob() throws InterruptedException {
	log.info("fixedRateJob START");
	Thread.sleep(1500); // 1.5초 지연
	log.info("fixedRateJob END");
}

@Scheduled(initialDelay = 1000, fixedDelay = 1000) // 1초 대기 후에 1초마다 실행
public void fixedDelayJob() throws InterruptedException {
	log.info("fixedDelayJob START...");
	Thread.sleep(500); // 0.5초 지연
	log.info("fixedDelayJob END...");
}
fixedDelay이 제대로 실행되지 않고 있다.


fixedRateJob이 이전과 똑같이 수행시간이 rate보다 길게 잡혀있다. 이렇게 될 경우, 싱글 스레드로 도는 특성상 다른 Scheduled 작업들도 정상적으로 수행되지 않는다. fixedDelayJob은 1.5초마다 실행되어야 정상일텐데, 로그 상에서는 무려 5초 뒤에나 실행이 되었다. 즉, 어느 것 하나 정시에 작동하지 않았다.
이를 방지하기 위해서는, 스케줄러가 멀티 스레딩으로 작동하게 만들어 줄 필요가 있다. 기본 스케줄러는 pool size가 1인 ThreadPool에서 작동하는데, 우리는 Scheduling 설정을 커스터마이징해서 이 Thread Pool을 늘릴 수 있다.

@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
    private final static int POOL_SIZE = 10;

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        final ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();

        threadPoolTaskScheduler.setPoolSize(POOL_SIZE);
        threadPoolTaskScheduler.setThreadNamePrefix("hello-");
        threadPoolTaskScheduler.initialize();

        taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }
}
적어도 이제 fixedDelay는 정상적으로 작동하게 되었다.


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

메서드의 매개변수인 ScheduledTaskRegistrar는 스케줄된 작업(cron, fixedDelay, fixedRate 추가 및 제거 등)을 전반적으로 관리해준다. 그 중에 setTaskScheduler 메서드는 스케줄된 Task들을 실행해줄 TaskScheduler를 선택할 수 있게 해준다. 이를 통해 우리는 직접 만든 ThreadPoolTaskScheduler를 주입시킬 수 있다.
 
실행 결과를 보면, 이전에 scheduling-1 로만 표시되던 스레드가 hello-n으로 바뀐 것을 확인할 수 있다. 설정한 대로 10의 size를 가지는 thread pool에서 작업들이 수행되게 된 것이다. 그 결과 fixedDelayJob은 정상적으로 1.5초마다 실행된다. (참고 : How to Schedule Tasks with Spring Boot)

(참고) 스케줄러 사용하기 - Async

이제 딱 한가지 문제만 남았다. ThreadPool을 사용했음에도 불구하고 fixedRateJob의 문제만큼은 해결이 되지 않았다. 결국 ThreadPool을 사용하더라도 Thread 하나에 Task 하나가 할당될 뿐, 같은 Task가 동시에 여러 Thread로 실행되지는 않는다는 의미이다. 이 문제는 비동기적으로 스케줄링을 수행할 수 있게 해주는 @Async을 사용하면 된다.
 
@Async을 사용하기 위해서 Application 클래스에 @EnableAsync를 넣어준다.

@SpringBootApplication
@EnableScheduling // 스케줄러 활성화
@EnableAsync // Async 활성화
public class SchedulerApplication {
    public static void main(String[] args) {
        SpringApplication.run(SchedulerApplication.class, args);
    }
}

그 후, fixedRateJob에 @Async를 넣는다.

private static int i = 0;

@Scheduled(initialDelay = 1000, fixedRate = 1000)
@Async
public void fixedRateJob() throws InterruptedException {
	int j = i;
	i++;
	log.info("fixedRateJob START with i = {}", j);
	Thread.sleep(1500);
	log.info("fixedRateJob END with i = {}", j);
}
fixedRate가 이제서야 정상 작동한다. (빨간색은 task 하나를 표시한 것이다)

실행 결과를 보면 fixedRateJob이 이제서야 정상작동하는 것을 알 수 있다. Async로 선언한 이후로, fixedRateJob이 "task-n"이라는 별도의 스레드에서 생성되고 있으며, 앞의 fixedRate가 아직 끝나지 않았더라도 새로운 task thread에 fixedRate를 넣어 실행시키고 있다는 것까지 확인할 수 있다.
특히, 해당 task가 어떤 thread에서 처리되는지 제대로 확인하기 위해, 실행될 때마다 지역변수 j에 전역변수 i의 값을 복사하고, i는 올리되 로그에는 j를 찍도록 했는데, 이를 통해 출력되는 메시지가 몇번째 실행된 Task였는지를 확인할 수 있다. 이를 통해 알 수 있는 부분은, 한번 실행된 task는 종료 때까지 쭉 같은 스레드를 사용하고 있다는 것을 확인할 수 있다.

@Async 스케줄링은 위에서 설명한 ThreadPool 스케줄링만큼이나 설명할게 많은 방식이다. 제대로 사용하기 위해서는 커스텀 Configuration을 사용해야만 한다.

References