본문 바로가기
Apache Tomcat

Tomcat 11 가상 스레드 켜기와 maxThreads 스레드풀의 차이 - 처리량과 메모리 비교

aappsroot·2026년 8월 24일·조회 3

Tomcat 스레드풀 이야기를 하면 늘 maxThreads=200을 몇으로 올릴지가 화제였다. 블로킹 I/O가 많은 서비스에서 요청이 몰리면 스레드 200개가 다 대기에 물려 큐가 쌓이고, 그렇다고 500, 1000으로 올리면 스레드 스택 메모리가 그만큼 늘어난다. JDK 21에서 가상 스레드가 정식 기능이 되고 Tomcat 11이 이를 지원하면서, 이 오래된 저울질을 다시 볼 기회가 생겼다. 이번 글에서 실제 설정과 확인 방법, 비교 관점을 정리한다.

요약: Tomcat 11 + JDK 21에서는 Connector에 useVirtualThreads="true"를 주거나 StandardVirtualThreadExecutor를 붙이면 요청마다 가상 스레드가 배정된다. 블로킹 I/O가 지배적인 워크로드에서는 maxThreads 상한에 막히지 않아 동시성이 크게 늘고 스레드당 메모리가 줄어든다. 반대로 CPU 바운드거나 synchronized 블록 안에서 블로킹하는 코드가 많으면 이점이 작거나 오히려 pinning으로 손해를 볼 수 있다.

1. 개요와 대상 버전

이 글의 기준은 다음과 같다. 버전 번호는 저장소 상태에 따라 다를 수 있다.

  • Apache Tomcat 11.0.x (Jakarta EE 11 세대)
  • JDK 21 (가상 스레드 정식 기능, JEP 444)
  • 설정 파일: $CATALINA_BASE/conf/server.xml

가상 스레드 실행기는 JDK 21 이상을 요구한다. Tomcat 11 자체는 Java 17에서도 뜨지만, 가상 스레드를 켜려면 21 이상이어야 한다. 먼저 런타임부터 확인한다.

$ java -version
openjdk version "21.0.4" 2024-07-16 LTS
OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.4+7 (build 21.0.4+7-LTS, mixed mode, sharing)

2. 가상 스레드가 무엇이고 왜 나왔나

가상 스레드는 OS 스레드가 아니라 JDK가 관리하는 가벼운 스레드다. 실제 실행은 소수의 플랫폼 스레드(캐리어 스레드) 위에서 이뤄지고, 가상 스레드가 블로킹 I/O를 만나면 캐리어에서 언마운트되어 다른 가상 스레드에 캐리어를 양보한다. 그래서 수만 개를 만들어도 OS 스레드는 코어 수만큼만 돈다.

기존 Tomcat은 요청 하나에 플랫폼 스레드 하나를 붙이는 thread-per-request 모델이다. 플랫폼 스레드는 생성 비용이 크고 스택으로 수백 KB에서 1MB가량을 예약하므로, 동시 요청 수가 곧 스레드 수가 되어 상한(maxThreads)에 걸린다. 가상 스레드는 이 상한을 사실상 없애는 것이 목적이다.

3. 기존 스레드풀(maxThreads) 모델의 기본값

비교 대상인 플랫폼 스레드풀부터 정리한다. Connector가 별도 Executor를 참조하지 않으면 내부 스레드풀을 쓰고, 이 풀의 상한이 maxThreads다. 공식 문서 기준 기본값은 다음과 같다.

  • maxThreads = 200 (동시에 처리 가능한 요청 수의 상한)
  • maxConnections = 8192 (동시에 받아들여 처리하는 연결 수)
  • acceptCount = 100 (maxConnections를 넘겼을 때 OS 대기 큐 길이)

Executor를 명시적으로 쓴다면 StandardThreadExecutor의 기본값은 maxThreads=200, minSpareThreads=25, maxIdleTime=60000(ms), namePrefix=tomcat-exec-이다. 요청이 201개째부터는 스레드가 빌 때까지 대기한다. 블로킹 응답이 느린 백엔드를 물고 있으면 200개가 전부 그 대기에 묶이는 상황이 자주 나온다.

4. 가상 스레드 활성화 방법

Tomcat 11에서 켜는 방법은 두 가지다. 하나는 Connector 속성 한 줄, 다른 하나는 전용 Executor를 선언해 참조하는 방식이다.

4-1. Connector에 useVirtualThreads 주기 (내부 실행기)

useVirtualThreads는 Connector가 내부(private) 실행기를 쓸 때 그 실행기를 가상 스레드로 바꾼다. 기본값은 false다. 주의할 점은, Connector에 executor 속성으로 외부 Executor를 연결해 두면 이 속성은 무시된다는 것이다.

<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           useVirtualThreads="true"
           redirectPort="8443" />

이 방식에서는 maxThreads가 의미를 잃는다. 요청마다 새 가상 스레드가 배정되므로 200이라는 상한이 사라진다고 보면 된다. 대신 동시 연결 수는 여전히 maxConnections(기본 8192)가 위에서 막는다.

4-2. StandardVirtualThreadExecutor를 선언해 참조하기

