톰켓과 네티 서버의 차이점

엄태권·2023년 10월 29일
3

Netty

목록 보기
3/3
post-thumbnail

Overview

이전 글에선 요청에 대한 Asynchronous한 요청을 받기위한 NIO 의 Tomcat에서의 동작을 알아보았습니다.
Tomcat은 New I/O Connector를 통해 사용자의 요청에대해 비동기적으로 동작할 수 있었습니다.

그럼 이번엔 Tomcat과 Netty 두 서버간의 어떤차이점이 있을까를 알아보도록 하겠습니다.

Tomcat & Netty

우리가 Asynchronous/Non-Blocking 하면 Netty서버를 떠올리는데는 이유가 있을겁니다.

그렇다면 왜 Spring의 WebFlux는 Netty를 쓸까가 궁금했습니다. WebFlux와 Tomcat을 잘 사용하면 되는거 아닐까란 의문이 들었습니다.

왜 Reactive Programming에서 Tomcat은 잘 이야기 되지 않을까요? 이번엔 각서버의 동작방식에 대한 차이를 알아보겠습니다.

Tomcat

우리는 이미 이전글에서 Tocmat이 어떻게 동작하는지 알아봤습니다. 간단히 이야기하면, Tomcat은 NIO Connector를 통해 사용자의 요청을 받습니다.

NIO Connector에서는 Acceptor를 통해 소켓을 수신하고 이를 PollerEvent Queue에 Publish 합니다. Poller는 해당 Event Queue의 소켓을 구독하고 있어 획득하고, Channel들을 Processing 합니다.

각 Channel들은 모두 Worker Thread에 할당되어 작업이 진행되며 이후는 CoyoteAdapter 로직을 통해 Servlet에 Dispatch하는 작업이 진행됩니다.

이전글에서 Tomcat은 9버전부터 NIO Connector를 기본으로 사용함을 이야기 했습니다.
또한 Spring MVC가 Asynchronouse/Non-Blocking 하게 동작하지 못하는 이유에 대해 Filter의 동작, Dispatcher Servlet의 동작방식 등에 그 원인이 있음을 이야기 했습니다.

그렇다면 왜 Spring의 WebFlux는 Netty를 쓸까가 궁금했습니다. WebFlux와 Tomcat을 잘 사용하면 되는거 아닐까란 의문이 들었습니다.

사실 의존성을 변경하여 WebFlux와 Tomcat을 사용할 수 있습니다. 그럼에도 불구하고 Netty를 쓰는 이유가 궁금했고 그 이유를 알아보겠습니다.

우선 Tomcat의 표준 HTTP Connector(NIO 등)에는 몇가지 속성을 지원합니다. 그중 몇가지 속성에 대해 확인해 보려합니다.

  • MaxConnections
    서버가 요청을 처리할 수있는 Connection의 수를 의미합니다.(기본값은 8192입니다.)
  • AcceptCount
    Connection수가 MaxConnection에 도달했을 경우 추가적인 Connection 요청을 대기시키는 공간(Queue)입니다.(기본값은 100입니다.)
  • MaxThread
    요청을 처리하는 Thread의 최대 수 입니다. 처리될 수 있는 동시 요청의 최대 수를 결정합니다.(기본값은 200 입니다.)

정리하자면, Tomcat은 기본적으로 8192개의 connection을 처리할수 있습니다. 이때 Thread pool에서 가능한 요청 처리 Thread가 모두 작업중일때(기본 200) 8192개 이후의 요청들은 모두 요청을 대기시키는 Queue로 이동하게 됩니다.

해당 Queue는 100개까지의 요청을 더 대기시킬 수 있으며, 이후의 요청에 대해서는 timeOut등의 처리가 이루어집니다.

이렇게 고정된 수의 Thread Pool을 통해 여러 작업을 실행하는 패턴을 Thread Pool Design Pattern이라 합니다.

[Thread Pool Design Patter]

작업이 주어지면 Thread Pool실행자에게 전달되고 지정된 Thread에 할당 됩니다. Thread가 작업을 완료하면, 다른 Thread를 요청하며, 사용가능한 Thread가 없을 경우 대기하거나 종료할 수 있습니다.

