스레드 풀 튜닝 — Tomcat과 @Async의 스레드 설정 최적화
부하 테스트에서 Tomcat 스레드 200개가 전부 소진되길래, 500개로 늘렸습니다. 처리량이 2.5배가 될 거라 기대했는데, 오히려 응답 시간이 더 느려지고 CPU 사용률만 치솟았습니다. 스레드는 많을수록 좋은 게 아니더라고요.
스레드를 늘리면 왜 항상 좋아지지 않는가
직관적으로는 스레드가 많을수록 동시에 처리할 수 있는 요청이 늘어나니 좋을 것 같습니다. 하지만 세 가지 이유로 무한정 늘릴 수 없습니다.
- 메모리 — 플랫폼 스레드 하나당 약 1MB의 스택 메모리를 소모합니다. 1000개로 늘리면 그것만 1GB입니다
- ** 컨텍스트 스위칭** — CPU 코어가 4개인데 스레드가 1000개면, OS가 스레드를 번갈아 전환하는 비용이 실제 작업보다 커집니다
- ** 뒷단 병목** — DB 커넥션 풀이 10개인데 스레드가 500개면, 490개 스레드가 커넥션을 기다리며 자원만 낭비합니다
스레드 풀 크기는 "크면 클수록 좋다"가 아니라, ** 앱의 작업 특성에 맞춰 적절히 산정 **해야 합니다.
Tomcat 스레드 풀 — 요청이 들어오면 어떻게 되나
내장 톰캣은 HTTP 요청마다 ** 워커 스레드 하나 **를 할당합니다. 스레드 풀의 세 가지 숫자가 요청의 운명을 결정합니다.
server:
tomcat:
threads:
max: 200 # 동시에 처리할 수 있는 요청 수 (기본 200)
min-spare: 10 # 항상 유지하는 유휴 스레드 수 (기본 10)
accept-count: 100 # 스레드 전부 사용 중일 때 대기열 크기 (기본 100)
max-connections: 8192 # 동시에 유지할 수 있는 TCP 연결 수 (기본 8192)
핵심은 이 숫자들의 ** 관계 **입니다. 8192개가 연결되어 있어도 동시에 일하는 건 200개뿐이고, 200개가 전부 바쁘면 100개까지 대기열에 넣고, 대기열마저 꽉 차면 Connection Refused 입니다.
스레드 수를 어떻게 정하는가
공식이 있긴 합니다: 스레드 수 = CPU 코어 수 x (1 + 대기시간/처리시간). 하지만 이론보다 중요한 건 앱이 CPU를 많이 쓰는지, I/O 대기가 많은지 구분하는 겁니다.
| 작업 특성 | 적정 스레드 수 | 이유 |
|---|---|---|
| CPU 바운드 (연산 위주) | CPU 코어 수 정도 | 스레드를 늘려도 CPU가 병목이라 효과 없음 |
| I/O 바운드 (DB, 외부 API) | 코어 수보다 훨씬 많이 | 스레드가 I/O 대기 중에 CPU는 놀고 있으므로, 다른 스레드가 CPU를 쓸 수 있음 |
대부분의 웹 서비스는 I/O 바운드(DB 조회, 외부 API 호출)이므로 기본 200개가 합리적인 출발점입니다. 하지만 정확한 값은 ** 부하 테스트로 실측 **해야 합니다. 모니터링에서 active 스레드가 항상 max에 가까우면 늘려야 하고, 절반도 안 쓰면 줄여도 됩니다.
요청 처리와 별개로 비동기 작업이 필요하다면 — @Async
주문 완료 후 이메일 발송, 로그 기록 같은 작업은 사용자 응답을 기다리게 할 필요가 없습니다. @Async를 붙이면 ** 별도 스레드에서 실행 **되어 호출자는 바로 반환됩니다.
@Service
public class NotificationService {
@Async
public void sendEmailAsync(String to, String content) {
emailClient.send(to, content); // 별도 스레드에서 실행
}
}
그런데 여기서 함정이 있습니다. @EnableAsync만 설정하고 TaskExecutor 빈을 등록하지 않으면, 기본으로 SimpleAsyncTaskExecutor 가 사용됩니다. 이름과 달리 스레드 풀이 아닙니다 — 매 호출마다 새 스레드를 생성합니다. 트래픽이 몰리면 수천 개 스레드가 만들어져서 OutOfMemoryError가 터집니다.
@Async 스레드 풀은 어떻게 설정하는가
ThreadPoolTaskExecutor를 빈으로 등록하면, 작업별로 적합한 스레드 풀을 분리할 수 있습니다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("emailExecutor")
public TaskExecutor emailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5); // 기본 스레드 수
executor.setMaxPoolSize(10); // 최대 스레드 수
executor.setQueueCapacity(50); // 큐 크기
executor.setThreadNamePrefix("email-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
@Async("emailExecutor") // 특정 executor 지정
public void sendEmail(String to, String content) { ... }
이 스레드 풀의 동작 순서가 중요합니다. Tomcat 스레드 풀과 비슷하지만 약간 다릅니다.
- corePoolSize(5) 이하 → 새 스레드를 생성해서 즉시 실행
- ** 코어 스레드가 전부 바쁘면** → 큐(50)에 대기시킴
- ** 큐도 꽉 차면** → maxPoolSize(10)까지 추가 스레드 생성
- ** 그마저도 꽉 차면** →
RejectedExecutionHandler가 호출됨
핵심은 ** 큐가 먼저 차고, 그다음에 스레드가 추가된다 **는 점입니다. corePoolSize=5, maxPoolSize=10이면 큐가 가득 찰 때까지는 스레드 5개로만 돌아갑니다.
거부 정책 — 큐도, 스레드도 꽉 차면?
| 정책 | 동작 | 추천 |
|---|---|---|
AbortPolicy (기본) | 예외 발생 | 실패를 명시적으로 알리고 싶을 때 |
CallerRunsPolicy | 호출 스레드에서 직접 실행 | ** 가장 안전** — 작업을 안 버리면서 자연스럽게 속도 조절 |
DiscardPolicy | 작업을 조용히 버림 | 유실해도 괜찮은 경우만 |
@Async의 함정들
예외가 사라진다
void 반환 타입의 @Async 메서드에서 예외가 발생하면 호출자에게 ** 전파되지 않습니다 **. 이메일 발송이 실패해도 아무도 모릅니다. CompletableFuture를 반환하거나 AsyncUncaughtExceptionHandler를 설정해야 합니다.
// CompletableFuture로 반환하면 호출부에서 예외를 잡을 수 있음
@Async
public CompletableFuture<Result> asyncWork() {
return CompletableFuture.completedFuture(riskyOperation());
}
같은 클래스 내부 호출은 동작하지 않는다
@Async도 AOP 프록시 기반입니다. this.asyncMethod()로 호출하면 프록시를 거치지 않아 동기로 실행됩니다. 비동기 메서드는 ** 별도 빈으로 분리 **해야 합니다.
@Transactional과 함께 쓰면 트랜잭션이 분리된다
@Async는 별도 스레드에서 실행되므로, 호출자의 트랜잭션에 참여하지 않고 ** 새 트랜잭션 **이 시작됩니다. 호출자 트랜잭션이 롤백되어도 비동기 작업은 이미 커밋되었을 수 있습니다.
이 모든 튜닝이 필요 없어지는 날 — Virtual Threads
Java 21의 Virtual Threads를 활성화하면, 스레드 풀 크기 산정이라는 고민 자체가 크게 줄어듭니다.
spring:
threads:
virtual:
enabled: true # Tomcat + @Async 모두 Virtual Threads 사용
** 왜 가능한가?** 플랫폼 스레드는 하나당 약 1MB이지만, Virtual Thread는 약 1KB입니다. 200개 대신 ** 수십만 개 **를 만들 수 있으므로 "스레드가 부족해서 대기"하는 상황 자체가 사라집니다. I/O 대기 중에 OS 스레드를 반환하므로 적은 코어로도 높은 동시성을 달성합니다.
단, synchronized 블록 안에서 블로킹 I/O를 하면 Virtual Thread가 OS 스레드에 고정(pinning)되어 이점이 사라집니다. synchronized 대신 ReentrantLock을 사용하세요. 또한 CPU 바운드 작업에는 이점이 없습니다 — Virtual Threads는 I/O 대기가 많은 작업에 최적화 된 기술입니다.
모니터링 — 적정 크기는 실측으로 찾는다
이론적 공식보다 확실한 건 실제 메트릭 입니다. Micrometer로 스레드 풀 상태를 모니터링하세요.
| 메트릭 | 의미 | 조치 기준 |
|---|---|---|
executor.active | 현재 일하고 있는 스레드 수 | 항상 max에 가까우면 스레드 부족 |
executor.queued | 큐에 대기 중인 작업 수 | 계속 쌓이면 처리 속도 < 유입 속도 |
executor.pool.size | 현재 풀의 스레드 수 | core와 max 사이에서 변동 |
active가 항상 max에 붙어 있고 queued가 계속 늘어나면 스레드를 늘리거나 뒷단(DB, 외부 API) 병목을 해결해야 합니다. 반대로 active가 전체의 절반도 안 되면 스레드가 과잉 할당된 겁니다.
주의할 점
1. @Async의 기본 executor는 스레드 풀이 아니다
SimpleAsyncTaskExecutor는 매 호출마다 새 스레드를 생성합니다. 반드시 ThreadPoolTaskExecutor를 빈으로 등록하세요.
2. @Async 스레드에서는 SecurityContext가 전파되지 않는다
@Async는 별도 스레드에서 실행되므로, 호출자의 SecurityContext(로그인 사용자 정보)가 없습니다. 비동기 메서드에서 SecurityContextHolder.getContext()를 호출하면 null이 반환됩니다. SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)로 전파하거나, 필요한 사용자 정보를 파라미터로 직접 넘겨야 합니다.
3. 스레드 풀만 늘리고 커넥션 풀은 안 늘리면 의미 없다
Tomcat 스레드를 500개로 늘려도 HikariCP 커넥션이 10개면, 490개 스레드가 커넥션 대기 상태로 자원만 낭비합니다. 스레드 풀, 커넥션 풀, DB 성능을 함께 고려해야 합니다.
정리
| 질문 | 답 |
|---|---|
| 스레드를 늘리면 항상 좋은가? | 아님. 메모리, 컨텍스트 스위칭, 뒷단 병목 |
| Tomcat 기본 스레드 수? | 200개. 대부분의 I/O 바운드 앱에 합리적 출발점 |
| @Async 기본 executor? | SimpleAsyncTaskExecutor — ** 반드시 교체** |
| 스레드 풀 동작 순서? | core → 큐 → max → 거부 정책 |
| 적정 크기는 어떻게? | 공식보다 ** 모니터링 실측** (active, queued) |
| 이 모든 게 귀찮다면? | Virtual Threads (spring.threads.virtual.enabled=true) |