상세 컨텐츠

본문 제목

네티 4.x 유저 가이드(Netty Uesr guide for 4.x)

IT이야기/Java

by Rahs 2018. 2. 26. 13:23

본문





Netty User guide for 4.x를 옮기며...
[최종 수정 일자 : 2018년 2월 26일]

문서 볼륨이 크지 않고, 이해가 필수적인 부분에 대해 굉장히 잘 설명되어 있어서 옮겨봅니다.

오역은 없을 것이라 생각됩니다만 원문과 비교하면서 보시길 권장드리며,

혹여나 잘못된 부분이 있을 경우, 댓글로 따끔하 게 알려주시면 확인하는대로 수정해놓도록 하겠습니다.

혹시 필요하신 분이 계시면 출처 표기 없이 자유롭게 가져가시거나, 수정하시거나 하셔도 관계 없습니다.



시작합니다.

Netty 4.x 버전 사용자를 위한 안내서



서문

개요

 상호간 통신을 위해서는 범용 어플리케이션이나 라이브러리가 흔히 사용됩니다. 웹서버로부터 필요한 정보를 얻어오거나, 웹 서비스의 원격 프로시저 호출을 위해 HTTP 클라이언트 라이브러리를 사용하는 것처럼요. 하지만 범용 프로토콜이나 이를 차용한 라이브러리를 사용하는 것이 조금 부적합한 경우가 존재합니다. 대용량 파일, 이메일을 주고 받는 용도로 범용 HTTP 서버를 사용하지 않습니다. 금융 정보나 다중접속 게임의 데이터처럼 준실시간 통신이 필요한 경우에도 보통은 범용 HTTP 서버 어플리케이션을 사용하지는 않는다는 것이죠.
 즉, 아주 제한적인 목적을 달성하기 위해, 고도로 최적화된 프로토콜이 필요한 경우가 생각보다 많다는 겁니다.
  AJAX 기반의 채팅 어플리케이션이나, 미디어 스트리밍, 혹은 대용량 파일 전송에 최적화된 HTTP 서버 프로토콜 구현이 필요한 경우를 생각해보시기 바랍니다. 개발자의 필요에 따라 완전히 새로운 프로토콜을 설계하거나, 새로운 프로토콜을 사용해야 하는 경우도 얼마든지 있을 수 있죠. 경우에 따라서는 기존에 존재하는 구형 시스템과의 호환성을 확보해야 할 수도 있는데 이런 경우, 개발자들은 안정성과 성능을 저하시키지 않고 얼마나 신속하게 개발을 완료하는지가 중요한 변수가 되기 십상입니다.
  

해결책

 Netty 프로젝트는 유지보수와 규모조정이 용이한 프로토콜 서버 및 클라이언트의 신속한 개발을 목적으로 개발된 이벤트-드리븐 방식의 네트워크 어플리케이션 프레임워크를 제공하고자 하는 노력의 일환으로 시작되었습니다.
  즉, Netty는 프로토콜 서버나 클라이언트와 같은 네트워크 어플리케이션의 쉽고 빠른 개발을 가능하게 하는 NIO 클라이언트 서버 프레임워크라고 할 수 있겠습니다. TCP나 UDP 소켓 서버 개발 등의 네트워크 프로그래밍 과정을 실로 엄청나게 간소화해줍니다.

  '쉽고 빠르다'지만 이로 인한 결과물의 유지보수가 고통스럽거나, 성능 문제가 있어서는 안 됩니다. Netty는 FTP, SMTP, HTTP 뿐 아니라 다양한 binary 및 text 기반 기존 프로토콜들에 대한 고찰과 경험을 바탕으로, 꼼꼼하게 설계되었습니다.
  그 결과로써 Netty는 성능이나 유지보수성에 대한 부정적인 영향 없이, 쉬운 개발과, 성능, 안정성, 그리고 유연성까지를 모두 확보하는데 성공하였습니다.
  
 동일한 장점들을 가지고 있다고 주장하는 다른 네트워크 어플리케이션 프레임워크를 사용 중인 개발자들도 분명히 많을지언데, 이 분들은 아마 Netty만의 장점이 도대체 무엇이냐라는 의문을 가질 겁니다. 이에 대한 답변은 프레임워크의 방향성이라고 할 수 있겠습니다. Netty는 애당초 개발자들에게 API와 구현 자체를 가장 편하게 수행하기 위한 목적으로 개발되었습니다. 눈으로 확인할 수 있는 장점은 아니지만, 이 안내문을 읽으면서, 또 Netty를 이용한 개발을 수행하면서 개발자가 이 '방향성'을 직접 느껴보실 수 있을 것입니다.

시작하기

 본문은 개발자가 빠르게 Netty에 익숙해질 수 있도록 간단한 예제들을 통해 Netty의 핵심적인 구조를 소개하고 있습니다. 본문의 끝에 다다르는 시점에, 이 글의 독자는 Netty 기반의 서버와 클라이언트 프로그램을 작성할 수 있을 것입니다. 핵심적인 내용부터 보길 원한다면 "2장, 설계 구조"를 먼저 보고 난 뒤, 다시 이 지점으로 돌아오길 권합니다.

