큐에 21억 개가 담기는데, 왜 요청이 거부될까?

2025. 12. 3.

배경

 
이번 글에서는 톰캣 설정을 건드리다가 생긴 의문, 학습한 내용을 정리해보고자 합니다.
 

server:
	tomcat:
    	threads:
        	max: 200

 
톰캣 공식 문서를 보면 maxThreads의 기본값이 200개입니다.

maxThreads: If not specified, this attribute is set to 200.

그리고 톰캣은 Thread per Request 모델을 사용합니다. 요청 하나당 스레드 하나가 할당되는 방식입니다.

The Connector creates a separate thread for each incoming HTTP request.

 

또한 스레드 풀 개수가 200개 일 때, 201번째 클라이언트(사용자)의 요청은 큐에서 대기한 후 스레드가 반환되면 처리된다고 합니다.

The maximum number of runnable tasks that can queue up awaiting execution before we reject them. Default value is Integer.MAX_VALUE

그리고 이 큐의 크기인 maxQueueSize는 Integer.MAX_VALUE 값을 기본으로 합니다.
 
그럼 새로운 클라이언트의 동시 요청이 1만 개든, 10만 개든 모든 요청이 거부되지 않고 큐에 담기는 걸까요?
 
결론 먼저 말하자면 그렇지 않습니다. 그 이유는 관리 가능한 커넥션 개수에 한계가 있기 때문입니다. 애플리케이션 수준의 스레드 풀의 대기 큐 최대 크기를 Integer.MAX_VALUE 만큼 크게 설정했다고 해서, OS 수준에서 관리 가능한 TCP 커넥션의 개수 또한 무한정 늘어나는 것은 아니기 때문입니다. 따라서 톰캣 스레드 풀의 maxQueueSize와 무관하게, 제한된 커넥션 유지 개수를 넘어가면 클라이언트의 요청은 거부됩니다. (정확히는 이미 커넥션이 맺어진 클라이언트의 요청이 아닌, 새로운 클라이언트의 요청일 경우입니다.)
 
그럼 클라이언트와의 커넥션은 언제 맺고, 누가, 어떻게 관리하는 걸까요?
Tomcat이 웹 애플리케이션 서버(WAS)라는 관점에서, TCP/IP Stack을 떠올려보면 이미 하위 계층에서 TCP 커넥션을 맺고 올라오겠거니 생각해 볼 수 있습니다.
 
하지만 더 나아가서 커넥션이 맺어진 상황에서 was는 클라이언트의 HTTP 요청이 서버에 도착했음을 어떻게 알고 스레드가 이를 처리하기까지의 메커니즘이 궁금했습니다.
 
그냥 직관적으로 생각했을 때 커넥션 당 하나의 스레드가 붙어서 요청을 처리하면 가능하겠다고 생각은 들었지만... (아닌 것 같아서ㅋㅋ)
그렇게 되면 커넥션이 1000개일 때 1000개의 스레드가 필요하기에..? 이런 문제들을 어떻게 해결했는가 학습해 보았습니다.
 
 

C10K 문제

"커넥션당 스레드 하나"는 사실 과거에 실제로 사용되던 방식입니다.
 
하지만 이 방식은 C10K 문제(Concurrent 10,000 connections problem)를 일으켰습니다.

It's time for web servers to handle ten thousand clients simultaneously, don't you think? After all, the web is a big place now. — The C10K problem (Dan Kegel, 1999)

C10K 문제는 1999년 Dan Kegel이 제기한 것으로, "웹 서버가 동시에 10,000개의 클라이언트를 처리해야 하는 시대가 왔다"는 문제의식에서 시작됐습니다.
 
1. 메모리 고갈
- 스레드 하나당 메모리 약 1MB
- 커넥션 10,000개 = 스레드 10,000개 = 10GB 메모리
 
2. 컨텍스트 스위칭 오버헤드
- CPU가 10,000개 스레드를 번갈아 실행
- 스레드 전환 비용이 큼
 
스레드가 대부분의 시간 동안 놀고 있음에도, 커넥션을 관리해야 한다는 이유만으로 스케줄링 대상이 됩니다.
예를 들어 커넥션 1,000개를 유지하려고 스레드 1,000개를 할당했는데 실제로 일하는 건 50개뿐입니다. 950개 스레드는 그냥 "혹시 이 커넥션으로 요청 오나?" 하고 대기만 하는 것입니다.
 
 

