티스토리 뷰

최근에 어플리케이션이 기동되는 동안 주기적으로 특정 작업이 수행되어야 한다는 요구사항이 있었다. 구체적으로는 MQ에서 메세지를 주기적으로 가져와야 하는 상황이었는데, 내부에서 사용하는 어플리케이션이 아니라서 누구나 유지보수 할 수 있도록… 이전에 공부했던 Spring Integration 같은 프레임워크를 사용하지 않아야 했다.

 

신입 시절에도 이와 비슷하게 DB에서 값을 폴링하여 특정 상태를 체크하는 요구사항이 있어 "쓰레드 풀을 생성하고, 비동기로 무한 루프를 돌면서 DB를 특정 시간 간격으로 조회하는 쓰레드를 실행"하는 조악한 방식을 쓴 적이 있는데(이 일과 관련해서도 할 말이 많다…), 아래의 짤처럼 언제까지 과거에 머물러 있을 수 없으니 조금 더 우아한 방식으로 스케줄링 작업을 개발해보려고 한다.

 

훗날 이렇게 되고 싶진 않다.

 

 

1. 프로젝트 준비

spring initializr에서 lombok만 추가하여 프로젝트를 생성했다. 물론 lombok가 필수는 아니다.

 

 

2. 단순 구현 - 싱글 쓰레드

먼저 가장 간단한 싱글 쓰레드를 활용해 개발을 해보자.

 

 

@EnableScheduling

우선 앞으로 구현할 스케줄러는 @Scheduled 어노테이션을 활용할 예정인데, @Scheduled 어노테이션이 붙은 빈을 컨테이너에서 인식하기 위해서는 @EnableScheduling 어노테이션을 사용해야 한다.

 

해당 어노테이션은 @Configuration 클래스에서 사용할 수도 있지만, 싱글 쓰레드로 구현할 예정이므로 @SpringBootApplication과 함께 사용했다.

@EnableScheduling
@SpringBootApplication
public class DemoApplication {
	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}

 

 

스케줄러

@Scheduled 어노테이션을 빈으로 등록된 객체의 메소드에 붙이면, 간단하게 스케줄러 완성. 예시는 1초(1,000ms) 간격으로 로깅을 해주는 스케줄러이다. 스케줄링 간격의 기준은 몇 가지가 있는데, 이는 뒤에서 설명하겠다.

@Slf4j
@Component
public class SampleScheduler {
    @Scheduled(fixedRate = 1000)
    public void fixedRate() {
        log.info("fixedRate Scheduler");
    }
}

 

결과

보시다시피 1초 간격으로 로깅이 되었음을 확인할 수 있다.

 

 

3. fixedRate vs fixedDelay

스케줄러의 작업 간격은 fixedRatefixedDelay로 설정할 수 있다. 언뜻보면 비슷해 보이지만, 결과물은 완전히 다르니 요구사항에 맞게 사용해야 한다.

 

 

fixedDelay

우선 비교적 계산 방법이 간단한 fixedDelay부터 설명하자면, 스케줄러 작업을 마치고 뒤와 다음 작업을 시작하는 시간 사이의 간격이다. 결과를 직관적으로 이해할 수 있게 스케줄러 작업의 시간을 늘려서 테스트했다.

@Scheduled(fixedDelay = 1000)
public void fixedDelay() throws InterruptedException {
    log.info("fixedDelay Scheduler");
    Thread.sleep(3000);
}

 

결과

스케줄러 작업의 시간 간격은 1초(1,000ms)로 설정했지만, 로깅은 4초 간격으로 이뤄지고 있다. 즉, 스케줄러 작업이 끝난 뒤(3초) 1초 후에 다시 스케줄링이 되고 있음을 알 수 있다.

 

 

 

fixedRate

fixedRate스케줄링 간격이 이름 그대로 고정되어 있는 것이다. 문제는 설정한 fixedRate 시간보다 스케줄링 작업 시간이 길어질 경우, 주기적인 간격으로 실행을 보장하지 않는다. 이를 해결하기 위해 비동기로 스케줄링을 하는 방법이 있는데, 이는 기회가 되면 다른 포스팅에서 작성하도록 하겠다.

