본문으로 바로가기
728x90
반응형
SMALL

Spring @Async ThreadPoolTaskExecutor가 포화되면 요청은 어떻게 될까?

애플리케이션을 개발하다 보면 시간이 오래 걸리는 작업을 비동기로 분리하는 경우가 많다.

예를 들면 다음과 같은 작업이다.

  • 사용자 프로필 변경 후 추가 정보 갱신
  • 로그 저장
  • 알림 발송
  • 외부 API 호출
  • DB insert 후 후처리 이벤트 실행

Spring에서는 보통 @AsyncThreadPoolTaskExecutor를 사용해 이런 작업을 비동기로 처리한다.

그런데 문득 이런 궁금증이 생겼다.

비동기 작업이 몰렸는데, Thread Pool의 최대 스레드 개수도 다 찼고, Queue도 가득 찼다면 어떻게 될까?
요청은 기다릴까?
유실될까?
아니면 내부적으로 어딘가에 저장될까?

이번 글에서는 ThreadPoolTaskExecutor가 포화됐을 때 어떤 일이 발생하는지 직접 테스트하고, 실무에서는 어떻게 대응해야 하는지 정리해본다.


1. 테스트 환경

테스트를 위해 다음과 같이 Thread Pool을 설정했다.

@EnableAsync
@Configuration
public class AsyncConfig {

    @Bean(name = "threadPoolTaskExecutor")
    public Executor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("Executor-");

        executor.initialize();
        return executor;
    }
}

설정 값은 다음과 같다.

설정 의미
corePoolSize 3 기본적으로 유지할 스레드 수
maxPoolSize 30 최대 생성 가능한 스레드 수
queueCapacity 10 대기 가능한 작업 큐 크기

그리고 비동기 이벤트 핸들러에서는 일부러 20초 지연을 주었다.

