[Spring] 비동기 처리: Async 🔀

🙈·2026년 8월 18일

@Async 어노테이션 하나만 붙이면 spring에서 비동기로 수행할 수 있습니다. 하지만 기본 설정 그대로 운영에 올렸다가는 트래픽이 몰리는 순간 서버가 스레드를 무한정 생성하며 죽어버릴 수 있습니다.

이 글에서는 @Async의 기본 사용법부터, 왜 기본 설정이 위험한지, 스레드 풀을 어떻게 관리해야 하는지, 그리고 내부적으로 어떻게 동작하는지까지 정리해보겠습니다.

1. 기본 사용법

@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으로 모두 다른 거 보이시나요? 스레드가 재사용되지 않고 매번 새로 생성되고 있다는 뜻입니다.

2. SimpleAsyncTaskExecutor의 함정

@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

왜 위험한가

  1. 메모리 고갈

    스레드가 생성될 때마다 OS는 콜스택을 담을 스택 메모리를 미리 예약해둡니다. 기본 크기는 스레드당 512KB~1MB 정도로 스레드가 1,000개면 그것만으로 수백 MB~GB 단위의 메모리가 사용됩니다.

  2. 컨텍스트 스위칭 비용 증가

    OS 스케쥴러가 관리해야 할 스레드가 늘어날수록 컨텍스트 스위칭 비용이 증가합니다.

  3. 동시성 제어 불가

    SimpleAsyncTaskExecutor는 요청이 들어오는 즉시 그대로 스레드를 생성합니다. 순간적으로 요청이 몰리면 그 순간의 트래픽 양이 곧 그대로 스레드 생성 개수가 됩니다.

  4. 연쇄 장애 가능성

    앞의 세 문제가 격치면 장애가 도미노처럼 번집니다.

    스레드 폭증
      → 메모리 고갈 (OutOfMemoryError)
      → 비동기 로직뿐만 아니라 요청을 받던 톰캣 스레드(http-nio-*)까지 함께 죽음
      → 동시에 DB 커넥션 풀(HikariCP 등)・외부 API 커넥션도 스레드 수만큼 고갈
      → 비동기와 무관한 동기 요청까지 커넥션을 못 구해 타임아웃

    시작은 스레드 하나였지만 결과는 서버 전체의 응답 지연·장애입니다.

앞의 네 가지 문제는 스레드 생성에 아무런 제한이 없다는 것에서 시작됩니다. 그러면 이 문제는 어떻게 해결할 수 있을까요?? 바로 스레드 풀을 관리하는 것입니다. 스레드를 미리 만들어 두고 만들어 둔 스레드는 버리지 않고 재사용하는 방법에 대해서 살펴보겠습니다.

3. Thread Pool 등록 및 관리

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늘어날 수 있는 최대 스레드 수
queueCapacitycore 스레드가 모두 사용 중일 때 작업을 대기시키는 큐의 용량
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=2
    • 요청이 들어오자마자 async-1~async-2 2개가 먼저 만들어져 처리를 시작합니다.
  • queueCapacity=50
    • core 2개가 모두 바쁘면 다음 50개는 스레드를 더 만들지 않고 큐에서 대기합니다.
    • 1000개는 2(core) + 50(queue)를 훌쩍 넘기 때문에 큐까지 꽉 찬 시점부터 maxPoolSize=10까지 스레드를 추가로 만들어(async-3~async-10) 처리합니다.
    • async-11처럼 10을 넘는 이름은 절대 나오지 않습니다. maxPoolSize가 상한선이기 때문입니다.