시작하기에 앞서

 본문에 소개되는 예제들을 구동하기 위해서는 다음과 같은 2가지의 최소 요구조건이 필요합니다: Netty의 최신 버전과, 1.6 또는 그 이상의 JDK입니다. 최신 버전의 Netty는 프로젝트 다운로드 페이지(http://netty.io/downloads.html)에서 받으실 수 있고, 올바른 버전의 JDK를 다운로드 받기 위해서는 원하는 JDK 공급자의 웹 사이트를 참조하시기 바랍니다.
 
  본문을 읽으면서 본문에 소개된 클래스에 대해 궁금한 부분이 생길 수도 있을 것입니다. 클래스에 대한 추가적인 정보가 필요한 경우, API 레퍼런스를 참조하기 바랍니다. 모든 클래스명은 레퍼런스에 명시되어 있으며, 편의를 위해 온라인 페이지에 대한 링크 형태로 제공됩니다. 잘못된 정보가 존재하는 경우 Netty project community(http://netty.io/community.html)에 알려주세요. 문법이나 오타, 혹은 문서의 품질 향상을 위한 제안 사항도 환영합니다.

Discard(폐기) 서버 작성하기

 세상에서 가장 간단한 프로토콜은 "Hello, World!가 아닌, DISCARD(폐기) 입니다. 이 프로토콜은 어떠한 응답도 제공하지 않고, 수신한 모든 데이터를 단순히 폐기하는 겁니다.
  이 DISCARD 프로토콜을 구현하기 위해 해야할 것이라고는 수신한 데이터를 무시하는 것밖에 없습니다. Netty가 발생시키는 I/O 이벤트를 처리하는 "핸들러"를 구현하는 것부터 바로 시작해보도록 하죠.

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    package io.netty.example.discard;
     
    import io.netty.buffer.ByteBuf;
     
    import io.netty.channel.ChannelHandlerContext;
    import io.netty.channel.ChannelInboundHandlerAdapter;
     
    /**
     * Handles a server-side channel.
     */
    public class DiscardServerHandler extends ChannelInboundHandlerAdapter { // (1)
     
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) { // (2)
            // Discard the received data silently.
            ((ByteBuf) msg).release(); // (3)
        }
     
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4)
            // Close the connection when an exception is raised.
            cause.printStackTrace();
            ctx.close();
        }
    }
    cs

  •  1. DiscardServerHandler는 ChannelInboundHandlerAdapter를 상속합니다. ChannelInboundHandlerAdapter는 ChannelInboudHandler의 구현체인데, ChannelInboundHandler는 개발자가 override해서 사용할 수 있도록 다양한 이벤트 핸들러 메서드를 제공합니다. 우선 지금 당장은 인터페이스를 직접 구현하기보다 ChannelInboundHandlerAdapter 클래스를 상속받는 것이 편하겠네요.
  •  
  •  2. 여기서 channelRead() 메서드를 override합니다. 이 메서드는 클라이언트로부터 새로운 데이터를 받을 때마다, 즉, 새로운 메시지를 받을 때마다 호출됩니다. 이 예제에서 수신한 메시지의 타입은 ByteBuf입니다.
  •  
  •   3. DISCARD 프로토콜을 구현하기 위해, 핸들러는 수신한 메시지를 완전히 무시해야만 합니다. ByteBuf는 참조형 객체로, 반드시 명시적인 release() 메서드를 통해 해제가 되어 줘야만 합니다. 핸들러에게 전달된 참조형 객체의 해제는 핸들러의 책임이라는 것을 반드시 기억하시기 바랍니다. 대개, channelRead() 핸들러 메서드는 아래와 같이 구현됩니다.
  •   
  • 1
    2
    3
    4
    5
    6
    7
    8
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        try {
            // Do something with msg
        } finally {
            ReferenceCountUtil.release(msg);
        }
    }
    cs

  •  4. exceptionCaught() 이벤트 핸들러는 (i) I/O 에러가 발생해서 Netty가 exception을 발생시킨 경우, 혹은, (ii) 핸들러 구현체에서 이벤트를 처리하는 도중에 exception이 발생한 경우, 관련 정보를 담고 있는 Throwable 객체와 함께 호출됩니다. 예외 상황에 대한 개발자의 개발 의도 등에 따라 connection을 닫기 전에 오류 코드에 대한 응답내용을 보내는 등의 변화는 있을 수 있겠지만 대체적으로 이 핸들러에서는 발생한 exception에 대한 로그를 기록하고, 사용된 채널도 닫아주게 됩니다.
  
 여기까지 잘 따라오셨습니다.  지금까지 DISCARD 서버의 앞쪽 반틈을 작성해봤습니다. 남은 것은 DiscardServerHandler를 탑재한 서버를 구동할 수 있는 main() 함수를 작성하는 것 뿐이네요.

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    package io.netty.example.discard;
     
    import io.netty.bootstrap.ServerBootstrap;
    import io.netty.channel.ChannelFuture;
    import io.netty.channel.ChannelInitializer;
    import io.netty.channel.ChannelOption;
    import io.netty.channel.EventLoopGroup;
    import io.netty.channel.nio.NioEventLoopGroup;
    import io.netty.channel.socket.SocketChannel;
    import io.netty.channel.socket.nio.NioServerSocketChannel;
     
    /**
     * Discards any incoming data.
    */
    public class DiscardServer {
        
        private int port;
        
        public DiscardServer(int port) {
            this.port = port;
        }
        
        public void run() throws Exception {
            EventLoopGroup bossGroup = new NioEventLoopGroup(); // (1)
            EventLoopGroup workerGroup = new NioEventLoopGroup();
            try {
                ServerBootstrap b = new ServerBootstrap(); // (2)
                b.group(bossGroup, workerGroup)
                 .channel(NioServerSocketChannel.class// (3)
                 .childHandler(new ChannelInitializer<SocketChannel>() { // (4)
                     @Override
                     public void initChannel(SocketChannel ch) throws Exception {
                         ch.pipeline().addLast(new DiscardServerHandler());
                     }
                 })
                 .option(ChannelOption.SO_BACKLOG, 128)          // (5)
                 .childOption(ChannelOption.SO_KEEPALIVE, true); // (6)
        
                // Bind and start to accept incoming connections.
                ChannelFuture f = b.bind(port).sync(); // (7)
        
                // Wait until the server socket is closed.
                // In this example, this does not happen, but you can do that to gracefully
                // shut down your server.
                f.channel().closeFuture().sync();
            } finally {
                workerGroup.shutdownGracefully();
                bossGroup.shutdownGracefully();
            }
        }
        
        public static void main(String[] args) throws Exception {
            int port;
            if (args.length > 0) {
                port = Integer.parseInt(args[0]);
            } else {
                port = 8080;
            }
            new DiscardServer(port).run();
        }
    }
    cs

  •  1. NioEventLoopGroup은 I/O 동작을 처리하는 다중 스레드 이벤트 루프입니다. Netty는 여러 방식의 통신을 위해 다양한 EventLoopGroup 구현체를 제공합니다. 이 예제에서는 서버측 어플리케이션을 구현하는 것이 목적이기 때문에 2개의 NioEventLoopGroup을 사용하게 될 겁니다. 첫번째 그룹은 보통 'boss'라고 많이 불리는데, 이 boss는 들어오는 연결을 수락하고, 해당 연결을 두번째 그룹에 등록해주는 역할을 수행합니다. 두번째 그룹 'worker'는 boss가 등록해준 연결에 대해 발생하는 트래픽을 처리하죠. 얼마나 많은 스레드를 사용할 것인지와, 생성된 Channel에 어떤 식으로 맵핑할 것인지가 EventLoopGroup의 구현체에 따라 달라지되, 생성자를 통해 별도로 지정해줄 수 있습니다.
  •  
  •  2. ServerBootstrap은 서버를 구성할 수 있도록 해주는 도우미 클래스라고 보면 되겠습니다. 개발자는 Channel 클래스를 직접적으로 이용해서 서버를 구성할 수도 있겠지만 이는 굉장히 지루한 작업이며, 대부분의 경우, 이런 직접적인 작업이 요구되지 않음을 알아두면 좋을 듯 합니다.
  •  
  •  3. 들어오는 새로운 연결을 수락하고 새로운 Channel을 생성하기 위해 NioServerSocketChannel 클래스를 사용하겠다고 명시했습니다.
  •  
  •  4. 이 지점에 명시된 핸들러는 새로 연결이 수락되고 채널이 생성되면 해당 채널에 대해 수행됩니다. ChannelInitializer는 사용자가 새로운 Channel에 대한 설정을 쉽게 수행할 수 있도록 하기 위한 목적으로 제공되는 핸들러입니다. 개발된 네트워크 어플리케이션을 구현하기 위한 DiscardServerHandler와 같은 핸들러를, 새롭게 생성된 Channel의 ChannelPipeline에 설정하는 용도로 주로 사용됩니다.
  •  
  •  5. Channel 구현체에 특화된 매개변수를 설정할 수도 있습니다. TCP/IP 서버 프로그램 작성하고 있는만큼, tcpNoDelay나 keepAlive와 같은 소켓 옵션을 부여해줄 수 있는 것이죠. 지원되는 ChannelOption들을 확인하려면 ChannelOption 클래스나 ChannelConfig 구현체의 API 레퍼런스를 참고하시기 바랍니다.
  •  
  •  6. option()도 있고, childOption()도 있다는 것을 알아차리셨나요? option()은 들어오는 연결 수락을 위한 NioServerSocketChannel에 추가적인 설정을 추가하기 위해 사용되고, childOption()은 부모 ServerChannel(이 예제에서는 NioServerSocketChannel)이 수락한 연결로 인해 생성된 Channel에 추가적인 설정을 추가하기 위해 사용됩니다.
  •  
  •  7. 준비가 다 된 것 같습니다. 이제는 정말 port를 지정해주고, 서버를 구동하는 것만 남았네요. 여기서는 예제가 구동되는 로컬 시스템의 모든 NIC(네트워크 인터페이스 카드)의 8080번 포트를 지정했습니다. 지정 주소만 다르다면 bind() 메서드를 개발자가 원하는대로 얼마든지 호출할 수 있게 된 것이죠.
 
 축하합니다! Netty를 이용한 첫번째 서버 개발을 마치셨네요.
 

수신한 데이터 살펴보기

 첫번째 서버 프로그래밍을 마친 만큼, 이게 정말 동작하는지를 확인해볼 필요가 있겠죠. 가장 쉬운 방법은 telnet 명령어를 이용하는 방법입니다. 예로써, 명령 프롬프트에서 telnet localhost 8080 을 입력하여 접속한 뒤, 아무 문자나 타이핑을 해보는 거죠.
  하지만, 그것만으로 서버가 잘 동작하고 있다고 말할 수 있을까요?
  DISCARD(폐기) 서버니까 알 수 없죠. 그 어떤 응답도 돌려주질 않으니까요. 서버가 동작하는지를 확인해보기 위해 DiscardServerHandler 핸들러의 channelRead() 메서드에 몇 줄의 코드를 더 추가해보도록 합니다.
  
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        try {
            while (in.isReadable()) { // (1)
                System.out.print((charin.readByte());
                System.out.flush();
            }
        } finally {
            ReferenceCountUtil.release(msg); // (2)
        }
    }
    cs

  •  1.이 비효율적인 루프는 사실상 System.out.println(in.toString(io.netty.util.CharsetUtil.US_ASCII) 로 대체될 수 있습니다.
  •  2. 이 라인 대신, in.release(); 를 넣어줄 수도 있겠습니다.

 지금 서버 쪽에서 telnet 명령을 실행 중이라면, 서버가 받은 데이터를 화면에 표시하고 있다는 것을 확인할 수 있을 겁니다.
 DISCARD 서버 예제의 full 소스 코드는 io.netty.example.discard 패키지에 포함되어 있습니다.

ECHO(에코) 서버 작성하기

 지금까지 서버는 아무런 응답 없이 수신한 데이터를 화면에 표시하기만 했습니다만, 보통 서버는 요청을 받으면 응답을 해야하기 마련이죠. ECHO 서버(받은 요청을 그대로 응답으로 돌려주는 서버, 일명 무지개반사 서버) 을 구현하면서 어떻게 클라이언트에게 응답 메시지를 줄 수 있는지를 배워보도록 하겠습니다.

 앞에서 구현해본 DISCARD(폐기) 서버와의 유일한 차이점은 받은 데이터를 화면에 표시하는게 아니라, 다시 돌려보낸다는데 있습니다. 한번 더 channelRead() 메서드를 수정해보도록 하죠.
 
  •  
    1
    2
    3
    4
    5
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ctx.write(msg); // (1)
        ctx.flush(); // (2)
    }
    cs

  •  1. ChannelHandlerContext 객체는 각종 I/O 이벤트와 동작을 트리거할 수 있는 다양한 기능을 제공합니다. 여기서는 write(Object) 메서드를 이용해서 전달받은 메시지를 그대로 써주고 있는 겁니다. DISCARD 예제 때와는 다르게, 전달받은 메시지에 대한 해제를 수행하고 있지 않다는 것에 주목하시기 바랍니다. 전달받은 메시지에 대한 해제가 분명히 핸들러의 책임인 것은 틀림없지만, write의 경우, write가 완전히 완료된 이후에 Netty가 해당 객체에 대한 해제를 수행해주기 때문에 해제문이 없는 것입니다.
  •  
  •   2. ctx.write(Object)를 수행한 것으로 write의 완료가 이루어지지 않습니다. Object는 내부 버퍼에 저장될 뿐이며, ctx.flush()에 의해 실질적으로 상대 시스템으로 전송되게 되죠. 이 두 줄은 간단하게 ctx.writeAndFlush(msg)로 줄여 사용할 수 있습니다.
  
 telnet을 실행해서 테스트를 다시 해본다면, 개발자가 입력한 문구가 되돌아오고 있는 것을 확인할 수 있을 것입니다.
 ECHO 서버 예제의 full 소스 코드는 io.netty.example.echo 패키지에 포함되어 있습니다.

TIME(시간) 서버 작성하기

 이번에 구현해볼 내용은 TIME(시간) 프로토콜입니다. 이전의 예제들과 다른 점은, 이번 예제 서버는 아무런 요청을 받지 않은 상황에서, 32-bit 정수로 구성된 메시지를 작성하고, 발송하되, 메시지 송신이 완료되면 해당 연결을 종료한다는 점입니다. 이 예제에서 독자는 메시지를 어떻게 만들고, 발송하는지, 그리고 완료 시에 연결을 어떻게 종료하는지를 배우게 될 것입니다.
 
  연결이 일단 수립이 되자마자 메시지를 발송하되, 입력 데이터는 무시할 것이기 때문에 이번만큼은 channelRead() 메서드를 사용해서는 안 됩니다. 대신, 이번에는 channelActive() 메서드를 사용하게 될 것이며, 사용 예는 아래와 같습니다.
  
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    package io.netty.example.time;
    public class TimeServerHandler extends ChannelInboundHandlerAdapter {
     
        @Override
        public void channelActive(final ChannelHandlerContext ctx) { // (1)
            final ByteBuf time = ctx.alloc().buffer(4); // (2)
            time.writeInt((int) (System.currentTimeMillis() / 1000L + 2208988800L));
            
            final ChannelFuture f = ctx.writeAndFlush(time); // (3)
            f.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) {
                    assert f == future;
                    ctx.close();
                }
            }); // (4)
        }
        
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            cause.printStackTrace();
            ctx.close();
        }
    }
    cs

  •  1. 앞서 설명한 내용과 같이, channelActive() 메서드는 연결이 수립되고 트래픽을 발생시킬 수 있는 시점에 호출됩니다. 현 시각을 나타내는 32-bit 정수를 이 메서드 안에서 작성해보도록 합니다.
 
  •  2. 새로운 메시지를 작성하기 위해, 메시지가 담길 버퍼를 먼저 할당해주어야 합니다. 32-bit 정수를 내보낼 것이기 때문에 최소 4 byte 용량을 가지는 ByteBuf 객체를 생성하도록 합니다. 이 새로운 버퍼 생성을 위해 ChannelHandlerContext.alloc()을 통해 ByteBufAllocator 객체를 반환받은 뒤, 4 byte의 크기를 가지는 새로운 버퍼를 생성자를 통해 생성했습니다.
 
  •  3. 이제 생성된 메시지를 여느 때와 같이 write 해보도록 합니다.
  •  헌데, 잠깐 짚고 넘어가야할 부분이 있죠. flip은 왜 생략되었을까요? NIO로 메시지를 보낼 때에는 대개 java.nio.ByteBuffer.flip() 메서드를 사용했을 겁니다. ByteBuf는 해당 메서드를 가지고 있지 않은데, 이는 ByteBuf 객체가  읽기용과 쓰기용 각각, 2개의 포인터를 가지고 있기 때문입니다. ByteBuf에 뭔가를 기록하게 되면 writer index는 증가하지만 reader index는 변하지 않습니다. reader index와 writer index는 메시지의 시작과 끝을 알려주는 역할을 수행하게 되구요.

  • 한편, NIO 버퍼는 flip 메서드를 호출하지 않는다면 메시지 내용의 시작과 끝을 명확하게 알 수 있는 방법을 별도로 제공하지 않습니다. 그래서 buffer에 대한 flip 처리를 빠트리게 되면 아무것도 전송되지 않거나, 올바르지 않은 데이터가 전송되기 마련이었습니다. Netty에서는 이런 문제가 발생할 수가 없는데, 이는 서로 다른 동작 타입에 대한, 서로 다른 포인터를 Netty에서 제공하기 때문입니다. 익숙해지면 삶이 한결 편안해진 것을 체감할 수 있을 겁니다. -- flipping 없는 삶이란!ㅋㅋㅋㅋㅋㅋ

  • 또 한 가지, 기억해야 할 부분은, ChannelHandlerContext.write() (그리고 writeAndFlush()도..) 메서드는 ChannelFuture 객체를 반환한다는 점입니다. ChannelFuture는 아직 수행되지 않은 I/O 동작을 나타냅니다. Netty에서는 모든 동작이 비동기적이기 때문에 이미 요청이 완료된 동작들이 아직 수행되지 않았을 수 있다는 것입니다. 아래 코드의 예를 보면, 메시지가 발송되기 전에 연결이 닫힐 수도 있다는 겁니다.

  • 1
    2
    3
    Channel ch = ...;
    ch.writeAndFlush(message);
    ch.close();
    cs

  • 그렇기 때문에 개발자는 ChannelFuture가 완료되고 난 이후에 close() 메서드를 호출해줄 필요가 있습니다. write() 메서드의 경우, 동작이 모두 완료되면 ChannelFuture의 모든 리스너에 동작이 완료되었음을 알려주고, ChannelFuture 자체를 반환해줍니다. close() 메서드도 마찬가지입니다. 즉각적으로 연결을 종료하지는 않으며, 이 메서드 역시 결과값으로써 ChannelFuture를 반환합니다.

  •  4. 그렇다면 write 요청이 완료되었다는 것을 개발자가 어떻게 알 수 있을까요? 반환된 ChannelFuture에 ChannelFutureListener 한개를 추가함으로써 간단히 알 수 있게 됩니다. 위 예제코드에서 우리는 익명 ChannelFutureListener 클래스를 만들었고, 이를 통해 작업이 완료되면 연결을 닫도록 했죠.
  •  사전 정의된(pre-defined) 리스너를 통해 아래와 같이 간소화된 코드를 사용할 수도 있습니다.
  
  • 1
    f.addListener(ChannelFutureListener.CLOSE);
    cs

 작성된 TIME(시간) 서버가 잘 동작하는지를 확인하기 위해 아래와 같은 UNIX rdate 명령어를 이용해볼 수 있습니다.
 
  • 1
    $ rdate -<port> -<host>
    cs

