Tomcat BIO Connector & NIO Connector

appti·2024년 3월 16일
0

분석

목록 보기
19/23

서론

톰캣에서는 다음과 같은 Connector를 제공합니다.

해당 글에서 살펴 볼 Conenctor는 BIO Connector와 Http11Protocol, NIO Connector와 Http11NioProtocol 입니다.

Http11*Protocol과 Connector는 모두 MBeanRegistration을 구현하고 있기는 하지만, 동작 과정 및 책임에서 큰 차이점을 보이므로 해당 글에서는 별도로 구분해서 다루도록 하겠습니다.

또한, 현재 스프링 부트 3버전 이상에서 사용하는 톰캣 9버전부터는 BIO Connector에 사용되는 Http11Protocol이 삭제된 상황입니다.
그러므로 BIO Connector의 경우 톰캣 6.5버전, NIO Connector의 경우 톰캣 9버전으로 진행하도록 하겠습니다.

컴포넌트

톰캣에는 다음과 같은 컴포넌트가 존재합니다.

공통

  • Connector
    • 클라이언트로부터 요청을 받아들이고 응답을 반환하는 컴포넌트입니다.
  • ProtocolHandler
    • Connector에 의해 사용되며, 특정 프로토콜(HTTP, AJP 등)의 처리를 담당합니다.
    • 요청을 파싱하고 응답을 생성하는 역할을 합니다.
  • Endpoint
    • 실제로 네트워크 연결을 관리하는 컴포넌트입니다.
    • 클라이언트와의 소켓 연결을 처리하고, 데이터의 송수신을 담당합니다.
  • Acceptor
    • 실제로 클라이언트와의 소켓 연결을 처리하는 컴포넌트입니다.
  • SocketProcessor
    • 클라이언트와의 소켓 연결을 실제로 처리하는 역할을 합니다.
    • 클라이언트의 요청을 읽고, 해당 요청을 처리한 후 응답을 클라이언트에게 전송합니다.
  • CoyoteAdapter
    • 톰캣의 Servlet 컨테이너인 Catalina와 Coyote 컴포넌트 사이의 어댑터 역할을 합니다.
    • HTTP 요청을 ServletRequest로 변환하고, ServletResponse를 HTTP 응답으로 변환합니다.

Tomcat 9 이전 & BIO

  • Http11Protocol
    • HTTP/1.1 프로토콜을 처리하는 ProtocolHandler의 BIO 구현체입니다.
  • JIoEndpoint
    • java.io를 사용하는 Endpoint의 구현체입니다.
  • AbstractEndpoint$Handler
    • Endpoint에 의해 처리되는 연결을 관리하는 핸들러의 추상 클래스입니다.
    • 연결을 실제로 처리하는 구체적인 로직은 이 핸들러의 구현체에 의해 정의됩니다.
    • BIO Connector가 remove 된 이후 ConnectionHandler로 명칭이 변경되었습니다.

Tomcat 9 이후 & NIO

  • Http11NioProtocol
    • HTTP/1.1 프로토콜을 처리하는 ProtocolHandler의 NIO 구현체입니다.
  • NioEndpoint
    • Java NIO를 사용하는 Endpoint의 구현체입니다.
  • Poller
    • NioEndpoint에서 사용되는 컴포넌트로, 이벤트 큐 기반으로 여러 연결 이벤트를 효율적으로 관리하기 위해 사용됩니다.
  • AbstractEndpoint$ConnectionHandler
    • Endpoint에 의해 처리되는 연결을 관리하는 핸들러의 추상 클래스입니다.
    • 연결을 실제로 처리하는 구체적인 로직은 이 핸들러의 구현체에 의해 정의됩니다.
    • BIO Connector가 remove 되기 전에는 Handler라는 이름이었습니다.

기본 동작 방식

톰캣은 주로 다음과 같이 동작합니다.

  1. Tomcat.start() 시 서버 소켓을 포함해 필요한 설정을 모두 진행합니다.
  2. Acceptor에서 클라이언트의 소켓 연결을 수락합니다.
  3. AbstractEndpoint$Handler(ConnectionHandler)에서 클라이언트와 연결된 소켓에서 발생하는 데이터 전송을 처리합니다.
  4. CoyoteAdapter를 통해 Request, Response를 ServletContext에서 사용할 수 있는 ServletRequest, ServletResponse로 변환합니다.
  5. 해당 요청인 ServletRequest, 해당 요청에 대한 응답에 사용할 ServletResopnse를 ServletContext에 전달합니다. 이후 요청은 스프링 부트의 DispatcherServet에 의해 처리됩니다.

