
요즘 엄무를 진행하며 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 구성시 해당 거부정책을 설정해 주시면 됩니다.
물론 쓰레드풀이 부족하지 않는 환경을 구성하는 게 무엇보다 중요합니다. 스레드풀 시스템 지표를 마이크로미터, 프로메테우스, 그라파나 등등으로 모니터링하여 서비스 요청 규모에 맞게 커스텀 구성하여 쓰레드풀이 고갈되지 않도록 관리하는 게 어느 무엇보다 중요합니다. 다만 언제나 우리는 최악을 염두해야 하기에 각 비즈니스 로직에 맞게 스레드풀이 고갈된다면 어떻게 대응할지를 거부정책을 통해 대응한다면 좀 더 안정적인 서비스를 구성할 수 있을듯합니다.
'Spring > experience' 카테고리의 다른 글
| CI/CD 잦은 배포에 대한 불안감 떨쳐내기 (0) | 2025.10.21 |
|---|---|
| Spring Framework에서 복잡한 비즈니스 로직 구조화하기 Filter Chain (1) | 2025.04.20 |
| Java 객체가 생성과 상태변경을 책임지는 방법 (1) | 2025.03.05 |
| SpringFramework 버그에 대한 불안감 떨쳐내기 Spock Test (0) | 2025.02.19 |
| 레거시 Spring Framework Project 패키지 리팩토링 (0) | 2025.02.16 |