Netty란? - (1)

이상윤·2025년 12월 13일

Spring

목록 보기
1/1

Redis Client에 대해 공부하던중, Lettuce / Redisson이 Netty 기반으로 동작한다고 해서 이에 관해 간단하게라도 알아보고 넘어가려고 한다.

우선 Netty를 이해하기 위해서는 사전 지식이 필요하며 이들은 Non-blocking 기반의 Multiplexing Network I/O와 ByteBuffer 그리고 EventLoop에 대한 내용이다.

이 글과 그림들의 내용은 binghe819님의 네티 이해하기 (Netty Deep Dive) 기반으로 작성되었다.

Netty란?

Netty는 비동기 이벤트 기반 네트워크 애플리케이션 프레임워크로,
Java에서 고성능 서버·클라이언트 네트워크 통신을 쉽게 구현하기 위해 만들어진 라이브러리다.

Netty의 Reactor 패턴 동작 원리를 그림으로 표현하면 다음과 같으며,

여기서 Event Loop와 Handler는 다음과 같은 역할을 한다.

  • Event Loop
    무한 반복문을 실행하며 Selector로부터 I/O 이벤트가 발생할 때까지 대기하다가 이벤트가 발생하면 해당 이벤트를 처리할 수 있는 Handler에게 디스패치한다.
    보통 특정 Channel에 대한 이벤트를 큐에 삽입할 때, 해당 이벤트를 처리할 수 있는 Handler도 같이 첨부해준다.
  • Handler
    이벤트를 받아 비즈니스 로직을 수행한다. (수행완료하고 결과에 맞는 이벤트를 다시 발행하기도한다.)

Client와의 Connention을 통해 생성된 Socket Channel을 Event Loop에 등록하면, 이 열린 Channel의 I/O부터 close까지의 생명주기를 모두 event loop가 관리하게 된다는 뜻이다.

이를 통해 Channel의 제어 흐름, 멀티 스레딩, 동시성 제어등을 Event Loop가 처리하게 된다.

Netty의 기본적인 동작 방식

Netty는 NIO Selector에 등록된 Channel에서 발생하는 이벤트들을 Channel에 매핑된 핸들러가 처리하는 구조이다.

이는 다음과 같은 그림으로 표현할 수 있다.

Selector에 등록된 Socket Channel Key중 이벤트가 발생한 SelectedKey들을 Task Queue에 넣고 등록된 Channel에 Handler Pipeline에 위임하여 네트워크 read/write와 비즈니스 로직을 처리한다.

Boss Group과 Child Group

Netty는 설정을 통해 여러 Event Loop를 Group으로 묶을 수 있으며 Group을 조합해서 애플리케이션을 구성한다.
이는 같은 역할을 수행하는 복수의 EventLoop를 EventLoop Group으로 묶을 수 있으며, EventLoopGroup은 크게 BossGroup과 ChildGroup으로 구분된다.
아래는 Netty의 EventLoop를 구성할 때의 가장 기본이 되는 구조를 도식화한 그림이다.

  • Boss Group
    새로운 클라이언트의 연결 요청만 처리한다. 이는 새로운 클라이언트와의 Socket Channel을 생성하고, accept() 이벤트를 처리한다.
    Boss Group은 클라이언트와의 연결만 처리하고 나머지 read/write같은 실제 비즈니스 로직의 경우는 Child Group 내의 EventLoop에 위임하여 처리한다.
  • Child Group
    Boss Group으로부터 위임받은 I/O작업을 실제로 수행한다.

하지만 위 구조가 절대적이지는 않으며, 클라이언트 역할로 Netty가 구동되는 경우(Redis Java Client, gRPC 등)는 Group 하나만으로 동작한다.

Netty를 이루고있는 구성요소

Netty는 실제 클라이언트-서버간의 네트워크 통신과정에서 여러 컴포넌트들이 각자의 역할을 수행하며 협력하며, 각 컴포넌트별 관계는 아래와 같다.