@Async("threadPoolTaskExecutor")
@EventListener
public void update(UserProfileUpdateEvent event) {
    try {
        Thread.sleep(20_000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return;
    }

    log.info("update event");
    csvReader.update(UserProfileEntity.update(event));
}

즉, 하나의 비동기 작업이 20초 동안 스레드를 점유하도록 만든 것이다.

이 상태에서 짧은 시간 동안 많은 이벤트를 발생시키면 Executor가 어떻게 동작하는지 확인할 수 있다.


2. ThreadPoolExecutor는 어떤 순서로 작업을 처리할까?

먼저 ThreadPoolTaskExecutor는 내부적으로 Java의 ThreadPoolExecutor를 사용한다.

ThreadPoolExecutor의 작업 처리 흐름은 대략 다음과 같다.

1. 현재 스레드 수가 corePoolSize보다 작다
   → 새 스레드를 만들어 작업 실행

2. corePoolSize만큼 스레드가 이미 실행 중이다
   → 새 작업을 Queue에 적재

3. Queue가 가득 찼다
   → maxPoolSize까지 추가 스레드 생성

4. maxPoolSize까지 스레드가 찼고 Queue도 가득 찼다
   → 작업 거절

이번 설정에 대입하면 다음과 같다.

corePoolSize = 3
maxPoolSize = 30
queueCapacity = 10

그러면 최대 수용 가능한 작업 수는 단순 계산으로 다음과 같다.

동시 실행 작업 수 30개 + 큐 대기 작업 수 10개 = 총 40개

즉, 작업 하나가 20초 동안 끝나지 않는 상황에서 40개를 초과하는 작업이 순간적으로 들어오면, 그 이후 작업은 더 이상 Executor에 들어갈 수 없다.

이때 발생하는 것이 RejectedExecutionException이다.


3. 실제 발생한 에러

부하를 걸어보면 다음과 같은 로그가 발생한다.

Executor [java.util.concurrent.ThreadPoolExecutor@397b9c2f
[Running, pool size = 30, active threads = 30, queued tasks = 10]]
did not accept task

로그를 보면 현재 상태가 명확하다.

pool size = 30
active threads = 30
queued tasks = 10

즉,

active thread == maxPoolSize
queue size == queueCapacity

상태가 된 것이다.

이 상태에서는 더 이상 작업을 실행할 스레드도 없고, 대기시킬 큐 공간도 없다.

결과적으로 새 작업은 Executor에 의해 거절된다.


4. 여기서 “요청이 유실된다”는 표현은 정확할까?

이 부분은 조금 정확히 구분해야 한다.

@Async 작업이 거절되었다고 해서 HTTP 요청 자체가 네트워크 레벨에서 사라진다는 의미는 아니다.

정확히는 다음에 가깝다.

비동기 작업을 Executor에 제출하는 데 실패한다.

즉, 요청 흐름은 살아있더라도 비동기 후처리 작업은 실행되지 못할 수 있다.

예를 들어 다음과 같은 코드가 있다고 해보자.

@PostMapping("/users/{id}")
public ResponseEntity<Void> updateUser(@PathVariable Long id) {
    userService.update(id);

    applicationEventPublisher.publishEvent(new UserProfileUpdateEvent(id));

    return ResponseEntity.ok().build();
}

이때 publishEvent() 이후 실행되는 @Async @EventListener가 Thread Pool 포화로 거절될 수 있다.

그러면 사용자 정보 수정은 성공했지만, 비동기 후처리인 프로필 갱신, 알림, 로그 저장 등은 실행되지 않을 수 있다.

따라서 “요청이 유실된다”보다는 다음 표현이 더 정확하다.

Thread Pool과 Queue가 모두 포화되면 비동기 작업 제출이 실패하고, 해당 작업은 실행되지 않을 수 있다.


5. 기본 거절 정책: AbortPolicy

ThreadPoolExecutor의 기본 거절 정책은 AbortPolicy다.

AbortPolicy는 작업을 더 이상 받을 수 없을 때 RejectedExecutionException을 던진다.

대표적인 거절 정책은 다음과 같다.

정책 동작
AbortPolicy 기본값. 작업 거절 시 RejectedExecutionException 발생
CallerRunsPolicy 호출한 스레드가 직접 작업 실행
DiscardPolicy 거절된 작업을 조용히 버림
DiscardOldestPolicy 큐에서 가장 오래된 작업을 버리고 새 작업을 넣으려고 시도

실무에서 가장 조심해야 할 정책은 DiscardPolicy다.

예외도 발생하지 않고 작업이 조용히 버려지기 때문에, 장애를 늦게 발견할 수 있다.


6. 대안 1: CallerRunsPolicy

작업 유실을 줄이기 위한 가장 간단한 방법 중 하나는 CallerRunsPolicy를 사용하는 것이다.

@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(100);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("Async-");

    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

    executor.initialize();
    return executor;
}

CallerRunsPolicy는 Thread Pool이 포화되었을 때 작업을 버리지 않고, 해당 작업을 제출한 스레드가 직접 실행하게 만든다.

예를 들어 HTTP 요청 스레드가 비동기 작업을 제출하다가 거절당하면, 그 요청 스레드가 직접 비동기 작업을 실행한다.

장점은 명확하다.

작업을 조용히 버리지 않는다.

하지만 단점도 크다.

비동기로 분리했던 작업이 다시 동기처럼 실행된다.

즉, 요청 스레드가 오래 걸리는 작업에 붙잡힐 수 있다.

만약 작업 하나가 20초 걸린다면, 사용자의 HTTP 요청도 20초 이상 지연될 수 있다. 트래픽이 더 몰리면 Tomcat 요청 스레드까지 고갈되어 전체 API 응답이 느려질 수 있다.

따라서 CallerRunsPolicy는 단순히 “유실 방지 정책”이라기보다는 “호출자에게 백프레셔를 거는 정책”에 가깝다.


7. 대안 2: 내부 BlockingQueue에 임시 저장하기

처음에는 이런 생각을 할 수 있다.

거절된 작업을 별도 Queue에 넣어두고, 나중에 Thread Pool에 여유가 생기면 다시 넣으면 되지 않을까?

예제 코드는 다음처럼 만들 수 있다.

@Configuration
@EnableAsync
public class AsyncConfig {

    private final BlockingQueue<Runnable> retryQueue = new LinkedBlockingQueue<>(100_000);

    @Bean(name = "threadPoolTaskExecutor")
    public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(10);
        executor.setThreadNamePrefix("Executor-");

