스프링 환경에서 작업할 때, 여러개의 작업을 동시에 요청할 경우 해당 작업들이 큐에 쌓이게 된다.
호출한 스레드에서 작업들은 순차적으로 이루어지며, 하나의 작업이 끝날 때까지 대기하는 상태에 놓인다.
이를 동기(Synchronous)라고 하며, 기본적인 스프링 동작 방식에 해당한다.
그러나 이전 작업이 끝날 때까지 다른 작업을 대기시키는 것은 비효율적이다.
해당 작업이 진행중인 동안, 다른 작업을 함께 진행시키면 될 일 아닌가?
호출한 스레드에서 작업을 시작하고 끝낸다면, 다른 스레드에 작업을 분담시킨다면 어떨까?
그렇다면 작업이 끝날 때까지 대기하지 않고 다른 작업을 처리할 수 있을 것이다.
이를 비동기(Asynchronous)라고 한다.
스프링 환경에서는 @Async 어노테이션을 사용함으로서 비동기 동작을 할 수 있다.
@EnableAsync 어노테이션을 먼저 선언하여 Spring에 @Async 어노테이션을 활성화한다.
이후 비동기로 작업할 메서드에 @Async 어노테이션을 붙이면 된다.
예시
@SpringBootApplication @EnableAsync public class ExampleApplication { public static void main(String[] args) { SpringApplication.run(Example.class, args); } }
@Async public void asyncMethod() { //... }
상기한 방식과 같이 main method가 존재하는 class에 @EnableAsync를 선언할 경우, SimpleAsyncTaskExecutor에 의해 스레드가 관리된다.
SimpleAsyncTaskExecutor는 매 작업 요청마다 새로운 스레드를 생성하여 처리한다.
어찌 됐든 비동기로 작업이 이루어지니 별 상관없다 생각할 수도 있으나, 스레드를 관리하고 재사용하는 것이 아니라는 점에 주목해야 한다.
새로운 스레드를 생성하고 제거하는 과정은 시스템 자원을 소모할 뿐더러, 시스템은 동시에 실행 가능한 스레드 수에 제한이 있다.
그런데 매 동작 호출마다 생성과 제거를 반복한다? 몇천번을 요청하면 몇천개의 스레드를 생성할 것인가? 이는 매우 비효율적이다.
그래서 사용하는 것이 ThreadPoolTaskExecutor다.
ThreadPoolTaskExecutor는 스레드풀 기반의 커스텀 TaskExecutor, Task 실행기의 일종이다.
다음과 같은 속성들을 통해 동작을 설정할 수 있다.
속성
- CorePoolSize
생성할 스레드 풀의 기본 스레드 개수
- MaxPoolSize
생성할 스레드 풀의 최대 스레드 개수
- QueueCapacity
작업(task) 대기열 큐의 크기
- RejectedExecutionHandler
큐가 부족해 예외가 발생하는 경우의 처리
- AbortPolicy : 처리되지 못한 작업을 생략한다. RejectExecutionHandler 예외를 발생시킨다.
- DiscardOldestPolicy : 오래된 작업을 생략한다.
- DiscardPolicy : 처리되지 못한 작업을 생략한다. AbortPolicy와는 달리 예외가 발생하지 않는다.
- CallerRunsPolicy : shutdown 상태가 아니라면 스레드풀을 호출한 스레드에서 처리한다.
- WaitForTasksToCompleteOnShutdown
shutdown 요청시 진행중인 작업 마무리 여부
- AwaitTerminationSeconds
진행중인 작업 마무리 대기시간 설정
WaitForTasksToCompleteOnShutdown 옵션이 true일 경우 활성화
- ThreadNamePrefix
생성할 각 스레드들의 통칭 문자열 설정
전체적인 동작은 다음과 같다.
1. 스레드풀에 작업을 등록할 시 스레드풀에 CorePoolSize만큼의 스레드가 존재하는지 확인한다.
1.1. 스레드 개수가 CorePoolSize보다 적으면, 스레드풀에 새로운 스레드를 생성하고 작업을 할당한다.
1.2. 스래드 개수가 CorePoolSize보다 크면, 스레드풀의 대기 상태 스레드에게 작업을 할당한다.
1.3. 만약 스레드풀의 모든 스레드가 작업하고 있어 대기중인 스레드가 없다면, 큐에 해당 작업을 넣어 대기시킨다.
1.4. 큐가 가득 찼다면, 현재 스레드풀의 스레드 수가 MaxPoolSize 이하일 경우에 한해 새로운 스레드를 생성하여 작업을 할당한다.
1.5. 더 이상 스레드를 생성할 수 없고 큐에도 대기시킬 수 없는 상태에 작업이 들어오면, RejectedExecutionHandler에 따른 동작을 실행한다.
2. 작업중인 스레드가 작업을 마치면, 큐에 대기중인 작업이 있는지 확인한다.
2.1. 있을 경우, 해당 작업을 가져와 다시 작업을 수행한다.
2.2. 없을 경우, 해당 스레드는 대기 상태가 되며, 현재 스레드풀의 스레드 개수가 CorePoolSize보다 크면 해당 스레드는 스레드풀에서 제거된다.
예시
@EnableAsync @Configuration public class AsyncConfig { @Bean public Executor asyncExecutorMethod() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(10); executor.setQueueCapacity(20); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(60); executor.setThreadNamePrefix("asyncTask:"); executor.initialize(); return executor; } }
@Async 어노테이션이 붙여진 메서드를 호출할 경우, Spring AOP에 따라 Proxy Object로 래핑하여 요청을 처리한다.
이를 통해 해당 동작을 비동기적으로 처리할 수 있다.
만약 호출 지점에 직접 @Async가 붙은 메서드를 인스턴스로 내부 생성하거나, 아예 내부에 해당 메서드를 넣을 경우 비동기적으로 처리할 수 없다.
그 이유는 Proxy Object로 래핑한 것에 요청하는 것과, 요청 및 동작이 하나의 Proxy Object 안에 래핑된 것은 다르기 때문이다.
Spring은 비동기적으로 동작하길 원하는 메서드에 관여해야 하는데, Bean을 사용하지 않거나 내부에서 직접 접근할 경우 Spring이 관여할 여지가 없어 해당 동작이 하나의 Proxy Object로 래핑되어 버린다.
따라서 비동기적으로 동작시킬 기능은 호출 지점과 독립된 메서드로 관리해야 한다.
https://xxeol.tistory.com/44#ThreadPoolTaskExecutor%EB%A5%BC%C2%A0%ED%99%9C%EC%9A%A9%ED%95%9C%20%EC%8A%A4%EB%A0%88%EB%93%9C%20%EA%B4%80%EB%A6%AC-1
https://goodgid.github.io/SpringBoot-Why-doesn't-it-work-with-Async
https://velog.io/@bonjugi/ThreadPoolTaskExecutor-%EC%98%B5%EC%85%98-%EC%84%A4%EB%AA%85#2-taskdecorator