TIME 클라이언트 작성하기

 DISCARD나 ECHO서버 때와는 달리, TIME 서버를 위해서는 클라이언트가 필요한데, 이는 사람이 직접 32-bit짜리 이진 데이터를 달력에 보이는 것과 같은 날짜 형태로 일일이 변환할 수는 없기 때문입니다. 이번 파트에서는 서버의 정상 동작을 어떻게 담보하는지, 그리고 Netty를 활용한 클라이언트를 어떻게 작성하는지를 배워볼 겁니다.

 Netty를 이용한 서버와 클라이언트 간의 가장 큰 차이점이자 유일한 차이점이라고 한다면 다른 Bootstrap과 Channel 구현체를 사용한다는 점이 되겠습니다. 다음 코드를 살펴보도록 하죠.
 
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    package io.netty.example.time;
    public class TimeClient {
        public static void main(String[] args) throws Exception {
            String host = args[0];
            int port = Integer.parseInt(args[1]);
            EventLoopGroup workerGroup = new NioEventLoopGroup();
            
            try {
                Bootstrap b = new Bootstrap(); // (1)
                b.group(workerGroup); // (2)
                b.channel(NioSocketChannel.class); // (3)
                b.option(ChannelOption.SO_KEEPALIVE, true); // (4)
                b.handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    public void initChannel(SocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new TimeClientHandler());
                    }
                });
                
                // Start the client.
                ChannelFuture f = b.connect(host, port).sync(); // (5)
     
                // Wait until the connection is closed.
                f.channel().closeFuture().sync();
            } finally {
                workerGroup.shutdownGracefully();
            }
        }
    }
    cs

  • 1. Bootstrap은 ServerBootstrap과 유사하지만, 클라이언트측의 서버와 무관한 채널, 혹은 연결이 요구되지 않는 채널을 위한 객체라는 점이 다르다고 볼 수 있겠습니다.

  • 2. 하나의 EventLoopGroup을 명시할 경우, 이 그룹은 boss이자 worker 그룹으로써 동작합니다만, 클라이언트측에서 boss 그룹으로서의 역할은 요구되지 않습니다.

  • 3. 클라이언트측 채널을 생성하기 위해서 NioServerSocketChannel 대신 NioSocketChannel이 사용됩니다.

  • 4. ServerBootstrap에서 사용되었던 childOption()이 사용되지 않는다는 점을 기억해두도록 합니다. 클라이언트측 SocketChannel은 부모 객체가 존재하지 않기 때문입니다.

  • 5. bind() 메서드가 아닌, connect() 메서드를 호출해야 합니다.

 보시는 바와 같이, 서버측 코드와 크게 다르지 않습니다. 이제 ChannelHandler 구현체를 보도록 할까요? 이 핸들러는 서버로부터 32-bit의 정수를 전달 받아, 사람이 읽을 수 있는 형태의 데이터로 변환하고, 화면에 변환된 시간을 표시해준 다음, 연결을 닫는 역할을 수행해야 할 것입니다.
 
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    package io.netty.example.time;
    import java.util.Date;
    public class TimeClientHandler extends ChannelInboundHandlerAdapter {
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            ByteBuf m = (ByteBuf) msg; // (1)
            try {
                long currentTimeMillis = (m.readUnsignedInt() - 2208988800L) * 1000L;
                System.out.println(new Date(currentTimeMillis));
                ctx.close();
            } finally {
                m.release();
            }
        }
     
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            cause.printStackTrace();
            ctx.close();
        }
    }
    cs

  • 1. TCP/IP 프로토콜을 이용할 때, Netty는 전달받은 데이터를 ByteBuf 객체를 통해 읽어들입니다.

 복잡해보이지도 않고, 서버측 소스와 크게 달라 보이는 것도 없습니다. 그런데, 이 핸들러에서는 간헐적으로 IndexOutOfBoundsException이 발생하지요. 왜 그런지 다음 파트에서 알아보도록 하겠습니다.
 