여러 Connector가 실행기를 공유하거나 스레드 이름 규칙을 맞추고 싶으면 Executor를 명시한다. className은 org.apache.catalina.core.StandardVirtualThreadExecutor다. 지원 속성은 namePrefix 하나이고 기본값은 tomcat-virt-다. 여기에는 maxThreads, minSpareThreads 같은 속성이 없다. 가상 스레드는 풀링하지 않기 때문이다.

<Service name="Catalina">
  <Executor name="vtExecutor"
            className="org.apache.catalina.core.StandardVirtualThreadExecutor"
            namePrefix="tomcat-virt-" />

  <Connector port="8080" protocol="HTTP/1.1"
             connectionTimeout="20000"
             executor="vtExecutor"
             redirectPort="8443" />
</Service>

둘 중에서는 4-2를 권한다. 설정에 의도가 드러나고, 스레드 이름 접두어가 명확해서 스레드 덤프를 읽을 때 바로 구분된다. 단일 Connector에 빠르게 실험만 할 거라면 4-1로 충분하다.

5. 실제로 켜졌는지 확인하기

설정만 보고 넘어가지 말고 런타임에서 확인한다. 가장 간단한 방법은 요청을 처리하는 스레드를 서블릿에서 직접 찍어 보는 것이다.

@WebServlet("/whoami")
public class WhoAmIServlet extends HttpServlet {
  protected void doGet(HttpServletRequest req, HttpServletResponse resp)
      throws IOException {
    Thread t = Thread.currentThread();
    resp.setContentType("text/plain");
    resp.getWriter().println(t + " isVirtual=" + t.isVirtual());
  }
}

가상 스레드가 켜져 있으면 이런 출력이 나온다. 이름 접두어가 앞서 설정한 tomcat-virt-인 점을 확인한다.