이제 각 컴포넌트의 역할을 알아보겠다.

Channel

Channel은 TCP 커넥션에 대한 I/O 작업을 처리하는 역할을 수행하며, 다음과 같은 구조를 가지고 있다.

구체적으로, 다음과 같은 기능을 제공한다.

  • Channel의 현재 상태
    채널이 열렸는지, 연결됐는지 같은 정보
  • Channel에 대한 설정
    send/receive 버퍼 크기 같은 정보
  • I/O명령
    read, write, close, connect같은 명령
  • Channel에 대한 I/O 이벤트를 처리할 Pipeline rhksfus cjfl
  • 연결된 Event Loop 조회
    그리고 필요에 따라, ChannelPipeline이나 ChannelHandler를 추가 및 제거하여 Channel I/O 이벤트에 대한 처리를 수행할 수 있다.

Unsafe interface

Channel 인터페이스를보면 내부 클래스로 Unsafe 인터페이스가 정의되어있다.
이 Unsafe란 용어는 Netty 뿐만 아니라 자바내에서도 많이 사용되는 용어이며, 보통 C나 커널 명령에 부합하는 low-level 명령을 호출할 때의 인터페이스 역할을한다.

Netty에서의 Unsafe도 커널내 Socket I/O 작업을 모두 수행하며, Channel내 I/O 작업이 필요하면 모두 이 Unsafe의 구현체에게 위임한다.

보통 유저 레벨의 코드에선 unsafe한 명령에 속하는 read, write의 I/O 명령을 직접 호출할 일이 거의 없으며 공식 문서에도 가능한 이 Unsafe 인터페이스의 구현체를 사용하길 추천하고있다.

실제 Unsafe 인터페이스의 구현체는 Channel의 epoll, kqueue같은 구현체별로 다 가지고 있다.

ChannelFuture와 ChannelPromise

  • ChannelFuture
    Channel I/O에 대한 비동기 처리 pending completion 결과를 나타낸다.
    Netty의 모든 작업은 비동기로 동작하며, 이때 모든 I/O 작업 요청의 결과로 void가 아닌 ChannelFuture를 return하게 된다.

ChannelFuture는 Java의 비동기 API인 Future와 유사한 역할을 수행하며, ChannelFuture를 통하면 비동기 요청한 처리가 완료되었는지 I/O 상태를 확인할 수 있다.

비동기 처리기때문에 요청후 바로 결과가 반환되지만, 실제 I/O 작업은 위와 같이 uncompleted, completed(success, fail, cancel)로 나뉘어 상태가 관리된다.

그리고 CompletableFuture과 동일하게 작업이 성공했던 실패했던 완료된다면 Callback Listener를 붙여 I/O 완료에 대한 처리를 할 수 있다.

  • ChannelPromise
    Channel write에 대한 처리를 수행하는 Handler인 ChannelOutboundHandler의 대부분의 메서드는 작업 완료시 알림을 받기위해 ChannelPromise를 인자로 받는다.

ChannelPromise는 ChannelFuture의 하위 인터페이스로써 setSuccess() 또는 setFailure() 와 같이 write 가능한 메서드를 정의하여 ChannelFuture을 불변으로 만들어준다.

ChannelHandler와 ChannelPipeline

Netty의 가장 핵심이자 기본이 되는 개념인 Channel이 TCP연결 후 생성되고 나면 ChannelPipeline을 Channel에 구성하게 된다. Netty는 기본적으로 데이터를 받기 위한 Input Stream을 Inbound, 내보내는 Output Stream을 Outbound라고 한다.
그리고 ChannelPipeline은 여러 ChannelHandler의 조합을 의미하며, ChannelHandler는 Channel에 대한 실질적인 read/write를 호출하고, 비즈니스를 수행하는 컴포넌트이다.

ChannelHandler는 개발자가 자유롭게 추가, 삭제할 수 있으며 여러 Handler를 구성하여 서로 상호작용할 수 있도록 제어할 수 있다. 또한, 각 Channel엔 여러 Handler의 조합을 나타내는 고유한 ChannelPipeline을 가지고 있다.