흐름 기반 통신 취급하기

Socket Buffer에 대해 짚고 넘어가야 할 점

 TCP/IP와 같은 흐름 기반 통신에서, 수신된 데이터는 Socket receive buffer에 저장되게 됩니다. 안타깝지만, 이 흐름 단위 통신용 버퍼는 패킷 단위 큐가 아닌, byte단위 큐라는 점입니다. 말인즉슨, 송신측에서 2개의 독립된 패킷으로, 2개의 메시지를 발송하더라도 운영체제에서는 이 데이터를 2개의 독립된 메시지가 아닌, 한 뭉치의 byte로 취급하게 된다는 겁니다. 이 때문에 수신 측에서 읽어들인 데이터가 송신측에서 발송한 데이터와 동일하다는 것을 보장할 수가 없는 것이죠.
  한 예로, 운영체제의 TCP/IP 스택이 3개의 패킷을 받았다고 가정해봅시다.
  
 <원문 사이트의 puml 파일 누락>
 
  흐름 기반 프로토콜의 일반적인 특성으로 인해, 높은 확률로 수신측에서는 다음과 같이 읽어들였을 겁니다.
 
 <원문 사이트의 puml 파일 누락>
 
  따라서, 서버나 클라이언트를 떠나 어찌됐든 수신측에서는 수신한 데이터를 응용단에서 활용할 수 있는 유의미한 단위로 분할해주는 일련의 작업이 필요하겠죠. 상기 예제의 경우라면 아래와 같이 분할해줄 수 있을 겁니다:

 <원문 사이트의 puml 파일 누락>
  

