스레드 풀 튜닝 — Tomcat과 @Async의 스레드 설정 최적화
Tomcat 스레드를 200개에서 500개로 늘리면 처리량이 2.5배가 될까요? 스레드가 많다고 항상 좋은 건 아닙니다.
개념 정의
스레드 풀 은 미리 생성해둔 스레드를 재사용하여 요청을 처리하는 메커니즘입니다. Spring Boot의 내장 Tomcat은 각 HTTP 요청을 하나의 워커 스레드에 할당하고, 요청이 끝나면 스레드를 풀에 반환합니다.
기본 설정
server:
tomcat:
threads:
max: 200 # 최대 워커 스레드 수 (기본: 200)
min-spare: 10 # 유휴 스레드 최소 수 (기본: 10)
accept-count: 100 # 대기 큐 크기 (기본: 100)
max-connections: 8192 # 최대 동시 커넥션 수 (기본: 8192)
connection-timeout: 20000 # 커넥션 타임아웃 ms
요청 처리 흐름
[클라이언트 요청]
↓
[max-connections(8192) 체크] → 초과 시 즉시 거부
↓
[워커 스레드 할당 시도]
├─ 유휴 스레드 있음 → 즉시 처리
└─ 모든 스레드 사용 중
↓
[acceptCount(100) 큐에 대기]
├─ 큐에 공간 있음 → 대기
└─ 큐도 가득 참 → 연결 거부
적정 스레드 수 산정
적정 스레드 수 = 동시 사용자 수 × 평균 요청 처리 시간 / 평균 요청 간격
하지만 이론적 공식보다 중요한 것은 실측입니다.
- CPU 바운드 작업 (연산 위주):
스레드 수 ≈ CPU 코어 수 - I/O 바운드 작업 (DB, 외부 API):
스레드 수 = CPU 코어 수 × (1 + 대기시간/처리시간)
대부분의 웹 서비스는 I/O 바운드이므로 CPU 코어 수보다 많은 스레드가 필요합니다. 하지만 너무 많으면 컨텍스트 스위칭 비용과 메모리 사용이 증가합니다.
@Async — 비동기 처리
기본 사용
@Configuration
@EnableAsync // 비동기 활성화
public class AsyncConfig {
}
@Service
public class NotificationService {
@Async
public void sendEmailAsync(String to, String content) {
// 별도 스레드에서 실행 — 호출자는 기다리지 않음
emailClient.send(to, content);
}
@Async
public CompletableFuture<UserProfile> fetchProfileAsync(Long userId) {
UserProfile profile = userApi.getProfile(userId);
return CompletableFuture.completedFuture(profile);
}
}
주의: 기본 SimpleAsyncTaskExecutor
@EnableAsync만 설정하면 기본으로 SimpleAsyncTaskExecutor가 사용됩니다. 이 구현체는 ** 매번 새 스레드를 생성 **하므로 운영 환경에서는 절대 사용하면 안 됩니다.
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;
}
이어서 스레드 풀 설정과 실행기(Executor)를 구성합니다.
@Bean("reportExecutor")
public TaskExecutor reportTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("report-");
executor.initialize();
return executor;
}
}
@Service
public class NotificationService {
@Async("emailExecutor") // 특정 executor 지정
public void sendEmail(String to, String content) {
emailClient.send(to, content);
}
}
스레드 풀 동작 흐름
새 작업 도착
↓
[corePoolSize(5) 이하?]
├─ Yes → 새 스레드 생성하여 즉시 실행
└─ No
↓
[큐(50)에 공간 있음?]
├─ Yes → 큐에 추가하여 대기
└─ No
↓
[maxPoolSize(10) 이하?]
├─ Yes → 추가 스레드 생성하여 실행
└─ No → RejectedExecutionHandler 호출
거부 정책 (RejectedExecutionHandler)
| 정책 | 동작 |
|---|---|
AbortPolicy (기본) | RejectedExecutionException 발생 |
CallerRunsPolicy | 호출 스레드에서 직접 실행 (백프레셔 효과) |
DiscardPolicy | 작업을 조용히 버림 |
DiscardOldestPolicy | 큐에서 가장 오래된 작업을 버리고 새 작업 추가 |
CallerRunsPolicy를 권장합니다. 작업을 버리지 않으면서 자연스럽게 속도를 조절하는 효과가 있습니다.
@Async 예외 처리
비동기 메서드에서 발생한 예외는 호출자에게 전달되지 않습니다.
// 방법 1: CompletableFuture로 예외 전달
@Async
public CompletableFuture<Result> asyncWithException() {
try {
Result result = riskyOperation();
return CompletableFuture.completedFuture(result);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
// 호출부
asyncService.asyncWithException()
.thenAccept(result -> log.info("성공: {}", result))
.exceptionally(e -> {
log.error("비동기 작업 실패: ", e);
return null;
});
// 방법 2: AsyncUncaughtExceptionHandler 전역 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (throwable, method, params) -> {
log.error("비동기 메서드 [{}] 예외: {}",
method.getName(), throwable.getMessage(), throwable);
// 알림 발송 등
};
}
}
@Async 주의사항
같은 클래스 내부 호출은 동작하지 않음
@Service
public class MyService {
public void caller() {
this.asyncMethod(); // @Async 동작하지 않음! (프록시 우회)
}
@Async
public void asyncMethod() {
// 동기로 실행됨
}
}
이어서 나머지 구현 부분입니다.
// 해결: 별도 빈으로 분리
@Service
public class CallerService {
@Autowired private AsyncService asyncService;
public void caller() {
asyncService.asyncMethod(); // 정상 동작
}
}
트랜잭션과 @Async
@Transactional
@Async // 주의: 별도 스레드에서 실행되므로 기존 트랜잭션에 참여하지 않음
public void asyncTransactional() {
// 새로운 트랜잭션이 시작됨
}
Virtual Threads (Java 21+)
Java 21부터 가상 스레드를 사용하면 스레드 풀 튜닝의 부담을 크게 줄일 수 있습니다.
# Spring Boot 3.2+
spring:
threads:
virtual:
enabled: true # Tomcat, @Async 모두 Virtual Threads 사용
이 설정 하나로 Tomcat의 요청 처리와 @Async 작업 모두 가상 스레드에서 실행됩니다.
Virtual Threads의 장점
플랫폼 스레드: ~1MB 메모리 × 200개 = ~200MB
가상 스레드: ~1KB 메모리 × 100,000개 = ~100MB
- 블로킹 I/O에서 OS 스레드를 반환하므로 수십만 동시 요청 처리 가능
- 스레드 풀 크기 산정이 불필요해짐
- 기존 동기 코드를 수정 없이 그대로 사용 가능
Virtual Threads 주의사항
// 1. synchronized 블록에서 OS 스레드를 점유(pinning)
synchronized (lock) {
// 이 안에서 블로킹 I/O를 하면 Virtual Thread의 이점이 사라짐
// ReentrantLock으로 대체
}
// 2. CPU 바운드 작업에는 이점이 없음
// 가상 스레드는 I/O 대기가 많은 작업에 최적화
// 3. ThreadLocal 남용 주의
// 수십만 가상 스레드 × ThreadLocal 데이터 = 메모리 문제
모니터링
// Micrometer로 스레드 풀 메트릭 노출
@Bean
public TaskExecutor monitoredExecutor(MeterRegistry registry) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("monitored-");
executor.initialize();
// 메트릭 등록
ExecutorServiceMetrics.monitor(
registry,
executor.getThreadPoolExecutor(),
"async-executor"
);
return executor;
}
모니터링 지표:
executor.pool.size: 현재 스레드 수executor.active: 활성 스레드 수executor.queued: 큐 대기 작업 수executor.completed: 완료된 작업 수
주의할 점
1. @Async의 기본 SimpleAsyncTaskExecutor는 매번 새 스레드를 생성한다
@EnableAsync만 설정하고 별도 TaskExecutor 빈을 등록하지 않으면, 기본으로 SimpleAsyncTaskExecutor가 사용됩니다. 이 구현체는 스레드 풀 없이 매 호출마다 새 스레드를 생성하므로, 트래픽이 몰리면 수천 개의 스레드가 생성되어 OutOfMemoryError가 발생합니다. 운영 환경에서는 반드시 ThreadPoolTaskExecutor를 설정하세요.
2. @Async 메서드의 예외가 호출자에게 전달되지 않아 실패를 모른다
void 반환 타입의 @Async 메서드에서 예외가 발생하면, 호출자에게 전파되지 않고 기본적으로 로그에만 남습니다. 이메일 발송이나 알림 같은 중요 작업이 조용히 실패할 수 있습니다. AsyncUncaughtExceptionHandler를 구현하여 예외를 감지하거나, CompletableFuture를 반환하여 호출부에서 예외를 처리해야 합니다.
3. Virtual Threads에서 synchronized 블록이 있으면 캐리어 스레드가 고정된다
Java 21의 Virtual Threads를 활성화하면 수십만 동시 요청을 처리할 수 있지만, synchronized 블록 안에서 블로킹 I/O를 수행하면 Virtual Thread가 캐리어(OS) 스레드에 고정(pinning)되어 이점이 사라집니다. synchronized 대신 ReentrantLock을 사용해야 Virtual Threads의 성능을 제대로 활용할 수 있습니다.
정리
| 항목 | 핵심 |
|---|---|
| Tomcat 기본 스레드 | 200개. 무작정 늘리면 컨텍스트 스위칭 비용 증가 |
| @Async 기본 Executor | SimpleAsyncTaskExecutor — 매번 새 스레드 생성. 반드시 교체 |
| 스레드 풀 동작 순서 | corePoolSize → 큐 → maxPoolSize → 거부 정책 |
| Virtual Threads | Java 21+ / spring.threads.virtual.enabled=true |
| 모니터링 | active, queued 메트릭으로 적정 크기 산정 |