$ curl -s http://localhost:8080/app/whoami
VirtualThread[#48,tomcat-virt-1]/runnable@ForkJoinPool-1-worker-3 isVirtual=true

플랫폼 스레드풀이면 Thread[#48,http-nio-8080-exec-5,5,main] isVirtual=false 형태로 나온다. 이름 접두어와 isVirtual 값 두 가지로 갈린다.

운영 중인 프로세스라면 스레드 덤프로 세는 방법도 있다. 가상 스레드는 일반 Thread.print에 다 나오지 않으므로 JSON 덤프를 쓴다.

$ jcmd <pid> Thread.dump_to_file -format=json /tmp/vt.json
$ grep -c "tomcat-virt-" /tmp/vt.json

6. 처리량과 메모리 비교

비교는 워크로드 성격에 따라 결과가 갈린다. 실측값은 하드웨어, 백엔드 지연, 커넥션 풀 크기에 크게 좌우되므로 여기서는 측정 방법과 관찰되는 경향만 적는다.

6-1. 측정용 부하 만들기

블로킹 I/O를 흉내 내는 엔드포인트에 동시성을 높여 부하를 준다. 아래는 hey로 동시 500개, 총 2만 요청을 던지는 예다.

$ hey -n 20000 -c 500 http://localhost:8080/app/io
$ hey -n 20000 -c 2000 http://localhost:8080/app/io

테스트 서블릿은 백엔드 지연을 흉내 내려고 Thread.sleep(200) 같은 블로킹을 한 번 넣어 두면 된다. 동시성을 200, 500, 2000으로 올려 가며 각 모델의 응답 시간 분포와 실패율을 비교한다.

6-2. 처리량 경향

동시성이 maxThreads(기본 200) 아래일 때는 두 모델 차이가 크지 않다. 요청 수가 스레드 수 안에 들어오므로 대기가 없다.

동시성이 200을 크게 넘고 요청이 대부분 블로킹 대기로 시간을 보내면 차이가 벌어진다. 플랫폼 모델은 201번째부터 큐에서 기다리므로 응답 시간의 꼬리(p95, p99)가 길어지고, 심하면 acceptCount 큐까지 차서 연결이 거절된다. 가상 스레드 모델은 상한에 막히지 않아 동시성이 올라가도 처리량이 유지되는 경향을 보인다. 개선 폭은 환경에 따라 다르지만, 블로킹 지연이 지배적일수록 커진다.

반대로 CPU 바운드 워크로드에서는 이점이 거의 없다. 계산은 결국 코어 수만큼만 병렬로 돌기 때문이다. 이때는 오히려 가상 스레드를 무한정 만들다 다운스트림(DB 커넥션 풀 등)을 과부하로 밀어 넣을 수 있어 주의해야 한다.

6-3. 메모리 경향

플랫폼 스레드는 스택으로 수백 KB에서 1MB를 예약한다. maxThreads=1000으로 올리면 그만큼의 스택 메모리를 각오해야 한다. 가상 스레드의 스택은 힙에 얹히고 필요할 때 자라고 줄어들어, 유휴 상태에서는 스레드당 메모리가 훨씬 작다. 그래서 같은 동시성이라면 가상 스레드 쪽 상주 메모리가 낮게 나오는 경향이다.

메모리는 이렇게 관찰한다. 스레드 수와 힙 사용량을 부하 전후로 비교한다.

$ jcmd <pid> Thread.print | grep -c "http-nio-8080-exec-"   # 플랫폼 모델 스레드 수
$ jcmd <pid> GC.heap_info
$ jstat -gc <pid> 1000 5

플랫폼 모델은 부하가 커지면 exec 스레드가 maxThreads까지 늘어나 고정되고, 가상 스레드 모델은 캐리어 스레드(ForkJoinPool worker)가 코어 수 근처에서 유지되는 것을 확인할 수 있다.

7. pinning 함정과 적용 기준

가상 스레드의 가장 흔한 함정은 pinning이다. 가상 스레드가 synchronized 블록이나 메서드 안에서 블로킹 I/O를 하면 캐리어에서 언마운트되지 못하고 OS 스레드를 붙든 채 대기한다. JDK 21에서는 이 상황이 자주 발생하면 가상 스레드의 장점이 사라진다. 레거시 라이브러리나 오래된 커넥션 풀이 synchronized로 임계 구역을 잡는 경우가 특히 문제다.

pinning이 일어나는지는 다음 옵션으로 확인한다.

$ export JAVA_OPTS="$JAVA_OPTS -Djdk.tracePinnedThreads=full"
$ sudo systemctl restart tomcat

pinning이 걸리면 catalina 로그에 스택 트레이스가 찍힌다. 문제가 되는 synchronizedReentrantLock으로 바꾸면 해소된다. 참고로 이 pinning 문제는 JDK 24의 JEP 491에서 JVM 모니터 구현이 개선되어 크게 완화됐다. JDK 21에 묶여 있다면 코드 쪽에서 대응해야 한다.

정리하면 다음 기준으로 판단한다.

  • 블로킹 I/O(원격 API 호출, 느린 DB, 외부 연동)가 응답 시간의 대부분이고 동시성이 maxThreads를 넘긴다: 가상 스레드가 유리하다.
  • CPU 계산이 주된 부하다: 이점이 거의 없다. 플랫폼 스레드풀을 튜닝하는 편이 예측 가능하다.
  • synchronized로 블로킹하는 코드나 라이브러리가 많다: 먼저 pinning을 점검하고, 해소되기 전에는 켜지 않는다.
  • 다운스트림 자원(DB 커넥션 풀)이 작다: 가상 스레드가 요청을 무한정 받아 커넥션 대기가 폭증할 수 있으므로, 세마포어나 커넥션 풀 크기로 동시성을 반드시 제한한다.

8. 마무리

가상 스레드는 maxThreads를 몇으로 잡을지 고민하던 문제를 다른 층위로 옮긴다. 상한이 사라지는 대신, 이제는 다운스트림 자원과 pinning을 신경 써야 한다. Tomcat 11 + JDK 21에서 켜는 것 자체는 속성 한 줄이나 Executor 하나로 끝난다. 실제 도입은 자기 서비스가 블로킹 I/O 바운드인지부터 확인하고, 부하 테스트로 처리량과 메모리를 직접 재본 뒤 결정하면 된다.

자주 묻는 질문

Tomcat 11에서 가상 스레드를 쓰려면 JDK 몇이 필요한가?

JDK 21 이상이 필요하다. 가상 스레드는 JEP 444로 JDK 21에서 정식 기능이 되었고, Tomcat 11의 StandardVirtualThreadExecutor도 최소 Java 21을 요구한다. Tomcat 11 자체는 Java 17에서도 기동되지만 가상 스레드는 켤 수 없다.

useVirtualThreads를 켜면 maxThreads 설정은 어떻게 되나?

요청마다 가상 스레드가 배정되므로 maxThreads 상한은 의미를 잃는다. 다만 동시 연결 수는 여전히 maxConnections(기본 8192)가 위에서 제한한다. 또한 Connector에 executor 속성으로 외부 Executor를 연결하면 useVirtualThreads는 무시된다.

가상 스레드가 실제로 켜졌는지 어떻게 확인하나?

서블릿에서 Thread.currentThread().isVirtual()을 찍어 보면 된다. 켜져 있으면 이름이 tomcat-virt-로 시작하고 isVirtual=true가 나온다. 운영 중이면 jcmd <pid> Thread.dump_to_file -format=json 으로 덤프해 tomcat-virt- 개수를 세면 된다.

가상 스레드로 바꾸면 항상 처리량이 오르나?

아니다. 블로킹 I/O가 응답 시간의 대부분이고 동시성이 maxThreads를 넘길 때 유리하다. CPU 바운드 워크로드에서는 계산이 코어 수만큼만 병렬로 돌기 때문에 이점이 거의 없다.

pinning이 무엇이고 JDK 21에서 어떻게 대응하나?

가상 스레드가 synchronized 블록이나 메서드 안에서 블로킹하면 캐리어 스레드에서 언마운트되지 못하고 OS 스레드를 붙드는 현상이다. -Djdk.tracePinnedThreads=full 로 진단하고, 문제가 되는 synchronized를 ReentrantLock으로 바꾸면 해소된다. JDK 24의 JEP 491에서 이 문제가 크게 완화됐다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.