        executor.setRejectedExecutionHandler((task, threadPoolExecutor) -> {
            boolean offered = retryQueue.offer(task);

            if (!offered) {
                throw new RejectedExecutionException("Retry queue is full");
            }

            log.warn("Task rejected. Saved to retryQueue. retryQueueSize={}", retryQueue.size());
        });

        executor.initialize();
        return executor;
    }

    @Bean
    public ApplicationRunner retryWorker(ThreadPoolTaskExecutor executor) {
        return args -> {
            ScheduledExecutorService scheduler =
                    Executors.newSingleThreadScheduledExecutor();

            scheduler.scheduleWithFixedDelay(() -> {
                Runnable task = retryQueue.poll();

                if (task == null) {
                    return;
                }

                try {
                    executor.execute(task);
                } catch (RejectedExecutionException e) {
                    boolean offered = retryQueue.offer(task);

                    if (!offered) {
                        log.error("Retry queue is full. Task dropped.");
                    }
                }
            }, 100, 100, TimeUnit.MILLISECONDS);
        };
    }
}

이 방식은 간단한 테스트에서는 그럴듯하게 동작한다.

Thread Pool이 포화되면 거절된 작업이 retryQueue에 들어가고, 이후 스케줄러가 주기적으로 작업을 꺼내 다시 Executor에 넣는다.

하지만 이 방식은 실무에서 매우 조심해야 한다.


8. 내부 Queue 방식의 문제점

내부 BlockingQueue 방식은 작업 유실을 줄여주는 것처럼 보이지만, 근본적인 해결책은 아니다.

8.1 JVM이 죽으면 Queue도 사라진다

LinkedBlockingQueue는 JVM 메모리 안에 존재한다.

서버가 재시작되거나 장애로 종료되면 Queue 안에 있던 작업은 모두 사라진다.

즉, “유실 방지”라고 보기 어렵다.

8.2 메모리 사용량이 증가한다

new LinkedBlockingQueue<>(100_000)

이렇게 크기를 크게 잡으면 당장은 많은 작업을 담을 수 있다.

하지만 트래픽이 계속 들어오고 처리 속도가 따라가지 못하면 Queue는 계속 쌓인다.

그 결과 JVM Heap 사용량이 증가하고, 최악의 경우 OOM으로 애플리케이션이 죽을 수 있다.

8.3 장애를 뒤로 미루는 구조가 될 수 있다

처리량은 그대로인데 Queue만 늘리면 문제는 해결된 것이 아니라 뒤로 밀린 것이다.

예를 들어 작업 처리 속도가 초당 100건인데, 유입 속도가 초당 1,000건이면 Queue는 계속 증가한다.

이 경우 필요한 것은 Queue 크기 증가가 아니라 처리량 개선, 부하 제어, 외부 큐 도입, 작업 분산이다.

8.4 중복 처리와 순서 보장이 어렵다

재시도 중에 애플리케이션이 죽거나, 작업 일부가 실패하거나, 같은 이벤트가 다시 들어오면 중복 처리가 발생할 수 있다.

따라서 비동기 작업은 가능하면 멱등성을 가져야 한다.

예를 들어 다음과 같은 처리가 필요하다.

eventId 기준 중복 처리 방지
처리 상태 저장
실패 작업 재시도 횟수 제한
DLQ 또는 실패 테이블 저장

9. 대안 3: 외부 MQ 사용

유실 방지가 중요하다면 내부 메모리 Queue보다 외부 MQ를 사용하는 편이 더 안전하다.

대표적으로 다음과 같은 선택지가 있다.

방식 특징
RabbitMQ 작업 큐, 재시도, DLQ 구성에 적합
Kafka 대량 이벤트 스트림, 순서 보장, 재처리에 적합
Redis Stream 비교적 가볍게 스트림 기반 큐 구성 가능
DB Queue 단순하지만 트래픽이 높으면 DB 부하 주의
Outbox Pattern DB 트랜잭션과 이벤트 발행 정합성 보장에 유리

특히 “사용자 정보 수정은 DB에 반영됐는데, 후처리 이벤트는 실패하면 안 된다” 같은 요구사항이라면 Outbox Pattern을 고려하는 것이 좋다.

예를 들면 다음 흐름이다.

