[Spring] 자바 비동기와 스프링 @Async
- [ Backend ]/Spring
- 2024. 7. 5.
자바의 비동기
자바에서 비동기 처리를 위해서는 쓰레드를 생성한 후 작업을 할당하는 방식을 주로 사용한다. 첫번째로 Thread 클래스를 상속받고, 상속받은 클래스에서 run()메서드를 오버라이딩해주는 방법이 있고, 두번째로는 Runnable 인터페이스를 구현하는 클래스를 정의하고 해당 클래스를 Thread의 생성자 파라미터로 전달하는 방법이 있다.
방법 1 - Thread 상속하기
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread: " + Thread.currentThread().getName());
}
}
void threadStart() {
Thread thread = new MyThread();
thread.start();
}
위와 같이 Thread 클래스를 상속받는 클래스를 만들고, run 메서드를 오버라이딩해준다.
방법 2 - Runnable 인터페이스 이용하기
Runnable 인터페이스를 구현하는 클래스를 정의하고, 해당 클래스를 Thread 클래스의 생성자 파라미터로 넘겨주었다.
Runnable 인터페이스는 위와 같이 생겼는데, 1개의 추상 메서드만을 가지는 함수형 인터페이스이므로 아래와 같이 람다식을 이용할 수 있다.
void main() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread: " + Thread.currentThread().getName());
}
};
Thread thread = new Thread(runnable);
thread.start();
}
하지만 이러한 방법들은 각 쓰레드의 작업 결과를 반환받을 수는 없다는 제약이 있다.
따라서 Java는 5버전부터(Java 1.5) Callable이라는 인터페이스를 통해 작업 결과를 반환할 수 있게 만들었다. 이는 Future라는 객체를 통해 비동기 작업의 결과를 받을 수 있게 만들었다.
하지만 Future에도 여전히 문제가 존재했다. 단순히 Future객체를 사용할 경우 메인 스레드는 생성한 스레드의 결과를 Future로 받기 위해서 블로킹 상태로 대기하게 되는 것이다. 이를 해결하기 위해서 Java 8에서는 CompletableFuture가 도입되었다.
CompletableFuture
CompletableFuture는 Future 인터페이스를 구현하면서 CompletableStage 인터페이스를 구현한다. 이를 활용하여 다른 작업들과 순차적으로 실행하고, 콜백 메서드를 활용할 수 있다. (구체적인 사용법은 구글링하면 많이 나옴)
하지만 무엇보다도 CompletableFuture가 갖는 강점은 Future.get() 호출시 작업이 완료될때까지 블로킹되는 문제를 해결했다는 것이다. 비동기 메서드를 활용하여 결과를 기다리지 않고 다른 작업을 계속 수행할 수 있으며, 별도의 스레드 풀을 활용하여 결과가 나올 때까지 메인 스레드가 블로킹되지 않도록 할 수 있다.
[예제 추가 예정]
스프링의 비동기
아래의 코드를 보자.
...
// execute하기 전에 다른 스레드가 다른 connection을 주입하는 것을 방지하기 위해 동기화
private static synchronized void setConnAndExecute(ExecutorService threadPool, Socket connection, RequestHandler handler) {
logger.debug("connection = " + connection);
handler.setConnection(connection);
Future<?> future = threadPool.submit(handler); // 쓰레드를 할당하고 비동기적으로 작업 배치
try{
future.get(); // Future.get()은 블로킹 방식으로 동작
} catch (ExecutionException | InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
...
ExecutorService와 Future를 사용하여 비동기 요청을 보내고, 블로킹 방식으로 대기한 후 결과값을 받아서 프로세스를 처리하고 있다.
스프링에서 요청을 비동기로 처리하기 위해서는 @Async 어노테이션을 가장 흔하게 사용한다. 그렇다면 이 @Async 어노테이션은 어떠한 방식으로 동작하고, 순수 자바 코드로 작성한 비동기 요청 방식과 어떠한 차이가 있을까?
Executor
스프링은 기본적으로 비동기 처리에 SimpleAsyncTaskExecutor를 사용하는데, 이 어노테이션은 작업마다 생성/소멸되는 새로운 스레드를 할당하기 때문에 스레드를 관리하기 위한 오버헤드가 존재한다. 따라서 사용자가 직접 다른 Executor를 사용하여 성능을 향상시킬 수 있는데, 대표적인 것이 ExecutorService와 유사하게 미리 생성된 스레드 풀을 통해서 스레드를 관리하는 ThreadPoolTaskExecutor이다.
SimpleAsyncTaskExecutor와 ThreadPoolTaskExecutor는 모두 자바의 java.util.concurrent.Executor 인터페이스를 구현하는 클래스이다. 아까 사용한 ExecutorService도 이 Executor 클래스를 구현하고 있으며, 쓰레드 풀의 크기, 최대 풀 크기, 큐 용량과 같은 다양한 정보들을 설정할 수 있다. 따라서 이러한 설정을 통해서 애플리케이션의 상황에 맞는 적절한 스레드 풀을 구성할 수 있다.
스프링에서는 @Configuration 설정 클래스를 통해서 쓰레드 풀을 커스텀할 수 있다. 스프링에서 ThreadPoolTaskExecutor 빈을 정의하고, 아래와 같이 필요한 설정을 추가해줄 수 있다.
@Configuration
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 풀 기본 크기 (초기 사이즈)
executor.setMaxPoolSize(20); // 풀 최대 크기
executor.setQueueCapacity(500); // 작업요청 대기 큐의 사이즈 (큐가 가득 차면 스레드 생성)
executor.initialize(); // 스레드풀 생성
return executor;
}
}
초기에는 setCorePoolSize()에 설정해놓은 값만큼 스레드 풀 크기가 설정된다. 이후에는 기존 스레드풀을 넘는 요청이 들어올 경우 setMaxPoolSize()를 통해서 설정한 값만큼 스레드 풀의 크기가 증가하는 것이 아니라, 초기 스레드풀보다 많은 작업 요청이 들어오면 setQueueCapacity()에 설정해둔 크기의 대기열에 요청을 넣고, 대기열을 초과할 경우 max size만큼 점진적으로 스레드를 생성한다. 다만 setMaxPoolSize는 점진적으로 생성되는 최대 스레드 수를 제한하는 방식이다.
@Async
@Async 어노테이션은 스프링 프레임워크에서 비동기 처리를 위해서 제공하는 어노테이션이다. 이 어노테이션은 메서드와 타입에 붙일 수 있는데, 주로 메서드 레벨에 선언되어 스프링이 해당 메서드를 비동기적으로 처리하도록 한다.
그렇다면 스프링은 어떻게 어노테이션을 통해서 요청을 비동기로 처리할까? 원리는 앞서 ExecutorService를 사용한 자바 코드와 별반 다르지 않다. 하지만 스프링은 같이 사용되는 @EnableAsync 어노테이션을 통해서 해당 메서드의 프록시 객체를 생성하고, 그것을 통해 실제 메서드의 호출을 비동기적으로 처리한다.
다시 말해서, @Async 어노테이션의 처리는 스프링 AOP방식으로 동작한다고 할 수 있다. 프로그래머가 @Async 어노테이션을 명시해주기만 한다면 메서드가 호출될 때 요청을 가로채서 프록시 패턴을 통해서 비동기 요청 처리 로직을 추가해 주는 것이다.
내부 호출
비동기 로직을 구현할 때 가장 쉽게 할 수 있는 실수는, @Async가 붙은 비동기 메서드를 동일한 클래스에서 호출하는 것이다. 예전에 스프링 프록시에 대해서 정리한 글에서, 내부 호출에 관해서 이야기한 적이 있다.
@Service
public class MyService {
@Async
public void asyncMethod() {
System.out.println("Async method execution");
}
public void syncMethod() {
// 동일 클래스 내에서 asyncMethod 호출
// 해당 메서드는 비동기로 동작하지 않는다.
asyncMethod();
}
}
스프링 AOP는 프록시 패턴이 적용된 클래스를 빈으로 등록하기 때문에 스프링 빈으로 등록된 클래스를 호출하면 프록시가 적용된 클래스가 호출된다. 하지만 동일한 클래스에서 메서드를 호출하게 되면, 스프링 빈의 프록시가 적용된 메서드가 아니라 'this' 객체를 통해서 메서드가 호출된다. 이는 프록시가 적용되지 않은 순수한 메서드이므로 비동기 처리가 이루어지지 않게 되는 것이다.
트랜잭션 관리
예전에 테스트해본 적이 있는데, 트랜잭션이 적용된 func1()에서 비동기로 func2()를 호출했을 때, func2()에서 exception이 터져도 별개의 스레드에서 실행되게 되므로 func1()의 트랜잭션 롤백은 발생하지 않는다. (스프링 트랜잭션은 스레드간 전파가 이루어지지 않는다)
비동기로 실행되던 메서드에서 예외를 던지자, ControllerAdvice를 통해서 설정해 놓은 ExceptionHandler가 비동기 호출된 함수에서도 발생한 예외를 처리하지 못했다.
만약 결과를 CompletableFuture를 통해서 기다리고 있었다면, 원래의 Exception 대신 CompletionException이 발생해서 func1()쪽에 비동기 상황에서 예외가 발생했다는 사실만 전달하고 있다.
결론
이러한 비동기 요청은 사용자의 요청을 처리하는 동안 긴 시간이 소요되는 작업, 다시 말해 동기 방식으로 처리할 경우 main application의 리소스를 많이 소모시키는 작업들을 처리하는데 적합하다. 하지만 고려해야 할 점들이 생각보다 많으니, 이러한 점들을 모두 고려한 후 사용하는 것이 좋다.
'[ Backend ] > Spring' 카테고리의 다른 글
[Spring] @Valid와 @Validated를 이용한 순차 검증 (0) | 2024.09.21 |
---|---|
[Spring] 서블릿 분석 - Spring은 어떻게 multipart/form-data를 처리할까 (0) | 2024.08.30 |
[Spring] Spring Batch 프로젝트에 적용해보기 (0) | 2024.04.21 |
[Spring] 프록시(Proxy)와 스프링 AOP (0) | 2024.02.04 |
[Spring] @JsonCreator 없이 immutable하게 역직렬화하기 (0) | 2024.01.17 |