Tomcat NIO Connector 내부 동작 — 소켓 한 바이트가 Servlet에 닿기까지
Spring Boot 애플리케이션이 8080 포트에 묶여 있을 때, 클라이언트가 보낸 첫 바이트가
@RestController메서드 호출까지 가는 경로는 한 번의 메서드 점프가 아닙니다. 두 종류의 스레드, 한 개의Selector, 다섯 개 이상의 Valve, 그리고LimitLatch라는 카운터가 그 사이에 끼어 있습니다. 이 글은 Tomcat 11.0의org.apache.coyote.http11.Http11NioProtocol기본 구성을 기준으로,apache/tomcatmain브랜치 소스를 따라 그 경로 전체를 한 장의 그림 위에 올려놓는 것을 목표로 합니다. 대상 독자는 Spring Boot 기본 내장 서버를 운영하면서maxThreads나acceptCount같은 설정이 정확히 어디서 작동하는지 짚어 보고 싶은 개발자입니다.
두 개의 세계 — Coyote와 Catalina
Tomcat 코드를 처음 열면 패키지가 둘로 갈라져 있는 것을 발견하게 됩니다. org.apache.coyote.*와 org.apache.catalina.*입니다. 이 분리는 1999년 Catalina 4.0이 설계될 때 도입되었고, 지금까지 Tomcat 아키텍처의 가장 큰 분기선입니다.
- Coyote — 프로토콜 어댑터입니다. HTTP/1.1, HTTP/2, AJP 같은 와이어 프로토콜을 파싱하고, 결과를 프로토콜 중립적인
org.apache.coyote.Request/Response객체로 노출합니다. Servlet API는 아예 모릅니다. - Catalina — Servlet 컨테이너입니다.
jakarta.servlet.Servlet사양에 따라 요청을 컨테이너 계층(Engine → Host → Context → Wrapper)으로 흘려보내고, 최종적으로Servlet.service(req, res)를 호출합니다.
두 세계를 잇는 다리가 org.apache.catalina.connector.CoyoteAdapter입니다. Coyote가 와이어 바이트에서 Request를 만들어 adapter.service(req, res)를 호출하면, Adapter가 Servlet 사양에 맞는 org.apache.catalina.connector.Request로 감싼 뒤 Catalina의 파이프라인에 밀어 넣습니다.
flowchart LR
Client[Client Socket] -->|bytes| Coyote
subgraph Coyote
Endpoint[NioEndpoint<br/>Acceptor/Poller/Worker]
Http11[Http11Processor<br/>request line + headers]
Endpoint --> Http11
end
Coyote -->|CoyoteAdapter.service| Catalina
subgraph Catalina
Engine[StandardEngineValve]
Host[StandardHostValve]
Context[StandardContextValve]
Wrapper[StandardWrapperValve]
Filter[ApplicationFilterChain]
Servlet[Servlet.service]
Engine --> Host --> Context --> Wrapper --> Filter --> Servlet
end
이 그림이 글 전체의 지도입니다. 이후 본문은 왼쪽에서 오른쪽으로 한 박스씩 들어가 각 박스 안에서 어떤 스레드가 어떤 자료구조 위에서 무엇을 하는지 풀어 갑니다.
Tomcat 컨테이너 계층
Coyote 안쪽으로 들어가기 전에, Catalina 쪽 컨테이너 계층을 먼저 정리합니다. 이것이 Coyote와 Catalina를 잇는 좌표계가 됩니다.
Server
└── Service
├── Connector(s) ─┐
└── Engine │
└── Host ├── Service 단위로 묶인다
└── Context │
└── Wrapper ─┘
- Server — JVM 안의 Tomcat 인스턴스 전체. 보통 1개입니다. 셧다운 포트(
8005)를 듣는 주체이기도 합니다. - Service — Connector 하나 이상과 Engine 정확히 하나를 묶는 단위입니다. 같은 Engine을 HTTP와 AJP 두 Connector가 공유하는 식으로 쓰입니다.
- Connector — 와이어 프로토콜 단입니다.
Http11NioProtocol,Http11Nio2Protocol,AjpNioProtocol등이 이 자리에 들어옵니다. Spring Boot 내장 Tomcat은 기본으로Http11NioProtocol하나만 띄웁니다. - Engine — Service 단위의 요청 처리 파이프라인입니다. Connector에서 올라온 모든 요청이 여기를 통과합니다.
- Host — 가상 호스트입니다.
www.example.com처럼 도메인 이름과 1:1로 묶입니다. - Context — 웹 애플리케이션 하나입니다. WAR 한 개 = Context 한 개입니다. Spring Boot fat jar에서는 보통 1개 Context만 존재합니다.
- Wrapper — Servlet 하나를 감싸는 컨테이너입니다. Spring MVC라면
DispatcherServlet이 단일 Wrapper에 매핑됩니다.
Engine, Host, Context, Wrapper는 모두 Container 인터페이스의 구현이고, 각자 Pipeline을 하나씩 가집니다. Pipeline은 Valve의 연결 리스트이고, 마지막 Valve(StandardXxxValve)는 다음 계층 컨테이너의 Pipeline으로 요청을 넘기는 역할을 합니다. 이 위계가 그대로 valve chain이 됩니다.
NioEndpoint — Acceptor와 Poller, 그리고 Worker
Coyote 측에서 실제 소켓을 다루는 자리가 Endpoint입니다. HTTP/1.1 + NIO 조합에서는 org.apache.tomcat.util.net.NioEndpoint가 그 자리에 들어갑니다.
NioEndpoint는 다음 다섯 inner class를 가집니다.
| 클래스 | 역할 |
|---|---|
Acceptor |
ServerSocketChannel.accept()를 호출하는 전용 스레드 |
Poller |
Selector 한 개를 돌리는 전용 스레드 |
PollerEvent |
Poller의 이벤트 큐 노드. 재사용된다 |
NioSocketWrapper |
SocketChannel + read/write buffer + timeout을 묶는 래퍼 |
SocketProcessor |
Executor에 제출되는 Runnable. TLS handshake 후 핸들러 호출 |
스레드는 정확히 세 부류입니다.
flowchart LR
OS[OS accept queue<br/>acceptCount] -->|accept| A[Acceptor thread<br/>1 instance]
A -->|setSocketOptions| P[Poller thread<br/>1 instance, Selector]
P -->|processSocket| W[Worker pool<br/>maxThreads]
W -->|handler.process| Coyote[Http11Processor]
Tomcat 8.5 시절에는 acceptorThreadCount로 Acceptor 수를 늘릴 수 있었지만, Tomcat 10 이후 deprecated 되었고 Tomcat 11에서는 무시되어 Acceptor는 항상 1개로 고정됩니다. Poller도 1개입니다. 즉 한 Connector당 전용 스레드 2개, 그리고 Worker pool이 따로 돌아갑니다.
Acceptor 스레드의 run 루프
Acceptor.run()은 셧다운 신호가 올 때까지 무한 루프를 돕니다. 한 사이클이 하는 일은 다음과 같습니다.
endpoint.countUpOrAwaitConnection()호출 —LimitLatch라는 카운터를 1 증가시킵니다. 만약 카운터가maxConnections에 도달했다면 여기서 블록됩니다. OS의 accept queue에 받아 둔 소켓이 있어도 Tomcat이 가져가지 않습니다.endpoint.serverSocketAccept()호출 —ServerSocketChannel.accept()를 통해 한 개의SocketChannel을 받습니다.endpoint.setSocketOptions(socket)호출 — 받은 소켓에 옵션을 적용하고 Poller에 등록합니다.
maxConnections는 그래서 OS의 accept queue 위에 또 하나의 게이트가 됩니다. 이미 받은 연결이 그 수에 도달하면, Acceptor가 멈추고 OS 큐에 더 들어오는 연결은 운영체제가 acceptCount까지 보관하다가 그 이후로는 OS 단에서 RST(또는 SYN drop, 플랫폼별)을 돌려보냅니다.
// Acceptor.run() (간략화)
while (!stopCalled) {
if (endpoint.isPaused()) { pauseSleep(); continue; }
if (!running) break;
endpoint.countUpOrAwaitConnection(); // LimitLatch up, maxConnections 도달 시 대기
SocketChannel socket = null;
try {
socket = endpoint.serverSocketAccept();
} catch (Exception e) {
endpoint.countDownConnection(); // 실패 시 counter 되돌리기
errorDelay(); // 50ms → 1.6s 지수 백오프
continue;
}
errorDelay = 0;
if (running && !endpoint.isPaused()) {
if (!endpoint.setSocketOptions(socket)) {
endpoint.closeSocket(socket); // setSocketOptions 실패 시 즉시 close
}
} else {
endpoint.destroySocket(socket);
}
}
setSocketOptions 안에서 일어나는 일이 NIO 모델의 핵심입니다. 새 SocketChannel을 NioChannel로 감싸 캐시(nioChannels)에서 가져오고, 그것을 NioSocketWrapper로 다시 감쌉니다. 그리고 socket.configureBlocking(false)로 non-blocking 모드를 설정한 뒤, Poller의 이벤트 큐에 등록 요청(OP_REGISTER)을 넣고 즉시 리턴합니다. Acceptor는 절대 Selector.register나 read를 직접 호출하지 않습니다. 데이터 한 바이트도 읽지 않고 다음 연결을 받으러 돌아갑니다.
Poller 스레드의 run 루프
Poller가 진짜 NIO 작업을 합니다.
// Poller.run() (간략화)
while (true) {
boolean hasEvents = events(); // 1) 이벤트 큐 드레인
int keyCount = (hasEvents)
? selector.selectNow()
: selector.select(selectorTimeout);
if (closed) { events(); timeout(0, false); selector.close(); break; }
Iterator<SelectionKey> it = keyCount > 0
? selector.selectedKeys().iterator() : null;
while (it != null && it.hasNext()) {
SelectionKey sk = it.next();
it.remove(); // 핵심: 안 지우면 같은 키가 다음 select에도 떨어진다
NioSocketWrapper sw = (NioSocketWrapper) sk.attachment();
if (sw != null) {
processKey(sk, sw);
}
}
timeout(keyCount, hasEvents); // 2) idle/keepalive 만료 검사
}
여기서 일어나는 일을 풀어 보면 다음과 같습니다.
events()—SynchronizedQueue<PollerEvent>로 들어와 있는 등록/관심 변경 요청을 처리합니다. 새 소켓 등록(OP_REGISTER), interest 변경(OP_READ↔OP_WRITE) 등을 여기서Selector에 반영합니다. 처리된PollerEvent는eventCache로 돌아가 다음에 재사용됩니다 —PollerEvent는 GC를 줄이려 의도적으로 reusable하게 설계됐습니다.selector.select(selectorTimeout)—epoll_wait(Linux 기준)를 호출해 준비된 채널이 생길 때까지 대기합니다. 기본selectorTimeout은 1000ms입니다.selectedKeys를 순회하면서processKey에서processSocket(sw, SocketEvent.OPEN_READ, true)를 부릅니다. 이때it.remove()를 잊으면 같은 키가 다음 사이클에도 그대로 떨어지는 고전적인 NIO 버그가 됩니다. Tomcat은 당연히remove()를 호출합니다.processSocket은SocketProcessorBase를 한 개 만들어 Executor에 제출합니다. 이 시점에서 처리 책임은 Worker 스레드로 넘어가고 Poller는 즉시 다음 키로 갑니다.timeout()— Selector에 등록된 모든 키를 훑어 idle/keepalive 시간이 만료된 연결을 끊습니다. 이 함수가 Tomcat의connectionTimeout/keepAliveTimeout이 실제로 작동하는 자리입니다.
PollerEvent — 왜 따로 큐가 있는가
SelectionKey.interestOps()는 같은 스레드에서 호출하지 않으면 OS와 Selector 내부 자료구조 사이에 race가 발생합니다. 그래서 Tomcat은 Poller 외부의 스레드(특히 Acceptor와 Worker)가 interest를 바꾸려 할 때 직접 interestOps()를 부르지 않고, PollerEvent를 만들어 큐에 넣는 방식을 씁니다. 그러면 Poller 스레드가 events()에서 큐를 드레인하면서 자기 스레드에서 안전하게 반영합니다.
addEvent(PollerEvent)
Acceptor ─────────────────────────▶ events Queue
Worker ─────────────────────────▶
│
events() drain
│
▼
Poller thread
│
▼
Selector.register / key.interestOps
PollerEvent가 재사용되는 이유도 이 구조 때문입니다. 매 요청마다 read/write interest 토글이 빈번하게 발생하므로, GC 압박을 줄이려고 eventCache: SynchronizedStack<PollerEvent>에서 풀링합니다.
Worker pool에서 일어나는 일 — SocketProcessor와 Http11Processor
Poller가 Executor에 던진 SocketProcessor.doRun()은 다음을 차례로 수행합니다.
- TLS handshake가 끝나지 않았다면
SSLEngine을 돌려 마저 끝냅니다. - 끝났으면
getHandler().process(socketWrapper, event)를 호출합니다.Handler는Http11NioProtocol의 inner class인ConnectionHandler이고, 이쪽이 connection 단위로Http11Processor를 캐시합니다. Http11Processor.process(socketWrapper, event)→service(socketWrapper)진입.
Http11Processor.service()는 한 connection 위에서 keep-alive로 들어오는 여러 요청을 처리하는 루프를 돕니다. 한 요청의 처리는 다음과 같습니다.
// Http11Processor.service() (개념적 발췌)
while (!getErrorState().isError() && keepAlive && !isAsync() && ...) {
if (!inputBuffer.parseRequestLine(keptAlive, ...)) { // method, URI, protocol
// 데이터 부족 — NIO에서는 OPEN_READ로 돌아간다
break;
}
prepareRequestProtocol(); // HTTP/1.0 vs 1.1
if (!inputBuffer.parseHeaders()) { break; } // 헤더 한 줄씩
prepareInputFilters(); // chunked / identity / gzip
validateHost(); // RFC 7230 Host 검증
try {
getAdapter().service(request, response); // ★ Catalina로 진입
} catch (...) { ... }
if (!isAsync()) {
endRequest();
finishResponse();
}
}
여기서 두 가지가 중요합니다.
- 데이터가 부족하면 그냥 리턴한다.
Http11InputBuffer.parseRequestLine은 소켓에서 읽을 수 있는 만큼 읽고, 한 줄을 완성하지 못하면false를 반환합니다. 그러면Http11Processor는SocketState.LONG을 리턴하고,ConnectionHandler가 이를 받아 socket을 Poller에 다시 등록(OP_READ)합니다. 다음 데이터가 도착하면 Poller가 다시 깨우고, 같은 connection의Http11Processor인스턴스가 이어서 파싱합니다. Worker 스레드는 그 사이 다른 작업을 합니다. getAdapter().service()가 Coyote와 Catalina의 경계선이다. 이 호출 전까지가 Coyote, 호출 이후가 Catalina입니다.
Http11InputBuffer는 socket buffer 위에 sliding window로 동작하며 transfer encoding 필터(chunked, identity, gzip 등)를 적용합니다. Http11OutputBuffer도 대칭으로 동작하고, sendfile이 가능한 응답이면 Poller의 OP_WRITE + transferTo() 경로로 위임할 수 있습니다.
CoyoteAdapter — 두 세계의 다리
org.apache.catalina.connector.CoyoteAdapter.service(coyoteReq, coyoteRes)는 Tomcat에서 가장 중요한 30줄짜리 메서드 중 하나입니다.
// CoyoteAdapter.service() (간략화)
Request request = (Request) coyoteReq.getNote(ADAPTER_NOTES);
Response response = (Response) coyoteRes.getNote(ADAPTER_NOTES);
if (request == null) {
request = connector.createRequest(); // org.apache.catalina.connector.Request
request.setCoyoteRequest(coyoteReq);
response = connector.createResponse();
response.setCoyoteResponse(coyoteRes);
request.setResponse(response);
response.setRequest(request);
coyoteReq.setNote(ADAPTER_NOTES, request);
coyoteRes.setNote(ADAPTER_NOTES, response);
}
response.setHeader("X-Powered-By", powered); // 옵션
boolean postParseSuccess = postParseRequest(coyoteReq, request, coyoteRes, response);
if (postParseSuccess) {
request.setAsyncSupported(connector.getService()
.getContainer().getPipeline().isAsyncSupported());
connector.getService().getContainer()
.getPipeline().getFirst()
.invoke(request, response); // ★ Engine pipeline 진입
}
// 응답 마무리, async 처리, 로깅, recycle
이 메서드가 하는 일을 정리하면 네 단계입니다.
- 객체 매핑 — Coyote의
Request/Response를 Servlet 사양의org.apache.catalina.connector.Request/Response로 감쌉니다. 두 객체는ADAPTER_NOTES(index 1)로 서로를 가리키므로, 한 connection의 다음 요청에서도 같은 객체를 재사용합니다. - postParseRequest — URI 디코딩과 정규화(
%2F처리,..거부 등), 세션 트래킹(cookie/URL rewriting), 호스트 매핑(Mapper가 URL을Engine → Host → Context → Wrapper로 풀어 줍니다), 인증 체크의 일부가 여기서 일어납니다. - Engine pipeline 진입 —
connector.getService().getContainer().getPipeline().getFirst().invoke(req, res). 여기서getContainer()가 반환하는 객체가 Engine입니다. - 마무리 — async가 아니면 access log를 쓰고, request/response 객체를
recycle()해 다음 요청에서 다시 쓰도록 풉니다.
postParseRequest의 URI 정규화가 빠진 시기가 있을 때 .. 디렉토리 탐험 공격이 가능했던 사례가 있고, 그래서 이 함수는 Tomcat security advisory에 자주 이름이 오르는 자리이기도 합니다.
Catalina pipeline — Engine부터 Wrapper까지
CoyoteAdapter가 호출한 getPipeline().getFirst().invoke()는 valve chain의 시작입니다.
flowchart TD
A[StandardEngineValve] -->|host pipeline| B[StandardHostValve]
B -->|context pipeline| C[StandardContextValve]
C -->|wrapper pipeline| D[StandardWrapperValve]
D -->|ApplicationFilterChain.doFilter| E[Servlet.service]
각 Valve가 무엇을 하는지 살펴 봅니다.
- StandardEngineValve —
request.getHost()를 보고, 매핑된 Host의 pipeline을 호출합니다. 가상 호스트가 없는 단일 도메인 환경에서는 거의 항상 default host로 직진합니다. - StandardHostValve — 두 가지를 합니다. (1) 현재 스레드의 ContextClassLoader를 해당 Context의 ClassLoader로 갈아 끼웁니다 — 같은 JVM에 여러 WAR가 떠 있을 때 서로의 클래스가 새지 않게 하는 자리입니다. (2) 에러 페이지 처리(ErrorReport)를 위한 try/catch도 둡니다.
- StandardContextValve — Servlet 사양의 진입 보호선입니다. WEB-INF 내부 자원 접근 차단, ServletRequestListener 통지, 그리고 Wrapper 매핑을 확정합니다.
- StandardWrapperValve — 실제로 Servlet 인스턴스를 가져오는 자리입니다.
wrapper.allocate()로 instance pool(단일 인스턴스 모델이거나,SingleThreadModel레거시면 pool)에서 Servlet을 꺼내고,ApplicationFilterFactory.createFilterChain()으로 web.xml 또는 @WebFilter로 등록된 필터들을 묶은ApplicationFilterChain을 만들고, 그 위에filterChain.doFilter(req, res)를 호출합니다. - ApplicationFilterChain — 등록된 필터 N개를 순회하며 마지막에
servlet.service(req, res)를 호출합니다. Spring Boot라면 이 시점에OncePerRequestFilter체인(Spring Security 필터 체인 등)이 들어와 있고, 그 끝이DispatcherServlet.service()입니다.
Spring MVC 안에서 일어나는 일은 별개 글의 영역입니다(이 블로그에서는 DispatcherServlet 동작 원리를 따로 다룹니다). 여기까지가 Tomcat의 책임 범위입니다.
핵심 설정과 그것이 작동하는 자리
Spring Boot 운영에서 자주 만지는 설정들을 위 모델 위에 올려놓으면 다음과 같습니다.
| 설정 키 (server.xml / application.yml) | 기본값 | 작동 위치 |
|---|---|---|
acceptCount / server.tomcat.accept-count |
100 | OS accept queue 크기. maxConnections 도달 후 OS가 보관하는 한도 |
maxConnections / server.tomcat.max-connections |
10000 (NIO) | Acceptor의 LimitLatch 임계치 |
maxThreads / server.tomcat.threads.max |
200 | Worker Executor의 최대 스레드 수 |
minSpareThreads / server.tomcat.threads.min-spare |
10 | Worker Executor의 최소 idle 스레드 수 |
connectionTimeout / server.tomcat.connection-timeout |
60000 (배포된 server.xml 기본은 20000) | request line 도착까지 기다리는 시간. Poller의 timeout() 검사 |
keepAliveTimeout / server.tomcat.keep-alive-timeout |
connectionTimeout과 동일 |
응답 직후 다음 요청 대기 시간 |
maxKeepAliveRequests / server.tomcat.max-keep-alive-requests |
100 | 한 connection이 처리 가능한 keep-alive 요청 수 |
processorCache |
200 (Spring Boot 기본 200, Tomcat 자체 기본 0) | Http11Processor 인스턴스 캐시 크기 |
socket.bufferPool |
-2 (자동) | NioChannel의 read/write buffer 풀 크기 |
maxConnections와 maxThreads는 자주 같은 값으로 묶어 생각되지만 완전히 다른 게이트입니다.
- maxConnections = Acceptor가 더 이상 받지 않는 시점. 즉 동시 open connection 수의 상한.
- maxThreads = Worker pool의 최대 크기. 즉 동시에 servlet 코드를 실행할 수 있는 처리 중인 요청 수의 상한.
NIO 모델에서는 connection이 idle 상태(keep-alive 대기, async, WebSocket 등)일 때 Worker 스레드를 잡지 않습니다. 그래서 maxConnections >> maxThreads인 비율(예: 10000 vs 200)이 정상이고, 의미가 있습니다. 200개 worker가 200개 요청을 처리하는 동안 다른 9800개 connection은 Poller의 Selector 위에 잠들어 있을 수 있습니다.
반대로 옛 BIO 모델에서는 connection 하나마다 worker 한 개를 잡았으므로 maxConnections == maxThreads가 강제됐습니다. BIO 커넥터는 Tomcat 8.5에서 제거됐고, APR/native 커넥터는 Tomcat 11에서 완전히 제거됐습니다. Tomcat 11에 남아 있는 선택지는 **NIO(기본)**와 NIO2(AIO 기반) 둘뿐입니다.
NIO vs NIO2
| 항목 | NIO (Http11NioProtocol) |
NIO2 (Http11Nio2Protocol) |
|---|---|---|
| 기반 API | java.nio.channels.Selector (epoll/kqueue) |
java.nio.channels.AsynchronousSocketChannel |
| Poller 스레드 | 있음(Selector.select 루프) |
없음(완료 콜백 모델) |
| 콜백 실행 자리 | Worker pool | JDK의 AsynchronousChannelGroup의 thread pool |
| 기본 여부 | 기본 | 옵션 |
NIO2는 OS의 진짜 비동기 IO(Windows IOCP, Linux io_uring/epoll 위 에뮬레이션)를 활용한다는 장점이 있지만, Tomcat의 운영 경험과 튜닝 문헌은 NIO에 압도적으로 쏠려 있고, Spring Boot도 NIO를 기본으로 둡니다. 이 글의 모든 그림은 NIO 기준입니다.
한 요청 한 사이클 — 종합
위 모든 조각을 한 요청의 lifeline으로 다시 묶으면 이렇게 됩니다.
flowchart TD
S0[1. Client TCP SYN] --> S1[2. OS accept queue<br/>up to acceptCount]
S1 --> S2[3. Acceptor thread<br/>countUpOrAwaitConnection<br/>serverSocketAccept]
S2 --> S3[4. setSocketOptions<br/>NioSocketWrapper + OP_REGISTER]
S3 --> S4[5. Poller events queue]
S4 --> S5[6. Poller drains events<br/>register OP_READ on Selector]
S5 --> S6[7. Selector.select<br/>fires on OP_READ]
S6 --> S7[8. processSocket<br/>submit SocketProcessor to Executor]
S7 --> S8[9. Worker thread<br/>SocketProcessor.doRun<br/>TLS handshake if needed]
S8 --> S9[10. Http11Processor.service<br/>parseRequestLine<br/>parseHeaders]
S9 --> S10[11. CoyoteAdapter.service<br/>wrap Request<br/>postParseRequest mapping]
S10 --> S11[12. Engine pipeline<br/>Engine -> Host -> Context -> Wrapper]
S11 --> S12[13. ApplicationFilterChain<br/>FilterN -> Servlet.service]
S12 --> S13[14. Response written via<br/>Http11OutputBuffer]
S13 --> S14[15. recycle objects<br/>OP_READ re-registered if keep-alive]
15단계로 보이지만, 같은 Http11Processor/Request/Response 객체가 keep-alive 동안 재사용된다는 점, 그리고 단계 4–7이 Acceptor와 Poller 두 스레드를 오간다는 점이 NIO 모델의 정수입니다. 객체 풀과 스레드 분리, 이 두 가지가 동시 10000 connection을 200 worker로 받아 낼 수 있게 해 줍니다.
운영 함정 — 같이 떠올리면 좋은 증상들
위 모델을 머릿속에 두면 다음 증상들이 어디서 발생하는지 짚을 수 있습니다.
- 응답이 갑자기 한 번에 멎고 잠시 뒤 풀린다 —
maxConnections에 도달한 상태에서 Acceptor가LimitLatch에서 블록 중. OS accept queue는acceptCount까지 받다가 그 뒤로 SYN drop. 풀리는 시점은 기존 connection 하나가 close되어 latch가 풀렸을 때. - Tomcat 스레드는 한가한데 응답이 늦다 — Worker가 아니라 다른 자리(downstream HTTP, DB)에서 막혀 있는 경우. NIO 모델은 idle connection에 worker를 안 잡으므로
Threads/idle은 항상 여유 있어 보일 수 있습니다. Connection: keep-alive인데 의도보다 자주 끊긴다 —maxKeepAliveRequests도달(기본 100),keepAliveTimeout초과, 또는 worker pool full로 인한 강제 close 셋 중 하나.server.tomcat.accept-count를 늘렸는데 효과가 없다 — accept queue는 OS가 관리합니다. Linux에서는net.core.somaxconn한도(Linux 5.4 이상에서 기본 4096, 이전 커널은 128)에 의해 잘립니다.sysctl net.core.somaxconn을 같이 올려야 합니다.- request line 파싱 도중 헤더가 부족해 연결이 닫힌다 —
connectionTimeout이 좁고, slow loris류 클라이언트나 프록시를 통한 헤더 전송 지연. Poller의timeout()이 작동. - WAR 두 개 띄웠더니 다른 앱의 클래스가 보인다 —
StandardHostValve의 ContextClassLoader 스위치가 작동 안 한 경우. 보통Thread.currentThread().setContextClassLoader()를 사용자 코드에서 직접 바꿔 두고 안 되돌렸을 때 발생. - NIO Selector 100% CPU — Tomcat 자체가 일으키는 문제는 드물지만, 동일 JVM에서 사용자 코드가 별도 Selector를 잘못 쓰고 있을 가능성. 또한 Tomcat은 selector spin을 의식해
JreCompat.openSelector()우회 경로를 두고 있습니다.
Spring Boot에서 보이는 자리
Spring Boot의 ServerProperties 안에서 위 설정들은 server.tomcat.* prefix로 매핑되고, TomcatWebServerFactoryCustomizer가 Connector 인스턴스에 reflect합니다. Connector는 내부적으로 ProtocolHandler(Http11NioProtocol)와 그 안의 Endpoint(NioEndpoint)에 setter들을 위임합니다. 즉 server.tomcat.max-connections=20000을 적으면 다음 경로를 타고 LimitLatch의 한도가 20000으로 바뀝니다.
server.tomcat.max-connections
→ ServerProperties.Tomcat.maxConnections
→ TomcatWebServerFactoryCustomizer.customize(factory)
→ factory.setMaxConnections(20000)
→ Connector.setProperty("maxConnections", 20000)
→ Http11NioProtocol → AbstractEndpoint.setMaxConnections(20000)
→ LimitLatch.setLimit(20000)
설정 한 줄이 어디까지 가는지를 따라가 보면, Coyote의 책임 범위, Catalina의 책임 범위, Spring Boot가 그 위에 얇게 얹는 사용자 친화 layer가 그대로 보입니다.
다시 한 장으로
처음에 그렸던 지도를 마지막 한 번 더 봅니다.
flowchart LR
OS[OS accept queue<br/>acceptCount]
OS --> Acceptor
subgraph Coyote
Acceptor[Acceptor thread<br/>LimitLatch / maxConnections]
Poller[Poller thread<br/>Selector + events queue]
Worker[Worker pool<br/>maxThreads]
Http11[Http11Processor<br/>InputBuffer / OutputBuffer]
Acceptor --> Poller --> Worker --> Http11
end
Http11 -->|adapter.service| Adapter[CoyoteAdapter]
subgraph Catalina
Engine[StandardEngineValve]
Host[StandardHostValve<br/>ContextClassLoader switch]
Context[StandardContextValve]
Wrapper[StandardWrapperValve<br/>allocate Servlet]
Filter[ApplicationFilterChain]
Servlet[Servlet.service<br/>= DispatcherServlet for Spring]
Engine --> Host --> Context --> Wrapper --> Filter --> Servlet
end
Adapter --> Engine
maxConnections는 Acceptor에, maxThreads는 Worker에, connectionTimeout/keepAliveTimeout은 Poller의 timeout 검사에, ContextClassLoader 스위치는 StandardHostValve에, URI 정규화는 CoyoteAdapter.postParseRequest에 있습니다. Tomcat을 처음 만져 보는 자리에서 자주 듣게 되는 단어들이 이 그림 위의 정확한 자리 하나씩에 대응합니다.
참고자료
- Apache Tomcat 11 Architecture Overview — https://tomcat.apache.org/tomcat-11.0-doc/architecture/overview.html
- Apache Tomcat 11 HTTP Connector Configuration — https://tomcat.apache.org/tomcat-11.0-doc/config/http.html
- Apache Tomcat 11 Valve Component — https://tomcat.apache.org/tomcat-11.0-doc/config/valve.html
NioEndpoint.java(Tomcat main branch) — https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/net/NioEndpoint.javaAcceptor.java— https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/net/Acceptor.javaLimitLatch.java— https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/threads/LimitLatch.javaHttp11Processor.java— https://github.com/apache/tomcat/blob/main/java/org/apache/coyote/http11/Http11Processor.javaHttp11InputBuffer.java— https://github.com/apache/tomcat/blob/main/java/org/apache/coyote/http11/Http11InputBuffer.javaCoyoteAdapter.java— https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/connector/CoyoteAdapter.javaStandardEngineValve.java— https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/core/StandardEngineValve.javaStandardHostValve.java— https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/core/StandardHostValve.javaStandardContextValve.java— https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/core/StandardContextValve.javaStandardWrapperValve.java— https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/core/StandardWrapperValve.java- Spring Boot
ServerProperties— https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java