첫번째 해결

 TIME 클라이언트 예제로 잠깐 다시 돌아가보도록 합시다. 여기서도 동일한 문제가 있으니까요. 32-bit 정수는 굉장히 작은 크기의 데이터이기 때문에 잘못 분할되는 일이 잦지는 않을 겁니다. 하지만, 문제는 그런 일이 발생할 수 있다는 것이고, 트래픽이 늘어나면 그 발생 확률이 높아질 것이라는 점입니다.
 
  가장 간단한 해결방법은 내부 버퍼가 수신된 데이터를 누적하도록 만드는 것이 되겠습니다. 수신한 데이터가 4-byte가 될 때까지 기다리도록 만드는 것이죠. 이 해결법을 도입하기 위해 수정된 TimeClientHandler 코드가 아래와 같습니다.
  
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    package io.netty.example.time;
    import java.util.Date;
    public class TimeClientHandler extends ChannelInboundHandlerAdapter {
        private ByteBuf buf;
        
        @Override
        public void handlerAdded(ChannelHandlerContext ctx) {
            buf = ctx.alloc().buffer(4); // (1)
        }
        
        @Override
        public void handlerRemoved(ChannelHandlerContext ctx) {
            buf.release(); // (1)
            buf = null;
        }
        
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            ByteBuf m = (ByteBuf) msg;
            buf.writeBytes(m); // (2)
            m.release();
            
            if (buf.readableBytes() >= 4) { // (3)
                long currentTimeMillis = (buf.readUnsignedInt() - 2208988800L) * 1000L;
                System.out.println(new Date(currentTimeMillis));
                ctx.close();
            }
        }
        
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
            cause.printStackTrace();
            ctx.close();
        }
    }
    cs

  • 1.ChannelHandler는2 사이클의 handlerAdded()와 handlerRemoved()의 리스너 메서드를 가집니다. 개발자는 지나치게 긴 시간동안 다른 프로세스의 개입이 차단되는 작업이 아니라면, 원하는 작업을 추가하여 해당 메서드들을 일종의 생성자와 소멸자로 이용할 수도 있습니다.

  • 2. 우선, 모든 수신된 데이터는 buf 버퍼안에 누적되게 됩니다.

  • 3. 그리고 buf 버퍼에 충분한 데이터(이 예제에서는 4 bytes)가 누적되었는지를 검사한 뒤, 실질적인 비지니스 로직을 시작합니다. 수신 데이터가 4 bytes가 되지 않을 경우, Netty는 channelRead() 메서드를 다시 호출하여, 추가적으로 데이터를 수신할 것이고, 궁극적으로는 총 4 bytes의 수신 자료를 획득하게 될 겁니다.

