1. 발단
별도의 커스텀 스레드풀을 빈으로 등록해주지 않고 @Async("customThreadPoolExecutor")
를 메서드에 붙여주고 스프링 컨테이너를 실행했을 때 당연히 빈을 못찾아서 실행부터 오류가 날거라고 생각했다.
@Service
public class MyService {
@Async("customTaskExecutor")
public void performAsyncTask() {
// 비즈니스 로직
System.out.println("비동기 작업 실행: " + Thread.currentThread().getName());
}
}
하지만 스프링 컨테이너는 잘 실행되었고 해당 기능을 호출하기 전까지 아무런 문제없이 구동되고 있었다.
그리고 해당 기능이 호출되었을 때에서야 에러가 발생되었다.
2. @Async 의 동작과정
스프링에서 @Async
의 동작과정을 간단히 요약해보면 다음과 같다.
동작 과정 요약
@Async
어노테이션이 붙은 메서드가 호출되면, 스프링 AOP가 메서드 호출을 프록시로 가로챈다.- 프록시는 따로 정의한
Executor
(커스텀 스레드풀)이나 없을 경우 SimpleThreadPoolExecutor 를 참조한다. Executor
는 해당 메서드를 비동기 작업으로 스레드풀에 제출한다.- 스레드풀의 스레드 중 하나가 메서드를 실행하여 비동기 처리한다.
위 발단에서의 현상을 살펴보면 2번 과정에서 정의한 커스텀 스레드풀 (bean) 이 없기때문에 NoSuchBeanDefinitionException
이 발생했음을 알 수 있다.
3. 커스텀 스레드풀 빈은 어떻게 등록되는가?
@Async 어노테이션에서 커스텀 스레드풀을 등록하고 싶다면
일반적으로 아래와 같이 커스텀 Configuration 클래스에서 따로 스레드풀 설정을 진행한다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
@Bean(name = "customTaskExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Custom-Executor-");
executor.initialize();
return executor;
}
}
위와 같은 설정을 해주면,
스프링에서는 처음 구동될 때 AnnotationConfigApplicationContext 와 GenericApplicationContext 클래스에서 빈을 등록하고 관리하는 과정을 담당한다. 이 때, ConfigurationClassPostProcessor 에서 @Configuration
, @Bean
과 같은 스프링의 설정 관련 어노테이션을 처리한다.
추가적으로 설명하자면, @Bean 어노테이션이 붙은 메서드 빈같은 경우 리턴타입이 빈의 타입이 되고 해당 빈을 주입받게되면 메서드를 invoke 하여 실행되는 방식이다. 더 자세한 설명은 ConfigurationClassPostProcessor
소스코드를 살펴보면 좋다. (org.springframework.context.annotation.ConfigurationClassPostProcessor 패키지)
따라서 Config 클래스에서 따로 설정해주게 되면 빈으로 등록되게 된다.
4. 그렇다면 @Async 은 어떻게 등록되고 관리되는데?
다시 한번 정리해보자.
@Async
는 스프링에서 비동기 메서드 실행을 관리하는 어노테이션으로, 스프링이 제공하는 AOP(Aspect-Oriented Programming)를 통해 동작한다. 이 어노테이션의 작동 방식과 스레드풀 관리는 다음과 같다.
1. 작동방식
스프링 컨테이너가 구동될 때, @EnableAsync
어노테이션을 통해 비동기 처리를 위한 설정이 활성화된다. 이때 스프링은 @Async
를 처리하기 위한 프록시 객체를 생성한다. 즉, @Async
가 붙은 메서드는 비동기 실행을 위해 AOP 프록시로 감싸지게 됩니다.
스프링이 구동되면서 @Async
를 처리할 수 있도록 다음과 같은 과정을 거친다.
@EnableAsync
:@EnableAsync
는 스프링의 비동기 기능을 활성화한다. 이 과정에서AsyncAnnotationBeanPostProcessor
를 등록하고 비동기 처리에 필요한 설정을 진행합니다.- AOP 프록시 생성: 스프링 컨테이너는
@Async
가 붙은 빈을 감싸는 AOP 프록시를 생성합니다. 이 프록시는 메서드 호출을 가로채고, 비동기 스레드풀을 통해 메서드를 비동기로 실행합니다.
2. 커스텀 스레드풀 이름을 정의하지 않은 경우
@Async
어노테이션에 특정한 스레드풀 이름을 지정하지 않으면, 스프링은 기본적으로 전역적으로 설정된 기본 스레드풀을 사용한다. 이때 기본적으로 사용되는 스레드풀은 SimpleAsyncTaskExecutor
이다. 이 스레드풀은 특별한 스레드풀 관리를 하지 않고, 호출 시마다 새로운 스레드를 생성한다.
- 커스텀 스레드풀을 정의하지 않거나 잘못된 스레드풀 이름을 사용한 경우:
- 만약
@Async("customThreadPoolName")
에서"customThreadPoolName"
이라는 이름의 빈이 스프링 컨텍스트에 존재하지 않으면, *NoSuchBeanDefinitionException
이 발생한다. 스프링은 스레드풀 이름으로 해당 빈을 찾기 때문에, 정의하지 않은 이름을 사용할 경우 스프링 컨테이너가 구동 중에 이를 인식하지 못하고 예외가 발생한다.
- 만약
음 그렇다면 스프링컨테이너 구동시 정의된 커스텀 스레드풀 빈이 없다면 에러가 발생해야하는 것이 맞다.
위에서 언급했던 AsyncAnnotationBeanPostProcessor
에 대해서 조금 더 알아보자.
하지만 그전에 ProxyAsyncConfiguration 에 대해서 짚고 넘어갈 필요가 있다.
3. ProxyAsyncConfiguration
package org.springframework.scheduling.annotation
에 존재하는 ProxyAsyncConfiguration 클래스를 살펴보자.
(spring6.1.12 버전이다)
asyncAdvisor() 메서드에서는 아래와 같은 동작과정을 진행한다.
- 빈 정의:
ProxyAsyncConfiguration
클래스는AsyncAnnotationBeanPostProcessor
를 통해 빈을 정의한다. 이 빈은 비동기 메서드 호출을 처리하기 위한 AOP 프록시를 생성한다. - 설정 적용:
AsyncAnnotationBeanPostProcessor
는 비동기 작업을 처리하기 위해 스레드풀과 예외 처리기를 설정한다.- 사용자 정의 비동기 어노테이션 타입이 지정된 경우, 해당 어노테이션 타입을 설정한다.
- 프록시 생성 시 클래스 기반 프록시를 사용할지, 프록시의 순서를 설정한다.
- 프록시 생성:
AsyncAnnotationBeanPostProcessor
는 스프링 컨테이너가 초기화될 때@Async
어노테이션이 붙은 메서드를 감싸는 프록시를 생성한다. 이 프록시는 비동기 메서드 호출을 처리하여 비동기적으로 실행한다.
4. AsyncAnnotationBeanPostProcessor
AsyncAnnotationBeanPostProcesor
에서는 setBeanFactory를 통해 실제 Adivosr(PointCut, Advice)와 @Annotation 을 등록한다.
그런뒤에 AbstractAdvisingBeanPostProcessor 클래스의 postProcessAfterInitialization 메서드에서 advisor 가 등록된다.
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (this.advisor != null && !(bean instanceof AopInfrastructureBean)) {
if (bean instanceof Advised) {
Advised advised = (Advised)bean;
if (!advised.isFrozen() && this.isEligible(AopUtils.getTargetClass(bean))) {
if (this.beforeExistingAdvisors) {
advised.addAdvisor(0, this.advisor);
} else {
if (advised.getTargetSource() == AdvisedSupport.EMPTY_TARGET_SOURCE && advised.getAdvisorCount() > 0) {
advised.addAdvisor(advised.getAdvisorCount() - 1, this.advisor);
return bean;
}
advised.addAdvisor(this.advisor);
}
return bean;
}
}
여기까지가 비동기 어노테이션의 초기화 설정 관련된 부분이다. (단계가 많이 스킵되었다.)
그렇다면 실제 @Async 어노테이션을 붙인 메서드의 호출은 어떤식으로 이루어질까?
5. 비동기 호출 과정
위 과정에서 AsyncAnnotationAdvisor 의 buildAdvisor() 메서드에서 AnnotationAsyncExecutionInterceptor 객체를 생성하고 configure 해주는 코드를 볼 수 있다.
1. AnnotationAsyncExecutionInterceptor
AnnotationAsyncExecutionInterceptor 는 AsyncExecutionInterceptor 를 상속받고 AsyncExecutionInterceptor 는 비동기 메서드 호출을 위한 기본 AOP 인터셉터 역할을 수행한다.
그리고 AnnotationAsyncExecutionInterceptor 는 위에서 등록된 advisor 로 proxy 객체에 접근할 때 해당하는 advisor 가 실행된다.
invoke메서드는 determineAsyncExectuor 에 의해 Async 전략을 정한뒤 해당 Thread를 실행시켜 Callback메서드를 받아온다. 기본적으로 Async는 Bean이 등록된 상태로 Advice를 진행하게 된다.
Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true);
Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true);
위와같이 클래스, 메서드 대상으로 pointCut 을 한다.
자, 이제 거의 다왔다. 바로 determineAsyncExectuor 메서드 구현을 확인함으로써 발단이 되었던 궁금증을 해결할 수 있었다.
2. determineAsyncExectuor()
determineAsyncExecutor(Method method)
메서드는 스프링의 비동기 메서드(@Async
어노테이션이 붙은 메서드)를 실행할 때 사용할 적절한 AsyncTaskExecutor
를 결정하는 메서드이다. 이 메서드는 메서드에 지정된 스레드풀을 찾고, 적절한 스레드풀을 캐싱하여 반복적인 요청에 효율적으로 대응한다.
세부동작을 조금 더 알아보자면,
1. 캐시된 AsyncTaskExecutor
확인:
- 먼저, 메서드별로 캐시된
AsyncTaskExecutor
가 있는지executors
맵에서 확인한다. 캐시된executor
가 있으면 이를 반환하고, 없으면 다음 단계로 넘어간다.
AsyncTaskExecutor executor = (AsyncTaskExecutor)this.executors.get(method);
2. 스레드풀 이름(qualifier) 가져오기:
String qualifier = this.getExecutorQualifier(method);
@Async
어노테이션에 지정된 스레드풀 이름(qualifier)을 가져온다. 커스텀 스레드풀을 만들었을 경우 메서드나 클래스에 명시된@Async
어노테이션에서value()
속성으로 스레드풀 이름이 정의되어있다.
3. 스레드풀 결정: ⭐⭐⭐⭐⭐
- 스레드풀 이름이 지정되어 있으면(
qualifier
값이 존재하는 경우):findQualifiedExecutor
메서드를 통해,beanFactory
에서 지정된 이름에 해당하는Executor
빈을 찾는다.
targetExecutor = this.findQualifiedExecutor(this.beanFactory, qualifier);
- 스레드풀 이름이 지정되어 있지 않으면:
- 기본적으로 설정된
Executor
(defaultExecutor)를 가져온다. 이는@Async
어노테이션에 스레드풀 이름이 명시되지 않은 경우 사용할 기본 스레드풀이다.
- 기본적으로 설정된
targetExecutor = (Executor)this.defaultExecutor.get();
이 때, qualifier
값이 존재하는경우 즉 스레드풀 이름이 지정되어있을 때 Executor
빈을 찾지 못하면 에러가 발생한다.
따라서, 스프링 컨테이너 초기화 때는 @Async 의 value 로 지정한 executor 빈 이름까지 확인하지 않고 실제 호출시(invoke() 메서드)에 확인해서 그때 그때 Executor 를 조회해오고 있음을 알 수 있다. 그렇기 때문에 당연히 커스텀 스레드풀 빈을 정의하지 않아도 구동시에는 에러가 발생하지 않았던 것이다.
5. 정리
- @EnableAsync 어노테이션을 적용한 뒤 스프링 컨테이너를 실행시키면, 초기화 과정에서
AsyncAnnotationBeanPostProcessor
에서@Async
어노테이션이 붙은 메서드를 감싸는 프록시를 생성하지만 value 로 정의된 Executor 빈을 찾는 과정은 실제 비동기 메서드 호출시에 수행된다. - 따라서, 스레드풀 빈을 정의하지 않고 @Async 어노테이션에 value 를 지정해도 스프링 컨테이너는 실행된다.
에러가 발생했을 때 trace 를 확인해보면 에러가 발생하는 지점을 잘 알수가 있다.
당연히 호출하는 시점에 스레드풀을 찾으려고 하기 때문에 런타임에 에러가 발생하는게 아닐까라고 짐작은 하고있었지만 그렇다면 스프링 컨테이너 초기화 과정에서는 어디까지를 진행하는거지? 의 대한 궁금증으로 해당 포스팅을 작성했다.
스프링 소스코드를 분석하면서 다시한번 정말 많은 BeanPostProcessor 가 있구나 알게되었고, 비동기 어노테이션은 어떻게 동작하고 관리하는지에 대해 정리할 수 있어서 좋은 시간이었다.
소스코드 분석은 https://velog.io/@jeongyunsung/스프링부트-해부학-Async-EnableAsync-AsyncAnnotationBeanPostProcessor 요 블로그를 많이 참고했다.
추가적으로 해당 블로그 마지막에 쓰여있는 Quiz 부분도 읽어보면 매우 좋다.
6. 보너스
@Async 어노테이션 인터페이스 소스코드를 보면 @Reflective 어노테이션을 발견할 수 있다. 이 어노테이션이 하는일은 뭘까?
package org.springframework.scheduling.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.aot.hint.annotation.Reflective;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Reflective
public @interface Async {
String value() default "";
}
@Reflective
애노테이션은 Spring Framework에서 제공하는 애노테이션이다.
정확히는 Spring AOT(Ahead of Time) 힌트 시스템의 일부로 사용된다고한다. Spring AOT는 주로 Native Image를 만들기 위한 GraalVM과의 호환성 작업에서 사용되며, 런타임에 필요한 리플렉션, 프록시, 리소스 등을 미리 분석하여 빌드 타임에 힌트를 제공함으로써 실행 성능을 최적화하려는 목적으로 사용된다.
Spring 3.x 이전에는 GraalVM과 관련된 지원이 없었고, Native Image 같은 개념도 존재하지 않았다고 한다. 대신, 전통적인 JVM 환경에서 애플리케이션이 실행되었으며, Spring은 JVM의 동적 기능을 활용한 방식으로 동작했다.
Spring 3.x 이후 부터 GraalVM 지원이 시작되면서, GraalVM을 사용해 네이티브 이미지를 생성할 수 있게 되었다.
- GraalVM 및 Native Image: GraalVM은 Ahead-of-Time(AOT) 컴파일러로, Java 애플리케이션을 네이티브 바이너리로 컴파일할 수 있다. 이를 통해 실행 성능과 메모리 사용량이 크게 최적화되며, 특히 컨테이너 환경이나 서버리스 환경에서 빠른 시작 속도가 필요할 때 유리하다.
- Spring Native 및 AOT 지원: Spring은 GraalVM을 사용하여 네이티브 이미지를 만들 때 동적 리플렉션, 프록시 생성, 리소스 로딩 등과 같은 기존 JVM 기능을 사용할 수 없다는 제약이 있었다. 이를 해결하기 위해 Spring AOT 지원이 도입되었고, 리플렉션 및 동적 기능에 대한 힌트를 제공하는 방식으로 네이티브 이미지에서도 동작할 수 있도록 변경되었습니다. 이때
@Reflective
같은 애노테이션이 등장하여 AOT 컴파일에서 필요한 힌트를 제공하게 되었다고한다.