여기서 중요한점은, 새로운 Channel이 TCP연결 후 생성되고 나면, Netty는 자동으로 ChannelPipeline도 같이 생성하여 Channel에 매핑한다는 점이다.

ChannelPipeline은 위 그림과 같이 Inbound 이벤트는 Linked-List Head에서 Tail까지의 InboundHandler로 전달되며, 반대로 Outbound 이벤트는 Linked-List의 Tail에서 Head까지 OutboundHandler로 전달된다.

ChannelHandler

ChannelHandler는 ChannelPipeline안에서 조합을 통해 실행된다.

  • ChannelHandler엔 Blocking code를 금지하는것이 좋다.
    ChannelPipeline내의 각 ChannelHandler는 EventLoop에서 발생한 I/O 이벤트를 전달받아 처리하게된다.

이때 EventLoop는 Single Thread 기반으로 동작하기때문에, 해당 스레드를 Blocking하지 않는게 굉장히 중요하다.

만약 Blocking하게 된다면 해당 EventLoop에 등록된 Channel 이벤트 처리에 아주 큰 부정적인 영향을 끼치게된다.

이로인해 ChannelHandler내 비즈니스 로직은 무조건 비동기와 callback을 활용해 Non-blocking하게 처리하는게 좋다.
비즈니스 처리를 다른 Thread로 비동기 요청하고, ChannelFuture, CompletableFuture등을 반환받아 처리가 완료되면 callback으로 Channel에 처리 완료된 내용을 I/O 부분만 Netty EventLoop에 위임하는 방식으로 구현하거나 동일한 Non-Blocking 스펙인 Reactive Streams의 구현체를 같이 사용함으로써 모든 로직 자체를 Non-Blocking하게 사용하면 된다.

  • ChannelHandlerAdaptor
    ChannelInboundHandler와 ChannelOutboundHandler 모두 순수 인터페이스라 모든 메서드를 구현해줘야하는 불편함이 존재하는데, Netty는 편의를 위해 ChannelHandlerAdaptor 추상 클래스를 구현한 어탭터 클래스를 제공한다.

    ChannelInboundHandlerAdaptor: Inbound I/O Event 어댑터 구현체.
    ChannelOutboundHandlerAdaptor: Outbount I/O Operation 어댑터 구현체.
    ChannelDuplexHandler: Inbound, Outbound Event 처리용 어댑터 구현체.

  • ChannelHandlerContext
    ChannelPipeline 내의 ChannelHandler간의 상호작용에 사용되는 객체이며, 이는 ChannelPipeline이 생성되면서 같이 생성된다.
    Context 객체를 통해 Handler들은 upstream과 downstream으로 이벤트를 전달할 수도, Pipeline을 동적으로 변경시킬 수도 있으며, key:value형태의 정보를 저장할 수도 있다.

ChannelHandlerContext의 기능중 하나가 바로 Handler간의 이벤트 전파 (event propagation)이다.
아래 그림은 실제 ChannelPipeline, ChannelHandler, ChannelHandlerContext가 어떤 관계를 가지는지 보여준다.

그리고 실제 이벤트 전파를 위한 실질적인 실행(invoke)은 ChannelHandlerContext가 수행한다. 아래는 세 Component가 어떻게 상호작용하는지 보여준다.

ChannelHandlerContext는 네이밍에서 알 수 있듯이, 파이프라인내 현재 Handler 실행 컨텍스트를 저장하고 처리하는 역할을 수행한다. 쉽게 말해, 다음 Handler를 찾아 Handler에 실행을 위임하고, 현재 스레드가 어느것이냐에따라 바로 실행하거나 TaskQueue에 넣는등의 처리를 수행한다.

이벤트 전파를 위해 ChannelHandlerContext는 아래와 같이 InboundHandler와 OutboundHandler에 대한 전파 메서드를 제공한다.

