본문 바로가기
Spring/experience

Spring Boot Thread Pool이 부족하게 된다면 어떤일이 일어날까.

by include_hoany 2025. 8. 11.

Chat Gpt 가 그려준 이미지

요즘 엄무를 진행하며 Chat Gpt에게 자주 물어보는 주제는 기술을 사용하면서 주의해야 할 점에 대한 내용이 대부분인 것 같습니다. 오늘의 주제 또한 이러한 질문으로 시작하게 되었습니다.
 

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@EnableAsync
@Configuration
public class ThreadPoolConfig {

    @Bean(name = "myThreadPool") // Bean 이름 지정
    public Executor myThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        executor.setCorePoolSize(5);             // 기본 스레드 수
        executor.setMaxPoolSize(10);             // 최대 스레드 수
        executor.setQueueCapacity(50);           // 대기 큐 크기
        executor.setThreadNamePrefix("MyPool-"); // 스레드 이름 prefix

        executor.initialize();
        return executor;
    }
    
}

일반적으로 Spring Boot에서 비동기작업 또는 병렬 작업을 할때 Thread Pool을 Spring IOC에  Bean으로 등록해 놓고 사용하실 겁니다. 그런데 의문이 들었던 건 혹시 대기큐가 꽉 찼는데 새로운 작업이 들어온다면? 어떻게 될까라는 생각이 들더군요. 만약 중요한 작업이 유실되면 안 될 텐데 라는 걱정과 함께..  직접 그러한 상황을 만들어보기로 했습니다.
 

@EnableAsync
@Configuration
public class ThreadPoolConfig {

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

        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(3);
        executor.setQueueCapacity(1);
        executor.setThreadNamePrefix("THREAD-MESSAGE-");

        executor.initialize();
        return executor;
    }

}

가장 먼저 기본 스레드, 최대 스레드풀 사이즈는 3으로 설정하고 대기큐 크기는 1개인 ThreadPool을 구성하였습니다.
 

@Slf4j
@Service
public class ThreadPoolService {

    private final MessageService messageService;

    public ThreadPoolService(MessageService messageService) {
        this.messageService = messageService;
    }

    public List<String> getMessages() throws InterruptedException, ExecutionException, ExecutionException {
        log.info("메세지 생성 - threadMessage");

        List<CompletableFuture<String>> futures = new ArrayList<>();
        
        for (int i = 0; i < 5; i++) {
            log.info("비동기 메세지 실행 : " + i);
            futures.add(messageService.getMessage(i));
        }


        List<String> messageList = futures.stream()
                .map(CompletableFuture::join)
                .collect(Collectors.toList());

        log.info("메세지 종료 - threadMessage");

        return messageList;
    }


}

비동기 작업 5개를 실행시키고 5개 작업이 완료되어 응답값을 리턴하도록 Service객체를 구성하였습니다.
 

@Slf4j
@Service
public class MessageService {

    @Async("threadMessage")
    public CompletableFuture<String> getMessage(int index) throws InterruptedException {
        log.info("메세지 생성 - " + index);
        int randomSleep = ThreadLocalRandom.current().nextInt(1000, 10001);
        Thread.sleep(randomSleep);
        return CompletableFuture.completedFuture(index + "메세지 생성 (대기 " + randomSleep + "ms)");
    }

}

비동기 작업은 메세지 String을 생성하고 비동기 작업 시간을 1초에서 10초 사이 랜덤하게 걸리도록 Thread.speep을 구성하였습니다.
 

@RestController
@RequestMapping("/api/v1/thread")
public class ThreadPoolController {

    private final ThreadPoolService threadPoolService;

    public ThreadPoolController(ThreadPoolService threadPoolService) {
        this.threadPoolService = threadPoolService;
    }

    @GetMapping
    public List<String> getMessages() throws InterruptedException, ExecutionException {

        return threadPoolService.getMessages();

    }

}

Thread Pool을 실행시키기 위한 EndPoint를 구성하고 실행하게 된다면? 5개 작업이 실행되겠지만 동시에 1, 2, 3 작업은 스레드풀에 존재하는 스레드로 실행되고 4번째 작업은 대기큐에 대기하게 됩니다. 그런데 마지막 5번째 작업은? 어떻게 될까요?
 

2025-08-11T22:19:35.464+09:00  INFO 13836 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 0
2025-08-11T22:19:35.480+09:00  INFO 13836 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 1
2025-08-11T22:19:35.481+09:00  INFO 13836 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 2
2025-08-11T22:19:35.481+09:00  INFO 13836 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 3
2025-08-11T22:19:35.481+09:00  INFO 13836 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 4
2025-08-11T22:19:35.485+09:00  INFO 13836 --- [blog] [HREAD-MESSAGE-1] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 0
2025-08-11T22:19:35.487+09:00  INFO 13836 --- [blog] [HREAD-MESSAGE-2] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 1
2025-08-11T22:19:35.487+09:00  INFO 13836 --- [blog] [HREAD-MESSAGE-3] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 2
2025-08-11T22:19:35.492+09:00 ERROR 13836 --- [blog] [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.core.task.TaskRejectedException: ExecutorService in active state did not accept task: java.util.concurrent.CompletableFuture$AsyncSupply@39c13cd9] with root cause