해결: 커넥션 수립과 요청 처리의 역할 분리

 
위 문제를 해결하기 위해서는 커넥션 당 무조건 스레드 하나가 아니라, 실제 요청이 올 때만 스레드를 할당해서 처리하면 됩니다.
이게 가능하게 하려면 요청이 왔음을 감지하고 스레드를 할당하는 역할을 해주는 별도의 스레드가 필요해집니다.
 
1개의 스레드가 1,000개의 커넥션을 감시하고 실제 요청 처리는 스레드 50개만 하는 것입니다.
 
 

IO Multiplexing - 하나의 스레드로 여러 커넥션 감시하기

그럼 어떻게 하나의 스레드가 여러 커넥션을 감시할 수 있을까요?
이는 결론 먼저 말하자면, 이미 os 수준에서 해당 메커니즘이 구현(커널 수준에서 소켓 상태를 관리)되어 있기 때문에 애플리케이션 (JVM) 수준에서도 쉽게 활용이 가능하다!라고 말할 수 있을 것 같습니다.
 
그리고 여기서 등장하는 것이 I/O multiplexing 개념입니다.
 
기존 방식

while (true) {
    byte[] data = socket.read();  // 데이터 올 때까지 블로킹
    if (data != null) {
        process(data);
    }
}

스레드 당 커넥션을 관리하는 기존의 방식이라면, 각 스레드에서 위와 같은 코드를 실행할 것입니다.
inputStream에 데이터가 들어올 때까지 스레드는 대기합니다.
 
IO Multiplexing

while (true) {
    List<Socket> readySockets = epoll_wait(allSockets);
    
    // 데이터 준비된 소켓만 처리
    for (Socket socket : readySockets) {
        workerThreadPool.submit(() -> {
            byte[] data = socket.read();  // 즉시 반환
            process(data);
        });
    }
}

I/O Multiplexing에서는 위 코드를 하나의 스레드가 실행하게 됩니다.
os에게 지금 읽을 데이터가 있는 소켓을 알려달라는 요청을 할 수 있고, 이렇게 읽을 데이터가 있는 소켓에 대해서만 요청 처리를 수행합니다.
 
핵심은 os가 지금 읽을 데이터가 있는 소켓만 알려준다는 것입니다. 이를 위해 Linux 기준으로 epoll이라는 구조체가 존재하고, 시스템 콜을 제공하고 있습니다.
 
이처럼 이벤트(데이터 도착)를 계속 감시하면서 루프를 도는 패턴을 이벤트 루프(Event Loop)라고 합니다. 톰캣의 Poller가 바로 이벤트 루프를 구현한 것입니다.
 
 

톰캣의 실제 구현 (Acceptor, Poller, Worker)

톰캣은 위에서 소개한 원리를 세 가지 컴포넌트로 나누어 구현했습니다.
 
설명을 위한 의사코드를 간단히 작성해 보겠습니다.
 
Acceptor

while (true) {
    Socket newSocket = serverSocket.accept();
    poller.register(newSocket);
}

Acceptor는 새 커넥션을 수락하여 만들어진 소켓을 Poller에 등록하는 역할을 합니다.
 
Poller

while (true) {
    List<Socket> readySockets = epoll_wait(registeredSockets);
    
    for (Socket socket : readySockets) {
        // HTTP 요청이 도착한 소켓을 Worker에 전달
        workerExecutor.execute(() -> {
            handleHttpRequest(socket);
        });
    }
}

Poller는 수천 개 커넥션을 epoll로 감시하여 HTTP 요청 데이터가 도착한 소켓만 감지하는 역할을 합니다.
요청 데이터가 들어왔다면 Worker 스레드풀에 작업을 넘깁니다.
 
제가 톰캣을 학습하며 들었던 의문의 답은 Poller에게 있습니다.

"커넥션이 맺어진 상황에서 WAS는 클라이언트의 HTTP 요청이 서버에 도착했음을 어떻게 알까?"

Poller가 epoll로 감시하다가, OS가 "이 소켓에 데이터 옴"이라고 알려주면 그때 Worker에게 전달함으로써 요청을 처리하게 됩니다.
 