1. 사용자 정보 수정
2. 같은 트랜잭션 안에서 outbox 테이블에 이벤트 저장
3. 별도 worker가 outbox 이벤트를 읽어서 처리
4. 성공하면 processed 처리
5. 실패하면 재시도 또는 DLQ 이동

이 구조는 애플리케이션이 중간에 죽어도 DB에 이벤트가 남아 있기 때문에 재처리가 가능하다.


10. Thread Pool 크기는 어떻게 잡아야 할까?

Thread Pool 설정은 단순히 크게 잡는다고 해결되지 않는다.

작업 성격에 따라 다르게 접근해야 한다.

CPU Bound 작업

CPU 연산이 많은 작업이라면 스레드를 너무 많이 늘려도 성능이 좋아지지 않는다.

오히려 Context Switching 비용만 증가한다.

일반적으로는 CPU 코어 수 근처에서 시작하는 것이 좋다.

스레드 수 ≈ CPU 코어 수

IO Bound 작업

DB, 외부 API, 파일 IO처럼 대기 시간이 많은 작업은 CPU Bound보다 더 많은 스레드를 둘 수 있다.

자주 사용되는 계산식은 다음과 같다.

Nthreads = Ncpu × Ucpu × (1 + W/C)
항목 의미
Ncpu CPU 코어 수
Ucpu 목표 CPU 사용률
W 대기 시간
C 실제 연산 시간

예를 들어 작업 대부분이 DB 응답 대기라면 스레드를 어느 정도 늘릴 수 있다.

하지만 DB 작업이라면 반드시 DB Connection Pool도 같이 봐야 한다.

Thread Pool만 100개로 늘리고 DB Connection Pool이 10개라면, 결국 대부분의 스레드는 DB 커넥션을 기다리며 블로킹된다.


11. DB 작업에서는 Connection Pool이 병목이 될 수 있다

이번 예제의 비동기 작업은 최종적으로 DB update를 수행한다.

csvReader.update(UserProfileEntity.update(event));

이 경우 Thread Pool만 보면 안 된다.

다음 리소스를 함께 봐야 한다.

Thread Pool
DB Connection Pool
DB insert/update 처리 속도
Transaction 점유 시간
Lock 경합
Index 비용
Disk IO

예를 들어 다음과 같은 설정이라고 해보자.

Thread Pool maxPoolSize = 100
HikariCP maximumPoolSize = 10

그러면 동시에 100개의 비동기 작업이 실행되더라도, DB Connection을 얻어 실제 쿼리를 수행할 수 있는 작업은 최대 10개뿐이다.

나머지 90개는 Connection을 기다린다.

결국 Thread Pool을 늘렸는데 처리량은 늘지 않고, 대기 스레드만 증가하는 상황이 될 수 있다.

따라서 DB 작업이 느릴 때는 다음을 같이 확인해야 한다.

HikariCP active connection 수
pending connection 수
connection timeout 발생 여부
쿼리 실행 시간
락 대기 시간
인덱스 사용 여부
batch insert 가능 여부

12. 실무에서 권장하는 대응 방향

Thread Pool 포화 문제는 단순히 maxPoolSizequeueCapacity를 키운다고 해결되지 않는다.

상황별로 접근이 달라야 한다.

작업이 유실되어도 되는 경우

예를 들어 단순 로그, 통계성 이벤트처럼 일부 유실이 허용되는 작업이라면 다음 전략을 사용할 수 있다.

작은 Queue
명확한 reject logging
metric 수집
일부 drop 허용

단, 이 경우에도 DiscardPolicy처럼 조용히 버리는 방식은 피하는 것이 좋다.

최소한 reject count를 metric으로 남겨야 한다.

작업이 반드시 처리되어야 하는 경우

알림, 결제, 정산, 상태 변경, 외부 시스템 연동처럼 반드시 처리되어야 하는 작업이라면 메모리 Queue에만 의존하면 안 된다.

이 경우는 다음 구조가 더 적절하다.

DB Outbox
RabbitMQ
Kafka
Redis Stream
DLQ
재시도 정책
멱등 처리

요청 자체를 늦추는 것이 괜찮은 경우

