요청마다 스레드를 새로 생성하면 어떻게 될까요? 스레드 하나당 기본 1MB 스택 메모리와 OS 스케줄링 비용이 발생합니다. 초당 수천 요청이 들어오는 서비스에서 이 비용은 서비스 전체를 죽입니다. 이 챕터는 스레드 풀의 동작 원리부터 CompletableFuture, Virtual Thread(Java 21)까지 설명합니다.
Spring의 ThreadPoolTaskExecutor, Tomcat의 스레드 풀, @Async 모두
내부적으로 ThreadPoolExecutor를 사용합니다. 동작 흐름을 정확히 알아야
병목 원인을 파악하고 설정값을 제대로 튜닝할 수 있습니다.
corePoolSize → 새 스레드 생성해서 즉시 처리
corePoolSize → workQueue에 작업 추가. 큐가 빌 때까지 기다림
maximumPoolSize → 임시 스레드 추가 생성
maximumPoolSize → RejectedExecutionHandler 실행
| 파라미터 | 의미 | 실무 주의점 |
|---|---|---|
corePoolSize |
항상 유지하는 기본 스레드 수 | 너무 낮으면 큐 적재 후 응답 지연 발생 |
maximumPoolSize |
트래픽 급증 시 늘어날 수 있는 최대 스레드 수 | 큐가 먼저 차야 확장됨. LinkedBlockingQueue 기본 크기=Integer.MAX → 실질적으로 확장 안 됨! |
workQueue |
처리 대기 작업을 쌓는 큐 | SynchronousQueue: 큐 없음(즉시 거부 or 확장) / LinkedBlockingQueue: 무한큐(maximumPoolSize 무의미) |
keepAliveTime |
core 초과 임시 스레드의 유휴 허용 시간 | 트래픽 감소 후 임시 스레드 정리 시간 |
RejectedExecutionHandler |
작업 거부 시 처리 정책 | AbortPolicy(예외)/CallerRunsPolicy(호출자 실행)/DiscardPolicy(무시) 중 선택 |
@EnableAsync만 추가하고 별도 설정 없이 @Async를 사용하면
SimpleAsyncTaskExecutor가 기본 Executor로 사용됩니다.
이 Executor는 요청마다 새 스레드를 생성합니다. 스레드 풀이 아닙니다.
트래픽이 몰리면 스레드가 무한정 생성되어 OOM이 발생합니다.
반드시 ThreadPoolTaskExecutor를 Bean으로 등록하고 @Async에 지정해야 합니다.
@Configuration
@EnableAsync
public class AsyncConfig {
// 아무 Executor Bean 없음
// → SimpleAsyncTaskExecutor 기본 사용
// → 요청마다 새 스레드 생성!
}
@Service
public class NotificationService {
@Async // 매 호출마다 새 스레드 생성
public void sendPush(Long userId) {
pushClient.send(userId);
}
}
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(
new CallerRunsPolicy()); // 거부 시 호출자 실행
executor.initialize();
return executor;
}
}
@Async("asyncExecutor") // Bean 이름 명시
public void sendPush(Long userId) { ... }
| 설정 | 기본값 | 의미 | 튜닝 포인트 |
|---|---|---|---|
server.tomcat.threads.max |
200 | 최대 동시 처리 HTTP 스레드 수 | I/O 바운드 서비스는 높여도 되지만 DB 커넥션 풀 크기와 연동 필요 |
server.tomcat.threads.min-spare |
10 | 항상 유지할 최소 스레드 수 (= corePoolSize) | 콜드 스타트 지연 방지를 위해 예상 최소 부하만큼 설정 |
server.tomcat.accept-count |
100 | max 스레드가 모두 사용 중일 때 OS 수준에서 대기할 요청 수 | 초과 시 connection refused. 스레드 풀 고갈 시 503 반환 전 마지막 버퍼 |
server.tomcat.connection-timeout |
20000ms | 클라이언트 연결 후 첫 바이트 수신 대기 시간 | 악성 클라이언트가 연결만 맺고 데이터를 안 보내는 Slow Loris 공격 방어 |
선착순 예산이 소진된 후에도 클라이언트가 계속 요청을 보내는 상황에서, 외부 API 호출(결제 서버, 포인트 서버) 타임아웃이 길면 스레드들이 I/O 대기 상태로 블로킹됩니다. max 200개 스레드가 모두 블로킹되면 신규 요청은 accept-count 큐에 쌓이고, 큐도 가득 차면 503 Service Unavailable이 발생합니다. 대책: 외부 API 타임아웃을 짧게(connectionTimeout 1~2s), CircuitBreaker로 빠른 실패, 또는 Virtual Thread(Java 21)로 블로킹 I/O 스레드 점유 없애기.
동기 코드처럼 읽히면서 비동기로 실행됩니다. 여러 비동기 작업을 체이닝하거나 병렬로 실행할 때 사용합니다.
CompletableFuture
.supplyAsync(() -> fetchUser(userId), executor)
.thenApply(user -> enrichWithProfile(user))
.thenApplyAsync(user -> callExternalApi(user),
executor) // 별도 스레드 풀 사용
.exceptionally(ex -> {
log.error("Failed", ex);
return defaultUser(); // fallback
})
.thenAccept(user -> cache.put(userId, user));
// 두 API를 병렬로 호출하고 결과 합치기
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(
() -> userClient.get(userId), executor);
CompletableFuture<Point> pointFuture =
CompletableFuture.supplyAsync(
() -> pointClient.get(userId), executor);
// 둘 다 완료되면 합치기 (병렬 실행)
CompletableFuture<UserWithPoint> combined =
userFuture.thenCombine(pointFuture,
(user, point) ->
new UserWithPoint(user, point));
// N개 알림을 병렬로 발송하고 전체 완료 대기
List<CompletableFuture<Void>> futures =
userIds.stream()
.map(id -> CompletableFuture.runAsync(
() -> sendPush(id), executor))
.collect(toList());
// 모두 완료될 때까지 대기
CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
).join(); // 블로킹 (타임아웃 설정 권장)
// executor 파라미터 없으면
// ForkJoinPool.commonPool() 사용 → 전역 공유!
CompletableFuture.supplyAsync(() -> {
return blockingDbCall(); // I/O 블로킹!
}); // ← executor 없음
// ForkJoinPool은 CPU 코어 수 기반 풀
// 블로킹 I/O 작업이 점유하면
// 전체 시스템 CF 처리가 마비됨
// → 반드시 전용 executor 지정!
Java 21의 Project Loom이 정식 출시되면서 Virtual Thread(가상 스레드)가 도입되었습니다. 블로킹 I/O가 많은 서비스에서 스레드 풀 크기 튜닝 부담을 획기적으로 줄여줍니다.
블로킹 동안 OS 스레드가 점유된 채로 idle. 스레드 풀 고갈 → 503
Carrier Thread(OS 스레드)는 CPU 코어 수만큼만 유지, Virtual Thread는 수백만 개도 가능
# application.properties
spring.threads.virtual.enabled=true
# Tomcat이 자동으로 Virtual Thread Executor 사용
# 별도 스레드 풀 튜닝 거의 불필요
// ❌ synchronized 블록 안에서 I/O → "Pinning"
// Virtual Thread가 Carrier에 고정되어
// 블로킹 효과가 없어짐
synchronized (lock) {
networkCall(); // pinning 발생!
}
// ✅ ReentrantLock 사용 (pinning 없음)
lock.lock();
try {
networkCall(); // park → Carrier 반환 가능
} finally { lock.unlock(); }
// CPU 집약 작업은 Virtual Thread 이점 없음
// (스레드 수 늘려도 CPU 코어 수가 병목)
요청이 들어올 때 현재 스레드 수가 corePoolSize보다 적으면 새 스레드를 만들어 처리합니다. corePoolSize 이상이 되면 workQueue에 쌓이고, 큐가 꽉 찼을 때 비로소 maximumPoolSize까지 스레드를 추가합니다. 주의할 점은 LinkedBlockingQueue의 기본 용량이 Integer.MAX_VALUE라 사실상 무한 큐인 경우, 큐가 절대 꽉 차지 않아 maximumPoolSize까지 스레드가 늘어나지 않습니다. 따라서 maximumPoolSize가 의미를 가지려면 queueCapacity를 유한하게 설정해야 합니다.
executor를 지정하지 않으면 ForkJoinPool.commonPool()을 사용합니다. 이 풀은 JVM 전역으로 공유되며 CPU 코어 수 기반으로 크기가 결정됩니다. 여기서 블로킹 I/O 작업(DB 쿼리, 외부 API)을 실행하면 공통 풀의 스레드가 모두 블로킹되어 시스템 전체의 CompletableFuture 처리가 마비될 수 있습니다. 항상 전용 I/O 스레드 풀을 별도로 생성해서 executor 파라미터로 전달해야 합니다.
Platform Thread는 OS 스레드와 1:1로 매핑됩니다. 블로킹 I/O 시 OS 스레드가 idle 상태로 점유됩니다.
스레드 생성 비용이 높아(기본 스택 ~1MB) 수천 개 이상 생성하기 어렵습니다.
Virtual Thread는 JVM이 관리하는 경량 스레드입니다. 블로킹 I/O 발생 시 Virtual Thread가 park되어
Carrier Thread(OS 스레드)를 반환하고, I/O 완료 시 남는 Carrier에 다시 mount됩니다.
OS 스레드는 CPU 코어 수만큼만 유지하고, Virtual Thread는 수백만 개도 생성 가능합니다.
단, CPU 집약 작업이나 synchronized 블록 내 I/O(Pinning)에서는 효과가 없습니다.
스레드 풀은 큐가 꽉 차야 max까지 늘어난다 — @Async 기본값(SimpleAsyncTaskExecutor)은 스레드 풀이 아니다. CompletableFuture에는 전용 executor를, Java 21이면 Virtual Thread로 블로킹 I/O 부담을 없애라.