본문으로 건너뛰기
  1. Posts/

VUser 300명, Tomcat Thread 60개 — 나머지 240개의 요청은 어디로 갔을까?

NineKoo9
작성자
NineKoo9
목차

이전 글: Spring Boot Tomcat threads.max와 HikariCP maximum-pool-size를 기본으로 설정하면 나타나는 현상

앞선 글에서 Tomcat의 threads.max를 200에서 60으로 줄이고, HikariCP의 maximum-pool-size를 10에서 40으로 늘려 응답 시간을 5~10배 개선했습니다.

그런데 하나의 의문이 남았습니다.

가상 유저(VUser)가 300명이 동시에 트래픽을 발생시키는데, Tomcat의 Thread Pool 사이즈를 60개로 줄이면 나머지 240개의 요청은 스레드를 얻기 위해 경합하지 않을까?

스레드를 줄였는데 성능이 좋아졌다면, 줄어든 스레드로 어떻게 동일한 트래픽을 처리할 수 있었는지 이해해야 합니다.


흔한 오해 — “1 Connection = 1 Thread”
#

클라이언트가 연결되거나 요청을 보내면 → Tomcat이 곧바로 Worker Thread를 하나 할당하고 → 해당 스레드가 커넥션의 전체 생명주기를 담당한다.

Spring MVC의 동기 서블릿 처리에서는 실제로 실행 중인 요청 하나가 Worker Thread 하나를 점유하는 것은 맞습니다. Controller, Service, DB I/O를 수행하는 동안 해당 요청은 하나의 Worker Thread 위에서 처리됩니다.

하지만 이것을 연결된 클라이언트 수나 도착한 요청 수만큼 Worker Thread가 즉시 필요하다는 뜻으로 이해하면 오해가 됩니다. 이 모델에서는 VUser 300명이 동시에 연결되면 Worker Thread도 300개가 필요하다고 보게 되고, 스레드가 60개면 나머지 240개의 요청은 거부되거나 실패해야 합니다.

하지만 실제로는 그렇지 않았습니다. VUser 300명의 부하에서도 요청이 사라지지 않았고, Error Rate는 0%였습니다.

이 차이를 이해하려면 Tomcat이 커넥션(Connection)스레드(Thread) 를 어떻게 관리하는지 알아야 합니다.


Tomcat의 큰 구조 — Coyote와 Catalina
#

Spring Boot의 내장 Tomcat은 크게 두 컴포넌트로 나뉩니다.

Coyote (커넥터) 는 소켓 I/O와 HTTP 프로토콜 처리를 담당합니다. 브라우저가 연결을 맺으면 소켓에서 바이트 스트림을 읽어 HTTP를 파싱하고, HttpServletRequest / HttpServletResponse 객체를 생성합니다. 이후 Catalina의 invoke()를 호출해 제어권을 넘기고, 응답이 완성되면 다시 바이트로 직렬화해 소켓으로 전송합니다.

Catalina (서블릿 컨테이너) 는 서블릿의 생명주기와 요청 라우팅을 관리합니다. Spring Boot에서는 앱 하나가 Catalina의 Context 하나에 해당합니다. DispatcherServlet은 Spring MVC의 프론트 컨트롤러이며, urlPatterns = "/"로 이 Context에 등록되어 있기 때문에 애플리케이션으로 들어오는 대부분의 웹 요청이 먼저 이 서블릿을 통과합니다.

브라우저
  → TCP 소켓 연결
  → Coyote: 소켓 I/O → HTTP 파싱 → Req/Res 객체 생성 → invoke()
  → Catalina (Context = Spring Boot 앱)
  → DispatcherServlet → HandlerMapping → @Controller
  → 응답 → Coyote → 소켓 → 브라우저

이 구조를 이해하면, 아래에서 다룰 NIO Connector가 Coyote 내부의 소켓 I/O 처리 방식임을 알 수 있습니다.


Tomcat NIO Connector 아키텍처
#

Spring Boot 3.x에서 사용하는 임베디드 Tomcat은 기본적으로 NIO(Non-blocking I/O) Connector를 사용합니다. NIO Connector는 요청을 처리하기 위해 3단계 파이프라인 구조로 동작합니다.

NIO Connector 3단계 파이프라인

1단계: Acceptor Thread
#

