들어가며
많은 Java 웹 애플리케이션, 특히 스프링 부트(Spring Boot)를 사용할 때 기본적으로 내장되어 동작하는 톰캣(Tomcat)은 어떻게 수많은 동시 요청을 효율적으로 처리할 수 있을까요? 그 비밀의 핵심에는 바로 커넥터(Connector)가 있습니다.
이번 글에서는 톰캣의 여러 커넥터 중에서도, 오늘날 사실상 표준으로 사용되는 NIOConnector의 내부 동작 원리를 심층적으로 파헤쳐보겠습니다. NIOConnector는 Java NIO(New I/O) 기반의 I/O 멀티플렉싱(Multiplexing) 방식을 사용하며, Linux 환경에서는 주로 epoll을 활용합니다.
이 글을 읽기 전에 제가 이전에 작성한 epoll의 내부 동작 방식 글을 먼저 참고하시면 NIOConnector의 동작 방식을 이해하는 데 큰 도움이 될 것입니다.
🚘 톰캣 아키텍처 살짝 엿보기

톰캣은 위 그림처럼 다양한 구성 요소로 이루어져 있습니다. 이 중에서도 클라이언트로부터 들어오는 요청을 받아 실제 작업을 수행할 스레드 풀(Worker Pool)에 요청을 전달하는 핵심적인 역할을 하는 커넥터(Connector)에 초점을 맞추겠습니다. 그리고 그중에서도 디폴트로 사용되는 NIOConnector의 동작 방식을 자세히 살펴보겠습니다.
🏑 NIOConnector의 핵심: Acceptor, Poller, WorkerPool

NIOConnector는 크게 세 가지 중요한 스레드(또는 스레드 그룹)를 중심으로 동작합니다.
Acceptor 스레드
단일 스레드로 동작하며, 클라이언트의 새로운 연결 요청을 수락(accept)하는 역할을 전담합니다. 이름 그대로 '수락자'입니다.
Poller 스레드
I/O 멀티플렉싱을 담당하는 핵심 스레드입니다. 주요 책임은 다음과 같습니다:
1. 이벤트 감지 및 위임: 등록된 소켓들을 Selector를 통해 감시하다가, I/O 이벤트(예: 데이터 수신)가 발생한 소켓이 있으면 해당 요청 처리를 Worker Pool에 위임합니다.
2. 소켓 등록: Acceptor 스레드가 수락한 새로운 소켓을 Selector에 등록하여 I/O 이벤트를 감시할 수 있도록 합니다.
여러 개의 Poller 스레드를 구성할 수도 있지만 (pollerThreadCount 속성), 일반적으로는 CPU 코어 수에 맞춰 설정하거나 기본값(보통 1 또는 2)을 사용합니다. 이 글에서는 이해를 돕기 위해 단일 Poller 스레드를 기준으로 설명하겠습니다.
WorkerPool (Worker 스레드 풀)
Poller 스레드로부터 전달받은 요청을 실제로 처리하는 스레드 풀입니다. 우리가 톰캣 설정을 커스터마이징할 때 maxThreads와 같이 스레드 풀의 크기를 조절하는 부분이 바로 이 Worker Pool에 해당합니다.

