Theme:

부하 테스트에서 Tomcat 스레드 200개가 전부 소진되길래, 500개로 늘렸습니다. 처리량이 2.5배가 될 거라 기대했는데, 오히려 응답 시간이 더 느려지고 CPU 사용률만 치솟았습니다. 스레드는 많을수록 좋은 게 아니더라고요.

스레드를 늘리면 왜 항상 좋아지지 않는가

직관적으로는 스레드가 많을수록 동시에 처리할 수 있는 요청이 늘어나니 좋을 것 같습니다. 하지만 세 가지 이유로 무한정 늘릴 수 없습니다.

  • 메모리 — 플랫폼 스레드 하나당 약 1MB의 스택 메모리를 소모합니다. 1000개로 늘리면 그것만 1GB입니다
  • ** 컨텍스트 스위칭** — CPU 코어가 4개인데 스레드가 1000개면, OS가 스레드를 번갈아 전환하는 비용이 실제 작업보다 커집니다
  • ** 뒷단 병목** — DB 커넥션 풀이 10개인데 스레드가 500개면, 490개 스레드가 커넥션을 기다리며 자원만 낭비합니다

스레드 풀 크기는 "크면 클수록 좋다"가 아니라, ** 앱의 작업 특성에 맞춰 적절히 산정 **해야 합니다.


Tomcat 스레드 풀 — 요청이 들어오면 어떻게 되나

내장 톰캣은 HTTP 요청마다 ** 워커 스레드 하나 **를 할당합니다. 스레드 풀의 세 가지 숫자가 요청의 운명을 결정합니다.

Tomcat 요청 처리 흐름

YAML
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를 붙이면 ** 별도 스레드에서 실행 **되어 호출자는 바로 반환됩니다.

JAVA
@Service
public class NotificationService {
    @Async
    public void sendEmailAsync(String to, String content) {
        emailClient.send(to, content);  // 별도 스레드에서 실행
    }
}

그런데 여기서 함정이 있습니다. @EnableAsync만 설정하고 TaskExecutor 빈을 등록하지 않으면, 기본으로 SimpleAsyncTaskExecutor 가 사용됩니다. 이름과 달리 스레드 풀이 아닙니다 — 매 호출마다 새 스레드를 생성합니다. 트래픽이 몰리면 수천 개 스레드가 만들어져서 OutOfMemoryError가 터집니다.


@Async 스레드 풀은 어떻게 설정하는가

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;
    }
}
JAVA
@Async("emailExecutor")  // 특정 executor 지정
public void sendEmail(String to, String content) { ... }

이 스레드 풀의 동작 순서가 중요합니다. Tomcat 스레드 풀과 비슷하지만 약간 다릅니다.

  1. corePoolSize(5) 이하 → 새 스레드를 생성해서 즉시 실행
  2. ** 코어 스레드가 전부 바쁘면** → 큐(50)에 대기시킴
  3. ** 큐도 꽉 차면** → maxPoolSize(10)까지 추가 스레드 생성
  4. ** 그마저도 꽉 차면** → RejectedExecutionHandler가 호출됨

핵심은 ** 큐가 먼저 차고, 그다음에 스레드가 추가된다 **는 점입니다. corePoolSize=5, maxPoolSize=10이면 큐가 가득 찰 때까지는 스레드 5개로만 돌아갑니다.

거부 정책 — 큐도, 스레드도 꽉 차면?

정책동작추천
AbortPolicy (기본)예외 발생실패를 명시적으로 알리고 싶을 때
CallerRunsPolicy호출 스레드에서 직접 실행** 가장 안전** — 작업을 안 버리면서 자연스럽게 속도 조절
DiscardPolicy작업을 조용히 버림유실해도 괜찮은 경우만

@Async의 함정들

예외가 사라진다

void 반환 타입의 @Async 메서드에서 예외가 발생하면 호출자에게 ** 전파되지 않습니다 **. 이메일 발송이 실패해도 아무도 모릅니다. CompletableFuture를 반환하거나 AsyncUncaughtExceptionHandler를 설정해야 합니다.

JAVA
// CompletableFuture로 반환하면 호출부에서 예외를 잡을 수 있음
@Async
public CompletableFuture<Result> asyncWork() {
    return CompletableFuture.completedFuture(riskyOperation());
}

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

@AsyncAOP 프록시 기반입니다. this.asyncMethod()로 호출하면 프록시를 거치지 않아 동기로 실행됩니다. 비동기 메서드는 ** 별도 빈으로 분리 **해야 합니다.

@Transactional과 함께 쓰면 트랜잭션이 분리된다

@Async는 별도 스레드에서 실행되므로, 호출자의 트랜잭션에 참여하지 않고 ** 새 트랜잭션 **이 시작됩니다. 호출자 트랜잭션이 롤백되어도 비동기 작업은 이미 커밋되었을 수 있습니다.


이 모든 튜닝이 필요 없어지는 날 — Virtual Threads

Java 21의 Virtual Threads를 활성화하면, 스레드 풀 크기 산정이라는 고민 자체가 크게 줄어듭니다.

YAML
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)
댓글 로딩 중...