MaxThread 설정을 봤을때 Tomcat은 요청에 대한 스레드 모델인 Thread Per Request 모델로 동작하며 이는 들어오는 각 요청에 스레드를 할당한다는 의미로 볼 수 있습니다.

즉 요청 스레드의 풀크기가 이미 최대인 경우 이후에 도착한 요청은 대기열에 추가되는 것입니다. 이러한 Thread Per Request 모델인 Tomcat의 문제점은 아래와 같은 상황에서 발생할 수 있습니다.

[가정 조건]
1. 우리에게는 해외로 송금할 수 있는 API가 있습니다.
2. 한국 -> 중국으로 송금시에 약 10초의 작업이 진행됩니다.
3. 평상시 한국->중국으로의 송금은 하루 약 100건 입니다.
4. 중국 중추절날 한국 -> 중국으로의 송금은 하루 2배 가까이 늘어납니다.
5. 단 모든 송금은 동일한 시간에 일어난다고 가정합니다.

편의를 위해서 수치를 좀 조정하겠습니다. 우리 송금 서버의 MaxThread는 1개로, 하루 송금은 1건으로, 가정해보겠습니다. 정상적인 동작이라면 하루 1건의 송금은 문제 없이 처리가될 수 있습니다.

사용자의 요청이 1개 들어오고 이를 Worker Thread 1개가 처리할 수 있습니다. 그러나 문제는 중추절 입니다. 만일 동일한 시간에 2개의 송금 요청이 들어올 경우 어떤 상황이 생길지 보겠습니다.

먼저 요청을 처리하는 Thread의 수가 1개이며, 작업의 시간이 10초가 걸리기 때문에 Tomcat은 2번째에 도착한 요청을 Connection 대기열(Queue)로 이동시킵니다. 이후 해당 요청을 처리하는 Thread의 작업이 완료되면 그제서야 요청을 넘겨주는 방식입니다.

그동안 사용자는 기다리는 상황이 생기게 됩니다. 만일 200개의 Thread가 모두 사용중이고, 사용자의 Connection 대기열도 모두 차있는 상황이라면 사용자는 계속해서 돌고있는 로딩창을 보다 연결이 끊기거나, 연결조차 할 수 없는 상황이 발생합니다.

결국 NIO Connector를 통해 사용자의 요청을 PollerEvent에 추가해놓고 이를 Poller가 Channel을 획득하고 Processing을 진행하더라도, 극한의 상황까지 가서 결국 많은 트레픽이 몰린 상황 에서 Worker Thread의 모든 Thread가 작업중 이라면 사용자는 기다리거나 연결을 할 수 없는 상황이 생깁니다.

이러한 Tomcat의 동작방식은 마치 벽에 공 던지기를 하는 모습과 비슷합니다. 공이 벽에맞고 돌아오기 까지 이후에 던질 공들은 대기하고 있어야 하는 상황과 같습니다.

그렇다면 Netty는 어떤식으로 동작하는 구조일까요?

Netty

우선 Netty는 비동기 이벤트 기반 네트워크 애플리케이션 프레임워크 입니다.

쉽게 말해 메일을 생각해 보도록 하겠습니다. 보낸 메시지에 대한 응답을 받을 수 도 있고 받지 못할수도 있습니다. 또한 메시지를 보내는 중에도 예상치 못한 메시지를 받을 수 도 있습니다. 답을기다리는 동안 다른 작업을 수행할 수도 있죠.

이러한 비동기적 Non-Blocking한 방식은 Blocking한 방식에 비해 훨씬 더 빠르며 많은 수의 이벤트가 처리가능한 점에서 경제적일 수 있습니다.
(적은 수의 스레드로 많은 연결을 모니터링 할 수 있다는점, 사용자가 응답을 기다리지 않아도 되는점 등)

그럼 Netty의 중요한 몇가지 구성요소와 동작방식을 알아보겠습니다.

Channel & Channel Future

이전 글에서 봤던 NIO의 구성요소 중 하나인 Channel입니다. 네트워크 소켓이나, I/O 작업에 대해 양방향으로 동작할 수 있는 통로입니다.

Netty의 Channel의 모든 I/O 동작은 비동기로 동작하며, I/O 작업의 결과 또는 상태에 대한 정보를 제공하는 ChannelFuture 인스턴스와 함께 반환됩니다.

