Spring Boot @Async

왜 @Async를 제대로 설정해야 하는가

Spring Boot에서 @Async는 메서드를 비동기로 실행하는 가장 간편한 방법이다. 이메일 발송, 로그 적재, 외부 API 호출처럼 요청 스레드를 블로킹하면 안 되는 작업에 광범위하게 쓰인다. 그러나 기본 설정만으로 운영에 투입하면 스레드 폭증, 예외 소실, self-invocation 무시 같은 함정에 빠진다.

이 글은 Spring Framework 공식 문서(Task Execution and Scheduling)와 Spring Boot 공식 문서(Task Execution and Scheduling)를 근거로, @Async의 내부 동작·스레드 풀 설계·예외 처리·운영 패턴을 실무 수준으로 정리한다.

1. @EnableAsync와 프록시 모드의 동작 원리

1-1. 기본 동작: proxy 모드

@EnableAsync를 선언하면 Spring은 @Async가 붙은 메서드를 가진 빈을 프록시로 래핑한다. 외부에서 해당 메서드를 호출하면 프록시가 가로채어 TaskExecutor에 작업을 위임한다.

@Configuration
@EnableAsync
public class AsyncConfig {
    // 기본 proxy 모드: advice mode = PROXY
}

핵심 제약 — self-invocation: 같은 클래스 내에서 this.asyncMethod()를 호출하면 프록시를 거치지 않으므로 동기로 실행된다. 이것은 @Transactional의 self-invocation 함정과 동일한 메커니즘이다.

@Service
public class OrderService {

    // ❌ self-invocation — 동기 실행됨
    public void placeOrder(Order order) {
        save(order);
        this.sendNotification(order);  // 프록시를 타지 않음
    }

    @Async
    public void sendNotification(Order order) {
        // 비동기를 기대했지만 호출 스레드에서 실행됨
    }
}

1-2. self-invocation 해결 3가지

방법 설명 장단점
별도 빈 분리 @Async 메서드를 다른 @Service로 이동 가장 단순하고 권장되는 방법. 역할 분리도 자연스러움
ApplicationContext에서 자기 자신 주입 @Lazy @Autowired private OrderService self; 순환 참조 주의. @Lazy로 회피 가능하나 코드 가독성 저하
AspectJ 모드 @EnableAsync(mode = AdviceMode.ASPECTJ) 컴파일/로드타임 위빙 필요. 설정 복잡도 증가

2. Spring Boot의 자동 구성 TaskExecutor

2-1. 자동 구성 동작 (Spring Boot 3.x)

Spring Boot는 컨텍스트에 Executor 빈이 없으면 ThreadPoolTaskExecutor를 자동 구성한다. Java 21+에서 spring.threads.virtual.enabled=true이면 SimpleAsyncTaskExecutor(가상 스레드)가 대신 사용된다.

자동 구성 기본값:

프로퍼티 기본값 설명
spring.task.execution.pool.core-size 8 항상 유지되는 코어 스레드 수
spring.task.execution.pool.max-size Integer.MAX_VALUE 최대 스레드 수
spring.task.execution.pool.queue-capacity Integer.MAX_VALUE 큐 용량
spring.task.execution.pool.keep-alive 60s 코어 초과 유휴 스레드 제거 시간
spring.task.execution.thread-name-prefix task- 스레드 이름 접두사
spring.task.execution.pool.allow-core-thread-timeout true 코어 스레드도 타임아웃 적용 여부
spring.task.execution.shutdown.await-termination false 종료 시 작업 완료 대기 여부
spring.task.execution.shutdown.await-termination-period 대기 최대 시간

2-2. 기본값의 함정: 무제한 큐

queue-capacityInteger.MAX_VALUE이므로 max-size는 사실상 무의미하다. ThreadPoolExecutor의 동작 방식상, 큐가 가득 차야 코어 이상의 스레드를 생성하기 때문이다. 즉 기본 설정에서는 코어 8개 + 무제한 큐로 동작하며, 작업이 폭주하면 큐만 무한히 쌓여 메모리 부족(OOM)이 발생한다.

3. 운영 수준 ThreadPoolTaskExecutor 설계

3-1. 직접 빈 등록

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("emailExecutor")
    public ThreadPoolTaskExecutor emailExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(100);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("email-");
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}

