Java 아키텍처·Spring 용어 사전
비동기·배치SimpleAsyncTaskExecutor · @Scheduled · 청크

@Async

메서드를 별도 스레드에서 실행시키는 애노테이션. 기본 설정이 위험해 반드시 Executor를 지정해야 한다.

메서드를 별도 스레드에서 실행시키는 애노테이션. 프록시로 동작한다.

기본 설정이 위험하다

@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 안에서 트랜잭션이 이어진다" → 스레드가 바뀌면 끊긴다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 비동기·스케줄링·배치 — @Async·@Scheduled·Batch