처리량보다 시스템 보호가 중요하다면 CallerRunsPolicy를 고려할 수 있다.

이 방식은 호출자 스레드가 직접 작업을 수행하므로 자연스럽게 요청 처리 속도를 늦춘다.

즉, 일종의 백프레셔 역할을 한다.

하지만 사용자 응답 시간이 길어질 수 있으므로 API 특성에 맞게 사용해야 한다.

실무에서는 보통 다음과 같은 관점으로 접근한다.

1. 모든 요청을 무조건 받으려고 하지 않는다.
2. 처리 가능한 수준까지만 받는다.
3. 초과 요청은 빠르게 거절하거나, 호출자를 늦춘다.
4. 반드시 처리해야 하는 작업은 메모리 Queue가 아니라 MQ나 DB Outbox에 저장한다.
5. Thread Pool의 RejectedExecutionHandler는 마지막 안전장치로 둔다.

즉, RejectedExecutionHandler는 장애를 해결하는 핵심 로직이라기보다는 “현재 Executor가 더 이상 작업을 받을 수 없다”는 사실을 드러내는 마지막 방어선에 가깝다.

따라서 대안을 고민할 때도 작업의 성격을 먼저 나누어야 한다.

이 작업은 유실되어도 되는가?
지연되어도 되는가?
반드시 처리되어야 하는가?
중복 처리되어도 괜찮은가?
서버가 재시작되어도 복구되어야 하는가?

단순 로그성 작업이라면 일부 유실을 허용하고 reject metric을 남기는 방식도 가능하다.
사용자 요청 흐름에 붙은 작업이라면 빠르게 실패시키고 429 또는 503으로 응답하는 방식이 더 안전할 수 있다.
반드시 처리되어야 하는 이벤트라면 @Async의 내부 Queue에만 의존하지 말고 RabbitMQ, Kafka, Redis Stream, DB Outbox 같은 durable queue 구조를 고려해야 한다.

결국 Thread Pool의 Queue는 순간적인 부하를 흡수하는 버퍼이지, 안정적인 메시지 저장소가 아니다.


13. 모니터링해야 할 지표

Thread Pool을 운영한다면 최소한 다음 지표는 모니터링하는 것이 좋다.

active thread count
pool size
queue size
completed task count
rejected task count
task execution time
task wait time

Spring Boot Actuator와 Micrometer를 사용하면 Thread Pool, HikariCP, JVM 메모리, HTTP 요청 지표를 함께 볼 수 있다.

특히 중요한 것은 rejected task count다.

Reject가 발생했다는 것은 이미 시스템이 순간 처리 한계를 넘었다는 의미다.

이 로그를 단순 에러로만 볼 것이 아니라, 트래픽 패턴과 처리량 한계를 파악하는 신호로 봐야 한다.


14. 결론

이번 테스트를 통해 확인한 내용은 다음과 같다.

ThreadPoolTaskExecutor는 설정한 maxPoolSizequeueCapacity를 초과하는 작업을 무한히 저장해주지 않는다.

Thread Pool이 최대치까지 찼고 Queue도 가득 차면, 새 작업은 거절된다.

기본 정책에서는 RejectedExecutionException이 발생한다.

따라서 비동기 작업을 사용할 때는 다음 질문을 반드시 해야 한다.

이 작업은 유실되어도 되는가?
지연되어도 되는가?
반드시 처리되어야 하는가?
중복 처리되어도 안전한가?
실패 시 재시도해야 하는가?
서버 재시작 후에도 남아 있어야 하는가?

단순 후처리라면 적절한 Thread Pool 설정과 Reject 로깅만으로도 충분할 수 있다.

하지만 반드시 처리되어야 하는 이벤트라면 @Async만으로는 부족하다.

그 경우에는 MQ, Outbox Pattern, 재시도 정책, DLQ, 멱등성 설계까지 함께 고려해야 한다.

결국 핵심은 이것이다.

Thread Pool은 작업 실행 자원이지, 안정적인 메시지 저장소가 아니다.

비동기 처리를 안정적으로 운영하려면 Thread Pool 크기뿐 아니라 Queue, DB Connection Pool, 재시도 구조, 장애 복구 전략까지 함께 설계해야 한다.

728x90
반응형
LIST