Acceptor는 1~2개의 스레드로 구성됩니다. OS의 accept queue에서 TCP 연결을 수락하고, 수락된 소켓 채널(SocketChannel)을 Poller에 등록하는 역할을 합니다.

Client → TCP 3-way handshake → OS accept queue → Acceptor.accept() → Poller에 등록

Acceptor는 연결을 수락만 할 뿐, 요청 데이터를 읽거나 비즈니스 로직을 실행하지 않습니다.

2단계: Poller Thread
#

Poller는 Java NIO의 Selector를 사용하여 소수의 스레드로 수천 개의 커넥션을 감시합니다.

Poller의 역할은 등록된 소켓 채널들을 모니터링하다가, 특정 채널에 읽을 수 있는 데이터가 도착하면 해당 소켓을 Worker Thread Pool에 전달하는 것입니다.

// Poller의 핵심 동작 (개념적 코드)
Selector selector = Selector.open();

while (running) {
    // 등록된 수천 개의 채널 중 데이터가 준비된 채널을 감지
    selector.select();

    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    for (SelectionKey key : selectedKeys) {
        if (key.isReadable()) {
            // 데이터가 도착한 소켓만 Worker Thread Pool로 전달
            workerThreadPool.execute(new SocketProcessor(key));
        }
    }
}

여기서 selector.select()가 어떻게 단 하나의 호출로 수천 개의 커넥션을 감시할 수 있는지 좀 더 자세히 살펴보겠습니다.

Acceptor가 새로운 커넥션을 수락하면, 해당 소켓 채널은 selector.register(channel, OP_READ)를 통해 Selector에 등록됩니다. Selector는 내부적으로 등록된 모든 채널의 파일 디스크립터(fd)를 관리합니다.

selector.select()가 호출되면, Java는 이 작업을 OS 커널의 I/O 멀티플렉싱 시스템 콜에 위임합니다.

  • Linux: epoll_wait() — 커널이 등록된 fd들의 상태 변화를 감시하고, 데이터가 도착한 fd만 반환
  • macOS: kqueue — 동일한 원리로 이벤트가 발생한 fd만 반환

이 시스템 콜들은 **이벤트 기반(event-driven)**으로 동작합니다. 등록된 수천 개의 커넥션을 하나씩 순회하며 확인하는 것이 아니라, 커널이 네트워크 인터럽트를 통해 데이터가 도착한 소켓을 이미 알고 있기 때문에 준비된 커넥션만 즉시 반환할 수 있습니다.

Poller 스레드의 동작 흐름:

selector.select()  ← 준비된 채널이 없으면 여기서 블로킹 (CPU 소비 없음)
       ├── 채널 A에 데이터 도착 → Worker Thread Pool에 전달
       ├── 채널 B에 데이터 도착 → Worker Thread Pool에 전달
       │   (나머지 수백 개의 채널은 여전히 Selector에 등록된 채 대기)
       └── 다시 selector.select() 호출 → 다음 이벤트 대기

즉, Poller 스레드 자체가 곧 감시 스레드입니다. 커넥션마다 별도의 감시 스레드가 할당되는 것이 아니라, 1~2개의 Poller 스레드가 selector.select() → 이벤트 처리 → selector.select() 루프를 반복하면서 모든 커넥션을 관리합니다. 준비된 채널이 없으면 select()에서 블로킹되어 CPU를 소비하지 않고, 이벤트가 발생하면 깨어나서 해당 채널만 Worker Thread Pool에 전달합니다.

핵심: Poller는 1~2개의 스레드만으로 수천 개의 커넥션을 감시합니다. 이것이 가능한 이유는 selector.select()가 OS 커널의 I/O 멀티플렉싱(epoll/kqueue)을 활용하여, 하나의 호출로 등록된 모든 채널의 상태를 확인하기 때문입니다. 커넥션을 유지하는 데 별도의 Worker 스레드가 필요하지 않습니다.

3단계: Worker Thread Pool
#

Worker Thread Pool은 threads.max(기본값 200, 현재 설정 60)만큼의 스레드로 구성됩니다. Poller로부터 전달받은 소켓에서 HTTP 요청을 읽고, DispatcherServlet을 호출하여 실제 비즈니스 로직을 실행합니다.

Poller → Worker Thread 할당 → HTTP 요청 파싱 → DispatcherServlet → Controller → Service → DB I/O → 응답 작성 → Worker Thread 반환

