Spring @Async 내부 동작 — AsyncExecutionInterceptor, TaskExecutor, 그리고 반환 타입의 비밀
메서드 하나에
@Async만 붙이면 비동기로 실행되는데, 정작 그 뒤에서 무슨 일이 벌어지는지 설명해 보라고 하면 막막해져요. 이 글은 Spring AOP 프록시가 메서드 호출을 어떻게 가로채고, 어떤 Executor를 골라서 어떤 식으로 결과를 감싸 돌려주는지 한 단계씩 따라가는 글이에요. Spring Framework 6/7과 Spring Boot 3.x 기준으로 정리했어요.
한 줄 어노테이션의 의문
서비스 클래스에 @Async만 붙이면, 호출자는 즉시 반환되고 메서드는 다른 스레드에서 돌아가요.
@Service
public class ReportService {
@Async
public CompletableFuture<Report> generate(long userId) {
Report report = heavyComputation(userId);
return CompletableFuture.completedFuture(report);
}
}
호출하는 쪽 코드는 이렇게 생겼어요.
CompletableFuture<Report> future = reportService.generate(42L);
future.thenAccept(this::send);
여기서 자연스럽게 떠오르는 질문이 몇 가지 있어요.
generate()는 분명 동기 메서드인데, 왜 즉시 반환될까요?CompletableFuture.completedFuture()로 반환한 값은 이미 완료된 future일 텐데, 왜 호출 시점부터 비동기일까요?- 어느 스레드 풀에서 실행되는 걸까요? 직접 만든 적이 없는데요?
void를 반환하는@Async메서드에서 예외가 나면 어디로 갈까요?
이 질문들의 답은 모두 한 클래스의 한 메서드 — AsyncExecutionInterceptor.invoke() — 에 있어요. 거기로 가는 길을 따라가 봐요.
전체 그림
@Async 메서드 한 번 호출이 일어나면 다음 다섯 단계가 차례로 실행돼요.
flowchart TD
A[Caller invokes proxy.generate] --> B[AsyncExecutionInterceptor.invoke]
B --> C[determineAsyncExecutor]
C --> D[Wrap real call as Callable]
D --> E[doSubmit by return type]
E --> F[executor.submitCompletable]
F --> G[Caller receives CompletableFuture immediately]
G --> H[Worker thread runs target method]
H --> I[Result completes the future]
캐릭터는 셋이에요. AOP 프록시가 가로채고, AsyncExecutionInterceptor가 작업으로 감싸고, AsyncTaskExecutor가 다른 스레드에서 실행해요. 이 분업이 어떻게 짜여 있는지 하나씩 봐요.
1단계 — @EnableAsync가 켜는 인프라
@Async만 붙인다고 알아서 동작하지 않아요. 어딘가에서 @EnableAsync로 스위치를 켜야 해요. Spring Boot라면 spring.task.execution.* 자동 설정이 그 역할을 대신해 주지만, 본질은 같아요.
@EnableAsync 소스를 들여다보면 끝에 한 줄이 있어요.
@Import(AsyncConfigurationSelector.class)
public @interface EnableAsync {
Class<? extends Annotation> annotation() default Annotation.class;
boolean proxyTargetClass() default false;
AdviceMode mode() default AdviceMode.PROXY;
int order() default Ordered.LOWEST_PRECEDENCE;
}
AdviceMode.PROXY(기본값)일 때 AsyncConfigurationSelector가 ProxyAsyncConfiguration을 골라 등록해요. 그 안에 등장하는 단 하나의 빈이 모든 일의 시작이에요.
@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() {
AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
bpp.configure(this.executor, this.exceptionHandler);
// ...
return bpp;
}
}
이 AsyncAnnotationBeanPostProcessor가 모든 빈을 훑으면서 @Async가 붙은 클래스나 메서드를 발견하면 거기에 프록시를 씌워요. 이미 다른 advisor가 만들어 둔 프록시가 있으면 거기에 advisor를 한 줄 더 끼워 넣어요. 새로 만들어야 한다면 인터페이스 기반 JDK 동적 프록시(또는 proxyTargetClass=true일 때 CGLIB)를 만들어요.
붙는 advisor는 AsyncAnnotationAdvisor 하나예요.
2단계 — Advisor가 들고 있는 두 부품, Pointcut과 Advice
AsyncAnnotationAdvisor는 Spring AOP의 advisor 그대로, 언제 끼어들지(pointcut) 와 끼어들어서 뭘 할지(advice) 두 가지를 들고 있어요.
public AsyncAnnotationAdvisor(
@Nullable Supplier<? extends @Nullable Executor> executor,
@Nullable Supplier<? extends @Nullable AsyncUncaughtExceptionHandler> exceptionHandler) {
Set<Class<? extends Annotation>> asyncAnnotationTypes = CollectionUtils.newLinkedHashSet(2);
asyncAnnotationTypes.add(Async.class);
ClassLoader classLoader = AsyncAnnotationAdvisor.class.getClassLoader();
try {
asyncAnnotationTypes.add((Class<? extends Annotation>)
ClassUtils.forName("jakarta.ejb.Asynchronous", classLoader));
} catch (ClassNotFoundException ex) {
// EJB API가 없으면 무시
}
try {
asyncAnnotationTypes.add((Class<? extends Annotation>)
ClassUtils.forName("jakarta.enterprise.concurrent.Asynchronous", classLoader));
} catch (ClassNotFoundException ex) {
// Jakarta Concurrent API가 없으면 무시
}
this.advice = buildAdvice(executor, exceptionHandler);
this.pointcut = buildPointcut(asyncAnnotationTypes);
}
여기서 두 가지가 눈에 띄어요.
- 기본 인식 어노테이션이 Spring의
@Async하나가 아니라, EJB 3.1의@jakarta.ejb.Asynchronous, Jakarta Concurrent의@jakarta.enterprise.concurrent.Asynchronous까지 셋이에요. 클래스패스에 있을 때만 추가해요. - pointcut은 클래스 레벨과 메서드 레벨을 둘 다 매칭하는 합성(composite) pointcut이에요.
pointcut 빌더는 이렇게 생겼어요.
protected Pointcut buildPointcut(Set<Class<? extends Annotation>> asyncAnnotationTypes) {
ComposablePointcut result = null;
for (Class<? extends Annotation> asyncAnnotationType : asyncAnnotationTypes) {
Pointcut cpc = new AnnotationMatchingPointcut(asyncAnnotationType, true);
Pointcut mpc = new AnnotationMatchingPointcut(null, asyncAnnotationType, true);
if (result == null) {
result = new ComposablePointcut(cpc);
} else {
result.union(cpc);
}
result = result.union(mpc);
}
return (result != null ? result : Pointcut.TRUE);
}
cpc는 클래스에 어노테이션이 붙은 경우, mpc는 메서드에 붙은 경우를 매칭해요. union으로 합쳐서 둘 중 하나라도 매칭되면 advisor가 발동해요. 그래서 @Async를 클래스에 붙이면 그 안의 public 메서드 모두가 비동기가 되고, 메서드에만 붙이면 그 메서드만 비동기가 돼요.
3단계 — AsyncExecutionInterceptor.invoke()의 안쪽
pointcut에 걸리면 호출이 AsyncExecutionInterceptor로 들어와요. 이 클래스는 AOP Alliance의 MethodInterceptor라서 invoke() 메서드 하나로 전부 표현돼요.
@Override
public @Nullable Object invoke(final MethodInvocation invocation) throws Throwable {
Class<?> targetClass = (invocation.getThis() != null
? AopUtils.getTargetClass(invocation.getThis()) : null);
final Method userMethod = BridgeMethodResolver.getMostSpecificMethod(
invocation.getMethod(), targetClass);
AsyncTaskExecutor executor = determineAsyncExecutor(userMethod);
if (executor == null) {
throw new IllegalStateException(
"No executor specified and no default executor set on AsyncExecutionInterceptor either");
}
Callable<Object> task = () -> {
try {
Object result = invocation.proceed();
if (result instanceof Future<?> future) {
return future.get();
}
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
handleError(cause == null ? ex : cause, userMethod, invocation.getArguments());
} catch (Throwable ex) {
handleError(ex, userMethod, invocation.getArguments());
}
return null;
};
return doSubmit(task, executor, userMethod.getReturnType());
}
흐름이 거의 명료해요. 정리해 봐요.
- 메서드 해석: 타깃 클래스에서 가장 구체적인 메서드를 찾아내요. 제네릭 브릿지 메서드를 거르는 역할이에요.
- Executor 선택:
determineAsyncExecutor()로 이 메서드에 쓸AsyncTaskExecutor를 결정해요. (자세한 건 4단계) - 작업 객체 생성: 진짜 호출(
invocation.proceed())을Callable로 감싸요. 이 Callable이 워커 스레드에서 돌아갈 본체예요. - 제출:
doSubmit()이 반환 타입에 맞춰 Executor에 작업을 던져요. (자세한 건 5단계)
여기서 핵심 포인트가 두 가지 있어요.
invocation.proceed()는 호출 시점이 아니라 워커 스레드에서 실행돼요
generate() 안에서 CompletableFuture.completedFuture(...)로 즉시 완료된 future를 만들어도, invocation.proceed() 자체가 워커 스레드 안의 람다에서 호출돼요. 그래서 메서드 본문 전체가 다른 스레드에서 실행되고, 호출자는 즉시 빈 future(또는 곧 채워질 future)를 받아요.
메서드가 또 Future를 반환하면 결과를 풀어서 다시 감싸요
람다 안에 if (result instanceof Future<?> future) { return future.get(); } 가 있어요. 사용자의 generate()가 이미 CompletableFuture<Report>를 반환하지만, 그 안에서 future.get()을 호출해서 실제 값을 꺼내요. 그러고 나서 바깥 단계인 doSubmit()이 이 값을 새 CompletableFuture에 다시 넣어요. 결과적으로 호출자에게 돌아가는 future와 메서드가 반환한 future는 서로 다른 객체예요.
4단계 — Executor 선택 규칙
protected @Nullable AsyncTaskExecutor determineAsyncExecutor(Method method) {
AsyncTaskExecutor executor = this.executors.get(method);
if (executor == null) {
Executor targetExecutor;
String qualifier = getExecutorQualifier(method);
if (this.embeddedValueResolver != null && StringUtils.hasLength(qualifier)) {
qualifier = this.embeddedValueResolver.resolveStringValue(qualifier);
}
if (StringUtils.hasLength(qualifier)) {
targetExecutor = findQualifiedExecutor(this.beanFactory, qualifier);
} else {
targetExecutor = this.defaultExecutor.get();
}
if (targetExecutor == null) {
return null;
}
executor = (targetExecutor instanceof AsyncTaskExecutor asyncTaskExecutor
? asyncTaskExecutor : new TaskExecutorAdapter(targetExecutor));
this.executors.put(method, executor);
}
return executor;
}
메서드별로 결과가 캐시돼요(ConcurrentHashMap<Method, AsyncTaskExecutor>). 두 번째 호출부터는 lookup 한 번이면 끝이에요.
선택 순서는 단순해요.
@Async("myExecutor")처럼 qualifier가 있으면 그 이름의 Executor 빈을 찾아요. SpEL이나${}플레이스홀더도 지원해요.- qualifier가 없으면
defaultExecutor로 떨어져요.
defaultExecutor는 또 어디서 올까요? AsyncExecutionAspectSupport.getDefaultExecutor()에 단계별로 정의돼 있어요.
protected @Nullable Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
if (beanFactory != null) {
try {
return beanFactory.getBean(TaskExecutor.class);
} catch (NoUniqueBeanDefinitionException ex) {
// TaskExecutor 빈이 여러 개면 'taskExecutor' 이름으로 다시 시도
try {
return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class);
} catch (NoSuchBeanDefinitionException ex2) {
// 'taskExecutor'라는 이름의 빈도 없으면 INFO 로그
}
} catch (NoSuchBeanDefinitionException ex) {
// TaskExecutor 타입이 아예 없으면 'taskExecutor' 이름의 Executor를 찾음
try {
return beanFactory.getBean(DEFAULT_TASK_EXECUTOR_BEAN_NAME, Executor.class);
} catch (NoSuchBeanDefinitionException ex2) {
// 둘 다 없으면 null 반환
}
}
}
return null;
}
여기서 DEFAULT_TASK_EXECUTOR_BEAN_NAME = "taskExecutor"예요. 정리하면 우선순위가 이래요.
- 컨텍스트에 유일한
TaskExecutor빈이 있으면 그것. - 여럿이면 그중
"taskExecutor"라는 이름을 가진 것. - 없으면
"taskExecutor"라는 이름의Executor빈. - 그것도 없으면
null. 이 경우AsyncExecutionInterceptor가 한 번 더 fallback해서 새SimpleAsyncTaskExecutor를 만들어요(서브클래스인AsyncExecutionInterceptor.getDefaultExecutor()의 추가 동작).
Spring Boot 3.x는 자동 설정으로 applicationTaskExecutor라는 이름의 ThreadPoolTaskExecutor를 등록하고, 동시에 "taskExecutor"라는 별칭(alias)을 부여해요. 그래서 별도 설정 없이도 @Async가 풀(pool) 기반으로 돌아가는 거예요. spring.task.execution.* 속성으로 코어 풀 사이즈·큐 용량·스레드 이름 등을 조절할 수 있어요.
5단계 — 반환 타입에 따른 doSubmit()
흐름의 마지막 조각이에요.
protected @Nullable Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
if (CompletableFuture.class.isAssignableFrom(returnType)) {
return executor.submitCompletable(task);
} else if (Future.class.isAssignableFrom(returnType)) {
return executor.submit(task);
} else if (void.class == returnType || "kotlin.Unit".equals(returnType.getName())) {
executor.submit(task);
return null;
} else {
throw new IllegalArgumentException(
"Invalid return type for async method (only Future and void supported): " + returnType);
}
}
분기는 네 가지뿐이에요.
| 반환 타입 | 실제 호출 | 호출자에게 돌아가는 객체 |
|---|---|---|
CompletableFuture<T> |
executor.submitCompletable(task) |
새 CompletableFuture |
Future<T> (구버전 호환) |
executor.submit(task) |
새 Future |
void 또는 Kotlin Unit |
executor.submit(task) (반환 버림) |
null |
| 그 외 | 즉시 IllegalArgumentException |
호출 자체가 실패 |
세 가지 시사점이 있어요.
사용자가 반환한 future는 버려져요
3단계에서 본 람다는 사용자의 future를 future.get()으로 풀어서 값만 꺼냈어요. 그 값을 다시 submitCompletable()이 만든 새 future에 넣어서 호출자에게 돌려줘요. 즉 호출자가 받는 CompletableFuture는 사용자가 반환한 그것이 아니에요. 결과 값은 같지만 객체는 달라요. 이걸 모르면 future를 두 번 합성하면서 혼란을 겪기 쉬워요.
ListenableFuture는 Spring 6.0부터 deprecated
이전엔 org.springframework.util.concurrent.ListenableFuture 가 별도 갈래로 있었어요. Spring 6.0부터 deprecated 됐고, 표준 CompletableFuture로 일원화돼요. 신규 코드는 CompletableFuture만 쓰는 게 안전해요.
반환 타입이 잘못되면 호출 시점에 예외
리스트나 String을 반환하는 메서드에 무심코 @Async를 붙이면, 사용자 코드에서 메서드를 호출하는 그 순간 IllegalArgumentException이 던져져요. 컴파일에서 잡히지 않으니 통합 테스트로 검증하는 게 안전해요.
void 메서드의 예외는 어디로 가나요
Future를 반환하는 메서드는 호출자가 future.get()을 부르는 시점에 예외가 다시 던져져요. 그런데 void라면 호출자가 결과를 받지 않는데, 워커 스레드에서 던진 예외는 어디로 갈까요?
AsyncExecutionAspectSupport.handleError()가 그 분기를 책임져요.
protected void handleError(Throwable ex, Method method, @Nullable Object... params) throws Exception {
if (Future.class.isAssignableFrom(method.getReturnType())) {
ReflectionUtils.rethrowException(ex);
} else {
try {
this.exceptionHandler.obtain().handleUncaughtException(ex, method, params);
} catch (Throwable ex2) {
logger.warn("Exception handler for async method '" + method.toGenericString() +
"' threw unexpected exception itself", ex2);
}
}
}
Future면 다시 던져서 future를 실패 상태로 만들고, 그게 아니면 AsyncUncaughtExceptionHandler.handleUncaughtException()을 호출해요. 기본 구현은 SimpleAsyncUncaughtExceptionHandler로, 스택 트레이스만 ERROR 로그에 남기고 끝나요.
조용히 사라지지 않게 하려면 AsyncConfigurer를 구현해서 핸들러를 갈아끼우면 돼요.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("report-async-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
// 로그 + 알림 시스템 전송 등
log.error("Async method {} failed with args {}", method, params, ex);
};
}
}
같은 클래스의 메서드를 부르면 왜 비동기가 안 될까요
가장 자주 마주치는 함정이에요.
@Service
public class ReportService {
public void run(long userId) {
this.generate(userId); // 비동기로 안 돌아요
}
@Async
public CompletableFuture<Report> generate(long userId) { ... }
}
run()이 호출하는 this.generate()는 프록시를 거치지 않아요. this는 실제 타깃 객체를 가리키지, 그 위를 감싸고 있는 AOP 프록시가 아니에요. AOP advice는 프록시 메서드를 거쳐야만 끼어드는데, this.generate()는 그 길을 우회해요. 결과적으로 AsyncExecutionInterceptor가 한 번도 호출되지 않고, 그냥 호출 스레드에서 동기 실행돼요.
AopContext.currentProxy()로 프록시를 끄집어내는 방법이 있긴 해요. 다만 두 가지 제약이 있어요.
@EnableAspectJAutoProxy(exposeProxy = true)로 프록시를 ThreadLocal에 노출해야 해요. 성능 비용이 있어서 기본은 false예요.@Async메서드가 다른 스레드에서 다시 자기 자신을 부른다면, ThreadLocal에 들어 있던 프록시 참조는 이미 사라졌어요. 워커 스레드는 그 ThreadLocal을 못 봐요.
가장 깨끗한 해결은 메서드를 다른 빈으로 분리하는 거예요. 그러면 호출이 자연스럽게 프록시를 통과해요.
Virtual Thread 시대의 @Async
Spring Boot 3.2(Spring Framework 6.1)부터 가상 스레드를 한 줄로 켤 수 있어요.
spring:
threads:
virtual:
enabled: true
이게 켜지면 자동 설정이 applicationTaskExecutor로 가상 스레드를 사용하는 SimpleAsyncTaskExecutor(setVirtualThreads(true) 적용)를 만들어요. @Async 메서드는 풀에서 미리 뜬 플랫폼 스레드 대신, 호출마다 새로 만들어지는 가상 스레드 위에서 실행돼요.
이때 동작은 이렇게 바뀌어요.
- 풀 사이즈·큐 용량 같은 개념이 없어져요. 가상 스레드는 매번 새로 만들어요.
- I/O 블로킹에서 캐리어 플랫폼 스레드를 점유하지 않아요. 그래서 동시 처리 가능한 비동기 작업 수가 사실상 OS·메모리 한계까지 늘어나요.
- 다만
synchronized블록에서는 여전히 캐리어 스레드를 핀(pin)할 수 있어요. JEP 491에서 이 제약이 완화되고 있어요.
AsyncExecutionInterceptor의 코드 경로는 똑같아요. 바뀌는 건 executor 자리에 어떤 AsyncTaskExecutor가 꽂히느냐 뿐이에요. 인터셉터 입장에서 executor.submit(task)는 그대로예요.
정리
@Async 한 줄 뒤의 흐름을 다시 한눈에 압축해 봐요.
flowchart LR
A["@EnableAsync"] --> B[AsyncAnnotationBeanPostProcessor]
B --> C[Wrap bean with proxy]
C --> D[AsyncAnnotationAdvisor]
D --> E[AsyncExecutionInterceptor.invoke]
E --> F[determineAsyncExecutor]
F --> G[doSubmit by return type]
G --> H[AsyncTaskExecutor.submitCompletable]
핵심을 다시 정리하면 이래요.
@EnableAsync는AsyncAnnotationBeanPostProcessor를 등록해서,@Async빈에AsyncAnnotationAdvisor를 끼운 프록시를 씌워요.- pointcut은 클래스·메서드 어노테이션을 합쳐서 매칭해요. Spring
@Async외에jakarta.ejb.Asynchronous,jakarta.enterprise.concurrent.Asynchronous도 자동 인식해요. AsyncExecutionInterceptor.invoke()가 진짜 호출을Callable로 감싸서AsyncTaskExecutor에 던져요.- Executor는 메서드의 qualifier > 유일한
TaskExecutor빈 >"taskExecutor"이름 빈 >SimpleAsyncTaskExecutorfallback 순서로 결정돼요. 결과는 메서드별로 캐시돼요. - 반환 타입은
CompletableFuture/Future/void(또는 KotlinUnit)만 허용돼요. 그 외엔 호출 시점에IllegalArgumentException이에요. void로 던진 예외는AsyncUncaughtExceptionHandler로 흘러가요. 기본 핸들러는 로그만 남겨요.- 같은 클래스 안의 자기 호출은 프록시를 우회하니까, 비동기로 돌리려면 메서드를 다른 빈으로 분리하는 게 가장 깨끗해요.
- Spring Boot 3.2 + Java 21에서
spring.threads.virtual.enabled=true만 켜면 가상 스레드 기반 Executor로 갈아끼워져요. 인터셉터 코드는 그대로예요.
@Async는 외부에서 보면 어노테이션 한 줄짜리 마법처럼 보이지만, 뜯어보면 Spring AOP의 일반적인 advisor + 평범한 Callable 제출 + Executor 추상화의 합성품이에요. 한 번 흐름을 따라가 보면, 자기 호출 함정이나 반환 타입 제약 같은 동작 원리가 자연스럽게 이해돼요.
참고자료
- Spring Framework — EnableAsync 어노테이션 소스: github.com/spring-projects/spring-framework — EnableAsync.java
- Spring Framework — AsyncExecutionInterceptor 소스: github.com/spring-projects/spring-framework — AsyncExecutionInterceptor.java
- Spring Framework — AsyncExecutionAspectSupport 소스: github.com/spring-projects/spring-framework — AsyncExecutionAspectSupport.java
- Spring Framework — AsyncAnnotationAdvisor 소스: github.com/spring-projects/spring-framework — AsyncAnnotationAdvisor.java
- Spring Framework — ProxyAsyncConfiguration 소스: github.com/spring-projects/spring-framework — ProxyAsyncConfiguration.java
- Spring Framework 공식 가이드 — Creating Asynchronous Methods: spring.io/guides/gs/async-method
- Spring Boot 공식 문서 — Task Execution and Scheduling: docs.spring.io — task-execution-and-scheduling
- Baeldung — How To Do @Async in Spring: baeldung.com/spring-async
- Spring Boot Issue #35710 — Auto-configure virtual thread async executor: github.com/spring-projects/spring-boot/issues/35710

