본문 바로가기
Development

Spring Boot @Async 스레드 폭증과 큐 적체 잡기: ThreadPoolTaskExecutor 설정과 MDC 전파

¯¯\_(ツ)_/¯·2026년 8월 16일·조회 1

@Async 하나 붙였다가 운영에서 스레드가 이상하게 늘거나 응답이 밀리는 상황은 자주 온다. 그런데 그 원인을 검색하면 "@Async는 요청마다 스레드를 무한 생성해서 서버가 죽는다"는 설명이 많이 나온다. 이 문장은 순정 Spring에서는 맞지만 Spring Boot에서는 틀린 진단이다. Boot는 실행기를 자동 구성하기 때문에 실제 실패 방식이 다르다. 이 글은 그 차이부터 바로잡고, 어디를 어떻게 조정하는지 순서대로 정리한다.

결론부터 말하면, Spring Boot 2.1 이상은 Executor 빈이 없을 때 코어 스레드 8개짜리 ThreadPoolTaskExecutor를 자동 구성하고 @Async가 이것을 쓴다. 기본 큐 용량이 사실상 무제한이라 진짜 위험은 스레드 폭증이 아니라 작업이 큐에 무한정 쌓여 힙이 부풀고 지연이 커지는 것이다. 1차 해법은 spring.task.execution.pool.* 프로퍼티로 이미 구성된 풀을 조정하는 것이고, 실행기를 여러 개 두거나 rejection 정책을 손봐야 하면 커스텀 ThreadPoolTaskExecutor 빈으로 교체한다.

1. @Async가 어떤 실행기를 쓰는지부터 정확히

@Async는 메서드를 호출한 스레드가 아니라 별도의 TaskExecutor에서 메서드를 실행하게 만드는 어노테이션이다. 이때 어떤 실행기가 잡히느냐가 전부를 결정하는데, 환경에 따라 세 갈래로 갈린다.

순정 Spring (Boot 없이 @EnableAsync만)

Spring Framework만 쓰고 실행기 빈을 하나도 등록하지 않으면 기본값은 SimpleAsyncTaskExecutor다. 이 실행기는 스레드를 재사용하지 않고 작업마다 새 플랫폼 스레드를 만든다. 요청이 몰리면 스레드 수가 그대로 따라 올라가고, 결국 아래 같은 에러로 프로세스가 죽는다.

java.lang.OutOfMemoryError: unable to create new native thread

"@Async가 서버를 죽인다"는 이야기는 이 경우를 가리킨다. 스레드 폭증이 실제로 일어나기 때문이다.

Spring Boot 2.1 이상 (플랫폼 스레드)

Boot는 TaskExecutionAutoConfiguration으로 실행기를 자동 구성한다. 사용자가 Executor 빈을 만들지 않으면 코어 스레드 8개짜리 ThreadPoolTaskExecutor가 등록되고 @Async가 그것을 쓴다. 자동 구성된 풀의 기본값은 다음과 같다.

  • core-size: 8
  • max-size: 무제한(Integer.MAX_VALUE)
  • queue-capacity: 무제한(Integer.MAX_VALUE)
  • keep-alive: 60s
  • thread-name-prefix: task-

여기서 큐가 무제한이라는 점이 핵심이다. ThreadPoolTaskExecutor는 코어 스레드가 다 차면 남는 작업을 큐에 넣고, 큐가 가득 찬 뒤에야 max-size까지 스레드를 늘린다. 큐가 무제한이면 그 지점에 영원히 도달하지 못한다. 그래서 순정 Boot에서 나타나는 증상은 스레드 폭증이 아니라, 스레드 8개가 계속 일하는 동안 처리 못 한 작업이 큐에 무한정 쌓여 힙이 부풀고 완료가 밀리는 것이다. 진단을 여기서 틀리면 엉뚱한 곳을 손대게 된다.

가상 스레드를 켠 경우

Java 21 이상에서 spring.threads.virtual.enabled=true를 주면 Boot는 가상 스레드 기반 SimpleAsyncTaskExecutor를 기본으로 쓴다. 이때는 작업마다 스레드를 만들어도 가상 스레드라 비용이 작아서, 앞의 플랫폼 스레드 폭증 문제와는 성격이 다르다. 다만 외부 자원 호출을 제한 없이 동시에 던지면 커넥션 풀 같은 다른 자원이 먼저 터질 수 있으니 별도의 동시성 제어가 필요하다.

2. 지금 무슨 일이 벌어지는지 확인

손대기 전에 실제 실행기와 큐 상태를 본다. 스레드 덤프에서 이름 접두사를 보면 어떤 실행기인지 바로 드러난다. 자동 구성 풀은 task-1, task-2처럼 붙고, SimpleAsyncTaskExecutor는 재사용이 없어 번호가 계속 커진다.

$ jstack <pid> | grep -E 'task-|SimpleAsync' | head
"task-1" #42 prio=5 os_prio=0 ... waiting on condition
"task-2" #43 prio=5 os_prio=0 ... runnable
...