Worker 스레드는 비즈니스 로직 실행이 끝나면 풀에 반환되고, 다음 요청을 처리할 준비를 합니다.


그래서 나머지 240개의 요청은 어디로 갔을까?
#

VUser 300명이 동시에 요청을 보내고, Worker Thread가 60개일 때의 흐름을 정리하면 다음과 같습니다.

3계층 큐 다이어그램
  1. 300개의 TCP 커넥션이 Tomcat에 수립됩니다. max-connections의 기본값은 8192(NIO/NIO2)이므로, 300개는 전체 용량의 3.7%에 불과합니다. 모든 커넥션이 문제없이 수용됩니다.

  2. Poller가 300개의 커넥션을 Selector에 등록하고 감시합니다. 이때 Worker 스레드는 소비되지 않습니다.

  3. 300개의 커넥션에서 데이터가 도착하면, Poller는 이를 감지하고 Worker Thread Pool에 작업을 전달합니다.

  4. 60개의 Worker 스레드가 먼저 도착한 60개의 요청을 처리합니다. 나머지 240개의 요청은 Worker Thread Pool의 TaskQueue에서 대기합니다.

  5. Worker 스레드가 하나의 요청 처리를 완료하면, TaskQueue에서 대기 중인 다음 요청을 가져와 처리합니다.

결론: 240개의 요청은 유실된 것이 아니라, NIO Poller가 커넥션을 유지한 상태에서 Worker Thread Pool의 큐에서 순서를 기다리고 있었습니다.


요청이 대기하는 3가지 계층
#

Tomcat NIO Connector에는 요청이 대기할 수 있는 3개의 계층이 존재합니다. 각 계층은 서로 다른 설정값으로 제어됩니다.

계층설정값기본값역할
OS TCP backlogserver.tomcat.accept-count100max-connections 도달 시 OS 커널 레벨 TCP 연결 대기열
Tomcat NIOserver.tomcat.max-connections8192 (NIO/NIO2)서버가 수락하고 처리하는 최대 커넥션 수
Worker Thread Poolserver.tomcat.threads.max200실제 요청을 처리하는 워커 스레드 수

각 계층의 동작
#

1계층 — Worker Thread Pool (threads.max)

모든 Worker 스레드가 바쁘면, 새로운 요청은 Worker Thread Pool의 내부 TaskQueue에 쌓입니다. 스레드가 반환되면 큐에서 대기 중인 작업을 하나씩 처리합니다.

2계층 — Tomcat NIO (max-connections)

Tomcat이 수락하고 Poller에 등록할 수 있는 최대 커넥션 수입니다. Tomcat 공식 문서에 따르면 NIO/NIO2의 기본값은 8192입니다. 이 값에 도달하면 Acceptor는 새 커넥션 수락을 일시 중단하고, 기존 커넥션이 닫힐 때까지 기다립니다.

3계층 — OS TCP backlog (accept-count)

max-connections에 도달하여 Acceptor가 수락을 중단하면, 이후 들어오는 TCP 연결 요청은 OS 커널의 TCP backlog 큐에 쌓입니다. 이 큐의 크기가 accept-count(기본값 100)입니다. 이 큐마저 가득 차면 OS는 새로운 연결 요청을 거부(Connection Refused) 합니다.

[VUser 300]
[OS TCP Backlog] ─── accept-count: 100 (max-connections 도달 시에만 사용)
[Acceptor Thread] ─── 커넥션 수락
[Poller (Selector)] ─── max-connections: 8192 (커넥션 감시, 스레드 소비 없음)
[Worker Thread Pool] ─── threads.max: 60 (실제 요청 처리)
[DispatcherServlet → Controller → Service → DB]

그렇다면 Tomcat이 거부하지 않고 받아들일 수 있는 최대 연결 수는?
#

3계층의 설정값을 합산하면 Tomcat이 Connection Refused 없이 수용할 수 있는 총 TCP 연결 수를 계산할 수 있습니다.

수용 가능한 최대 연결 = max-connections + accept-count
                     = 8192 + 100
                     = 8292개

여기서 주의할 점은, 이 8292개의 연결이 모두 동시에 “처리"되는 것은 아니라는 것입니다. 각 계층에서의 상태가 다릅니다.

