Deep Dive /Java Web /1

1. Browser -> Connector (HTTP Request)

이 문서는 open-source/tomcat 실제 코드 기준으로, 브라우저 요청이 Connector 경계에 도달해 Coyote 레이어에서 처리되는 흐름을 추적합니다. 핵심은 “accept -> poller -> socket processor -> protocol processor -> adapter” 호출 체인입니다.


시퀀스 흐름

sequenceDiagram actor Browser participant Acceptor as Acceptor participant Poller as NioEndpoint.Poller participant Processor as SocketProcessor participant Handler as ConnectionHandler participant Http11 as Http11Processor participant Adapter as CoyoteAdapter Browser->>Acceptor: TCP connect + HTTP bytes Acceptor->>Poller: setSocketOptions -> register Poller->>Processor: OP_READ -> processSocket Processor->>Handler: getHandler().process(...) Handler->>Http11: processor.process(...) Http11->>Http11: parse request line + headers Http11->>Adapter: Adapter.service(req, res)

코드 기준 결론

  1. 소켓 수락은 org.apache.tomcat.util.net.Acceptor#run()에서 수행되고, 실제 accept() 호출은 NioEndpoint#serverSocketAccept()가 담당합니다.
  2. 수락된 소켓은 NioEndpoint#setSocketOptions()에서 non-blocking으로 전환된 뒤 poller.register(socketWrapper)로 Selector에 등록됩니다.
  3. Poller가 OP_READ 이벤트를 받으면 processSocket(..., SocketEvent.OPEN_READ, true)로 작업을 Executor에 디스패치합니다.
  4. Executor 스레드의 NioEndpoint.SocketProcessor#doRun()getHandler().process(...)를 호출합니다.
  5. AbstractProtocol.ConnectionHandler#process(...)는 Processor를 선택/생성하고 processor.process(...)를 호출합니다.
  6. HTTP/1.1에서는 AbstractHttp11Protocol#createProcessor()new Http11Processor(this, adapter)를 만들고, Http11Processor#service(...)에서 최종적으로 getAdapter().service(request, response)가 호출됩니다.
  7. AdapterConnector#initInternal()에서 new CoyoteAdapter(this)로 주입됩니다. 즉 1번의 끝지점이 2번(CoyoteAdapter.service)의 시작지점입니다.

오픈 소스 호출 흐름 (NIO + HTTP/1.1)

  1. 브라우저가 TCP 연결을 생성하고 HTTP 바이트를 전송합니다.
  2. Acceptor#run()endpoint.serverSocketAccept()를 호출해 연결을 수락합니다.
  3. NIO 구현에서는 NioEndpoint#serverSocketAccept()serverSock.accept()를 실행합니다.
  4. Acceptor#run()은 이어서 endpoint.setSocketOptions(socket)를 호출합니다.
  5. NioEndpoint#setSocketOptions()socket.configureBlocking(false)poller.register(socketWrapper)를 수행합니다.
  6. Poller#events()가 등록 이벤트를 처리하며 SelectionKey.OP_READ로 Selector에 등록합니다.
  7. Poller#run()이 준비된 키를 순회하고 processKey(...)에서 읽기 이벤트 시 processSocket(socketWrapper, SocketEvent.OPEN_READ, true)를 호출합니다.
  8. AbstractEndpoint#processSocket()createSocketProcessor(...)로 작업 객체를 만들고 executor.execute(sc)로 실행합니다.
  9. NioEndpoint.SocketProcessor#doRun()getHandler().process(socketWrapper, event)를 호출합니다.
  10. AbstractProtocol.ConnectionHandler#process(...)가 Processor를 선택/생성한 뒤 processor.process(wrapper, status)를 호출합니다.
  11. HTTP/1.1의 경우 AbstractHttp11Protocol#createProcessor()에서 생성된 Http11Processorservice(...)를 실행합니다.
  12. Http11Processor#service(...) 내부에서 요청라인/헤더 파싱 후 getAdapter().service(request, response)로 컨테이너 어댑터에 위임합니다.

왜 이런 코드 구조인가 (설계 의도 분석)

1) 왜 AcceptorPoller를 분리했는가

Acceptor#run()은 연결 수락 자체에만 집중하고, 실제 I/O 처리는 Poller/Processor로 넘깁니다.

  • Acceptor#run() 주석과 코드에서 countUpOrAwaitConnection()으로 최대 연결 수를 먼저 제어합니다.
  • setSocketOptions() 주석에 “성공하면 적절한 processor에게 handed off”라고 명시되어 있습니다.
  • pause 상태에서 tight loop -> 짧은 sleep으로 전환하는 로직이 있어, 종료/일시정지 시 CPU 과점을 줄이려는 의도가 드러납니다.

