@Async 어노테이션 하나만 붙이면 spring에서 비동기로 수행할 수 있습니다. 하지만 기본 설정 그대로 운영에 올렸다가는 트래픽이 몰리는 순간 서버가 스레드를 무한정 생성하며 죽어버릴 수 있습니다.
이 글에서는 @Async의 기본 사용법부터, 왜 기본 설정이 위험한지, 스레드 풀을 어떻게 관리해야 하는지, 그리고 내부적으로 어떻게 동작하는지까지 정리해보겠습니다.
@Async를 쓰려면 먼저 @EnableAsync를 붙여야 합니다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return new SimpleAsyncTaskExecutor("SimpleAsyncTaskExecutor-");
}
}
getAsyncExecutor() 는 @Async 메서드를 실행할 때 사용할 Executor를 지정합니다. 이걸 사용하지 않으면 Spring Boot는 TaskExecutionAutoConfiguration이 자동 등록한 ThreadPoolTaskExecutor 를 사용하므로 오버라이드하였습니다.
그리고 비동기로 실행하고 싶은 메서드에 @Async를 붙입니다.
@Service
public class EmailService {
@Async
public void sendEmail(String to) {
log("send email to " + to);
}
}
@RestController
@RequiredArgsConstructor
public class EmailController {
private final EmailService emailService;
@PostMapping("/email")
public void send() {
emailService.sendEmail("test@test.com");
log("요청 접수 완료"); // sendEmail이 끝나길 기다리지 않고 바로 리턴됨
}
}
이제 email 요청을 보내는 스레드의 이름을 확인해봅시다.
23:31:35.739 [SimpleAsyncTaskExecutor-1] send email to test@test.com
23:31:35.739 [http-nio-8080-exec-1] 요청 접수 완료
23:31:41.528 [http-nio-8080-exec-2] 요청 접수 완료
23:31:41.528 [SimpleAsyncTaskExecutor-2] send email to test@test.com
23:31:48.092 [SimpleAsyncTaskExecutor-3] send email to test@test.com
23:31:48.091 [http-nio-8080-exec-3] 요청 접수 완료
스레드 이름이 SimpleAsyncTaskExecutor-1, -2, -3으로 모두 다른 거 보이시나요? 스레드가 재사용되지 않고 매번 새로 생성되고 있다는 뜻입니다.
@EnableAsync만 설정하고 별도로 Executor를 등록하지 않으면 Spring은 기본값으로 SimpleAsyncTaskExecutor를 사용합니다.
이름 때문에 오해하기 쉬운데 이건 스레드 풀이 아닙니다. 요청이 들어올 때마다 새 스레드를 생성하고 작업이 끝나면 그 스레드를 버립니다. 스레드를 미리 만들어두고 재사용하는 ThreadPoolTaskExecutor와는 완전히 다른 방식입니다.
아래처럼 짧은 시간 안에 @Async 메서드를 대량으로 호출하면 어떻게 될까요.
@RestController
@RequiredArgsConstructor
public class TestController {
private final EmailService emailService;
@GetMapping("/test")
public String test() {
for (int i = 0; i < 1000; i++) {
emailService.sendEmail("test" + i + "@test.com");
}
return "done";
}
}
SimpleAsyncTaskExecutor를 쓰는 상태에서 이 요청을 실행하면 짧은 시간 안에 스레드가 수천 개 생성됩니다.
23:33:30.671 [SimpleAsyncTaskExecutor-11] send email to test10@test.com
23:33:30.675 [SimpleAsyncTaskExecutor-28] send email to test27@test.com
23:33:30.673 [SimpleAsyncTaskExecutor-22] send email to test21@test.com
23:33:30.676 [SimpleAsyncTaskExecutor-38] send email to test37@test.com
23:33:30.676 [SimpleAsyncTaskExecutor-37] send email to test36@test.com
...
23:33:30.789 [SimpleAsyncTaskExecutor-996] send email to test995@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-997] send email to test996@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-998] send email to test997@test.com
23:33:30.789 [SimpleAsyncTaskExecutor-999] send email to test998@test.com
23:33:30.790 [SimpleAsyncTaskExecutor-1000] send email to test999@test.com
메모리 고갈
스레드가 생성될 때마다 OS는 콜스택을 담을 스택 메모리를 미리 예약해둡니다. 기본 크기는 스레드당 512KB~1MB 정도로 스레드가 1,000개면 그것만으로 수백 MB~GB 단위의 메모리가 사용됩니다.
컨텍스트 스위칭 비용 증가
OS 스케쥴러가 관리해야 할 스레드가 늘어날수록 컨텍스트 스위칭 비용이 증가합니다.
동시성 제어 불가
SimpleAsyncTaskExecutor는 요청이 들어오는 즉시 그대로 스레드를 생성합니다. 순간적으로 요청이 몰리면 그 순간의 트래픽 양이 곧 그대로 스레드 생성 개수가 됩니다.
연쇄 장애 가능성
앞의 세 문제가 격치면 장애가 도미노처럼 번집니다.
스레드 폭증
→ 메모리 고갈 (OutOfMemoryError)
→ 비동기 로직뿐만 아니라 요청을 받던 톰캣 스레드(http-nio-*)까지 함께 죽음
→ 동시에 DB 커넥션 풀(HikariCP 등)・외부 API 커넥션도 스레드 수만큼 고갈
→ 비동기와 무관한 동기 요청까지 커넥션을 못 구해 타임아웃
시작은 스레드 하나였지만 결과는 서버 전체의 응답 지연·장애입니다.
앞의 네 가지 문제는 스레드 생성에 아무런 제한이 없다는 것에서 시작됩니다. 그러면 이 문제는 어떻게 해결할 수 있을까요?? 바로 스레드 풀을 관리하는 것입니다. 스레드를 미리 만들어 두고 만들어 둔 스레드는 버리지 않고 재사용하는 방법에 대해서 살펴보겠습니다.
AsyncConfigurer를 구현해서 직접 ThreadPoolTaskExecutor를 등록하면 이 문제를 해결할 수 있습니다.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}
| 설정 | 의미 |
|---|---|
corePoolSize | 기본으로 유지하는 스레드 수 |
maxPoolSize | 늘어날 수 있는 최대 스레드 수 |
queueCapacity | core 스레드가 모두 사용 중일 때 작업을 대기시키는 큐의 용량 |
threadNamePrefix | 스레드 이름 접두사 (로그 추적에 유용) |
RejectedExecutionHandler | 큐도 꽉 차고 max까지 다 찼을 때 처리 전략 |
ThreadPoolTaskExecutor는 아래 순서로 동작합니다.
1. 요청 도착
2. core 스레드에 여유가 있다면 → 새 스레드 생성해서 즉시 처리
3. core가 꽉 찼다면 → queue에 대기
4. queue도 꽉 찼다면 → max까지 스레드를 추가로 생성해서 처리
5. max까지 다 찼고 queue도 꽉 찼다면 → RejectedExecutionHandler 실행
RejectedExecutionHandler의 기본 정책은 예외를 던지는 것(AbortPolicy)이지만 상황에 따라 다음과 같은 정책을 선택할 수 있습니다.
| 정책 | 설명 |
|---|---|
AbortPolicy (기본값) | RejectedExecutionException 발생 |
CallerRunsPolicy | 작업을 제출한 스레드가 직접 실행 (호출자가 느려지지만 유실은 안 됨) |
DiscardPolicy | 조용히 버림 |
DiscardOldestPolicy | 큐에서 가장 오래된 작업을 버리고 새 작업을 추가 |
이제 다시 많은 양의 async를 처리하는 api를 수행하고 로그를 확인해봅시다.
...
15:28:28.130 [ async-10] send email to test59@test.com
15:28:28.133 [ async-5] send email to test8@test.com
15:28:28.133 [ async-3] send email to test5@test.com
15:28:28.132 [ async-2] send email to test7@test.com
15:28:28.132 [ async-4] send email to test6@test.com
15:28:28.131 [http-nio-8080-exec-1] send email to test64@test.com
15:28:28.135 [ async-4] send email to test15@test.com
15:28:28.131 [ async-8] send email to test4@test.com
15:28:28.131 [ async-1] send email to test3@test.com
15:28:28.131 [ async-9] send email to test2@test.com
15:28:28.135 [ async-1] send email to test18@test.com
15:28:28.135 [ async-8] send email to test17@test.com
15:28:28.135 [http-nio-8080-exec-1] send email to test77@test.com
15:28:28.135 [ async-4] send email to test16@test.com
...
async-1~async-10까지만 반복해서 등장합니다. 코드에 설정한 값으로 왜 2개(corePoolSize)가 아니라 10개(maxPoolSize)까지 늘어났는지 계산해보면 이렇습니다.
corePoolSize=2async-1~async-2 2개가 먼저 만들어져 처리를 시작합니다.queueCapacity=50maxPoolSize=10까지 스레드를 추가로 만들어(async-3~async-10) 처리합니다.async-11처럼 10을 넘는 이름은 절대 나오지 않습니다. maxPoolSize가 상한선이기 때문입니다.여기서 한 가지 더 확인해볼 점이 있습니다. corePoolSize(2) + queueCapacity(100) + (maxPoolSize(10) - corePoolSize(2)) = 110개까지만 스레드 풀이 흡수할 수 있는데 요청은 1000개입니다. 나머지 약 890개는 어디로 갈까요? RejectedExecutionHandler로 CallerRunsPolicy를 지정해뒀기 때문에 이 초과분은 버려지지 않고 요청을 보낸 스레드(http-nio-*)가 직접 실행합니다. 로그에서 async-가 아니라 http-nio-가 찍히면서 sendEmail이 실행되는 줄이 바로 이 경우입니다. 이 구간은 사실상 동기 처리가 되는 셈이라 그만큼 API 응답이 늦어집니다.
corePoolSize를 CPU 코어 수 근처로, 큐는 작게corePoolSize를 더 넉넉하게, 큐도 여유 있게@Async 는 Spring의 AOP 프록시 메커니즘 위에서 동작합니다.
@EnableAsync를 선언하면 Spring은 AsyncAnnotationBeanPostProcessor를 등록합니다. 이 후처리기가 @Async가 붙은 메서드를 가진 빈을 감지하고 그 빈에 대한 프록시 객체를 만듭니다.
흐름을 그려보면 이렇습니다.
[호출자] → [프록시 객체] → (원본 메서드 대신) Executor에 작업 제출
→ [스레드 풀] → 실제 메서드 실행
외부에서 @Async 메서드를 호출하면 실제로는 원본 객체가 아니라 프록시가 먼저 호출을 가로챕니다.
이 프록시 기반 동작 방식 때문에 생기는 가장 흔한 실수가 바로 같은 클래스 내부 호출입니다.
@Service
public class OrderService {
public void processOrder() {
this.sendNotification(); // ❌ @Async가 동작하지 않음!
}
@Async
public void sendNotification() {
System.out.println(Thread.currentThread().getName());
}
}
processOrder()에서 this.sendNotification()으로 호출하면 이 호출은 프록시를 거치지 않고 원본 객체의 메서드를 직접 호출하게 됩니다. 프록시는 스프링 컨테이너가 빈을 주입할 때 개입하는 것이지 객체 내부의 this 호출까지 가로챌 수는 없기 때문입니다. 결과적으로 sendNotification()은 동기적으로, 즉 호출한 스레드에서 그대로 실행됩니다.
해결 방법은 다른 빈으로 분리하여 호출하는 것입니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final NotificationService notificationService; // 별도 빈으로 분리
public void processOrder() {
notificationService.sendNotification(); // ✅ 프록시를 거쳐 정상 동작
}
}
프록시는 대상 클래스를 상속해서 메서드를 오버라이드하는 방식으로 만들어집니다. private 메서드는 애초에 오버라이드가 불가능해서 @Async를 붙여도 프록시가 가로챌 방법이 없습니다.
@Async
private void sendEmail(String to) { ... } // 프록시가 가로챌 수 없어서 그냥 동기 실행됨
protected도 Spring이 공식적으로 지원 범위 밖이라고 명시하고 있습니다.
void, Future<T>, CompletableFuture<T>만 의미 있음프록시가 메서드를 호출한 시점엔 실제 로직이 아직 실행되지도 않은 상태입니다. void는 애초에 아무것도 안 돌려주면 되고 Future/CompletableFuture는 "나중에 결과 줄게"라는 약속 객체만 즉시 리턴하면 됩니다.
@Async
public String sendEmail(String to) {
...
return "발송 성공";
}
String result = emailService.sendEmail("a@test.com");
// result는 항상 null. 메서드 내부에서 뭘 리턴했든 상관없이 버려짐
void 반환 시 예외가 호출자에게 전파되지 않음sendEmail()의 실제 코드는 호출자 스레드(http-nio-*)가 아니라 풀 안의 다른 스레드(async-3 같은)에서 실행됩니다. 예외는 그 예외가 터진 스레드의 콜스택을 타고만 전파되는데 호출자 스레드는 이미 리턴받고 다음 코드로 넘어간 뒤라 예외를 받아줄 콜스택 연결고리가 없습니다.
@Async
public void sendEmail(String to) {
if (to == null || to.isEmpty()) {
throw new NullPointerException("to is null or empty");
}
log("send email to " + to);
}
public void send() {
emailService.sendEmail(null);
log("요청 접수 완료"); // 예외와 상관없이 무조건 찍힘
}
send() 수행 결과는 다음과 같습니다.
21:45:21.507 [http-nio-8080-exec-8] 요청 접수 완료
2026-08-18T21:45:21.508+09:00 ERROR 67704 --- [spring] [ async-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void com.example.spring.async.EmailService.sendEmail(java.lang.String)
java.lang.NullPointerException: to is null or empty
at com.example.spring.async.EmailService.sendEmail(EmailService.java:14) ~[classes/:na]
...
send()는 실패 여부를 알 방법이 없고 try-catch로도 잡을 수 없습니다.
✅ @Async를 사용할 때는 Thread Pool 을 등록하여 사용할 것
✅ corePoolSize / maxPoolSize / queueCapacity를 작업 특성에 맞게 조정
✅ 프록시로 동작
@Async는 짧은 어노테이션 하나로 비동기 처리를 가능하게 해주는 편리한 기능이지만 그 이면에는 AOP 프록시와 스레드 풀이라는 명확한 동작 원리가 있습니다. 이걸 이해하지 못한 채 기본 설정 그대로 사용하면 트래픽이 몰리는 순간 예상치 못한 장애로 이어질 수 있습니다.
다음 글에서는 @Async의 반환 타입으로 쓰이는 Future와 CompletableFuture의 차이, 그리고 여러 비동기 작업을 조합하는 패턴을 다뤄보겠습니다.