두번째 해결방법

 첫번째 해결방법으로 TIME 클라이언트에 발생했던 문제를 해결할 수는 있었지만, 수정된 핸들러가 그리 깔끔해보이지는 않는다는 것을 알 수 있습니다. 길이가 가변적인 필드를 포함하는, 복수개의 필드로 구성된 보다 복잡한 프로토콜을 첫번째 해결방법으로 제작한다고 한다면, 개발된 ChannelInboundHandler 구현체는 순식간에 유지보수가 불가능할 정도로 복잡해지고, 이해하기도 어려워질 겁니다.
 
 앞선 예제들에서 아마 독자는 ChannelPipeline에 2개 이상의 ChannelHandler를 추가할 수 있다는 것을 겪어봤을 겁니다. 두번째 해결방법은 이를 이용하는 것인데, 하나의 큼지막했던 ChannelHandler를 모듈단위로 쪼개듯이 여러 개의 핸들러로 나누어 처리하는 겁니다. 이를 통해 개발된 프로그램의 복잡도를 줄일 수 있죠.
  예로써, TimeClientHandler를 2개의 핸들러로 분할하는 것을 들 수 있습니다.
  
  • 수신 데이터가 유의미한 단위로 분할될 수 있도록 하는 TimeDecoder와,
  • 초기의 TimeClientHandler로 나누는 것입니다.

 다행스럽게도 Netty에서는 위의 첫번째 항목을 쉽게 구현할 수 있도록, 상속 가능한 클래스를 제공하고 있습니다.
 
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    package io.netty.example.time;
    public class TimeDecoder extends ByteToMessageDecoder { // (1)
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // (2)
            if (in.readableBytes() < 4) {
                return// (3)
            }
            
            out.add(in.readBytes(4)); // (4)
        }
    }
    cs

  • 1.ByteToMessageDecoder는 ChannelInboundHandler의 구현체로, 수신데이터 분할 이슈를 해결하게 됩니다.

  • 2. ByteToMessageDecoder는  내부 누적버퍼를 가지고 있으며, 새로운 데이터가 수신될때마다 decode() 메서드를 호출합니다.

  • 3. decode()는 누적 버퍼에 충분한 데이터가 쌓이지 않은 경우, out에 아무런 동작도 수행하지 않습니다. 충분한 데이터가 쌓였을 경우, 즉, 이 예제에서는 4 bytes의 데이터가 버퍼에 누적되면 ByteToMessageDecoder가 decode() 메서드를 호출할 것입니다.

  • 4. decode() 메서드가 out에 객체를 추가했다는 것은, decoder가 메시지를 성공적으로 유의미하게 분할했다는 것을 의미합니다. 이후 즉각적으로 ByteToMessageDecode는 누적 버퍼에 남아있는 데이터는 폐기합니다. 여러 개의 메시지를 decode하기 위해 별도의 코드가 필요하지 않다는 것을 기억하시기 바랍니다. ByteToMessageDecoder가 더 이상 out에 추가할 객체가 없을 때까지 decode() 메서드를 호출하기 때문입니다.

 이제 ChannelPipeline에 삽입할 핸들러가 한 가지 늘어났으니, TimeClient 코드에서 ChannelInitializer 구현체 소스를 수정하겠습니다.
 
  • 1
    2
    3
    4
    5
    6
    b.handler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel ch) throws Exception {
            ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
        }
    });
    cs

  만약 모험적인 성향이 강한 개발자라면, decoder를 더욱 간소화할 수 있는 ReplayingDecoder를 사용해보고 싶을지도 모르겠습니다. 추가적인 정보를 위해서는 API 레퍼런스를 참조하기 바랍니다.
  
  • 1
    2
    3
    4
    5
    6
    7
    public class TimeDecoder extends ReplayingDecoder<Void> {
        @Override
        protected void decode(
                ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
            out.add(in.readBytes(4));
        }
    }
    cs

 추가적으로 Netty는 사용자들이 유지보수 불가능한 덩어리 핸들러를 만들어야만 하는 상황에 처해지지 않도록, 대부분의 프로토콜을 손쉽게 이용할 수 있는 out-of-the-box 디코더를 제공합니다. 상세한 예제 코드는 다음 패키지들에 포함되어 있습니다.
 
  • io.netty.example.factorial - binary 프로토콜에 유용하게 사용할 수 있습니다.
  • io.netty.example.telnet - 텍스트 라인 기반 프로토콜에 유용하게 사용할 수 있습니다.