즉, accept 단계는 “새 연결 유입 제어”, poll 단계는 “기존 연결 이벤트 처리”로 역할을 분리해 확장성과 운영 안정성을 동시에 확보합니다.

2) 왜 non-blocking + Selector를 쓰는가

NioEndpoint#setSocketOptions()에서 socket.configureBlocking(false)를 강제하고 poller.register(socketWrapper)로 등록합니다. 그리고 Poller#events()SelectionKey.OP_READ로 소켓을 Selector에 등록합니다.

이 구조는 스레드 하나당 소켓 하나를 블로킹으로 잡아두지 않고, 소수의 스레드가 다수 소켓 이벤트를 multiplexing 하게 만들어 동시성 효율을 높입니다.

3) 왜 Poller 이벤트를 즉시 처리하지 않고 Executor로 디스패치하는가

Poller#processKey(...)는 바로 비즈니스 처리를 하지 않고 processSocket(..., dispatch=true)를 호출합니다. AbstractEndpoint#processSocket(...)dispatch가 true이면 executor.execute(sc)로 별도 컨테이너 스레드에서 실행합니다.

이 설계의 이유는 Selector 루프가 무거운 처리를 직접 수행하면 전체 소켓 감시 지연이 커지기 때문입니다. 즉, Poller는 “이벤트 감지”, SocketProcessor는 “실제 처리”로 분리해 지연 전파를 막습니다.

4) 왜 캐시/재사용 코드가 많은가

Tomcat은 고빈도 요청에서 GC 비용을 줄이기 위해 객체 재사용을 적극 사용합니다.

  • NioEndpoint.PollerEvent 주석: “cacheable object … avoid GC”
  • AbstractEndpoint#processSocket(...): processorCache.pop() 후 재사용
  • AbstractProtocol.ConnectionHandler#process(...): recycledProcessors.pop() 재사용

요청당 객체를 매번 새로 만들면 지연이 튀기 쉬워서, 이벤트 객체/프로세서를 풀링해 처리량과 지연 안정성을 확보합니다.

5) 왜 ConnectionHandler에서 timeout을 NO-OP 처리하는 분기가 있는가

AbstractProtocol.ConnectionHandler#process(...)에는 timeout 이벤트가 이미 불필요해진 경우 즉시 SocketState.OPEN으로 반환하는 분기가 있습니다. 코드 주석도 “dispatch 지연 때문에 timeout이 더 이상 필요 없을 수 있으니 불필요한 처리 회피”를 명시합니다.

이유는 timeout 스레드와 실제 처리 스레드가 분리되어 있기 때문입니다. 늦게 도착한 timeout 이벤트를 그대로 처리하면 정상 요청까지 불필요하게 닫을 수 있습니다.

6) 왜 Adapter 경계를 두는가

Adapter 인터페이스 주석은 “coyote-based servlet container entry point”라고 명확히 정의합니다. 또한 Connector#initInternal()에서 new CoyoteAdapter(this)를 생성해 protocolHandler.setAdapter(adapter)로 주입합니다.

즉, Coyote(프로토콜 처리)는 Adapter 인터페이스까지만 알고, Catalina(서블릿 컨테이너) 구현 세부는 모르게 설계되었습니다. 프로토콜 처리와 컨테이너 처리를 분리해 교체 가능성과 유지보수성을 높이려는 목적입니다.

7) 왜 Http11Processor#service(...)가 이렇게 많은 분기를 가지는가

Http11Processor#service(...)에는 실제 운영에서 필수인 분기가 함께 들어 있습니다.

  • 부분 요청 읽기(parseHeaders() 실패 시 open socket 유지)
  • 업그레이드(Connection: upgrade) 처리
  • keep-alive 요청 수 제한
  • 파싱 실패 시 400/500/503 상태 설정
  • 마지막에 getAdapter().service(...) 위임

이 메서드는 단순 파서가 아니라 “HTTP/1.1 상태머신 + 커넥션 수명 제어 + 컨테이너 진입 게이트” 역할을 동시에 수행하도록 작성되어 있습니다.


왜 이게 1번의 본질인가

1번은 아직 Catalina 파이프라인에 들어가기 전 단계입니다. Http11Processor가 바이트를 HTTP 요청으로 파싱하고 Adapter.service(...)를 부르는 순간까지가 정확히 1번입니다. 그 다음 2번 문서에서 다루는 CoyoteAdapter.service(...)가 시작됩니다.


다음 탐구

다음 문서 2번에서 CoyoteAdapter.service() 내부를 보며, 왜 Request/Response 래핑과 postParseRequest()가 필요한지 이어서 파고들면 전체 흐름이 연결됩니다.