Actuator를 붙였다면 큐와 활성 스레드 지표를 직접 볼 수 있다. 큐 길이가 계속 우상향하면 큐 적체가 확정이다.

$ curl -s localhost:8080/actuator/metrics/executor.queued
$ curl -s localhost:8080/actuator/metrics/executor.active
$ curl -s localhost:8080/actuator/metrics/executor.pool.size

3. 1차 해법: Boot 프로퍼티로 풀 조정

Boot에서 가장 간단한 조정은 이미 자동 구성된 풀을 프로퍼티로 손보는 것이다. 코드 변경 없이 application.yml만 바꾸면 된다. 핵심은 큐 용량을 유한한 값으로 두어서, 큐가 차면 max-size까지 스레드가 늘고 그다음엔 rejection이 작동하도록 만드는 것이다.

spring:
  task:
    execution:
      pool:
        core-size: 8
        max-size: 32
        queue-capacity: 200
        keep-alive: 60s
      thread-name-prefix: app-async-

이렇게 두면 동작이 예측 가능해진다. 코어 8개가 차면 큐 200칸에 담기고, 그것도 차면 스레드가 32개까지 올라간다. 32개까지 다 쓰고 큐도 가득이면 그때 작업이 거부된다. 무제한 큐일 때처럼 힙이 조용히 부푸는 대신, 한계에서 명시적으로 신호가 온다.

단일 실행기로 충분하고 rejection 정책이나 컨텍스트 전파가 필요 없다면 여기서 끝내도 된다. Boot의 자동 구성 실행기는 실무에서 자주 쓰는 선택이다.

4. 커스텀 ThreadPoolTaskExecutor 빈으로 교체

실행기를 용도별로 여러 개 두거나, rejection 정책을 지정하거나, 뒤에서 다룰 컨텍스트 전파를 붙이려면 빈으로 직접 정의하는 편이 낫다. Executor 빈을 만들면 Boot의 자동 구성은 물러나고 이 빈이 쓰인다.

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("appExecutor")
    public ThreadPoolTaskExecutor appExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(32);
        executor.setQueueCapacity(200);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("app-async-");
        executor.setRejectedExecutionHandler(
                new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
        executor.initialize();
        return executor;
    }
}

여기서 걸리기 쉬운 함정이 두 가지 있다. 첫째, new ThreadPoolTaskExecutor()를 직접 만들면 기본값이 Boot 자동 구성과 다르다. 순정 Spring의 ThreadPoolTaskExecutor코어 1, max 무제한, 큐 무제한이다. 즉 값을 명시하지 않으면 스레드 하나로 무제한 큐를 물고 도는 셈이 되니, 위처럼 네 값을 모두 지정한다. 둘째, @Bean으로 직접 만들 때는 initialize()를 호출해야 풀이 실제로 생성된다. 팩터리 초기화에 기대지 말고 명시적으로 부른다.

실행기가 하나뿐이면 @Async가 자동으로 이 빈을 잡지만, 여러 개를 두면 이름으로 지정한다.

@Async("appExecutor")
public void sendNotification(Long userId) {
    // ...
}

5. rejection 정책: 한계에 닿았을 때 무엇을 할까

큐를 유한하게 잡으면 언젠가는 거부 상황이 온다. 그때의 처리 방식이 rejection 정책이고, setRejectedExecutionHandler로 지정한다. 네 가지가 있다.

  • AbortPolicy: 기본값. 거부된 작업을 버리고 예외를 던진다. Spring은 이를 TaskRejectedException으로 감싼다. 호출 측에서 실패를 인지해야 하는 작업에 맞는다.
  • CallerRunsPolicy: 거부된 작업을 호출한 스레드에서 그대로 실행한다. 제출 속도가 자연스럽게 느려지는 backpressure 효과가 있어 유실 없이 유입을 눌러야 할 때 쓴다. 다만 톰캣 워커 스레드에서 무거운 작업이 실행되면 요청 처리가 밀릴 수 있으니 작업 성격을 보고 선택한다.
  • DiscardPolicy: 거부된 작업을 조용히 버린다. 유실을 감당할 수 있는 부가 작업에만 쓴다.
  • DiscardOldestPolicy: 큐에서 가장 오래된 작업을 버리고 새 작업을 넣는다. 최신 데이터가 중요한 경우에 쓴다.

유실이 곤란하고 유입을 눌러야 하는 상황이면 CallerRunsPolicy가 무난하다. 실패를 상위로 전파해 재시도나 알림으로 이어가야 하면 기본 AbortPolicy를 두고 예외를 처리한다.

6. MDC와 SecurityContext 전파 함정

@Async 메서드는 다른 스레드에서 돌기 때문에 ThreadLocal에 담긴 값이 넘어가지 않는다. 대표적으로 로깅 문맥인 MDC와 인증 정보인 SecurityContext가 그렇다. MDC(Mapped Diagnostic Context)는 SLF4J가 스레드별로 들고 다니는 키값 맵으로, 요청 ID 같은 걸 로그 패턴에 찍는 데 쓴다. 비동기 스레드로 넘어가면 이 맵이 비어서 로그의 traceId가 갑자기 사라진다.

