Tomcat 스레드를 200개에서 500개로 늘리면 처리량이 2.5배가 될까요? 스레드가 많다고 항상 좋은 건 아닙니다.

개념 정의

스레드 풀 은 미리 생성해둔 스레드를 재사용하여 요청을 처리하는 메커니즘입니다. Spring Boot의 내장 Tomcat은 각 HTTP 요청을 하나의 워커 스레드에 할당하고, 요청이 끝나면 스레드를 풀에 반환합니다.

기본 설정

YAML
server:
  tomcat:
    threads:
      max: 200          # 최대 워커 스레드 수 (기본: 200)
      min-spare: 10     # 유휴 스레드 최소 수 (기본: 10)
    accept-count: 100   # 대기 큐 크기 (기본: 100)
    max-connections: 8192  # 최대 동시 커넥션 수 (기본: 8192)
    connection-timeout: 20000  # 커넥션 타임아웃 ms

요청 처리 흐름

PLAINTEXT
[클라이언트 요청]

[max-connections(8192) 체크] → 초과 시 즉시 거부

[워커 스레드 할당 시도]
    ├─ 유휴 스레드 있음 → 즉시 처리
    └─ 모든 스레드 사용 중

    [acceptCount(100) 큐에 대기]
        ├─ 큐에 공간 있음 → 대기
        └─ 큐도 가득 참 → 연결 거부

적정 스레드 수 산정

PLAINTEXT
적정 스레드 수 = 동시 사용자 수 × 평균 요청 처리 시간 / 평균 요청 간격

하지만 이론적 공식보다 중요한 것은 실측입니다.

  • CPU 바운드 작업 (연산 위주): 스레드 수 ≈ CPU 코어 수
  • I/O 바운드 작업 (DB, 외부 API): 스레드 수 = CPU 코어 수 × (1 + 대기시간/처리시간)

대부분의 웹 서비스는 I/O 바운드이므로 CPU 코어 수보다 많은 스레드가 필요합니다. 하지만 너무 많으면 컨텍스트 스위칭 비용과 메모리 사용이 증가합니다.

@Async — 비동기 처리

기본 사용

JAVA
@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 설정

JAVA
@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)를 구성합니다.

JAVA
    @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;
    }
}
JAVA
@Service
public class NotificationService {

    @Async("emailExecutor")  // 특정 executor 지정
    public void sendEmail(String to, String content) {
        emailClient.send(to, content);
    }
}

스레드 풀 동작 흐름

PLAINTEXT
새 작업 도착

[corePoolSize(5) 이하?]
    ├─ Yes → 새 스레드 생성하여 즉시 실행
    └─ No

    [큐(50)에 공간 있음?]
        ├─ Yes → 큐에 추가하여 대기
        └─ No

        [maxPoolSize(10) 이하?]
            ├─ Yes → 추가 스레드 생성하여 실행
            └─ No → RejectedExecutionHandler 호출

거부 정책 (RejectedExecutionHandler)

정책동작
AbortPolicy (기본)RejectedExecutionException 발생
CallerRunsPolicy호출 스레드에서 직접 실행 (백프레셔 효과)
DiscardPolicy작업을 조용히 버림
DiscardOldestPolicy큐에서 가장 오래된 작업을 버리고 새 작업 추가

CallerRunsPolicy를 권장합니다. 작업을 버리지 않으면서 자연스럽게 속도를 조절하는 효과가 있습니다.

@Async 예외 처리

비동기 메서드에서 발생한 예외는 호출자에게 전달되지 않습니다.

JAVA
// 방법 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;
    });
JAVA
// 방법 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 주의사항

같은 클래스 내부 호출은 동작하지 않음

JAVA
@Service
public class MyService {

    public void caller() {
        this.asyncMethod();  // @Async 동작하지 않음! (프록시 우회)
    }

    @Async
    public void asyncMethod() {
        // 동기로 실행됨
    }
}

이어서 나머지 구현 부분입니다.

JAVA
// 해결: 별도 빈으로 분리
@Service
public class CallerService {
    @Autowired private AsyncService asyncService;

    public void caller() {
        asyncService.asyncMethod();  // 정상 동작
    }
}

트랜잭션과 @Async

JAVA
@Transactional
@Async  // 주의: 별도 스레드에서 실행되므로 기존 트랜잭션에 참여하지 않음
public void asyncTransactional() {
    // 새로운 트랜잭션이 시작됨
}

Virtual Threads (Java 21+)

Java 21부터 가상 스레드를 사용하면 스레드 풀 튜닝의 부담을 크게 줄일 수 있습니다.

YAML
# Spring Boot 3.2+
spring:
  threads:
    virtual:
      enabled: true  # Tomcat, @Async 모두 Virtual Threads 사용

이 설정 하나로 Tomcat의 요청 처리와 @Async 작업 모두 가상 스레드에서 실행됩니다.

Virtual Threads의 장점

PLAINTEXT
플랫폼 스레드: ~1MB 메모리 × 200개 = ~200MB
가상 스레드: ~1KB 메모리 × 100,000개 = ~100MB
  • 블로킹 I/O에서 OS 스레드를 반환하므로 수십만 동시 요청 처리 가능
  • 스레드 풀 크기 산정이 불필요해짐
  • 기존 동기 코드를 수정 없이 그대로 사용 가능

Virtual Threads 주의사항

JAVA
// 1. synchronized 블록에서 OS 스레드를 점유(pinning)
synchronized (lock) {
    // 이 안에서 블로킹 I/O를 하면 Virtual Thread의 이점이 사라짐
    // ReentrantLock으로 대체
}

// 2. CPU 바운드 작업에는 이점이 없음
// 가상 스레드는 I/O 대기가 많은 작업에 최적화

// 3. ThreadLocal 남용 주의
// 수십만 가상 스레드 × ThreadLocal 데이터 = 메모리 문제

모니터링

JAVA
// 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 기본 ExecutorSimpleAsyncTaskExecutor — 매번 새 스레드 생성. 반드시 교체
스레드 풀 동작 순서corePoolSize → 큐 → maxPoolSize → 거부 정책
Virtual ThreadsJava 21+ / spring.threads.virtual.enabled=true
모니터링active, queued 메트릭으로 적정 크기 산정
댓글 로딩 중...