아래는 ChannelFuture에서 제공하는 결과와 상태정보입니다.

* 응답이 오면 Completed 로 간주(isDone) 이후 상태값이 별도로 반환됨.

                                       +---------------------------+
                                       | Completed successfully    |
                                       +---------------------------+
                                  +---->      isDone() = true      |
  +--------------------------+    |    |   isSuccess() = true      |
  |        Uncompleted       |    |    +===========================+
  +--------------------------+    |    | Completed with failure    |
  |      isDone() = false    |    |    +---------------------------+
  |   isSuccess() = false    |----+---->      isDone() = true      |
  | isCancelled() = false    |    |    |       cause() = non-null  |
  |       cause() = null     |    |    +===========================+
  +--------------------------+    |    | Completed by cancellation |
                                  |    +---------------------------+
                                  +---->      isDone() = true      |
                                       | isCancelled() = true      |
                                       +---------------------------+

추가로 이러한 Channel은 아래와 같은 LifeCycle을 가지고 있습니다. 이러한 상태 변화가 발생함에 따라 해당 이벤트가 생성되며 이는 ChannelHandler로 전달됩니다.

ChannelHandler

ChannelHandler인바운드아웃바운드 데이터 처리에 적용되는 애프리케이션 로직의 컨테이너 역할을 합니다. 애플리케이션의 비즈니스 로직이 들어가는 곳이며, 이는 하나 이상의 ChannelHandler에 있는 경우가 많습니다.

[참고]
ChannelInboundHandlerAdapter의 경우 인바운드 메시지를 비워주는 코드가 필요합니다.

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf message = (ByteBuf) msg;
        try {
            while (message.isReadable()) {
                System.out.print((char) message.readByte());
                System.out.flush();
            }
        } finally {
            //Netty는 try-with-resource문을 사용하지 않을까 의문이었고 결론적으로 Netty 4.1은 대부분 Java 6에 대해 컴파일 되어서임을 알게되었다.
            ReferenceCountUtil.release(msg);
        }
    }

위처럼 finally를 통해 메시지를 비워줘야 하는데 SimpleChannelInboundHandler의 경우 이러한 작업을 ChannelRead0() 메소드를 통해 자동으로 메시지 해제를 지원한다.

[SimpleChannelInboundHandler.java]

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        boolean release = true;
        try {
            if (acceptInboundMessage(msg)) {
                @SuppressWarnings("unchecked")
                I imsg = (I) msg;
                channelRead0(ctx, imsg);
            } else {
                release = false;
                ctx.fireChannelRead(msg);
            }
        } finally {
            if (autoRelease && release) {
                ReferenceCountUtil.release(msg);
            }
        }
    }

이러한 ChannelHandlerChannelPipeline에 등록되어 관련있는 ChannelHandler끼리의 연결이 진행됩니다. 이때의 ChnnelHandler의 LifeCycle은 아래와 같습니다.

ChannelPipeline

ChannelPipeline는 채널을 통해 흐르는 인바운드와 아웃바운드 이벤트에 동작하는 ChannelHandelr 인스턴스들의 체인입니다.