3-2. 풀 사이징 설계 원칙

파라미터 설계 기준
corePoolSize 평상시 동시 작업 수. CPU 바운드: CPU 코어 수. I/O 바운드: 코어 수 × (1 + 대기/처리 비율)
maxPoolSize 피크 시 허용 최대 스레드. 무제한 금지. 일반적으로 core의 2–4배
queueCapacity 반드시 유한값으로 설정. 100~500 범위. 큐가 차면 max까지 스레드 증가 → 그래도 차면 rejection
rejectedExecutionHandler CallerRunsPolicy 권장: 호출 스레드가 직접 실행하여 백프레셔 역할. AbortPolicy는 RejectedExecutionException 발생
waitForTasksToComplete Graceful Shutdown 시 큐 잔여 작업 완료 후 종료. Kubernetes 환경 필수

3-3. 용도별 Executor 분리

하나의 풀로 모든 비동기 작업을 처리하면, 느린 작업(외부 API)이 빠른 작업(로그 적재)의 스레드를 잠식한다. 용도별 Executor를 분리하고 @Async("executorName")으로 지정한다.

@Async("emailExecutor")
public void sendEmail(String to, String body) { ... }

@Async("auditExecutor")
public void writeAuditLog(AuditEvent event) { ... }

@Async에 이름을 지정하지 않으면 기본 Executor가 사용된다. 빈이 여러 개일 때 어떤 것이 기본인지 혼란이 생기므로, 항상 명시적으로 지정하는 것을 권장한다.

4. 반환 타입: void vs CompletableFuture

4-1. void 반환

호출자가 결과를 기다리지 않는 fire-and-forget 패턴. 예외가 발생해도 호출자에게 전파되지 않는다.

@Async("emailExecutor")
public void sendWelcomeEmail(User user) {
    emailClient.send(user.getEmail(), "Welcome!");
}

4-2. CompletableFuture 반환

결과를 받거나 여러 비동기 작업을 조합할 때 사용한다.

@Async("apiExecutor")
public CompletableFuture<ExchangeRate> fetchRate(String currency) {
    ExchangeRate rate = externalApi.getRate(currency);
    return CompletableFuture.completedFuture(rate);
}

// 호출부: 여러 API 동시 호출 후 합산
CompletableFuture<ExchangeRate> usd = rateService.fetchRate("USD");
CompletableFuture<ExchangeRate> eur = rateService.fetchRate("EUR");
CompletableFuture.allOf(usd, eur).join();
// usd.get(), eur.get() 사용

주의: CompletableFuture 반환 시 예외는 get()이나 join() 호출 시점에 ExecutionException/CompletionException으로 전파된다. get()을 호출하지 않으면 예외가 소실된다.

4-3. 반환 타입별 예외 전파 비교

반환 타입 예외 전파 적합한 용도
void AsyncUncaughtExceptionHandler로만 전달 fire-and-forget (알림, 로그)
Future<T> / CompletableFuture<T> get()/join() 시 ExecutionException으로 전파 결과 필요, 작업 조합

5. 예외 처리: AsyncUncaughtExceptionHandler

5-1. void 메서드의 예외 소실 문제

void 반환 @Async 메서드에서 예외가 발생하면 기본적으로 WARN 로그만 남고 무시된다. 운영 환경에서 이메일 발송 실패를 모르고 넘어갈 수 있다.

5-2. 커스텀 핸들러 등록

AsyncConfigurer를 구현하여 전역 예외 핸들러를 등록한다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(32);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(
            new ThreadPoolExecutor.CallerRunsPolicy()
        );
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (throwable, method, params) -> {
            log.error("Async 예외 — method={}, params={}", 
                method.getName(), Arrays.toString(params), throwable);
            // Sentry, Slack 알림 등 운영 알림 연동
        };
    }
}

주의: AsyncConfigurer를 구현하면 Spring Boot의 자동 구성 Executor가 비활성화된다. getAsyncExecutor()에서 직접 설정해야 한다.

6. Executor 빈 탐색 우선순위

@Async에 Executor 이름을 지정하지 않았을 때 Spring이 사용하는 Executor 탐색 순서:

  1. AsyncConfigurer.getAsyncExecutor() 반환값
  2. 타입이 TaskExecutor인 빈 (유일한 경우)
  3. 이름이 taskExecutor인 빈
  4. Spring Boot 자동 구성 ThreadPoolTaskExecutor (위 모두 없을 때)

