메서드를 별도 스레드에서 실행시키는 애노테이션. 프록시로 동작한다.
기본 설정이 위험하다
@EnableAsync
@Service
class NotificationService {
@Async
void send(Long userId) { ... } // Executor 를 지정하지 않았다
}
Executor 를 정의하지 않으면 (그리고 가상 스레드도 안 켰으면)
→ 요청마다 새 스레드를 만드는 실행기가 쓰인다
→ 트래픽이 늘면 스레드가 무제한으로 생긴다
→ OutOfMemoryError: unable to create new native thread
"트래픽이 늘자 메모리가 터졌다" 의 흔한 원인이다.
반드시 Executor를 지정한다
@Bean("taskExecutor")
ThreadPoolTaskExecutor taskExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(10);
ex.setMaxPoolSize(20);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("async-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 넘치면 호출 스레드가 직접
return ex;
}
@Async("taskExecutor")
void send(Long userId) { ... }
큐 용량과 거부 정책을 반드시 정한다 — 무한 큐는 메모리를, 무한 스레드는 OS 자원을 소진한다.
가상 스레드를 쓰면
spring:
threads:
virtual:
enabled: true # Java 21+ · @Async 가 가상 스레드로 돈다 (풀 설정은 무시된다)
프록시 함정이 그대로 적용된다
public void outer() { this.send(id); } // 자기 호출 → 동기로 실행된다
@Async가 조용히 무시되는 원인 1위다. 예외도 안 난다.
반환값과 예외
@Async CompletableFuture<Result> run() { return CompletableFuture.completedFuture(...); }
// void 반환이면 예외가 호출자에게 전달되지 않는다 → AsyncUncaughtExceptionHandler 로 잡는다
트랜잭션은 전파되지 않는다
스레드가 바뀌면 트랜잭션 컨텍스트(ThreadLocal)도 따라가지 않는다
- @Async 메서드는 자기 트랜잭션을 새로 열어야 한다
- 엔티티를 넘기면 준영속이라 지연 로딩이 터진다 (id 를 넘긴다)
대량 처리는 청크로
// Spring Batch: read → process → write 를 chunk 단위로 커밋한다
.<Order, OrderDto>chunk(1000, tx)
한 번에 다 읽어 커밋하면 메모리가 터지고 롤백도 오래 걸린다. 청크 단위로 나눠 커밋하는 것이 정석이다.
면접 함정
- ❌ "@Async만 붙이면 비동기" →
@EnableAsync가 필요하고, 자기 호출이면 동기로 돈다. - ❌ "@Async 안에서 트랜잭션이 이어진다" → 스레드가 바뀌면 끊긴다.