BIO Connector

톰캣 9 이전의 BIO Connector 동작 과정은 다음과 같습니다.

  1. ApplicationContext refresh 과정 중 Tomcat이 생성됩니다.
    refresh 과정 중 TomcatServletWebServerFactory에서 Tomcat을 생성하고 Connector, Worker 스레드 풀 생성, 서버 소켓 생성 등이 작업을 수행합니다.
    이 때 Connector 내부에 세팅되는 ProtocolHandler는 Http11Protocol이며, Adapter는 CoyoteAdapter입니다.
    Http11Protocol은 내부적으로 Endpoint를 가지고 있으며, Endpoint는 Acceptor를 가지게 됩니다.
    이 때 Http11Protocol 내부에 세팅되는 Endpoint는 JIoEndpoint입니다.
  2. LifeCycleProcessor에 의해 Tomcat.start()가 호출되면 Acceptor에서 소켓 연결 작업을 수행합니다.
    Acceptor.run()은 클라이언트에게 소켓 연결 요청이 올 때 까지 대기하기 때문에 하나의 스레드에서는 하나의 소켓 연결만을 처리할 수 있습니다.
  3. Acceptor에서 소켓 연결에 성공하면 Handler를 통해 클라이언트의 요청을 처리할 수 있습니다.
    Handler는 Http11ConnectionHandler로, 내부적으로 Http11Processor를 사용해 요청을 처리합니다.
  4. Http11Processor는 내부에 세팅된 CoyoteAdatper에서 Request를 생성하고 Service로부터 컨테이너를 가져와 Context에 요청을 전달합니다.
    이 때의 Context는 ServletContext를 의미합니다.
    이 ServletContext에 전달된 요청은 이후 스프링 부트의 DispatcherServlet에 의해 처리됩니다.

특징

Acceptor 스레드가 서버 소켓에서 accept()를 호출하는 코드입니다.
해당 스레드에서 클라이언트의 요청이 있는지 여부와는 관계 없이 무조건 accept()를 호출하기 때문에, 클라이언트가 실제 소켓 연결 요청을 해야지만 블록에서 해제됩니다.

이로 인해서 하나의 Acceptor 스레드에서 하나의 소켓만을 관리할 수 있습니다.

NIO 동작 방식

톰캣 9 이후의 NIO Connector 동작 방식은 다음과 같습니다.

  1. ApplicationContext refresh 과정 중 Tomcat이 생성됩니다.
    refresh 과정 중 TomcatServletWebServerFactory에서 Tomcat을 생성하고 Connector 생성, Worker 스레드 풀 생성, 서버 소켓 생성 등의 초기화 작업을 수행합니다.
    이 때 Connector 내부에 세팅되는 ProtocolHandler는 Http11NioProtocol이며, Adapter는 CoyoteAdapter입니다.
    Http11Protocol은 내부적으로 Endpoint를 가지고 있으며, Endpoint는 Acceptor를 가지게 됩니다.
    이 때 Http11Protocol 내부에 세팅되는 Endpoint는 NioEndpoint입니다.
    NioEndpoint는 내부적으로 Poller를 가지고 있으며, 생성과 동시에 Selector.open()으로 셀렉터를 설정합니다.
  2. LifeCycleProcessor에 의해 Tomcat.start()가 호출되면 Acceptor에서 소켓 연결 작업을 수행합니다.
  3. Acceptor에서 소켓 연결에 성공하면 해당 소켓을 Poller에 등록합니다.
    소켓 연결 성공 시 반환되는 것은 java.nio의 SocketChannel입니다.
    Poller에 등록할 때 SocketChannel을 NioSocketWrapper로 변환합니다.
  4. Poller는 NioSocketWrapper를 셀렉터 및 큐에 등록합니다.
    클라이언트와의 소켓 연결이 완료된 상태이므로 클라이언트가 전송하는 데이터를 읽기 위해 이벤트는 SelectionKey.OP_READ로 설정합니다.
    큐에 저장할 때 NioSocketWrapper로 변환합니다.
  5. Poller.run()은 무한 반복문을 통해 Acceptor가 등록한, 클라이언트와 연결된 소켓의 이벤트를 감시합니다.
    이 때 항상 이벤트 큐에 저장된 PollerEvent를 통해 이전에 처리하지 못한 소켓을 셀렉터에 등록하거나 소켓을 닫게 됩니다.
  6. 준비된 소켓을 별도의 스레드 풀에서 처리합니다.
    이 때 SocketProcessor를 거쳐 AbstractProtocol$ConnectionHandler에서 소켓이 처리되며, ConnectionHandler는 내부적으로 Http11Processor를 사용해 요청을 처리합니다.
  7. Http11Processor는 내부에 세팅된 CoyoteAdatper에서 Request를 생성하고 Service로부터 컨테이너를 가져와 Context에 요청을 전달합니다.
    이 때의 Context는 ServletContext를 의미합니다.
    이 ServletContext에 전달된 요청은 이후 스프링 부트의 DispatcherServlet에 의해 처리됩니다.