계층연결 수상태
Worker Thread Poolthreads.max개 (60개)실제로 비즈니스 로직을 실행 중
Tomcat NIO Poller최대 8192개Selector에 등록되어 감시 중 (스레드 소비 없음)
OS TCP Backlog최대 100개OS 커널에서 TCP 연결만 수립된 채 대기

즉, 8292개의 연결을 거부 없이 받아들일 수 있지만, 그 중 실제로 요청을 동시에 처리하는 것은 threads.max개뿐입니다. 나머지는 Poller에서 감시되거나 Worker Thread Pool의 TaskQueue에서 대기하는 상태입니다.

8293번째 TCP 연결부터는 OS가 Connection Refused를 반환합니다. 3계층 모두가 가득 찬 상태에서 더 이상 연결을 수용할 공간이 없기 때문입니다.

우리의 상황에서는?
#

VUser 300명의 경우:

  • 300 커넥션 « 8192 (max-connections) → 모든 커넥션 수용 가능, 전체 용량의 3.7%에 불과
  • OS TCP backlog(accept-count: 100)는 max-connections에 도달했을 때만 사용되는 대기열이므로, 이 시나리오에서는 큐가 비어 있는 상태
  • 60개의 Worker 스레드가 300개의 요청을 순차적으로 처리, 나머지 240개는 TaskQueue에서 대기

BIO vs NIO — 왜 예전 모델에서는 이것이 불가능했을까?
#

Tomcat 7 이전에는 BIO(Blocking I/O) Connector가 기본이었습니다. BIO에서는 커넥션과 스레드가 1:1로 매핑됩니다.

항목BIO (Blocking I/O)NIO (Non-blocking I/O)
Connection : Thread 비율1:1N:1
max-connections 기본값= threads.max8192
유휴 커넥션 비용스레드 1개 점유 (비쌈)Selector 등록 (저렴)
threads.max=60일 때최대 60개 커넥션만 가능8192개 커넥션 가능

BIO에서는 threads.max=60이면 동시에 60개의 커넥션만 유지할 수 있었습니다. 61번째 연결은 스레드가 반환될 때까지 대기해야 했습니다. 이 구조에서는 스레드를 줄이면 곧바로 처리 가능한 커넥션 수가 줄어들기 때문에, 동일 트래픽에서 성능이 저하될 수 있었습니다.

하지만 NIO에서는 커넥션 관리와 요청 처리가 분리되어 있습니다. Poller가 수천 개의 커넥션을 스레드 없이 유지하므로, threads.max를 줄여도 커넥션 수용 능력에는 영향이 없습니다.

Spring Boot 3.x의 임베디드 Tomcat은 NIO Connector(Http11NioProtocol)를 기본으로 사용합니다.


코드로 직접 확인하기
#

이론만으로는 확신하기 어렵습니다. 실제로 커넥션 수와 스레드 수가 독립적으로 동작하는지 코드로 확인해 보겠습니다.

테스트 실행 환경: 아래 테스트들은 애플리케이션을 먼저 실행한 상태에서 localhost:8080으로 직접 HTTP 요청을 보내는 방식입니다. @SpringBootTest를 사용한 통합 테스트가 아니므로, 테스트 전에 application.yml 설정을 변경한 뒤 서버를 실행해야 합니다.

테스트 1: Slow Endpoint를 이용한 배치 처리 확인
#

먼저, 의도적으로 느린 엔드포인트를 만들어 Worker 스레드가 점유된 상태를 유지합니다.

@RestController
public class SlowEndpointController {

    @GetMapping("/test/slow")
    public String slowEndpoint() throws InterruptedException {
        String threadName = Thread.currentThread().getName();
        long startTime = System.currentTimeMillis();

        Thread.sleep(3000); // 3초간 스레드 점유

        long elapsed = System.currentTimeMillis() - startTime;
        return String.format("Thread: %s, Elapsed: %dms", threadName, elapsed);
    }
}

그리고 application.yml에서 threads.max=5로 설정한 뒤, 동시에 20개의 요청을 보냅니다.

# application.yml
server:
  tomcat:
    threads:
      max: 5