실제로 스프링 부트 애플리케이션을 기본 설정으로 실행한 후 디버깅 모드에서 스레드 목록을 살펴보면, 위 이미지와 같이 http-nio-8080-Acceptor, http-nio-8080-Poller, 그리고 다수의 http-nio-8080-exec-* (Worker 스레드) 형태의 스레드들을 확인할 수 있습니다.
이제 각 구성 요소가 코드 레벨에서 어떻게 동작하는지 자세히 살펴보겠습니다.
🍯 NIOConnector의 주요 내부 클래스
본격적으로 살펴보기 전에, NIOConnector 내부에서 중요한 역할을 하는 몇 가지 클래스를 먼저 소개하겠습니다.
NIOEndpoint
NIOConnector의 핵심 로직을 담고 있는 클래스입니다. 연결 수락(Accept), I/O 이벤트 감지(Poll), 그리고 실제 요청 처리 위임 까지 이어지는 전체 파이프라인의 흐름을 제어합니다. Acceptor와 Poller는 이 NIOEndpoint의 내부 클래스로 구현되어 있기도 합니다.
NioChannel
Java NIO의 SocketChannel을 톰캣 내부에서 사용하기 용이하도록 감싸고, 소켓에 대한 읽기/쓰기 버퍼 관리 등 저수준 I/O 작업을 처리하는 1차 래퍼 클래스입니다.
NioSocketWrapper:NioChannel을 한 번 더 감싼 2차 래퍼 클래스입니다. 톰캣 내부의 소켓 상태 관리, 타임아웃 처리, 에러 처리 등 보다 상위 수준의 소켓 관련 로직 및 업무 흐름을 담당합니다.
Q. 왜 SocketChannel을 두 번에 걸쳐 Wrapping 할까요?
A. 이처럼 SocketChannel을 두 번에 걸쳐 래핑하는 이유는 관심사의 분리 때문입니다.
각 래퍼 클래스는 계층적으로 역할을 분담합니다:
SocketChannel ← Java NIO의 실제 소켓 (네트워크 통신 담당)
↑
NioChannel ← 읽기/쓰기 버퍼 처리, 기본적인 I/O 작업 담당 (1차 래퍼, I/O 중심)
↑
NioSocketWrapper ← 톰캣 내부 로직 연동, 소켓 상태 관리, 타임아웃 등 (2차 래퍼, 톰캣 로직 중심)
환경
OS: Linux (epoll 사용 가정)
Tomcat 버전: 10.1.x