특징

위 코드는 Poller가 이벤트 큐 기반으로 소켓을 처리하는 과정에서 소켓 옵션을 설정하는 코드입니다.
셀렉터, 채널 등 java.nio에 있는 클래스를 사용하는 것으로 유추할 수도 있지만, 위와 같이 소켓에서 블로킹 기능을 비활성화 시켜 NIO 기반으로 동작하고 있음을 확인할 수 있습니다.

위 코드는 Tomcat.start() 호출 시 호출되는, NioEndpoint.startInternal()의 일부입니다.

별도의 스레드 풀 없이 단일 스레드로 Poller의 스레드를 실행시키고 있음을 확인할 수 있습니다.

이는 톰캣에서 일반적으로 다른 컴포넌트 실행 시 스레드 풀 기반으로 동작시키는 것과는 대조적입니다.

이렇게 단일 스레드로 Poller를 실행시키는 이유 중 하나는 Thread-safe를 위해서입니다.
java.nio의 경우 일반적으로 Thread-safe하지 않기 때문에 멀티 스레드 환경에서 동기화 기법을 적용해야 합니다.
톰캣은 이러한 동기화 기법으로 이벤트 큐 기반의 단일 스레드로 Poller를 동작시켜 동시성 문제를 회피했다고 볼 수 있습니다.

이벤트 큐 기반의 Acceptor, Poller

Acceptor와 Poller는 이벤트 큐 기반으로 동작하고 있음을 확인했습니다.
Acceptor가 Producer, Poller가 Consumer 역할을 수행합니다.

Acceptor와 Poller 싱글 스레드로 동작하는 것을 확인할 수 있습니다.
즉, Acceptor와 Poller가 싱글 스레드기 반의 이벤트 큐 구조라는 것이며 이는 톰캣의 NIO Connector 동작의 핵심 구조입니다.

동작 과정을 그림으로 표현하면 다음과 같습니다.

Acceptor 클라이언트와 소켓 연결을 수행하므로, produce할 때 NIO 이벤트 타입을 항상 SelectionKey.OP_READ로 설정합니다.

Poller는 내부적으로 셀렉터를 가지고 있기 때문에, 이벤트 큐를 consume해 가져온 PollerEvent에 명시된 NIO 이벤트 타입을 기반으로 셀렉터에 등록합니다.

서버가 소켓으로 파일 등의 데이터를 전송해야 하는 경우, Poller가 내부적으로 다시 해당 이벤트를 Produce합니다.

이후, Poller가 NIO 이벤트를 무한 반복문을 통해 감시하다가 처리할 수 있는 이벤트(소켓 데이터 송수신)가 발생하면 Worker 스레드 풀에 의해 요청이 처리됩니다.

이를 통해 톰캣은 java.nio를 활용해 멀티 플렉싱 기반 다중 접속 방식을 구현했다는 것을 알 수 있습니다.

java.nio는 동시성 문제가 발생할 수 있기 때문에, 싱글 스레드 기반의 이벤트 큐 구조로 구현되어있으며, 싱글 스레드여도 NIO 기반으로 구현했기 때문에 하나의 스레드에서 여러 클라이언트의 요청을 수행할 수 있습니다.

또한, 이러한 구조로 인해 Worker 스레드 풀에서 클라이언트의 요청을 처리하는 순서는 알 수 없지만, 적어도 Worker 스레드 풀로 전달되는 클라이언트의 요청은 순서대로 전달되는 것을 확인할 수 있습니다.

결론

  • 톰캣에서는 9버전 이전에는 BIO Connector가 존재했지만, 9버전 이후에는 효율을 위해 BIO Connector를 완전히 제거(deprecated -> remove) 했습니다.
  • 톰캣은 내부적으로 Acceptor와 Poller를 활용해, 이벤트 큐 기반으로 멀티 플렉싱 기반 다중 서버 접속 방식을 java.nio를 통해 구현했습니다.
    • Acceptor와 Poller는 싱글 스레드로 동작하며, 이를 통해 java.nio의 동시성 문제를 해결했습니다.
profile
안녕하세요

0개의 댓글