마지막으로 Worker입니다.
 
Worker

public void handleHttpRequest(Socket socket) {
    HttpRequest request = parseRequest(socket);
    HttpResponse response = processBusinessLogic(request); // DB 조회, 연산 등
    socket.write(response);
}

Worker는 요청 데이터를 받아 실제 비즈니스 로직 처리를 수행하게 됩니다.
 
실제 동작 시나리오
 
동시 접속자가 1,000명이라는 가정으로 흐름을 정리해 보겠습니다.
(참고: 여기서 1,000명 접속은 순간적으로 커넥션이 수립된 상황을 의미합니다. 일반적인 HTTP API 서버에서 수천 개 커넥션이 계속 유지되는 것은 비정상적이며, 대부분은 Keep-Alive 타임아웃 내에 종료됩니다.)
 
1. 커넥션 수립
먼저 톰캣의 Acceptor는 미리 listening socket을 만들고 대기하게 됩니다.
그리고 클라이언트 1~1,000명의 접속 요청이 오면, acceptor 스레드는 accept()를 1000번 호출하고 모두 poller에 등록합니다.
 
2. 커넥션 감시
poller에 1000개의 소켓이 등록된 상태이며 epoll_wait() 호출하여 os의 알림을 대기합니다. 실제 50개의 HTTP 요청이 왔다면, 50개의 socket을 리턴합니다.
 
3. 요청 처리
200개의 worker 중 요청 처리를 위해 50개의 worker 스레드가 활성화됩니다.
 
 
톰켓 설정 값
 
위 내용까지를 이해한 후 톰켓 설정 값을 살펴보겠습니다.

server:
  tomcat:
    threads:
      max: 200
      min-spare: 10
    
    accept-count: 100
    max-connections: 8192

- max: Worker 스레드 풀 크기이며 "동시에 처리 가능한 요청 수"라고 할 수 있습니다.
- accept-count: Acceptor가 accept 하기 전 os 레벨에서 대기하는 커넥션 수 (OS 레벨의 TCP backlog 큐 크기 )
- max-connections: Poller가 관리할 최대 커넥션 수
 

한계 초과 상황

 
각 설정이 한계를 넘으면 어떻게 될까요?
 
1. max-connections 초과
이때 새로운 클라이언트 접속 시도를 하면 Acceptor가 accept() 호출을 멈추고 OS TCP backlog 큐(accept-count 크기만큼)에서 대기하게 됩니다.
그러다 기존 커넥션이 종료되면 대기 중이던 커넥션이 수락됩니다.
 
2. accept-count 초과
max-connections도 꽉 찼고 accept-count도 꽉 찬 경우에는 새로운 클라이언트가 접속 시도하면 클라이언트는 Connection refused 에러를 받게 됩니다.
 
 
정말 많은 트래픽이 몰린다면 이러한 상황이 벌어질 수 있습니다.

accept-count를 초과하여 Connection refused를 반환하는 경우 TCP 수준에서 연결을 거부하는 것이기 때문에 실제 운영 상황에서 서버에 로그가 남지 않아 원인 파악이 어려울 수 있습니다.

그래서 모니터링에서 max-connections가 최대치에 가까워지면 경고 알림을 준다던지 등 전략을 취하거나, 환경에 맞게 설정 값을 변경해도 좋을 것 같다는 생각이 듭니다.

 

마무리

학습을 통해 알게 된 것은 커넥션 관리와 요청 처리가 분리되어 있다는 것이었습니다. 덕분에 c10k 문제를 해결하고 실제 작업하는 Worker 스레드만 가용할 수 있습니다. 그리고 OS 레벨의 epoll 같은 메커니즘이 이미 구현되어 있어서, 애플리케이션에서는 그걸 활용만 하면 된다는 점도 흥미로웠습니다. 모니터링 시에도 max-connections나 Worker 스레드 사용률 같은 세부 지표의 의미도 이해할 수 있게 되었습니다. 앞으로도 학습한 내용 자주 올려보도록 하겠습니다.

 

참고 자료

 

Thread per Connection vs Thread per Request | Baeldung

Learn about the difference between the server threading models per connection and per request.

www.baeldung.com