🌙 1. Acceptor: 새로운 연결을 맞이하는 문지기
Acceptor 스레드는 이름에서 알 수 있듯이 클라이언트로부터의 새로운 소켓 연결을 accept()하는 역할을 담당합니다. 연결이 수락되면, 해당 소켓을 실제 I/O 이벤트를 감시할 Poller 스레드에게 등록하는 작업을 위임합니다.
Acceptor의 run() 메서드 핵심 로직을 통해 그 역할을 명확히 파악할 수 있습니다.
@Override
public void run() {
while(1) {
// 1. endpoint.serverSocketAccept()를 통해 새로운 연결 요청을 기다리고 수락합니다.
SocketChannel socket = endpoint.serverSocketAccept();
// 2. 수락된 소켓에 대한 초기 설정을 수행하고 Poller에 등록을 요청합니다.
endpoint.setSocketOptions(socket)
}
}
위 코드에서 보듯이 Acceptor는 루프 내에서 endpoint.serverSocketAccept()를 호출하여 블로킹 상태로 새로운 연결을 기다립니다. 연결이 들어오면 endpoint.setSocketOptions(socket) 메서드를 호출하여 소켓을 초기화하고 Poller에게 등록을 위임합니다.
setSocketOptions: 소켓 설정 및 Poller 등록 위임
NIOEndpoint 클래스에 정의된 setSocketOptions 메서드를 좀 더 자세히 살펴보겠습니다.
@Override
protected boolean setSocketOptions(SocketChannel socket) {
// 1. SocketChannel을 감싸는 NioChannel과 NioSocketWrapper 객체를 생성하고 초기화합니다.
SocketBufferHandler bufhandler = new SocketBufferHandler(
socketProperties.getAppReadBufSize(),
socketProperties.getAppWriteBufSize(),
socketProperties.getDirectBuffer());
NioChannel channel = new SecureNioChannel(bufhandler, this);
NioSocketWrapper socketWrapper = new NioSocketWrapper(channel, this);
channel.reset(socket, socketWrapper);
connections.put(socket, socketWrapper);
socketWrapper.setReadTimeout(getConnectionTimeout());
socketWrapper.setWriteTimeout(getConnectionTimeout());
// 2. 준비된 NioSocketWrapper를 Poller에 등록 요청합니다.
poller.register(socketWrapper);
return true;
}
setSocketOptions 메서드 내부에서는 앞서 설명한 NioChannel과 NioSocketWrapper를 생성하고, 소켓 버퍼 핸들러 설정, 타임아웃 설정 등 필요한 초기화를 진행합니다. 가장 중요한 부분은 poller.register(socketWrapper)를 호출하여 Poller에게 '이 소켓을 관리해달라'고 요청하는 것입니다.
Poller.register: Poller에게 소켓 등록 이벤트 전달
Acceptor가 호출한 poller.register()는 Poller 스레드가 새로운 소켓을 인지하도록 하는 첫 단계입니다.
public void register(final NioSocketWrapper socketWrapper) {
socketWrapper.interestOps(SelectionKey.OP_READ); // 이 소켓은 읽기 이벤트에 관심있음!
PollerEvent pollerEvent = createPollerEvent(socketWrapper, OP_REGISTER); // 등록(OP_REGISTER) 이벤트 생성
// Poller의 이벤트 큐에 PollerEvent를 추가합니다.
addEvent(pollerEvent);
}
Poller.register() 메서드는 전달받은 NioSocketWrapper에 대해 읽기 작업(SelectionKey.OP_READ)에 관심이 있음을 표시하고, OP_REGISTER 타입의 PollerEvent를 생성하여 Poller 내부의 이벤트 큐(events)에 추가합니다. 이 이벤트는 '새로운 소켓을 Selector에 등록하라'는 의미를 가집니다.
Poller.addEvent: 이벤트 큐와 Poller 깨우기
addEvent 메서드는 생성된 PollerEvent를 큐에 넣고, 필요하다면 잠자고 있는 Poller 스레드를 깨웁니다.
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
private void addEvent(PollerEvent event) {
events.offer(event); // 이벤트 큐에 이벤트 추가
if (wakeupCounter.incrementAndGet() == 0) { // Poller가 깨어있지 않다면
selector.wakeup(); // Selector를 깨워서 즉시 이벤트를 처리하도록 함
}
}
events는 Poller 객체 내부에 선언된 SynchronizedQueue<PollerEvent> 타입의 큐입니다. 이벤트 큐에 PollerEvent를 추가(offer)한 후, wakeupCounter를 증가시키고 selector.wakeup()을 호출합니다.
이 wakeup() 호출은 Poller 스레드가 selector.select() 메서드에서 블로킹되어 잠들어 있을 경우, 즉시 깨워서 새로운 이벤트를 처리하도록 만들기 위함입니다. wakeupCounter는 불필요한 wakeup() 호출을 줄이기 위한 최적화 장치로, Poller 스레드가 이미 깨어있거나 깨어날 예정이라면 wakeup()을 생략하기도 합니다.
Q. 왜 Acceptor가 직접 Selector에 등록하지 않고 이벤트 큐를 사용할까요?
그렇다면 왜 Acceptor 스레드가 직접 SocketChannel.register()를 호출하여 Selector에 소켓을 등록하지 않고, 굳이 Poller 스레드에게 이벤트 큐를 통해 작업을 위임하는 복잡한 방식을 사용할까요?
핵심적인 이유는 Selector 관련 작업(특히 register나 select)이 내부적으로 동기화 로직을 포함하며, 경우에 따라 블로킹될 수 있기 때문입니다. 만약 단일 스레드로 동작하는 Acceptor가 register 작업 중 잠시라도 블로킹된다면, 그동안 새로운 클라이언트 연결을 수락하지 못하게 되어 전체 시스템의 응답성과 처리량에 심각한 병목 현상을 유발할 수 있습니다. Acceptor는 오직 accept()에만 집중하여 최대한 빠르게 새로운 연결을 받아들여야 합니다.
이러한 문제를 해결하기 위해, 톰캣은 생산자-소비자 패턴(Producer-Consumer Pattern)을 적용합니다.
- 생산자(Producer): Acceptor 스레드는 '소켓 등록 요청' 이벤트를 생산하여 이벤트 큐에 넣습니다.
- 소비자(Consumer): Poller 스레드는 이 큐에서 이벤트를 소비하여 실제 Selector 등록 작업을 수행합니다.
이렇게 역할을 분리함으로써 각 스레드는 자신의 핵심 작업에 집중할 수 있게 되어 시스템 전체의 성능을 최적화합니다.
🕷 2. Poller: I/O 이벤트 감시 및 작업 분배
Poller 스레드는 NIOConnector의 심장과도 같은 역할을 수행하며, 주요 책임은 다음과 같습니다:
1. I/O 이벤트 감지 및 위임: Selector를 사용하여 자신에게 등록된 여러 소켓들을 감시합니다. 특정 소켓에서 읽기 가능한 데이터가 도착하는 등의 I/O 이벤트가 발생하면, 해당 소켓에 대한 처리 작업을 Worker 스레드 풀에 위임합니다.
2. 소켓 등록 이벤트 처리: Acceptor 스레드로부터 전달받은 이벤트 큐의 '소켓 등록' 요청을 처리합니다. 즉, 새로운 SocketChannel을 Selector에 등록하여 I/O 감시 대상에 포함시킵니다.
Poller 스레드의 run() 메서드는 이러한 핵심 동작들을 수행하는 무한 루프로 구성됩니다.
소스 코드: NioEndpoint.Poller.run()
@Override
public void run() {
while (true) { // 핵심은 무한 루프
boolean hasEvents = false;
// [1] 이벤트 큐에 쌓인 이벤트들 처리 (예: 새로운 소켓 등록)
hasEvents = events();
if (wakeupCounter.getAndSet(-1) > 0) {
// 누군가 wakeup()을 호출해서 강제로 깨어난 경우, Non-Blocking select 실행
keyCount = selector.selectNow();
} else {
// 처리할 이벤트가 없으면, 타임아웃을 가지고 Blocking select 실행 (epoll_wait)
keyCount = selector.select(selectorTimeout);
}
wakeupCounter.set(0); // wakeup 상태 초기화
// [2] selector.select() 결과, I/O 이벤트가 발생한 소켓들 처리
Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
if (socketWrapper != null) {
processKey(sk, socketWrapper); // Worker 스레드에 작업 위임
}
}
// 타임아웃 처리 로직 등 (생략)
timeout(keyCount, hasEvents);
}
getStopLatch().countDown();
}
Poller의 run() 메서드 로직을 간략화하면 다음과 같은 흐름으로 요약할 수 있습니다:
while (true) {
[1] 이벤트 큐에 담긴 작업 처리 (events() 메서드, 예: 소켓 등록)
[2] Selector로 I/O 이벤트 발생 대기 (selector.select(), OS의 epoll_wait 호출)
[3] 발생한 I/O 이벤트가 있다면 해당 소켓 처리 (Worker 스레드에 위임)
}
events(): 이벤트 큐의 소켓 등록 처리
Poller의 run() 루프 시작 부분에서 호출되는 events() 메서드는 Acceptor가 요청한 소켓 등록 작업을 실제로 수행합니다.
public boolean events() {
boolean result = false;
PollerEvent pe;
// 큐에 있는 모든 이벤트를 처리
for (int i = 0, size = events.size(); i < size && (pe = events.poll()) != null; i++) {
result = true;
NioSocketWrapper socketWrapper = pe.getSocketWrapper();
SocketChannel sc = socketWrapper.getSocket().getIOChannel();
int interestOps = pe.getInterestOps(); // 보통 OP_REGISTER
if (interestOps == OP_REGISTER) {
// 핵심: SocketChannel을 Selector에 OP_READ 관심으로 등록!
sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper);
} else {
// 기타 다른 이벤트 처리 (예: 관심 operation 변경 등)
}
}
return result;
}
이 events() 메서드는 Poller의 이벤트 큐(events)를 확인하여 쌓여있는 PollerEvent들을 처리합니다. 위 코드에서는 OP_REGISTER 이벤트, 즉 새로운 소켓 등록 요청을 처리하는 부분만 간추렸습니다.
큐에서 이벤트를 하나씩 꺼내(poll()), 해당 SocketChannel을 Selector에 읽기 작업(SelectionKey.OP_READ) 감시 대상으로 등록(sc.register(...))합니다. 리눅스 환경이라면 이 과정에서 내부적으로 epoll_ctl() 시스템 콜을 통해 해당 소켓의 파일 디스크립터(fd)가 epoll 인스턴스에 추가됩니다.
Java의 Selector.register() 메서드는 세 번째 인자로 attachment를 받을 수 있는데, 이는 해당 SelectionKey에 연결할 사용자 정의 객체입니다. 톰캣은 여기에 NioSocketWrapper를 첨부하여, 나중에 이벤트 발생 시 어떤 소켓에 대한 이벤트인지 식별하고 관련 정보를 쉽게 가져올 수 있도록 합니다. (리눅스 epoll의 epoll_event.data 필드에 이 정보가 매핑된다고 생각할 수 있습니다.)
selector.select(): I/O 이벤트 대기 (feat. epoll_wait)
다시 Poller의 run() 메서드로 돌아가서, selector.select() 호출 부분을 살펴보겠습니다.
if (wakeupCounter.getAndSet(-1) > 0) {
// 강제로 일어났으므로, Non-Blocking select 한번 실행
keyCount = selector.selectNow();
} else {
// 이벤트, 소켓 다 처리 이후 blocking 돌입
keyCount = selector.select(selectorTimeout);
}
Poller의 run() 메서드 루프는 먼저 events()를 호출하여 이벤트 큐를 처리한 후, selector.select()를 호출하여 실제 I/O 이벤트를 기다립니다. 여기서 wakeupCounter의 값에 따라 selectNow() (논블로킹) 또는 select(timeout) (블로킹) 중 하나가 호출됩니다.
wakeupCounter.getAndSet(-1) > 0
addEvent()에서 selector.wakeup()이 호출되어 Poller 스레드가 강제로 깨어났음을 의미합니다. 이 경우, 이미 처리해야 할 이벤트(예: 새로운 소켓 등록)가 있거나, 짧은 시간 내에 다른 I/O 이벤트가 발생했을 가능성이 있으므로 selector.selectNow()를 호출하여 블로킹 없이 즉시 확인합니다.
else (wakeupCounter <= 0)
특별히 깨울 이유가 없었다면, selector.select(selectorTimeout)을 호출하여 selectorTimeout 동안 블로킹 상태로 I/O 이벤트를 기다립니다. 이 호출이 리눅스에서는 epoll_wait() 시스템 콜에 해당하며, 지정된 시간 동안 감시 중인 소켓에서 I/O 이벤트가 발생하거나 타임아웃될 때까지 대기합니다.
소켓으로 들어온 요청 실제 처리: Worker 스레드로의 위임
selector.select() 또는 selector.selectNow() 호출 후, I/O 이벤트가 발생한 소켓에 대한 정보는 selector.selectedKeys()를 통해 얻을 수 있습니다.
Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null;
while (iterator != null && iterator.hasNext()) {
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
if (socketWrapper != null) {
processKey(sk, socketWrapper); // 이 키(소켓)에 대한 이벤트 처리
}
}
Poller는 이 SelectionKey들을 순회하면서 각 키에 연결된 NioSocketWrapper (앞서 register 시 첨부했던 객체)를 가져옵니다. 이를 통해 어떤 소켓에서 이벤트가 발생했는지 식별할 수 있습니다.
이렇게 식별된 NioSocketWrapper와 SelectionKey를 processKey(sk, socketWrapper) 메서드로 전달하여 실제 요청 처리 준비 및 Worker 스레드로의 위임 작업을 수행합니다.
processKey와 processSocket: Worker 스레드 풀에 작업 할당
processKey 메서드는 다양한 검사 후, 핵심적으로 NioEndpoint의 processSocket 메서드를 호출합니다. 이 메서드가 실질적인 작업 위임 로직을 담고 있습니다.
// NioEndpoint.java
public boolean processSocket(SocketWrapperBase<S> socketWrapper, SocketEvent event, boolean dispatch) {
// 1. SocketProcessor (Runnable) 생성: 이 소켓에 대한 작업을 정의
SocketProcessorBase<S> sc = createSocketProcessor(socketWrapper, event);
// 2. Executor (Worker 스레드 풀) 가져오기
Executor executor = getExecutor();
// 3. 준비된 SocketProcessor (Runnable)를 Executor (Worker 스레드 풀)에 제출하여 실행합니다.
executor.execute(sc);
return true;
}
processSocket 메서드에서는 먼저 createSocketProcessor(socketWrapper, event)를 통해 SocketProcessorBase의 인스턴스를 생성합니다. SocketProcessorBase는 Runnable 인터페이스를 구현한 클래스로, 특정 소켓(socketWrapper)에서 발생한 이벤트를 처리하는 로직을 담고 있습니다. 즉, HTTP 요청 파싱, 서블릿 실행 등의 실제 작업을 수행할 '작업 단위'입니다.
그 다음, getExecutor()를 통해 미리 설정된 Worker 스레드 풀을 가져와, 생성된 SocketProcessor를 executor.execute(sc)를 통해 제출합니다. 이로써 해당 소켓에 대한 요청 처리는 Worker 스레드 중 하나에게 비동기적으로 할당됩니다.
Poller 스레드의 주된 책임은 여기까지입니다: I/O 이벤트를 감지하고, 해당 요청을 처리할 Runnable 객체를 만들어 Worker 스레드 풀에 넘겨주는 것. 이후의 실제 HTTP 요청 처리, 비즈니스 로직 수행 등은 Worker 스레드의 몫입니다.
마치며
지금까지 톰캣 NIOConnector의 핵심 구성 요소인 Acceptor, Poller, 그리고 Worker Pool이 어떻게 유기적으로 협력하여 클라이언트의 요청을 효율적으로 처리하는지 그 내부 동작 흐름을 코드와 함께 자세히 살펴보았습니다.
Acceptor: 새로운 연결 요청을 받아 소켓을 생성하고, Poller에게 등록을 위임합니다.
Poller: 이벤트 큐를 통해 전달받은 소켓을 Selector에 등록하고, Selector를 통해 I/O 이벤트를 감지합니다. 이벤트가 발생한 소켓에 대해서는 SocketProcessor를 생성하여 Worker 스레드 풀에 작업을 위임합니다.
Worker 스레드 풀: Poller로부터 전달받은 SocketProcessor (Runnable)를 실행하여 실제 HTTP 요청 처리 및 비즈니스 로직을 수행합니다.
이러한 구조는 각 스레드가 자신의 역할에 집중하게 하여 블로킹을 최소화하고, 시스템 자원을 효율적으로 사용하여 높은 동시 처리 성능을 달성할 수 있도록 합니다. 이 글을 통해 톰캣의 내부 동작에 대한 이해가 깊어지셨기를 바랍니다.
Reference
https://giron.tistory.com/155
https://px201226.github.io/tomcat/
https://velog.io/@cjh8746/%EC%95%84%ED%8C%8C%EC%B9%98-%ED%86%B0%EC%BA%A3%EC%9D%98-NIO-Connector-%EC%99%80-BIO-Connector%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://jh-labs.tistory.com/329
https://tomcat.apache.org/tomcat-10.1-doc/architecture/overview.html
https://github.com/apache/tomcat/blob/10.1.x/java/org/apache/tomcat/util/net/Acceptor.java
https://github.com/apache/tomcat/blob/10.1.x/java/org/apache/tomcat/util/net/NioEndpoint.javat
https://github.com/openjdk/jdk17/blob/master/src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java
'스프링' 카테고리의 다른 글
| @ExceptionHandler는 어떻게 동작할까? Spring 예외 처리의 내부 동작 원리 파헤치기 (2) | 2025.06.25 |
|---|---|
| Hibernate의 객체 매핑 성능 비밀: 내부 동작 원리 파헤치기 (0) | 2025.05.11 |
| JPA의 @Transactional(readOnly = true) 동작 (0) | 2025.05.11 |