빈이 여러 개인데 이름을 지정하지 않으면 NoUniqueBeanDefinitionException이 발생할 수 있다. 반드시 @Async("name")으로 명시하거나 @Primary를 지정한다.

7. Java 21 가상 스레드와 @Async

Spring Boot 3.2+에서 spring.threads.virtual.enabled=true를 설정하면 자동 구성 Executor가 SimpleAsyncTaskExecutor(가상 스레드)로 교체된다.

항목 ThreadPoolTaskExecutor (플랫폼 스레드) SimpleAsyncTaskExecutor (가상 스레드)
스레드 생성 비용 높음 (OS 스레드) 극히 낮음 (JVM 관리)
풀 사이징 필수 (core/max/queue) 불필요 (작업당 새 가상 스레드)
I/O 대기 중 스레드 점유 점유 (블로킹) 자동 언마운트
CPU 바운드 작업 적합 (스레드 수 제한으로 경합 제어) 주의 필요 (과도한 병렬화 가능)
ThreadLocal 호환 완전 호환 ScopedValue 전환 권장 (ThreadLocal pinning 주의)

실무 판단: I/O 바운드 비동기 작업이 대부분이면 가상 스레드가 유리하다. CPU 바운드나 ThreadLocal 의존 코드(MDC, SecurityContext 등)가 많으면 플랫폼 스레드 풀을 유지하되, 점진적으로 전환한다.

8. Graceful Shutdown과 Kubernetes 연동

Pod 종료 시 SIGTERM을 받으면 Spring Boot는 Graceful Shutdown을 시작한다. 이때 비동기 작업이 큐에 남아있으면 유실될 수 있다.

# application.yml
spring:
  task:
    execution:
      shutdown:
        await-termination: true
        await-termination-period: 30s
  lifecycle:
    timeout-per-shutdown-phase: 30s

server:
  shutdown: graceful

설계 포인트:

  • await-termination-period는 Kubernetes terminationGracePeriodSeconds(기본 30s)보다 짧게 설정한다.
  • 큐에 남은 작업이 타임아웃 내에 끝나지 않으면 InterruptedException이 발생한다.
  • 중요 작업은 DB에 상태를 기록하고 재시도 메커니즘(BullMQ, SQS 등)을 병행한다.

9. 운영 체크리스트

  1. self-invocation 확인: 같은 클래스에서 @Async 메서드를 호출하지 않는지 코드 리뷰.
  2. queue-capacity 유한 설정: 기본 무제한을 반드시 변경. OOM 방지의 핵심.
  3. rejectedExecutionHandler 지정: CallerRunsPolicy로 백프레셔 확보.
  4. AsyncUncaughtExceptionHandler 등록: void 메서드 예외를 모니터링 시스템에 연동.
  5. Executor 이름 명시: @Async(“name”)으로 항상 지정. 다중 풀 환경에서 혼란 방지.
  6. 스레드 이름 접두사 설정: 로그에서 어떤 풀의 스레드인지 즉시 식별.
  7. Graceful Shutdown 설정: await-termination + Kubernetes terminationGracePeriodSeconds 정합.
  8. 스레드 풀 모니터링: Micrometer + Prometheus로 active/queue-size/pool-size 메트릭 수집.

마무리

@Async는 단순해 보이지만 프록시 모드, 스레드 풀 사이징, 예외 전파, 종료 시 작업 유실까지 고려할 요소가 많다. 핵심을 정리하면:

  • self-invocation은 proxy 모드의 근본적 제약. 빈 분리로 해결한다.
  • queue-capacity를 유한값으로 설정해야 max-size가 의미를 갖고 OOM을 방지한다.
  • void 반환 시 예외가 소실되므로 AsyncUncaughtExceptionHandler를 반드시 등록한다.
  • CompletableFuture로 반환하면 호출부에서 예외를 받을 수 있고 작업 조합이 가능하다.
  • Java 21 가상 스레드는 I/O 바운드 시 풀 사이징 고민을 없애지만 ThreadLocal pinning에 주의한다.

참고 문서:

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux