DevYGwan
비동기 처리 시 @Async와 Springboot의 마법 본문
기본적으로 스프링에서 비동기 처리를 할때, 기본적으로 비동기 처리에 필요한 Executor를 등록하고 @Async 어노테이션과 @EnableAsync을 사용해서 비동기 처리를 진행합니다. 이때 이에 대한 원리를 제대로 알지 못해 제가 등록한 Executor와 @Async 어노테이션에서 사용하는 Executor가 다른 문제를 확인했고 이를 공유하려고 합니다.
간단하게 위에서 말한 개념들을 설명하자면,
- Executor : Java 표준 라이브러리에서 제공하는 인터페이스로, 비동기 작업을 실행하는 데 사용됩니다.
- 구현체로 ThreadPoolExecutor, ForkJoinPool이 있습니다.
- 쓰레드풀을 관리하고 작업을 실행하는 역할을 합니다.
- @Async : 메서드를 비동기적으로 실행하도록 만들어줍니다.
- 이를 통해 메서드 실행이 동기적으로 이루어지는 것이 아니라, 별도의 쓰레드에서 실행되도록 할 수 있습니다.
- @EnableAsync : Spring 프레임워크에서 비동기 실행 기능을 활성화하는 데 사용되는 어노테이션입니다.
- @Async 어노테이션이 붙은 메서드는 비동기적으로 실행되도록 설정합니다.
- 비동기 실행과 관련된 설정(예: 쓰레드 풀 크기, 실행 전략 등)을 커스터마이징할 수 있습니다.
- 비동기 실행을 위한 AOP 기반 프록시가 자동으로 생성됩니다.
그렇다면, 어떤 문제를 확인했는지 설명드리도록 하겠습니다.
@Configuration
@EnableAsync
public class AsyncConfigV1 {
private static final int CORE_POOL_SIZE = 2;
private static final int MAX_POOL_SIZE = 4;
private static final int queueCapacity = 20;
@Bean("CustomTaskExecutor")
public Executor CustomTaskExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("CustomTaskExecutor-");
executor.initialize();
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
@Service
public class AsyncTestService {
@Async
public CompletableFuture<String> callOnlyAsync() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
이러한 코드로 제가 CustomTaskExecutor을 Bean으로 등록하고 @Async 어노테이션을 통해 특정 메서드를 비동기로 처리했습니다. 그 후, 테스트를 진행하니 제대로 비동기 처리가 되는 것을 확인 할 수 있었고 저는 제가 등록한 CustomTaskExecutor 을 사용해서 비동기 처리가 진행된다고 생각하고 있었습니다.
그러다 문득 의문이 들었습니다.
@Async 어노테이션이 어떻게 내가 등록한 빈을 알고 이를 사용할까?
결론부터 말하자면, "이렇게 해서는 스프링에서 Default로 설정된 Executor를 사용하고 추가적으로 명시하거나 설정해야 내가 등록한 빈을 사용할 수 있다"가 정답입니다. 즉 저는 빈으로 Executor을 등록했지만, 단순히 등록만 했기 때문에 내가 등록한 빈을 사용하지 않고 비동기 처리가 됐습니다. 그렇다면 어떻게 스프링에서 이러한 처리가 되는지 설명드리도록 하겠습니다.
※ @EnableAsync 사용 후 Spring 비동기 처리과정
- @EnableAsync을 통해 Spring 컨텍스트에 비동기 지원이 활성화됩니다.
- AsyncConfigurationSelector라는 클래스를 통해 필요한 설정을 로드합니다.
- AsyncConfigurationSelector는 AsyncAnnotationBeanPostProcessor을 등록합니다.
- AsyncAnnotationBeanPostProcessor는 빈 생성 과정에서 @Async 어노테이션이 된 메서드를 찾아 AOP 기반의 프록시 객체를 생성합니다. 이때 해당 클래스 안에 Executor을 설정합니다.
- 프록시 빈은 원본 메서드의 기능을 수행하면서 비동기 실행을 처리합니다.
- 비동기 실행 시, 프록시 빈은 비동기 실행을 위해 Executor 구현체를 사용하여 작업을 실행합니다.
그렇다면, 이러한 기본 설정이 어떻게 처리가 되는지 코드를 통해 확인해보도록 하겠습니다.
※ 처리과정을 공식 문서 코드를 통해 확인하기
위에서 설명한 공식문서를 실제 spring-framework 코드를 통해 확인해보도록 하겠습니다. 코드 출처는 아래와 같습니다.
※ AsyncConfigurationSelector
* @author Chris Beams
* @author Juergen Hoeller
* @since 3.1
* @see EnableAsync
* @see ProxyAsyncConfiguration
*/
public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {
private static final String ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME =
"org.springframework.scheduling.aspectj.AspectJAsyncConfiguration";
/**
* Returns {@link ProxyAsyncConfiguration} or {@code AspectJAsyncConfiguration}
* for {@code PROXY} and {@code ASPECTJ} values of {@link EnableAsync#mode()},
* respectively.
*/
@Override
@NonNull
public String[] selectImports(AdviceMode adviceMode) {
return switch (adviceMode) {
case PROXY -> new String[] {ProxyAsyncConfiguration.class.getName()};
case ASPECTJ -> new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME};
};
}
}
AsyncConfigurationSelector 예시 코드는 이렇게 되어 있습니다. 이에 대해서 간단히 설명드리면, selectImports 메서드의 경우 Spring에서 비동기 처리를 설정할 때 @EnableAsync 어노테이션의 mode 속성에 따라 적절한 구성 클래스를 선택하여 반환하는 역할을 합니다. 기본적으로 @EnableAsync 어노테이션의 mode 속성은 PROXY 입니다. 그렇다면, 해당 프록시와 관련된 클래스인 ProxyAsyncConfiguration 코드를 확인해보도록 하겠습니다.
※ ProxyAsyncConfiguration
* @author Chris Beams
* @author Stephane Nicoll
* @author Juergen Hoeller
* @since 3.1
* @see EnableAsync
* @see AsyncConfigurationSelector
*/
@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {
@Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
Assert.state(this.enableAsync != null, "@EnableAsync annotation metadata was not injected");
AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
bpp.configure(this.executor, this.exceptionHandler);
Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
bpp.setAsyncAnnotationType(customAsyncAnnotation);
}
bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
bpp.setOrder(this.enableAsync.<Integer>getNumber("order"));
return bpp;
}
ProxyAsyncConfiguration 클래스 코드는 이렇게 되어 있습니다. 뭔가 처리를 많이 하는군여... 근데 뭔가 위에서 설명한 @EnableAsync 사용 후 Spring 비동기 처리과정과 관련된 내용이 많이 있는 것 같습니다. 이에 대한 코드 중 중요한 부분을 분석해보면,
- Assert.state(this.enableAsync != null, "@EnableAsync annotation metadata was not injected");
- this.enableAsync가 null이 아닌지 확인합니다. 이는 @EnableAsync 어노테이션이 있는지를 확인하고 없다면 에러를 발생시키는 코드입니다.
- AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
- AsyncAnnotationBeanPostProcessor 인스턴스 생성
- bpp.configure(this.executor, this.exceptionHandler);
- executor와 exceptionHandler를 사용하여 AsyncAnnotationBeanPostProcessor를 구성합니다
- bpp에 대한 추가적인 설정을 끝낸 후 bbp(AsyncAnnotationBeanPostProcessor)를 return 한다.
이러한 과정을 통해 @Async 어노테이션이 처리됩니다. 코드와 함께 보니 어떻게 진행되는지를 좀 더 잘 이해할 수 있었습니다.
그렇다면, AsyncAnnotationBeanPostProcessor이 어떤 Executor을 사용해서 처리가 될까요?
위의 내용은 어떻게 Executor을 사용하는지에 대한 설명입니다. 이를 요약하자면, 기본적으로 메서드에 @Async을 지정할 때 사용되는 Executor는
- AsyncConfigurer가 있는 경우 해당 구현체에서 선언한 Executor을 사용합니다.
- 메서드를 실행할 때 기본값이 아닌 Executor 사용하고 싶은 경우, @Async 어노테이션의 value 값을 통해 사용할 수 있습니다.
그래서 전, AsyncConfigurer 코드를 확인해봤습니다.
* @author Chris Beams
* @author Stephane Nicoll
* @since 3.1
* @see AbstractAsyncConfiguration
* @see EnableAsync
*/
public interface AsyncConfigurer {
/**
* The {@link Executor} instance to be used when processing async
* method invocations.
*/
@Nullable
default Executor getAsyncExecutor() {
return null;
}
/**
* The {@link AsyncUncaughtExceptionHandler} instance to be used
* when an exception is thrown during an asynchronous method execution
* with {@code void} return type.
*/
@Nullable
default AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return null;
}
- 위에서 첨부한 공식 코드에 적혀 있는 내용입니다.
- 해당 인터페이스를 구현한 AsyncConfigurerSupport 클래스도 잇지만 주석에, @deprecated로 나와 있어 따로 설명은 생략하겠습니다.
그렇다면, 이는 return을 null로 한다는 건데 null 일 경우 어떻게 처리가 될지 확인해보기위해 전체적인 코드를 다시 찾아봤습니다. 그랬더니 spring-task.xsd 파일에 아래와 같은 내용이 있었습니다.
요악하자면, 크게 3가지로 정리할 수 있습니다.
- 비동기 메서드를 호출할 때 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTaskExecutor가 기본으로 사용됩니다.
- Spring 3.1.2를 기준으로 @Async에 개별적인 Executor을 부여될 수 있습니다.
- 여기에 지정된 Executor는 자격이 없는 @Async에 기본값으로 작용한다는 것을 의미합니다.
그렇다면, 해당 부분이 실제로 그렇게 동작하는지 테스트를 진행해보도록 하겠습니다.
테스트는 3개의 시나리오로 Test를 진행하도록 하겠습니다.
※ 시나리오 1 - 사용할 Executor가 지정되지 않을 경우 @Async 테스트
@Service
public class AsyncTestService {
@Async
public CompletableFuture<String> callOnlyAsync() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
@SpringBootTest
class AsyncV1TestServiceTest {
@Autowired
public AsyncTestService asyncTestService;
@DisplayName("비동기 처리 시 @Async 어노테이션에 아무것도 없을 때 테스트")
@Test
void test() throws ExecutionException, InterruptedException {
var response = asyncTestService.callOnlyAsync();
System.out.println(response.get());
}
}
- 첫번째 시나리오는 별도의 Executor을 등록하지 않고 @Async 어노테이션을 사용했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
- 테스트 결과 : SimpleAsyncTaskExecutor-1 출력
- 결과 해석 : 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTask Executor가 기본으로 사용
- 스프링 컨테이너에는 따로 SimpleAsyncTask Executor가 따로 빈으로 등록되지 않음
※ 시나리오 2 - AsyncConfigurer을 상속 받아 구현체를 구현 후 @Async 테스트
@EnableAsync
@Configuration
public class AsyncConfigV2 implements AsyncConfigurer {
private static final int CORE_POOL_SIZE = 2;
private static final int MAX_POOL_SIZE = 4;
private static final int queueCapacity = 20;
@Override
public Executor getAsyncExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("AsyncConfigurerThreadExecutor-");
executor.initialize();
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
@Service
public class AsyncTestService {
@Async
public CompletableFuture<String> callOnlyAsync() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
@SpringBootTest
class AsyncV1TestServiceTest {
@Autowired
public AsyncTestService asyncTestService;
@DisplayName("비동기 처리 시 AsyncConfigurer로 Executor 선언 후 테스트")
@Test
void test() throws ExecutionException, InterruptedException {
var response = asyncTestService.callOnlyAsync();
System.out.println(response.get());
}
}
- 두번째 시나리오는 AsyncConfigurer을 상속 받은 클래스를 구현하고 @Async 어노테이션을 사용했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
- 테스트 결과 : AsyncConfigurerThreadExecutor-1 출력
- 결과 해석 : AsyncConfigurer을 상속 받아 구현체를 구현하면 해당 구현체에서 선언한 Executor을 사용
- AsyncConfigurer에서 구현한 Executor는 스프링 컨테이너에 "applicationTaskExecutor" 로 저장됨
※ 시나리오 3 - Executor 직접 구현해 빈에 등록하고, @Async("이름")을 통해 선언 후 @Async 테스트
@Configuration
@EnableAsync
public class AsyncConfigV1 {
private static final int CORE_POOL_SIZE = 2;
private static final int MAX_POOL_SIZE = 4;
private static final int queueCapacity = 20;
@Bean("CustomTaskExecutor")
public Executor CustomTaskExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("CustomTaskExecutor-");
executor.initialize();
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
@Service
public class AsyncTestService {
@Async("CustomTaskExecutor")
public CompletableFuture<String> callOnlyAsync() {
return CompletableFuture.completedFuture(Thread.currentThread().getName());
}
}
@SpringBootTest
class AsyncV1TestServiceTest {
@Autowired
public AsyncTestService asyncTestService;
@DisplayName("비동기 처리 시 @Async에 Executor 선언 후 테스트")
@Test
void test() throws ExecutionException, InterruptedException {
var response = asyncTestService.callOnlyAsync();
System.out.println(response.get());
}
}
- 마지막 시나리오는 Executor를 직접 빈으로 등록하고, @Async 어노테이션의 value 값에서 해당 Executor를 선언했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
- 테스트 결과 : CustomTaskExecutor-1 출력
- 결과 해석 : Custom Executor 구현해 빈으로 등록하고, @Async("빈 이름")을 통해 선언해주면 해당 Executor을 사용합니다.
- Custom Executor는 스프링 컨테이너에 @Bean에 설정한 이름인, "CustomTaskExecutor" 로 저장됨
정리하자면, @Async은 메서드를 비동기적으로 실행하도록 만들어주는데, 이때 Executor을 사용하여 비동기에 사용할 쓰레드 풀 환경을 제공해줍니다. 이때 @Async 처리 된 메서드가 사용하는 Executor는
1. 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTask Executor가 기본으로 사용됩니다.
2. AsyncConfigurer을 상속 받아 구현체를 구현하면 해당 구현체에서 선언한 Executor을 사용합니다.
3. Custom Executor 구현해 빈으로 등록하고, @Async("빈 이름")을 통해 선언해주면 해당 Executor을 사용합니다.
※ 저의 구현 코드에서의 문제점
Custom Executor 구현해 빈으로 등록하고 @Async 어노테이션을 사용해서 비동기 처리를 진행했지만, @Async 어노테이션에 value 값에 내가 구현한 Custom Executor의 빈 이름을 명시하지 않아서 생기는 문제였습니다. 위와 같이 테스트를 진행한 결과 저의 Custom Executor는 빈에 잘 등록이 되어 있지만, @Async을 사용한 메서드에서는 Executor를 SimpleAsyncTaskExecutor을 사용하는 것으로 확인했습니다. 이후에, value로 명시하거나 Config 클래스에 AsyncConfigurer을 상속하여 구현하는 방식으로 문제를 해결했습니다.
그렇다면, SimpleAsyncTaskExecutor랑 제가 구현한 Custom Executor(ThreadPoolTaskExecutor) 의 차이를 설명하는 것으로 이번 블로그를 마치겠습니다.
※ SimpleAsyncTaskExecutor vs ThreadPoolTaskExecutor
특성 | SimpleAsyncTaskExecutor | ThreadPoolTaskExecutor |
쓰레드 생성 | 매번 새로운 쓰레드를 생성 | 쓰레드 풀을 관리하고, 재사용 가능 |
대기 큐 | 사용하지 않음 | 작업 대기 큐를 사용하여, 큐에 대기할 수 있음 |
쓰레드 수 제한 | 무제한 | 코어 쓰레드 수와 최대 쓰레드 수 설정 가능 |
쓰레드 이름 | SimpleAsyncTaskExecutor-1, SimpleAsyncTaskExecutor-2, ... | 사용자 설정 가능 (ThreadNamePrefix 속성) |
- SimpleAsyncTaskExecutor는 작업이 많아질 때마다 새로운 쓰레드를 계속 생성하므로, 시스템 자원이 부족해질 수 있어 많은 작업을 비동기로 처리해야 할 때 성능 문제를 일으킬 수 있습니다.
- ThreadPoolTaskExecutor는 기존 쓰레드를 재사용하여 효율적으로 리소스 관리가 가능하고 쓰레드 수 제한이 가능합니다. 또한 작업 큐를 사용하여 들어오는 작업을 큐에 저장하고 처리할 수 있어 비동기 처리 시 많은 장점을 가지고 있습니다.
'Study > Spring' 카테고리의 다른 글
Flyway 도입 및 오픈소스 기여 (1) | 2024.09.26 |
---|---|
Mocking 시 예상대로 동작하지 않는다면, 인스턴스 동등성 문제를 의심해라 (1) | 2024.06.08 |
DB관련 Test 코드 작성 시 유의할 점 :: @DataJpaTest (0) | 2024.05.04 |
JVM Memory의 3가지 메트릭 지표 - Committed Memory란? (0) | 2024.04.02 |
조회 성능 향상을 위한 캐시 처리 With Redis (1) | 2024.01.29 |