해결책은 TaskDecorator다. 작업을 제출하는 시점(호출 스레드)의 컨텍스트를 복사해 두었다가 실행 스레드에서 채워 넣고, 끝나면 정리한다.

public class ContextCopyingDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> mdc = MDC.getCopyOfContextMap();
        SecurityContext security = SecurityContextHolder.getContext();
        return () -> {
            try {
                if (mdc != null) {
                    MDC.setContextMap(mdc);
                }
                SecurityContextHolder.setContext(security);
                runnable.run();
            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();
            }
        };
    }
}

이 데코레이터를 실행기에 붙인다.

executor.setTaskDecorator(new ContextCopyingDecorator());

SecurityContext만 넘기면 되는 경우엔 Spring Security가 주는 DelegatingSecurityContextAsyncTaskExecutor로 실행기를 감싸는 방법도 있다. 이 래퍼는 제출 시점의 인증 정보를 실행 스레드로 복사해 준다.

@Bean("appExecutor")
public Executor appExecutor(ThreadPoolTaskExecutor delegate) {
    return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}

finally에서 MDC.clear()clearContext()를 반드시 호출한다. 풀 스레드는 재사용되므로, 정리하지 않으면 이전 작업의 인증 정보나 로그 문맥이 다음 작업에 남아 엉뚱한 사용자 정보가 섞이는 사고로 이어질 수 있다.

7. 종료 시 작업 유실 막기

배포로 애플리케이션이 내려갈 때 큐에 남은 작업이 그냥 버려지면 곤란하다. 커스텀 빈 예제에 넣은 두 줄이 그 대비다.

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

프로퍼티 방식에서도 동일하게 지정할 수 있다.

spring:
  task:
    execution:
      shutdown:
        await-termination: true
        await-termination-period: 30s

이렇게 두면 종료 신호가 와도 진행 중이거나 큐에 있는 작업을 지정한 시간까지 마무리하고 내려간다.

자주 묻는 질문

Spring Boot에서 @Async가 요청마다 스레드를 무한 생성하나?

아니다. Spring Boot 2.1 이상은 Executor 빈이 없으면 코어 스레드 8개짜리 ThreadPoolTaskExecutor를 자동 구성하고 @Async가 이것을 쓴다. 기본 큐 용량이 무제한이라 실제 위험은 스레드 폭증이 아니라 작업이 큐에 쌓여 힙이 부풀고 지연이 커지는 것이다. 작업마다 새 스레드를 만드는 SimpleAsyncTaskExecutor가 기본이 되는 경우는 Boot 없이 @EnableAsync만 켠 순정 Spring이거나, 가상 스레드를 활성화한 경우다.

코드를 고치지 않고 풀을 조정하는 방법은?

spring.task.execution.pool.core-size, max-size, queue-capacity, keep-alive 프로퍼티로 자동 구성된 풀을 그대로 조정할 수 있다. 특히 queue-capacity를 유한한 값으로 두어야 큐가 찬 뒤 max-size까지 스레드가 늘고 rejection이 작동한다. 기본값은 큐 무제한이라 한계 신호 없이 힙만 늘 수 있다.

new ThreadPoolTaskExecutor()의 기본값은 무엇인가?

순정 Spring의 ThreadPoolTaskExecutor를 직접 생성하면 코어 1, 최대 무제한, 큐 무제한이다. Boot 자동 구성(코어 8)과 다르므로, 빈으로 직접 만들 때는 corePoolSize, maxPoolSize, queueCapacity를 반드시 명시하고 initialize()를 호출해야 한다.

rejection 정책은 어떤 걸 고르나?

유실이 곤란하고 유입을 눌러야 하면 CallerRunsPolicy가 무난하다. 호출 스레드에서 작업을 실행해 제출 속도가 자연히 느려지는 backpressure 효과가 있다. 실패를 상위로 전파해 재시도하려면 기본 AbortPolicy를 두고 TaskRejectedException을 처리한다. 유실을 감당할 부가 작업에는 DiscardPolicy나 DiscardOldestPolicy를 쓴다.

@Async에서 로그의 traceId나 로그인 정보가 사라지는 이유는?

@Async 메서드는 다른 스레드에서 실행되므로 ThreadLocal에 담긴 MDC와 SecurityContext가 전파되지 않는다. TaskDecorator로 제출 시점의 컨텍스트를 복사해 실행 스레드에서 채우고, finally에서 반드시 정리한다. SecurityContext만 필요하면 DelegatingSecurityContextAsyncTaskExecutor로 실행기를 감싸도 된다. 정리를 빠뜨리면 풀 스레드 재사용 때문에 다음 작업에 이전 사용자 정보가 남을 수 있다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.