
멀티 스레드
Spring Boot와 같은 자바 기반 서버는 기본적으로 멀티 스레드 구조로 동작한다.
이 과정에서 대표적으로 Thread Pool(스레드 풀)을 활용한다.

스레드 풀은 요청이 들어올 때마다 스레드를 새로 생성하는 대신,
미리 확보해둔 일정 개수의 스레드를 재사용한다.
이를 통해 스레드 생성과 해제에 드는 비용을 줄이고,
서비스가 안정적이고 일관된 성능을 유지하도록 지원한다.
예를 들어 Java의 ThreadPoolExecutor를 사용하면
요청을 처리할 스레드 풀을 설정해두고,
요청이 몰릴 경우 큐에 작업을 대기시키거나 정책에 따라 거부 처리할 수 있다.
이러한 방식이 멀티 스레드 기반의 동시성 처리이며,
부하 분산을 위한 일종의 "스레드 로드 밸런싱(Thread Load Balancing)" 으로 볼 수 있다.
스레드 풀 설정 지정하기
스레드 풀에 대한 관리를 미리 확인해보자.
스레드 풀은 미리 일정 개수의 스레드를 생성해두고 필요할 때 비동기 작업을 해당 스레드에 할당하는 방식으로 동작한다.
이 덕분에 매번 스레드를 새로 생성·소멸하는 비용을 줄이고, 안정적인 자원 관리를 할 수 있다.
코드로 가보자
먼저 스레드 풀의 주요 파라미터를 다음과 같이 정의해보자.
private static final int CORE_POOL_SIZE = 3;
private static final int MAX_POOL_SIZE = 5;
private static final int QUEUE_CAPACITY = 10;
private static final long KEEP_ALIVE_TIME = 10_000L;
나는 위와 같이 스레드 풀에 존재하는 스레드 기본의 수를 3개로 해놓고(기본적으로 유지 수),
최대 새엇ㅇ 가능한 스레드의 수를 5개로 한다.
그리고 작업에 대한 대기 큐의 크기는 10개로 설정한다.
그리고 코어 스레드(메인) 이외의 스레드가 유지된느 시간은 keep-alive 최대 10초로 설정한다.
@Bean(name = "threadPoolExecutor")
public ThreadPoolExecutor threadPoolExecutor() {
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue(QUEUE_CAPACITY);
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat("app-async-%d")
.setDaemon(false)
.build();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.MILLISECONDS,
workQueue,
threadFactory,
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setThreadFactory(threadFactory);
executor.allowCoreThreadTimeOut(true);
executor.prestartAllCoreThreads();
return executor;
}
위의 코드를 통해 코어 스레드를 미리 시작하고
코어 스레드의 타임아웃도 허용하도록 설정했다.
이제 스프링의 @Async를 통하여 해당 풀을 바로 사용할 수 있다.
그리고 스레드가 어떻게 관리되는지에 대하여 확인해보기 위하여 다음과 같은 메서드를 작성했다.
@Async("threadPoolExecutor")
public void doAsyncWithPool() {
logger.info("스레드에 대한 테스트 중입니다...");
}
이제 테스트의 로그를 확인해보면
스레드에 대한 정보를 확인해볼 수 있다.

실행 후 로그를 확인해보니
설정한 코어 스레드(3개)가 생성되어 작업을 처리하는 것을 확인할 수 있다.
그러면 이번에는 스레드 풀과 큐가 모두 포화 상태일 때의 동작을 확인해보자!!
최대 스레드의 개수는 5개로 동일하고, 메서드의 스레드 작업에 대하여 10초 await를 추가함으로써 빠르게 포화상태를 유도해보았다
@Async("threadPoolExecutor")
public void doAsyncWithPoolInWait() {
logger.info("스레드에 대한 테스트 시작 중입니다...");
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 인터럽트 플래그 복원
logger.error("스레드가 인터럽트 되었습니다.", e);
}
logger.info("10초 대기 후 작업을 마쳤습니다.");
}
설정상 최대 스레드 수는 5개이므로
5개 이상의 요청이 동시에 들어오면 큐(10개)로 작업이 쌓이고
큐마저 가득 차면 정책(executor.allowCoreThreadTimeOut(true);)에 따라 동작한다.

위와 같은 결과를 확인할 수 있음
이는 설정했던 'app-async-'의 별도의 스레드 풀이 아닌
http-nio로 생성된 것으로 보아
Tomcat의 HTTP 요청 처리 스레드가 직접 작업을 담당하고 있음을 확인할 수 있다.
CallerRunsPolicy를 사용해 큐가 꽉 차면 요청 스레드가 직접 작업을 실행하는 것이다.
그러면 이번에는 포화 상태에서 아웃되는 것을 확인해보자
만약에 상황에 따라서 명시적으로 예외를 던져 흐름을 처리하고 싶다면
RejectedExecutionHandler를 설정해주면 된다
위의 설정에서 다음 코드로 변경을 한다.
RejectedExecutionHandler rejectionHandler = (runnable, executor) -> {
throw new RejectedExecutionException("포화 상태입니다: 스레드풀과 큐가 모두 가득 찼습니다.");
};
이 설정을 적용하면 큐와 스레드 풀이 모두 포화될 경우 즉시 예외가 발생하고, 로그에서 해당 에러를 확인할 수 있다.
이제 해당 큐에 대하여 확인해보자
동일한 서비스에 대하여
연달아서 요청을 보내면 다음과 같은 에러가 뜨는 것을 확인 가능하다