ChnnelHandler는 아래와 같이 ChannelPoipeline에 추가됩니다.

  • ChannelInitialize 구현이 ServerBootStrap에 등록됩니다.
  • ChannelInitializer.initChannel()호출시 파이프라인에 ChannelHandler를 추가합니다.
  • ChannelInitializer는 이후 ChannelPoipeline에서 자신을 제거합니다. 아래는 ChannelPipeline의 동작 원리입니다.
                                                     I/O Request
                                                via Channel or
                                            ChannelHandlerContext
                                                          |
      +---------------------------------------------------+---------------+
      |                           ChannelPipeline         |               |
      |                                                  \|/              |
      |    +---------------------+            +-----------+----------+    |
      |    | Inbound Handler  N  |            | Outbound Handler  1  |    |
      |    +----------+----------+            +-----------+----------+    |
      |              /|\                                  |               |
      |               |                                  \|/              |
      |    +----------+----------+            +-----------+----------+    |
      |    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
      |    +----------+----------+            +-----------+----------+    |
      |              /|\                                  .               |
      |               .                                   .               |
      | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
      |        [ method call]                       [method call]         |
      |               .                                   .               |
      |               .                                  \|/              |
      |    +----------+----------+            +-----------+----------+    |
      |    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
      |    +----------+----------+            +-----------+----------+    |
      |              /|\                                  |               |
      |               |                                  \|/              |
      |    +----------+----------+            +-----------+----------+    |
      |    | Inbound Handler  1  |            | Outbound Handler  M  |    |
      |    +----------+----------+            +-----------+----------+    |
      |              /|\                                  |               |
      +---------------+-----------------------------------+---------------+
                      |                                  \|/
      +---------------+-----------------------------------+---------------+
      |               |                                   |               |
      |       [ Socket.read() ]                    [ Socket.write() ]     |
      |                                                                   |
      |  Netty Internal I/O Threads (Transport Implementation)            |
      +-------------------------------------------------------------------+
    예시로 아래와 같은 상황에서의 채널 파이프라인의 동작 예시를 보겠습니다.
    ChannelPipeline p = ...;
    p.addLast("1", new InboundHandlerA());
    p.addLast("2", new InboundHandlerB());
    p.addLast("3", new OutboundHandlerA());
    p.addLast("4", new OutboundHandlerB());
    p.addLast("5", new InboundOutboundHandlerX());

    주어진 예제 구성에서 이벤트가 인바운드일 때 핸들러 평가 순서는 1, 2, 3, 4, 5입니다. 이벤트가 아웃바운드될 때는 5, 4, 3, 2, 1 순입니다. 이 원칙에 따라 ChannelPipeline은 스택 깊이를 줄이기 위해 특정 핸들러의 평가를 건너뜁니다.

    인바운드 일경우 3과 4는 ChannelInboundHandler를 구현하지 않으므로 인바운드 이벤트의 실제 평가 순서는 1, 2, 5가 될 수 있습니다.

    아웃바운드의 경우 1과 2는 ChannelOutboundHandler를 구현하지 않으므로 아웃바운드 이벤트의 실제 평가 순서는 5, 4, 3이 될 수 있습니다.

    5가 ChannelInboundHandler와 ChannelOutboundHandler를 모두 구현하는 경우 인바운드 및 아웃바운드 이벤트의 평가 순서는 각각 125와 543이 될 수 있습니다.

Bootstrap

Netty의 부트스트랩은 애플리케이션의 네트워크 계층 구성을 위한 컨테이너를 제공합니다.

네트워크 계층을 구성하기 위한 컨테이너를 제공하며, 여기에는 프로세스를 지정된 포트에 바인딩하거나
한 프로세스를 지정된 포트의 지정된 호스트에서 실행 중인 다른 프로세스에 연결합니다.

일반적으로 연결에 대해 포트에 바인딩하는 것을 Server측의 BootStrap 이라고하며, 지정된 포트의 호스트에 연결하는 것을 Client측의 BootStrap이라고 합니다.

이 두가지는 각각 아래의 표와 같은 EventLoopGroup을 가집니다.

왜 서버측의 EventLoopGroups은 2개 필요할까요?

우선 서버 채널은 로컬 포트에 바인딩된 서버의 자체 수신 소켓을 위한 EventLoopGroup이 필요합니다. 다음 으로는 들어오는 클라이언트의 연결을 처리하기 위해 생성된 모든 채널이 포함됩니다.

서버채널과 연관된 이벤트 루프 그룹은 들어오는 연결 요청을 생성하는 이벤트 루프를 할당하는데, 이는 들어오는 연결 요청에 대한 채널 생성을 담당합니다.

연결이 수락되면 두 번째 이벤트 루프 그룹은 이벤트 루프를 해당 채널에 이벤트 루프를 할당합니다.

EventLoop

시대가 바뀔수록 여러 코어 또는 CPU를 갖춘 컴퓨터가 일반화되어 있기 때문에 대부분의 최신 애플리케이션은 애플리케이션은 시스템 리소스를 효율적으로 사용하기 위해 정교한 멀티스레딩 기술을 사용합니다.

이는 위에서 이야기 했던 Tomcat의 thread pooling pattern 과 같습니다. 이는 컨텍스트 전환 비용을 없애지는 못합니다. 또한 프로젝트 수명 기간 동안 다른 스레드 관련 문제가 발생할 수 있습니다. Netty는 이를 단순화하기 위해 EventLoop를 활용합니다.

