비동기 작업을 위한 스레드
스프링은 비동기 작업을 위한 TaskExecutor라는 인터페이스를 제공한다. 스프링의 TaskExecutor 인터페이스는 자바의 Executor 인터페이스와 동일하다. 그리고 스프링이 제공하는 TaskExecutor의 구현체는 다음과 같다.
- SyncTaskExecutor
- SimpleAsyncTaskExecutor
- ConcurrentTaskExecutor
- ThreadPoolTaskExecutor
- DefaultManagedTaskExecutor
- ...
@EnableAsync의 JavaDoc을 살펴보면 다음과 같이 설명되어 있다.
By default, Spring will be searching for an associated thread pool definition:* either a unique {@link org.springframework.core.task.TaskExecutor} bean in the context,* or an {@link java.util.concurrent.Executor} bean named "taskExecutor" otherwise. If* neither of the two is resolvable, a {@link org.springframework.core.task.SimpleAsyncTaskExecutor}* will be used to process async method invocations.
즉, 아무 설정도 하지 않으면, 기본적으로 SimpleAsyncTaskExecutor가 사용된다고 한다.
하지만 스프링부트 환경에서는 다르다. 스프링 부트에서는 컨텍스트에 Executor 빈이 없는 경우, Executor의 하위 인터페이스인 AsycnTaskExecutor 인터페이스를 자동 구성한다. 이때 대표적인 구현체로 SimpleAsyncTaskExecutor와 ThreadPoolTaskExecutor가 있는데 다음 기준에 따라 구현체가 결정된다.
- 가상 스레드가 활성화된 경우(Java 21 이상 및 spring.threads.virtual.enabled가 true로 설정된 경우), 가상 스레드를 사용하는 SimpleAsyncTaskExecutor가 사용된다.
- 그렇지 않으면, ThreadPoolTaskExecutor가 사용된다.
In the absence of an Executor bean in the context, Spring Boot auto-configures an AsyncTaskExecutor. When virtual threads are enabled (using Java 21+ and spring.threads.virtual.enabled set to true) this will be a SimpleAsyncTaskExecutor that uses virtual threads. Otherwise, it will be a ThreadPoolTaskExecutor
* 참고: https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
즉, 스프링 환경에서 @Async는 기본적으로 SimpleAsyncTaskExecutor를 사용하지만, 스프링부트에서는 AutoConfiguration 과정을 통해 ThreadPoolTaskExecutor 또는 SimpleAsyncTaskExecutor가 사용되는 것이다.
ThreadPoolTaskExecutor의 설정 값은 org.springframework.boot.autoconfigure.task 패키지의 TaskExecutionProperties에 따른다. 그리고 기본 값은 다음과 같다.
- queueCapacity: Integer.MAX_VALUE (unbounded)
- coreSize: 8
- maxSize: Integer.MAX_VALUE
즉, 큐 사이즈가 무한이므로, maxSize 값은 의미가 없다. 즉, 아무리 요청이 많이 몰려도 모두 큐에 저장되고 8개의 스레드에 의해 처리된다. 스레드 수가 8개로 고정되어 있기 때문에, CPU와 메모리 사용량을 예측할 수 있다. 그러나 갑작스럽게 요청이 몰릴 경우, 8개의 스레드로만 요청을 처리하므로 요청 처리가 지연될 수 있다.
설정을 변경하고 싶으면 application.yml 파일의 spring.task.execution.pool 설정을 바꾸거나, 설정 정보 클래스를 만들면 된다. 다음과 같이 yml 파일에 설정하면, 해당 값들이 TaskExecutionProperties에 대입된다.
spring.task.execution.pool.max-size=16
spring.task.execution.pool.queue-capacity=100
spring.task.execution.pool.keep-alive=10s
스케줄링 작업을 위한 스레드
비동기 작업 외에도 스프링은 미래의 특정 시점에 실행할 작업을 스케줄링하기 위한 TaskScheduler라는 인터페이스를 제공한다.
그리고 다음 기준에 따라 주입되는 구현체가 결정된다.
- 가상 스레드가 활성화된 경우(Java 21 이상 및 spring.threads.virtual.enabled가 true로 설정된 경우), SimpleAsyncTaskScheduler가 사용된다.
- 가상 스레드가 활성화되지 않은 경우, ThreadPoolTaskScheduler가 사용된다.
그리고 ThreadPoolTaskScheduler는 기본적으로 하나의 스레드를 사용하며, spring.task.scheduling을 통해 설정 값을 변경할 수 있다.
If virtual threads are enabled (using Java 21+ and spring.threads.virtual.enabled set to true) this will be a SimpleAsyncTaskScheduler that uses virtual threads. This SimpleAsyncTaskScheduler will ignore any pooling related properties.
If virtual threads are not enabled, it will be a ThreadPoolTaskScheduler with sensible defaults. The ThreadPoolTaskScheduler uses one thread by default and its settings can be fine-tuned using the spring.task.scheduling namespace.
* 참고: https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
@Scheduled, @Async 사용 예제
각 상황을 테스트해보자. 우선 @Scheduled와 @Async를 사용하기 위해 @EnableScheduling, @EnableAsync 어노테이션을 붙여주었다.
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class SchedulerApplication {
public static void main(String[] args) {
SpringApplication.run(SchedulerApplication.class, args);
}
}
그리고 스레드 이름을 구분하기 쉽도록 하기 위해 application.yml 파일에 다음과 같이 설정해줬다.
spring:
task:
execution:
thread-name-prefix: "executor-"
scheduling:
thread-name-prefix: "scheduler-"
1. 싱글 스레드 + 동기
다음은 @Scheduled 스레드가 싱글 스레드이면서 동기적으로 동작하는 코드이다.
@Slf4j
@Component
public class Example {
@Scheduled(fixedDelay = 1000)
public void func1() {
log.info("작업1 시작");
log.info("작업1 종료");
}
@Scheduled(fixedDelay = 1000)
public void func2() {
log.info("작업2 시작");
log.info("작업2 종료");
}
}
- TaskScheduler는 싱글 스레드이기 때문에 작업1과 작업2는 같은 스레드에 의해 수행된다.
- 즉, 작업1의 수행이 끝난 후에 작업2가 수행된다.
따라서 작업1의 수행 시간이 오래 걸릴 때 문제가 된다. 다음은 작업1의 수행 시간이 5초인 상황이다.
@Slf4j
@Component
public class Example {
@Scheduled(fixedDelay = 1000)
public void func1() throws InterruptedException {
log.info("작업1 시작");
Thread.sleep(5000); // 작업1의 수행 시간: 5초
log.info("작업1 종료");
}
@Scheduled(fixedDelay = 1000)
public void func2() {
log.info("작업2 시작");
log.info("작업2 종료");
}
}
- 싱글 스레드로 두 개의 작업이 수행되므로, 작업1의 수행이 완료될 때까지 작업2가 대기하게 된다. 따라서 작업2는 1초에 한 번씩 수행되어야 하는데 작업 1의 수행 시간으로 인해 5초에 한 번 수행된다.
- 그리고 작업1도 1초에 한 번 수행되어야 하는데, 자신의 작업이 한 번 수행되는데 5초가 걸리므로 5초에 한 번 수행된다.
따라서 이런 문제를 해결하기 위해 스레드 수를 늘리거나, @Async를 통해 비동기로 수행하는 방법이 있다.
2. 멀티 스레드 + 동기
먼저 멀티 스레드인 경우이다. 다음과 같이 TaskSchduler 스레드 수를 2로 설정해줬다.
spring:
task:
execution:
thread-name-prefix: "executor-"
scheduling:
thread-name-prefix: "scheduler-"
pool:
size: 2 // 스레드 수 2로 설정
그리고 아까와 같은 코드를 다시 실행해보면
@Slf4j
@Component
public class Example {
@Scheduled(fixedDelay = 1000)
public void func1() throws InterruptedException {
log.info("작업1 시작");
Thread.sleep(5000); // 작업1의 수행 시간: 5초
log.info("작업1 종료");
}
@Scheduled(fixedDelay = 1000)
public void func2() {
log.info("작업2 시작");
log.info("작업2 종료");
}
}
- 작업1과 작업2가 별도의 스레드에서 실행된다. 따라서 작업2는 작업1의 수행 시간과 별개로 1초마다 정상적으로 수행된다.
- 그러나 각 스레드는 동기적으로 동작하므로, 작업 1은 여전히 1초가 아닌 5초마다 수행된다.
3. 싱글 스레드 + 비동기
이번에는 스레드 수는 그대로 1개로 두고, @Async를 통해 비동기로 동작하는 경우이다.
@Slf4j
@Component
public class Example {
@Async
@Scheduled(fixedDelay = 1000)
public void func1() throws InterruptedException {
log.info("작업1 시작");
Thread.sleep(5000); // 작업1의 수행 시간: 5초
log.info("작업1 종료");
}
@Async
@Scheduled(fixedDelay = 1000)
public void func2() {
log.info("작업2 시작");
log.info("작업2 종료");
}
}
- 스케줄러를 트리거하는 스레드(TaskScheduler)와 별도로 TaskExecutor 스레드를 통해 작업이 수행된다. 즉, 작업 수행은 TaskExecutor를 통해 수행되므로, 싱글 스레드인 TaskScheduler가 1초마다 작업1, 2를 호출한다. 즉, 작업1의 수행 시간이 5초가 걸리더라도, 그와 별개로 1초마다 작업1, 2가 호출된다.
- TaskExecutor의 디폴트 core 스레드 수는 8개이다. 따라서 스레드가 최대 8번까지 사용된 것을 확인할 수 있다.
4. 멀티 스레드 + 비동기
@Scheduled 스레드 수를 2로 설정하고, @Async를 통해 비동기로 동작하는 경우이다.
- 3번 경우와 마찬가지로, 스케줄러를 트리거하는 스레드와 스케줄링 작업을 수행하는 스레드가 별개의 스레드이다.
- 그런데 스케줄러를 트리거하는 스레드가 싱글 스레드가 아니라 2개이다. 따라서 작업1을 트리거하는 스레드와 작업2를 트리거하는 스레드가 다를 수 있다. 따라서 로그를 보면 반드시 작업1이 트리거 된 후 작업2가 트리거 되는 것이 아니라, 그 순서가 일정하지 않은 것을 알 수 있다.
- 만약 동일한 특정 시점에 트리거되는 스케줄링 작업이 여러 개라면?
- 싱글 스레드일 경우, 특정 시점에 여러 작업을 순차적으로 트리거하므로 약간의 시간이 걸릴 것이다.
- 따라서 이런 오차를 줄이고 싶다면 TaskScheduler 스레드 수를 늘리는 것이 방법이 될 수 있을 것 같다.
정리
스프링에서 @Async와 @Schduled를 사용할 때 사용되는 스레드에 대해 알아봤고, 예제를 통해 두 어노테이션을 따로 또 함께 사용할 때의 차이를 알아봤다.
만약 특정 스케줄링 작업의 수행 시간이 오래 걸리는데, 동일 시점에 다른 스케줄링 작업도 트리거되어야 한다면? 기본적으로 TaskSchduler의 스레드는 싱글 스레드이므로, 작업1의 수행이 끝난 후에 작업2의 스케줄링 작업이 트리거될 것이다. 따라서 의도와 달리, 작업2의 트리거 시간이 지연될 수 있다.
스레드에 대한 개념을 바로 잡아 이런 문제가 발생하지 않도록 상황에 따라 적절히 스레드 수를 조정하고 비동기 처리를 이용하자.
Reference
- https://docs.spring.io/spring-framework/reference/integration/scheduling.html
- https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
- https://devoong2.tistory.com/entry/Spring-Async-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95-%EB%B0%8F-TaskExecutor-ThreadPool
- https://junuuu.tistory.com/1020
'Backend > Spring' 카테고리의 다른 글
스프링 스케줄러(@Scheduled) 사용 시 주의사항 (1) | 2024.12.06 |
---|---|
스프링의 트랜잭션 전파(REQUIRED, REQUIRES_NEW) (0) | 2024.01.17 |
스프링의 트랜잭션 AOP(@Transactional)의 특징과 주의사항 (0) | 2024.01.16 |
스프링의 트랜잭션 (0) | 2024.01.16 |
JDBC(Java Database Connectivity), 커넥션 획득 방법 (0) | 2024.01.15 |