@Test
@DisplayName("threads.max보다 많은 동시 요청을 보내도 모든 요청이 처리된다 - Connection ≠ Thread 확인")
void batchProcessing() throws Exception {
    // given - 스레드(5개)보다 4배 많은 동시 요청 준비
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    CountDownLatch latch = new CountDownLatch(totalRequests);
    List<Future<String>> futures = new ArrayList<>();

    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    long testStart = System.currentTimeMillis();

    // when - 20개의 요청을 동시에 전송
    for (int i = 0; i < totalRequests; i++) {
        futures.add(executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();

                HttpResponse<String> response = client.send(request,
                        HttpResponse.BodyHandlers.ofString());

                long elapsed = System.currentTimeMillis() - testStart;
                return String.format("[%5dms] Status: %d, Body: %s",
                        elapsed, response.statusCode(), response.body());
            } finally {
                latch.countDown();
            }
        }));
    }

    latch.await(30, TimeUnit.SECONDS);

    // then - 모든 요청이 5개씩 배치로 처리되어 응답을 받는다
    for (Future<String> future : futures) {
        System.out.println(future.get());
    }

    executor.shutdown();
}

결과
#

Slow Endpoint 테스트

결과를 보면 정확히 5개씩 같은 시간에 응답이 돌아오지 않고, 같은 배치 내에서도 수 밀리초의 차이가 발생합니다. 이는 20개의 클라이언트 스레드가 요청을 보내는 시점이 미세하게 다르고, Worker 스레드가 작업을 완료한 뒤 TaskQueue에서 다음 요청을 가져가는 타이밍도 OS의 스레드 스케줄링에 따라 달라지기 때문입니다. 중요한 것은 전체적인 패턴 — ~3초, ~6초, ~9초, ~12초 구간에 걸쳐 5개씩 묶여서 처리되고, 20개의 요청이 하나도 유실되지 않았다는 점입니다. Connection ≠ Thread임을 확인할 수 있습니다.

테스트 2: Tomcat 내부 상태 조회
#

Tomcat의 내부 상태를 코드로 직접 확인할 수도 있습니다. 먼저 Tomcat의 커넥션 수와 스레드 풀 상태를 JSON으로 반환하는 엔드포인트를 만듭니다. AbstractProtocolgetConnectionCount() 메서드는 Tomcat 공식 API에서 제공하며, JMX를 통해서도 접근 가능합니다.

@RestController
@RequiredArgsConstructor
public class TomcatInternalController {

    private final WebServerApplicationContext webServerAppContext;

    @GetMapping("/tomcat/internals")
    public Map<String, Object> getTomcatInternals() {
        TomcatWebServer tomcatWebServer =
                (TomcatWebServer) webServerAppContext.getWebServer();

        Connector connector = tomcatWebServer.getTomcat()
                .getConnector();

        AbstractProtocol<?> protocol =
                (AbstractProtocol<?>) connector.getProtocolHandler();

        ThreadPoolExecutor executor =
                (ThreadPoolExecutor) protocol.getExecutor();

        Map<String, Object> info = new LinkedHashMap<>();

        // Connector 레벨
        info.put("maxConnections", protocol.getMaxConnections());
        info.put("currentConnections", protocol.getConnectionCount());
        info.put("acceptCount", protocol.getAcceptCount());

        // Thread Pool 레벨
        info.put("maxThreads", executor.getMaximumPoolSize());
        info.put("currentThreads", executor.getPoolSize());
        info.put("activeThreads", executor.getActiveCount());
        info.put("queueSize", executor.getQueue().size());

        return info;
    }
}

테스트 1과 동일한 threads.max=5 설정에서, 20개의 slow 요청으로 스레드를 점유시킨 뒤 내부 상태를 조회합니다.

@Test
@DisplayName("커넥션 수와 활성 스레드 수가 독립적으로 관리됨을 확인한다")
void tomcatInternals() throws Exception {
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    // 20개의 slow 요청을 동시에 전송 (각 3초 소요)
    for (int i = 0; i < totalRequests; i++) {
        executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();
                client.send(request, HttpResponse.BodyHandlers.ofString());
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }

    // 요청들이 서버에 도달할 시간을 확보한 뒤 내부 상태 조회
    Thread.sleep(1000);

    HttpRequest internalsRequest = HttpRequest.newBuilder()
            .uri(URI.create("http://localhost:8080/tomcat/internals"))
            .GET()
            .build();

    HttpResponse<String> response = client.send(internalsRequest,
            HttpResponse.BodyHandlers.ofString());

    System.out.println(response.body());
    executor.shutdown();
}