스레드 풀 관련 정리
정리해보면,
- 코어 스레드 수와 최대 스레드 수는 서비스의 동시성 특성에 맞게 조절해야 한다.
- 큐 크기를 적절히 설정하지 않으면 요청 폭주 시 작업이 지연되거나 거부될 수 있다.
- RejectedExecutionHandler를 활용하면 포화 시 원하는 방식으로 대응할 수 있다.
스레드 풀 설정은 단순히 숫자 몇 개를 바꾸는 것처럼 보이지만,
실제 서비스의 안정성과 성능을 좌우하는 중요한 요소이므로 미리 충분히 검증해두는 것이 중요하다!
웹 어플리케이션에서 고려할 점
그럼 어떤 게 좋은 스레드 풀일까?
웹 서비스를 운영하려면 단순히 스레드 수를 늘리는 것보다도 더 고려할 요소들이 많다.
스프링 부트(Spring Boot)가 내장 톰캣(Tomcat) 서버 위에서 동작할 때, 톰캣 서블릿 스레드 풀, HikariCP 커넥션 풀, @Async 실행기, HTTP 클라이언트 커넥션 풀 등 여러 풀의 설정이 서로 얽혀 있기 때문에 주로 이것들을 동시에 고려해서 잘 설계하면 좋다
우선은 각 pool의 역할이 무엇인지도 정리해본다
1. 톰캣 서블릿 스레드 풀 (max-threads)
톰캣은 요청마다 서블릿 스레드를 하나씩 할당해 처리한다.( 기본값은 200개임)
클라이언트 HTTP 요청을 처리하는 첫 진입점으로 생각해주면 된다. 가장 기본적인 처리의 스레드이다.
주로 추천하는 정도는 HikariCP.maximumPoolSize + (20~40%) 의 정도라고 한다..(gpt 추천)
DB를 사용하지 않는 요청(캐시 히트, 정적 리소스 등)이 일부 섞여 있을 수 있으므로 약간의 여유를 둔다고 한다.
단, HikariCP보다 2배 이상 크게 잡으면 큐잉 지연·컨텍스트 스위칭 폭증 위험이 있다고 한다
2. HikariCP 커넥션 풀 (maximumPoolSize)
대부분의 API는 DB를 1회 이상 왕복한다는 가정 하에서 HikariCP의 설정이 병목을 좌우한다.
HikariCP 커넥션은 데이터베이스와의 커넥션 재사용에 중요하다.
추천 산정으로는 maximumPoolSize = 2 × vCPU 라고 한다...
예를 들어 vCPU 4개 → 8개, vCPU 8개 → 16개
범위: 8~32 내에서 부하 테스트로 조정
기본적으로는 10개로 되어있는 것 확인 가능하다.
기억해야하는 원칙으로는
"DB를 사용하는 경로의 동시성 ≤ maximumPoolSize" 이다!
이 규칙을 어기면 요청은 결국 대기열에서 블로킹되며, 톰캣 스레드를 과도하게 늘려도 효과가 없다고 한다.
3. @Async 풀들(Bulkhead)
DB 사용하는 작업 풀과 분리하는 것이 좋다
서버 운영 환경에 맞게 잘 조절해보자
4. HTTP client connection pool
이것은 외부 API 호출에서 설정되는데, 만약에 외부 api를 자주 호출한다면 http Client나 WebClient 커넥션 풀 설정을 또 따로 할 수 있다.
RestTemplate에 대한 별도의 설정을 cofiguration으로 등록하거나 WebClient 설정을 등록해주면 된다.
결론적으로는 유동적으로 환경에 따라서 잘 정리해주는 것이 중요하다고 생각한다
'백엔드 > SpringBoot' 카테고리의 다른 글
| [SpringBoot] Spring MVC와 Spring WebFlux를 요청 처리 방식 비교해보기 (0) | 2025.10.05 |
|---|---|
| [SpringBoot] 스프링에서의 Service 와 Bean의 차이점 (1) | 2025.10.04 |
| [SpringBoot] SpringBoot 트랜잭션과 이벤트 발행에서의 전파 관련 트러블 슈팅 (0) | 2025.09.15 |
| [SpringBoot] UnitTest (1) | 2025.08.27 |
| [SpringBoot] Spring에서 비동기 작업과 스케줄링 - ThreadPoolTaskScheduler 편 (0) | 2025.08.22 |