ByteBuf 대신 POJO(Plain Old Java Object)를 사용하기

 지금까지 살펴본 모든 예제에서는 프로토콜 메시지의 기본 데이터 구조로써 ByteBuf를 사용해왔습니다. 이번에는 TIME 프로토콜 서버 및 클라이언트 예제를 ByteBuf가 아닌, POJO를 사용하여 구현해보도록 하겠습니다.
 
 ChannelHandler에서 POJO를 사용함으로써 얻어지는 이점은 명확합니다. 핸들러에 대한 유지보수와 ByteBuf에서 필요한 데이터를 추출해내는 코드를 핸들러 바깥에서 재사용하기에도 용이해진다는 것이죠.  TIME 서버와 클라이언트 예제에서는 32-bit 정수만을 다루었기에 ByteBuf를 직접적으로 이용하는 것에 대해 별다른 무리가 없었습니다만, 실제로 사용되는 프로토콜을 구현하기는데는 상당한 무리가 따르기에, 이같은 이점을 활용할 필요가 있습니다.

 우선 UnixTime이라는 클래스를 하나 정의해보도록 하겠습니다.
 
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    package io.netty.example.time;
    import java.util.Date;
    public class UnixTime {
     
        private final long value;
        
        public UnixTime() {
            this(System.currentTimeMillis() / 1000L + 2208988800L);
        }
        
        public UnixTime(long value) {
            this.value = value;
        }
            
        public long value() {
            return value;
        }
            
        @Override
        public String toString() {
            return new Date((value() - 2208988800L) * 1000L).toString();
        }
    }
    cs

 이제 TimeDecoder로 하여금 ByteBuf 대신 UnixTime을 만들도록 바꿔야겠죠.


  • 1
    2
    3
    4
    5
    6
    7
    @Overrideprotected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        if (in.readableBytes() < 4) {
            return;
        }
     
        out.add(new UnixTime(in.readUnsignedInt()));
    }
    cs

 수정된 디코더를 보면 TimeClientHandler가 더이상 ByteBuf를 사용하지 않는 것을 알 수 있습니다.
 
  • 1
    2
    3
    4
    5
    6
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        UnixTime m = (UnixTime) msg;
        System.out.println(m);
        ctx.close();
    }
    cs
 
 훨씬 간단하고, 또 보기 좋지 않나요? 동일한 기법이 서버측에도 활용될 수 있습니다. TimeServerHandler도 수정해봅시다.
 
  • 1
    2
    3
    4
    5
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        ChannelFuture f = ctx.writeAndFlush(new UnixTime());
        f.addListener(ChannelFutureListener.CLOSE);
    }
    cs

 이제 UnixTime을 다시 ByteBuf로 바꿔주는 ChannelOutboundHandler 구현체인 인코더(encoder)만 남았습니다. decoder보다 훨씬 간단하게 작성되는데, 이는 메시지를 부호화(encoding)할 때에는 패킷 분할을 고려할 필요가 없기 때문이죠.
 
  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    package io.netty.example.time;
    public class TimeEncoder extends ChannelOutboundHandlerAdapter {
        @Override
        public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
            UnixTime m = (UnixTime) msg;
            ByteBuf encoded = ctx.alloc().buffer(4);
            encoded.writeInt((int)m.value());
            ctx.write(encoded, promise); // (1)
        } 
    cs

  • 1. 이 라인에는 짚고 넘어가야할 부분이 2가지가 있습니다.
  • 첫째로, 부호화된 데이터에 대한 버퍼 기록 및 실제 송신까지에 대한 처리 결과를 성공 혹은 실패의 형태로 Netty가 기록할 수 있도록, ChannelPromise 객체를 원형 그대로 넘겨주는 것입니다.
  • 둘째로는 ctx.flush() 메서드를 호출하지 않은 것입니다. 왜냐하면 flush() 메서드를 override 할 목적으로 작성된 별도의 핸들러 ㅔ서드인 void flush(ChannelHandlerContext ctx)가 존재하기 때문입니다.

 추가적인 간소화를 위해서, MessageToByteEncode 객체를 아래와 같이 활용할 수도 있습니다:
 
  • 1
    2
    3
    4
    5
    6
    public class TimeEncoder extends MessageToByteEncoder<UnixTime> {
        @Override
        protected void encode(ChannelHandlerContext ctx, UnixTime msg, ByteBuf out) {
            out.writeInt((int)msg.value());
        }
    }
    cs

 마지막으로 남은 작업은 TimeServerHandler에 앞서, 서버측 ChannelPipeline에 만들어진 TimeEncoder를 넣어주는 것이 되겠습니다. 이 부분은 직접 수행해보면서 결과를 확인하면 되겠습니다.
 

 개발된 어플리케이션을 종료하기

 Netty 어플리케이션을 종료한다는 것은, 개발자가 생성한 모든 EventLoopGroup들을 종료시킨다는 것으로, 보통은 shutdownGracefully() 메서드를 통해 간단히 수행할 수 있습니다. 이 메서드는 Future를 반환하는데, 이 Future를 통해, EventLoopGroup와 해당 group에 종속된 모든 Channel들이 완전하게 종료되었다는 것을 알 수 있습니다.
 

 마치며

 본 문서에서 우리는 Netty를 이용하여 온전하게 동작하는 네트워크 어플리케이션을 어떻게 만들 수 있는지에 대해 빠르고 간편하게 살펴보았습니다. 다음에 제공될 예정인 문서에서는 Netty에 대한 추가적인 정보들이 포함될 것입니다. 독자분들이 io.netty.example 패키지에 포함되어 있는 Netty 예제들을 살펴 본다면, Netty를 이해하고 사용하는데 있어 보다 큰 편익을 얻을 것이라 생각됩니다.
  Netty Community는 독자들의 질문과 제안에 항상 열려있다는 것을 기억해시길 바라며, 본 문서를 마칩니다.





도움이 되셨다면 따뜻한 (꼭 따뜻한.. 중얼중얼..) 댓글을 부탁드립니다.


실컷 다 번역해놓고 다른 분께서 이미 번역해놓으신 게 있다는 걸 발견했습니다.


이래서 검색이 중요합니다 여러분.. 머리가 나쁘면 손발이 고생을 한다더니ㅠㅠ


하지만 번역하면서 기본적인 내용에 대해 알게되고, 이해한 부분이 적지 않으니까!


나름의 소용은 있었다고 스스로를 위안하면서 글을 줄입니다.


15년에 작성된 최익필님의 네티 가이드 링크도 같이 남겨놓습니다.

'IT이야기 > Java' 카테고리의 다른 글

SpEL (Spring Expression Language)의 간략한 이해  (0) 2017.02.28
Thread를 생성하는 3가지 방법  (0) 2017.02.08

관련글 더보기

댓글 영역