결과
#

Tomcat 내부 상태 조회 — 1차 실행
Tomcat 내부 상태 조회 — 2차 실행

두 번 실행한 결과 모두 currentConnections: 22, currentThreads: 5로 동일하지만, activeThreads는 1차에서 2개, 2차에서 4개로 달라졌습니다. 이는 /tomcat/internals를 조회하는 시점이 배치 간 전환 타이밍에 따라 달라지기 때문입니다. queueSize가 0인 것도 같은 이유로, 조회 시점에 이미 대기 중인 요청이 Worker 스레드에 전달된 뒤였기 때문입니다.

핵심은 커넥션이 22개 유지되는 동안에도 스레드는 최대 5개만 사용된다는 점입니다. 나머지 커넥션은 Poller가 스레드 없이 관리하고 있으며, 이것이 NIO Connector의 핵심 동작입니다.

테스트 3: 커넥션 한계를 초과하면?
#

NIO에서도 한계는 존재합니다. max-connectionsaccept-count를 극단적으로 줄여 한계를 확인해 보겠습니다.

# application.yml
server:
  tomcat:
    max-connections: 10
    accept-count: 5
    threads:
      max: 3

이 설정에서 20개의 동시 요청을 보내면:

구간커넥션 수상태
1~10번째10개Tomcat이 수락, Poller에서 관리
11~15번째5개OS TCP backlog 큐에서 대기
16~20번째5개수용 불가 — OS가 연결 거부 또는 타임아웃
@Test
@DisplayName("max-connections + accept-count를 초과하는 요청은 실패한다")
void connectionLimit() throws Exception {
    // given - max-connections(10) + accept-count(5) = 15개를 초과하는 20개 요청 준비
    int totalRequests = 20;
    ExecutorService executor = Executors.newFixedThreadPool(totalRequests);
    CountDownLatch latch = new CountDownLatch(totalRequests);
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);

    HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    long testStart = System.currentTimeMillis();

    // when - 20개의 요청을 동시에 전송
    for (int i = 0; i < totalRequests; i++) {
        final int requestId = i;
        executor.submit(() -> {
            try {
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create("http://localhost:8080/test/slow"))
                        .GET()
                        .build();

                HttpResponse<String> response = client.send(request,
                        HttpResponse.BodyHandlers.ofString());

                long elapsed = System.currentTimeMillis() - testStart;
                successCount.incrementAndGet();
                System.out.printf("[%5dms] Request %d: SUCCESS (status=%d)%n",
                        elapsed, requestId, response.statusCode());

            } catch (Exception e) {
                long elapsed = System.currentTimeMillis() - testStart;
                failCount.incrementAndGet();
                System.out.printf("[%5dms] Request %d: FAILED (%s)%n",
                        elapsed, requestId, e.getMessage());
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(120, TimeUnit.SECONDS);

    // then
    System.out.printf("%n성공: %d, 실패: %d%n", successCount.get(), failCount.get());
    executor.shutdown();
}

결과
#

커넥션 한계 초과 테스트의 콘솔 출력

타임스탬프를 기준으로 결과를 분석하면:

시점상태
~3초3개 SUCCESS1번째 배치 (threads.max=3)
~5초5개 FAILEDconnectTimeout(5초) 초과로 타임아웃
~6초3개 SUCCESS2번째 배치
~9초3개 SUCCESS3번째 배치
~12초1개 SUCCESS4번째 배치 (첫 10개 중 마지막)
~35초3개 SUCCESSbacklog 요청 — keep-alive 만료 후 수락
~38초2개 SUCCESSbacklog 요청 — keep-alive 만료 후 수락

최종 결과는 성공 15개, 실패 5개입니다.

먼저, Tomcat이 수락한 10개의 커넥션은 threads.max: 3에 의해 3개씩 배치로 처리되어 ~3초, ~6초, ~9초, ~12초에 걸쳐 모두 성공합니다. 이 동안 TCP backlog 큐의 요청 중 5개는 클라이언트의 connectTimeout(5초)를 초과하여 클라이언트가 먼저 타임아웃으로 연결을 포기합니다.

흥미로운 점은 ~12초에 첫 10개 처리가 모두 끝났는데, 나머지 backlog 요청이 ~35초에야 처리된다는 것입니다. 약 23초의 공백이 발생한 이유는 HTTP/1.1 keep-alive 때문입니다. 응답이 끝나도 커넥션이 바로 닫히지 않아 max-connections: 10 자리가 계속 차 있는 상태이므로, Tomcat의 기본 keepAliveTimeout(20초)이 만료되어 기존 커넥션이 해제된 후에야 backlog의 요청이 수락됩니다.

이 테스트는 NIO에서도 커넥션 수용 한계가 존재함을 보여줍니다. 기본값(max-connections=8192, accept-count=100)에서는 총 8292개의 동시 커넥션을 수용할 수 있으므로, VUser 300은 전혀 문제가 되지 않았던 것입니다.


이전 글의 결과를 다시 해석하기
#

이제 NIO Connector의 구조를 이해했으니, 이전 글의 결과를 다시 살펴보겠습니다.

threads.max=200일 때 (이전 글의 기본 설정)
#

이전 글에서 확인한 Grafana 대시보드를 NIO 관점에서 다시 해석해보겠습니다.

JVM Thread 상태 — timed-waiting 197개
HikariCP 커넥션 상태 — Active 10개, Pending 189개
  • 200개의 Worker 스레드가 모두 활성화되어 요청을 처리
  • HikariCP 커넥션은 10개뿐 → 200개 스레드가 10개 커넥션을 경합
  • 190개의 스레드가 timed-waiting 상태로 커넥션을 대기
  • 2개의 vCPU에서 200개 스레드 → 과도한 컨텍스트 스위칭
  • 결과: GET 1초, POST 1.2초

threads.max=60일 때 (이전 글의 최적 설정)
#

  • 60개의 Worker 스레드만 활성화
  • 나머지 240개 커넥션은 NIO Poller가 스레드 없이 관리
  • HikariCP 커넥션은 40개 → 60개 스레드가 40개 커넥션을 사용 (훨씬 적은 경합)
  • 2개의 vCPU에서 60개 스레드 → 컨텍스트 스위칭 비용 감소
  • 결과: GET 200ms, POST 125ms

스레드를 줄여도 성능이 좋아진 진짜 이유는, NIO가 커넥션 관리와 요청 처리를 분리해주었기 때문입니다. 덕분에 적은 수의 스레드가 자원 경합 없이 효율적으로 일할 수 있었습니다.

성능 향상은 두 가지 요인이 함께 작용한 결과입니다:

  1. NIO가 커넥션 관리를 분리: 스레드를 줄여도 300개의 커넥션은 모두 유지됨
  2. 스레드-커넥션 풀 비율 개선: 200:10에서 60:40으로 → timed-waiting 경합 대폭 감소

정리
#

이 글에서는 “스레드를 줄였는데 왜 성능이 좋아졌을까?“라는 질문에 대해 Tomcat NIO Connector의 아키텍처를 통해 답을 찾았습니다.

핵심 내용을 정리하면:

  1. Connection ≠ Thread: NIO Connector에서 커넥션과 스레드는 1:1로 매핑되지 않습니다. Poller가 Selector를 사용하여 소수의 스레드로 수천 개의 커넥션을 관리합니다.

  2. 3계층 큐잉 모델: 요청은 OS TCP backlog → Tomcat NIO(Poller) → Worker Thread Pool의 3계층을 거쳐 처리됩니다. 각 계층은 accept-count, max-connections, threads.max로 제어됩니다.

  3. threads.max는 병렬 처리 수를 제어하는 것이지, 서버가 수용할 수 있는 커넥션 수를 제어하는 것이 아닙니다. 커넥션 수용 능력은 max-connections(기본값 8192)가 결정합니다.

  4. 스레드 수 감소 → 성능 향상의 메커니즘: NIO가 커넥션 관리를 분리해주었기 때문에, 스레드를 줄여도 커넥션은 유지되면서 컨텍스트 스위칭과 자원 경합만 줄어들었습니다.

처음에는 단순히 설정값을 조정하는 것이 성능에 어떤 영향을 미치는지만 궁금했습니다. 하지만 그 원인을 소켓 수준까지 파고들면서 Tomcat이 어떻게 동시성을 관리하는지, 그리고 왜 Spring MVC 기반의 블로킹 모델에서도 적은 수의 스레드로 많은 요청을 처리할 수 있는지를 이해할 수 있었습니다.