@Scheduled(fixedRate = 1000)
public void fixedRate() throws InterruptedException {
    log.info("fixedRate Scheduler");
    Thread.sleep(3000);
}

 

결과

스케줄러 작업 시간 간격은 1초로 설정했지만, 로깅은 3초 간격으로 이뤄지고 있다. 즉, 스케줄러 작업이 끝난 뒤(3초)에는 스케줄러 간격(1초)을 이미 넘어선 상태이므로 작업이 즉시 이뤄지는 있는 것이다.

 

 

4. 멀티 쓰레드로 스케줄링

별도의 쓰레드 풀 설정이 없을 경우, 스케줄러는 싱글 쓰레드로 동작한다. 이것은 스케줄러가 예상과는 다르게 동작하는 원인이 된다.

 

 

싱글 쓰레드

@Scheduled(fixedRate = 1000)
public void fixedRate1() throws InterruptedException {
    log.info("{} fixedRate1() start", Thread.currentThread().getName());
    Thread.sleep(3000);
}

@Scheduled(fixedRate = 1000)
public void fixedRate2() throws InterruptedException {
    log.info("{} fixedRate2() start", Thread.currentThread().getName());
    Thread.sleep(3000);
}

 

결과

총 두 개의 스케줄러를 사용하고 있지만, scheduling-1이라는 쓰레드 하나를 사용해 스케줄링을 진행하고 있다. 즉, 각각 3초 간격으로 작업이 이뤄질 것으로 예상된 스케줄러들의 작업은 다른 스케줄러 작업이 끝나야 시작할 수 있으므로 6초 간격으로 로깅이 되고 있음을 확인할 수 있다.

 

 

 

멀티 쓰레드

멀티 쓰레드를 활용해 스케줄러 별로 쓰레드를 할당해준다면, 이 문제를 해결할 수 있다. 멀티 쓰레드를 설정하는 방법은 다양하지만, @EnableScheduling 어노테이션의 주석을 참고했고 풀 사이즈를 2개로 설정하여 쓰레드 풀을 생성했다.

@Configuration
public class TaskSchedulerConfiguration implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskScheduler());
    }

    @Bean(destroyMethod = "shutdown")
    public Executor taskScheduler() {
        return Executors.newScheduledThreadPool(2);
    }
}
  • SchedulingConfigurer: 주로 예약된 작업을 실행할 때 사용되는 TaskScheduler 빈을 설정할 때 사용

 

결과

싱글 쓰레드로 돌던 스케줄러가 개별 쓰레드를 할당 받아 스케줄링되고 있다. 즉, 예상한대로 스케줄러 별로 3초 간격으로 스케줄링 되고 있음을 확인할 수 있다.

 

 

5. 스케줄링 관련 추가 키워드

이번 포스팅에 스케줄링 관련된 모든 내용을 담기에는 분량이 많아질 것 같아서 일부 내용은 생략하거나 간단하게 작성했는데, 조금 더 깊게 공부를 하실 분들은 아래 키워드를 참고하면 좋을 것 같다.

 

 

비동기 처리(@EnableAsync, @Async)

앞서 살펴보았지만 fixedDelay를 활용해 스케줄링을 할 경우 스케줄링 작업 간의 간격은 일정하나 스케줄링 작업 자체에 드는 시간을 고려하지 않고, fixedRate를 활용해 스케줄링을 하면 스케줄링 작업에 드는 시간이 fixedRate로 설정한 시간보다 커질 경우 원하는대로 동작하지 않을 수 있다.

하지만 실제로 운영 환경에서는 스케줄링 작업이 일정한 시간 간격으로 수행되어야 상황이 충분히 있을 수 있는데, 이 경우는 비동기 처리를 활용하면 해결 방법을 찾을 수 있을 것이다.

 

 

awaitility 라이브러리

스케줄링 테스트는 필연적으로 시간과 엮일 수 밖에 없는데, 테스트에서 시간을 고려하는 것은 쉬운 문제는 아니라고 생각한다. 그런데 awaitility 라이브러리를 사용할 경우, 이에 대한 고민을 어느정도 해결해줄 수 있을 것이다.

 

 

 

참고 자료 🙇‍♂️

댓글