// inbound event propagation
ChannelHandlerContext.fireChannelRegistered()
ChannelHandlerContext.fireChannelActive()
ChannelHandlerContext.fireChannelRead(Object)
ChannelHandlerContext.fireChannelReadComplete()
ChannelHandlerContext.fireExceptionCaught(Throwable)
ChannelHandlerContext.fireUserEventTriggered(Object)
ChannelHandlerContext.fireChannelWritabilityChanged()
ChannelHandlerContext.fireChannelInactive()
ChannelHandlerContext.fireChannelUnregistered()

// outbound event propagation
ChannelOutboundInvoker.bind(SocketAddress, ChannelPromise)
ChannelOutboundInvoker.connect(SocketAddress, SocketAddress, ChannelPromise)
ChannelOutboundInvoker.write(Object, ChannelPromise)
ChannelHandlerContext.flush()
ChannelHandlerContext.read()
ChannelOutboundInvoker.disconnect(ChannelPromise)
ChannelOutboundInvoker.close(ChannelPromise)
ChannelOutboundInvoker.deregister(ChannelPromise)

ChannelInitializer

Channel이 생성되었을 때 ChannelPipeline을 생성, 초기화 및 매핑해주는 역할을 수행하는 컴포넌트이며, 코드로 알아보면 다음과 같다.

public class ChannelInitializerExample extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();

        pipeline.addLast(new EchoServerFirstChildHandler());
    }
}

Bootstrap

Selector를 사용하면 여러 리소스를 모니터링 할 수 있으며, 이벤트가 준비되면 적절한 Handler에 위임하고 다시 모니터링을 계속한다. 이를 EventLoop라고한다.
그리고 이러한 EventLoop는 Group으로 묶여, boss와 child로 구분된 후 각자의 역할을 수행한다.

Bootstrap은 Channel, EventLoopGroup등 Netty로 작성한 네트워크 애플리케이션의 동작 방식과 환경을 설정하는 도우미 클래스다. 이를 통해 Netty 애플리케이션을 시동할 수 있으며, 각종 설정도 할 수 있다.

bootstrap으로 설정할 수 있는 요소는 다음과 같다.

  • 전송 계층 (소켓 모드와 I/O 종류)
  • 이벤트 루프
  • 채널 파이프라인 설정
  • 소켓 주소와 포트
  • 소켓 옵션

코드를 보면 다음과 같다.

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup) // 1
            .channel(NioServerSocketChannel.class) // 2
            .handler(new ChannelInitializer<NioServerSocketChannel>() { // 3
                @Override
                protected void initChannel(NioServerSocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    pipeline.addLast(new EchoServerInboundHandler());
                }
            })
            .childHandler(new ChannelInitializer<SocketChannel>() { // 4
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    pipeline.addLast(new EchoServerChildInboundHandler());
                }
            });

    // 서버 시작
    log.info("start server...");
    ChannelFuture f = b.bind(8080).sync(); // 5
    f.channel().closeFuture().sync();
} finally {
    log.info("close server...");
    workerGroup.shutdownGracefully();
    bossGroup.shutdownGracefully();
}

각 주석의 코드를 해석하면 아래와 같다.

  1. 이벤트 루프 그룹 설정.
  2. 비동기 네트워킹에 사용될 서버 채널 설정. (NioServerSocketChannel, EpollServerSocketChannel)
  3. boss 이벤트 루프 채널 초기화 클래스 설정. (ServerSocketChannel에 대한 핸들러 설정)
  4. worker 이벤트 루프 채널 초기화 클래스 설정. (SocketChannel에 대한 핸들러 설정.)
  5. Netty 서버를 특정 port에 bind한다.
    이때 bind 메서드는 비동기이나, 바로 뒤에 sync를 호출함으로써 bind될 때까지 blocking된다.

여기까지 쓰고, EventLoop부터는 다음 글에서 정리해보겠다.

0개의 댓글