여기서 한 가지 더 확인해볼 점이 있습니다. corePoolSize(2) + queueCapacity(100) + (maxPoolSize(10) - corePoolSize(2)) = 110개까지만 스레드 풀이 흡수할 수 있는데 요청은 1000개입니다. 나머지 약 890개는 어디로 갈까요? RejectedExecutionHandlerCallerRunsPolicy를 지정해뒀기 때문에 이 초과분은 버려지지 않고 요청을 보낸 스레드(http-nio-*)가 직접 실행합니다. 로그에서 async-가 아니라 http-nio-가 찍히면서 sendEmail이 실행되는 줄이 바로 이 경우입니다. 이 구간은 사실상 동기 처리가 되는 셈이라 그만큼 API 응답이 늦어집니다.

실무 팁

  • CPU 바운드 작업: corePoolSize를 CPU 코어 수 근처로, 큐는 작게
  • I/O 바운드 작업(외부 API 호출, DB 조회 등): 스레드가 대기 시간이 기니 corePoolSize를 더 넉넉하게, 큐도 여유 있게
  • 작업 성격이 다르면 Executor를 여러 개 등록하고 이름으로 구분해서 쓰는 것도 방법입니다.

4. 동작 방식 - AOP Proxy

@Async 는 Spring의 AOP 프록시 메커니즘 위에서 동작합니다.

@EnableAsync를 선언하면 Spring은 AsyncAnnotationBeanPostProcessor를 등록합니다. 이 후처리기가 @Async가 붙은 메서드를 가진 빈을 감지하고 그 빈에 대한 프록시 객체를 만듭니다.

흐름을 그려보면 이렇습니다.

[호출자] → [프록시 객체] → (원본 메서드 대신) Executor에 작업 제출 
                                                → [스레드 풀] → 실제 메서드 실행

외부에서 @Async 메서드를 호출하면 실제로는 원본 객체가 아니라 프록시가 먼저 호출을 가로챕니다.

Self-invocation 문제

이 프록시 기반 동작 방식 때문에 생기는 가장 흔한 실수가 바로 같은 클래스 내부 호출입니다.

@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(); // ✅ 프록시를 거쳐 정상 동작
    }
}

그 외 제약사항

1. public이어야 함

프록시는 대상 클래스를 상속해서 메서드를 오버라이드하는 방식으로 만들어집니다. private 메서드는 애초에 오버라이드가 불가능해서 @Async를 붙여도 프록시가 가로챌 방법이 없습니다.

@Async
private void sendEmail(String to) { ... } // 프록시가 가로챌 수 없어서 그냥 동기 실행됨

protected도 Spring이 공식적으로 지원 범위 밖이라고 명시하고 있습니다.

2. 반환 타입은 void, Future<T>, CompletableFuture<T>만 의미 있음

프록시가 메서드를 호출한 시점엔 실제 로직이 아직 실행되지도 않은 상태입니다. void는 애초에 아무것도 안 돌려주면 되고 Future/CompletableFuture는 "나중에 결과 줄게"라는 약속 객체만 즉시 리턴하면 됩니다.

@Async
public String sendEmail(String to) {
    ...
    return "발송 성공";
}
String result = emailService.sendEmail("a@test.com");
// result는 항상 null. 메서드 내부에서 뭘 리턴했든 상관없이 버려짐

3. 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로도 잡을 수 없습니다.

5. 세 줄 정리

✅ @Async를 사용할 때는 Thread Pool 을 등록하여 사용할 것
✅ corePoolSize / maxPoolSize / queueCapacity를 작업 특성에 맞게 조정
✅ 프록시로 동작

@Async는 짧은 어노테이션 하나로 비동기 처리를 가능하게 해주는 편리한 기능이지만 그 이면에는 AOP 프록시와 스레드 풀이라는 명확한 동작 원리가 있습니다. 이걸 이해하지 못한 채 기본 설정 그대로 사용하면 트래픽이 몰리는 순간 예상치 못한 장애로 이어질 수 있습니다.

다음 글에서는 @Async의 반환 타입으로 쓰이는 FutureCompletableFuture의 차이, 그리고 여러 비동기 작업을 조합하는 패턴을 다뤄보겠습니다.

profile
개발 일기🌱

0개의 댓글