Netty의 EventLoop는 동시성네트워킹 두 가지 API를 사용하는 구조 입니다. 먼저, io.netty.util.concurrent 패키지는 JDK 패키지인 java.util.concurrent 를 빌드하여 스레드 실행기를 제공합니다. 둘째, io.netty.channel 패키지의 클래스는 채널 이벤트와 인터페이스하기 위해 이를 확장합니다.

이러한 구조에서 이벤트 루프는 절대 변경되지 않는 정확히 하나의 스레드에 의해 구동됩니다,.
구성 및 사용 가능한 코어에 따라 사용 가능한 코어에 따라 리소스 사용을 최적화하기 위해 여러 이벤트 루프가 생성될 수 있으며, 단일 이벤트 루프가 여러 채널을 서비스하도록 할당될 수 있습니다.

아래는 EventLoop에 대한 다이어그램 입니다.

EventLoop의 기본 개념은 아래와 같습니다.

객체(Event Emitters)에서 발생한 이벤트와 이벤트 루프의 연관 관계를 보여줍니다. 객체에서 발생한 이벤트이벤트 큐에 입력되고 이벤트 루프는 큐에 입력된 이벤트가 있을 때 해당 이벤트를 꺼내서 이벤트를 실행합니다. 이것이 이벤트 루프의 기본 개념 입니다.

이 개념에 더해 이벤트 루프가 지원하는 스레드는 종류에 따라 단일 스레드다중 스레드가 있으며, 처리한 이벤트의 결과를 돌려주는 방식에 따라 CallbackFuture 패턴으로 나뉘며 Netty는 이 두가지 패턴을 모두 지원합니다.

위의 그림을 간략하게 설명하자면 EventLoopGroup에 고정된 3개의 EventLoop의 동작 방식입니다. 일반적으로 Netty는 단일 스레드 이벤트다중 스레드 이벤트 모두 종류에 상관없이 순서를 보장합니다. 어떻게 가능할까요? 이는 아래의 3가지 특징과 같습니다.

  • Netty의 이벤트는 Channel에서 발생합니다.
  • EventLoop 객체는 이벤트 큐를 가지고 있습니다.
  • Netty Channel은 하나의 EvenLoop에 등록됩니다.

즉 하나의 Channel에서 발생한 이벤트는 항상 동일한 EventLoop의 스레드에서 처리되기 때문에 발생 순서와 처리 순서가 일치하게 됩니다. 또한 EventLoop Thread의 Event Queue 공유 를 막기위해 각 EventQueue를 각 Loop Thread 내부에 두기 때문에 순서 보장이 가능합니다.

BossGroup

각 포트별로 접속을 허용해주는 그룹을 이야기합니다. Tomcat에서는 Acceptor의 역할과 동일한 역할을 수 행하며, 커넥션이 정상적으로 이루어질 경우 Accepted 된 채널을 WorkerThread로 전달합니다.

WorkerGroup

보통 Cpu Core * 2의 갯수만큼 WorkerThreadPool이 생성되며, Non-blocking 모드에서 채널에 읽기와 쓰기 작업을 Non-blocking으로 처리할 수 있습니다.

Netty 흐름

결국 위의 사진을 보면, BossGroup을 통해 Accepted된 채널들이 각 WorkerThread로 전달되고, 해당 Worker Goup은 해당된 채널의 이벤트에 따라 EventLoop가 동작됩니다. 각 EventLoop에는 각자의 EventQeue를 보유하고 있어 Event의 순서를 보장해 줄수 있고, 해당되는 Channel들은 PipeLine에 구축된 ChannelHandler들의 체이닝에 따라 동작이 진행되는 구조입니다.

이러한 Netty의 동작 방식을 Tomcat의 벽에 공튀기기와 비교해 본다면 레일위에 공을 놓는 것과 같습니다. 즉 레일위에 화물열차가 돌고있고, 사용자는 거기에 공을 넣으면 알아서 처리되고 응답을 받는 것으로 이해할 수 있습니다.

참조

profile
https://github.com/Eom-Ti

0개의 댓글