java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.CompletableFuture$AsyncSupply@39c13cd9 rejected from org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1@5ee9e3bc[Running, pool size = 3, active threads = 3, queued tasks = 1, completed tasks = 0]


2025-08-11T22:19:39.739+09:00  INFO 13836 --- [blog] [HREAD-MESSAGE-3] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 3

RejectedExecutionException 오류를 맞닥뜨리게 되고 해당 작업은 유실되게 됩니다. 이렇게 예외가 발생하는건 ThreadPool을 구성할 때 별도 거부정책을 설정하지 않으면 기본적으로 ThreadPoolExecutor.AbortPolicy 거부정책이 설정되어 스레드풀이 부족하게 된다면 작업을 거부하고 RejectedExecutionException예외를 발생시키기 때문입니다.
 

@EnableAsync
@Configuration
public class ThreadPoolConfig {

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

        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(3);
        executor.setQueueCapacity(1);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setThreadNamePrefix("THREAD-MESSAGE-");

        executor.initialize();
        return executor;
    }

}

그렇다면 쓰레드풀이 부족하더라도 무조건적으로 유실되지 않고 로직을 처리할 수 있는 방법은? 스레드 풀이 부족하다면 해당 작업을 요청했던 스레드가 직접 해당 작업을 처리할 수 있도록 ThreadPoolExecutor.CallerRunsPolicy 거부정책을 설정하면 작업 처리속도는 느려지겠지만 작업이 유실되지 않고 안전하게 실행할 수 있습니다.
 

2025-08-11T22:37:13.967+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 메세지 생성 - threadMessage
2025-08-11T22:37:13.970+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 0
2025-08-11T22:37:13.993+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 1
2025-08-11T22:37:13.994+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 2
2025-08-11T22:37:13.994+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 3
2025-08-11T22:37:13.997+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 비동기 메세지 실행 : 4
2025-08-11T22:37:13.997+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 4
2025-08-11T22:37:13.998+09:00  INFO 12512 --- [blog] [HREAD-MESSAGE-1] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 0
2025-08-11T22:37:13.998+09:00  INFO 12512 --- [blog] [HREAD-MESSAGE-3] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 2
2025-08-11T22:37:13.999+09:00  INFO 12512 --- [blog] [HREAD-MESSAGE-2] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 1
2025-08-11T22:37:15.015+09:00  INFO 12512 --- [blog] [HREAD-MESSAGE-3] c.i.b.threadpool.service.MessageService  : 메세지 생성 - 3
2025-08-11T22:37:22.658+09:00  INFO 12512 --- [blog] [nio-8080-exec-2] c.i.b.t.service.ThreadPoolService        : 메세지 종료 - threadMessage

안전하게 모든 작업이 유실되지 않고 처리된걸 로그로 확인할 수 있습니다. 그러면 거부 정책은 위에 두 개만 있냐? 는 아니고 총 4개의 정책이 존재합니다.
 

거부 정책

  • AbortPolicy (기본값)
    • 큐와 스레드가 모두 꽉 차면 RejectedExecutionException 예외를 던짐.
    • 작업은 버려짐.
  • CallerRunsPolicy
    • 거부된 작업을 작업 제출을 시도한 호출자 스레드에서 직접 실행.
    • 작업이 버려지지 않지만, 호출자 스레드가 작업 실행하느라 속도가 느려질 수 있음.
  • DiscardPolicy
    • 거부된 작업을 그냥 버림 (아무 예외나 로그 없음).
  • DiscardOldestPolicy
    • 큐에서 가장 오래된 작업을 버리고, 새로 제출된 작업을 큐에 넣음.
public class MyCustomPolicy implements RejectedExecutionHandler {

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
    
        System.out.println("작업 거부됨: " + r.toString());
        
    }
    
}

만약 4개의 기본 거부정책 구현체외 커스텀한 거부정책을 사용하고싶다면 RejectedExecutionHandler를 직접 구현할 수 있습니다. 해당 거부정책을 구현후 Thread Pool 구성시 해당 거부정책을 설정해 주시면 됩니다.
 
물론 쓰레드풀이 부족하지 않는 환경을 구성하는 게 무엇보다 중요합니다. 스레드풀 시스템 지표를 마이크로미터, 프로메테우스, 그라파나 등등으로 모니터링하여  서비스 요청 규모에 맞게 커스텀 구성하여 쓰레드풀이 고갈되지 않도록 관리하는 게 어느 무엇보다 중요합니다. 다만 언제나 우리는 최악을 염두해야 하기에 각 비즈니스 로직에 맞게 스레드풀이 고갈된다면 어떻게 대응할지를 거부정책을 통해 대응한다면 좀 더 안정적인 서비스를 구성할 수 있을듯합니다.