본문 바로가기

서버운영 (TA, ADMIN)/네트워크

[네트워크] Netty 프로젝트 3.1 가이드

The Problem

사람들은 다른 애플리케이션과의 통신을 위해 일반적인 목적의 애플리케이션이나 라이브러리를 사용합니다. 예를 들어, 우리는 웹서버로부터 정보를 가져오고 웹서비스를 통해 원격 프로시저를 호출하기 위해 HTTP 클라이언트 라이브러리를 사용합니다.


그러나, 일반적인 목적의 프로토콜이나 이를 구현한 것은 때때로 그렇게 잘 확장되지 않습니다. 덩치 큰 파일, 전자 메일 메시지, 금융정보와 멀티 플레이어 게임 데이터와 같은 실시간 메시지 교환을 위해 우리는 일반적인 목적의 HTTP 서버를 사용하지는 않을 것입니다. 필요한 것은 특수한 목적을 위해 매우 최적화된 프로토콜 구현입니다. 예를 들면, 여러분은 Ajax 기반 채팅 애플리케이션, 미디어 스트리밍, 혹은 덩치 큰 파일 전송을 위해 최적화된 HTTP 서버를 구현하고 싶을지도 모릅니다. 심지어 조건에 정교하게 맞춰진 전혀 새로운 프로토콜을 설계하고 구현하고 싶을 수도 있습니다.


불가피한 또 다른 이슈는 기존 시스템과의 호환성을 보장하기 위해 기존 시스템과의 호환성을 보장하기 위해 기존의 독자적인 프로토콜을 다루어야만 하는 때입니다. 이 경우 중요한 것은 얼마나 빨리 그 포로토콜을 구현하는 동시에 그 결과 애플리케이션의 안정성과 성능을 훼손하지 않는 것입니다.



The Solution

Netty 프로젝트는 유지보수성이 좋고 높은 성능과 확장성을 갖는 프로토콜 서버와 클라이언트를 빨리 개발하기 위해 비동기 이벤트 구동 네트워크 애플리케이션 프레임워크와 툴을 제공하기 위한 노력의 결과입니다.


즉, Netty는 프로토콜 서버 및 클라이언트와 같은 네트워크 애플리케이션을 빠르고 쉽게 개발하는 것을 가능하게 해주는 NIO 클라이언트 서버 프레임워크입니다. Netty는 TCP, UDP 소켓 서버 개발과 같은 네트워크 프로그래밍을 매우 간단하고 능률적으로 만들어줍니다.


"빠르고 간단하게"라는 어구가, 결과 애플리케이션의 유지보수성이나 성능 문제가 발생할 것이라는 것을 의미하지는 않습니다. Netty는 FTP, SMTP, HTTP 및 다양한 바이너리/텍스트 기반 레거시 프로토콜의 수많은 구현을 통해 얻은 경험을 가지고 신중하게 설계되었습니다. 그 결과, Netty는 어떠한 타협 없이도 쉬운 개발, 성능, 안정성 및 유연성이라는 목표를 달성하는 방법을 찾는데 성공하였습니다.


몇몇 사용자들은 위와 똑같은 장점을 가지고 있다고 주장하는 다른 네트워크 어플리케이션 프레임워크를 이미 알고 있을 수도 있기에, Netty가 이들과 다른 점이 무엇인지 질문할지도 모릅니다. 그 대답은 바로 Netty가 바탕을 두고 있는 철학입니다. Netty는 자주 사용하는 API와 구현 두 측면에 있어 모두 매우 편안함을 제공하도록 설계되었습니다. 이는 가시적이지 않지만, 가이드 문서를 확인하며 Netty를 다루어 보면 이러한 철학은 도움이 될 것입니다.



Chapter 1. Getting Started

이 장에서는 Netty를 빠르게 시작할 수 있도록 간단한 예제와 함께 Netty의 핵심 요소에 관해 다루고 있습니다. 이 장이 끝날때 즈음, Netty 기반 클라이언트와 서버를 즉시 만들수 있게 될 것입니다.


하향식(top-down) 학습 방법을 선호한다면, Chapter2. Architectural Overview를 먼저 보는 것도 나쁘지 않습니다.



1.1. Before Getting Started

Netty를 실행하기 위한 최소한의 필요 조건은 Netty의 최신버전과 JDK 1.5 이상의 버전 두가지입니다. 최신버전 Netty는 프로젝트 다운로드 페이지에서 이용 가능합니다. 이 두 가지로도 거의 대부분의 프로토콜을 구현하는데 충분하다는 것을 알게될 것입니다.


가이드에서 소개된 클래스들에 관해 자세히 알고 싶을때마다, API 레퍼런스를 참고하면 좋습니다. 이 문서에 있는 모든 클래스들은 API 레퍼런스에서 상세하게 설명되어있습니다. 


1.2. Writing a Discard Server

세상에서 가장 단순한 프로토콜은 'Hello, World!'가 아니라 DISCARD입니다. 이는 수신 받은 데이터에 대해 어떠한 응답도 하지 않고 데이터를 버리는 프로토콜입니다.


DISCARD 프로토콜 구현을 위해, 해야할 일은 단지 수신된 모든 데이터를 무시하는 것입니다. 바로 핸들러 구현부터 시작해보겠습니다. 이는 Netty에 의해 생성된 I/O 이벤트를 처리합니다.


package org.jboss.netty.example.discard;

@ChannelPipelineCoverage("all")
public class DiscardServerHandler extends SimpleChannelHandler {
    
    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) {
        e.getCause().printStackTrace();
        
        Channel ch = e.getChannel();
        ch.close();
    }
    
}


1. ChannelPipelineCoverage는 어노테이션 타입이 부여된 이 핸들러 인스턴스가 하나 이상의 Channel(과 이 채널과 관련된 ChannelPipeline)에 의해 공유될 수 있는지 구분하기 위해 핸들러에 어노테이션을 부여합니다. DiscardServerHandler는 어떠한 상태 정보도 관리하지 않으므로, "all"이라는 값으로 어노테이션이 부여되었습니다.

2. DiscardServerHandler는 SimpleChannelHandler를 상속하고 있는데, 이 클래스는 ChannelHandler를 구현하고 있습니다. SimpleChannelHandler는 여러분이 재정의할 수 있는 다양한 이벤트 핸들러 메소드를 제공합니다. 지금 당장은 여러분이 직접 핸들러 인터페이스를 구현하기 보다는 SimpleChannelHandler를 상속하는 것으로 충분합니다.

3. 여기에서 messageReceived 이벤트 핸들러 메소드를 재정의하고 있습니다. 이 메소드는 MessageEvent를 받아 호출됩니다. 클라이언트로부터 새로운 데이터를 수신할 때마다, MessageEvent는 수신받은 데이터를 포함합니다. 이 예제에서, DISCARD 프로토콜을 구현하기 위해 아무것도 수행하지 않음으로써, 수신 데이터를 무시합니다.


여기까지 DISCARD 서버의 절반을 구현한 것입니다. 이제 남은 것은 DiscardServerHandler를 가지고 서버를 시작시키는 main 메소드를 작성하는 것입니다.


package org.jboss.netty.example.discard;

import java.net.InetSocketAddress;
import java.util.concurrent.Executors;

public class DiscardServer {
    public static void main(String[] args) throws Exception {
        ChannelFactory factory = 
                new NioServerSocketChannelFactory(
                        Executors.newCachedThreadPool(),
                        Executors.newCachedThreadPool()
                    );
        
        ServerBootstrap bootstrap = new ServerBootStrap(factory);
        
        DiscardServerHandler handler = new DiscardServerHandler();
        ChannelPipeline pipeline = bootstrap.getPipline();
        pipeline.addLast("handler", handler);
        
        bootstrap.setOptions("child.tcpNoDelay", true);
        bootstrap.setOptions("child.keepAlive", true);
        bootstrap.bind(new InetSocketAddress(8080));
    }
}

1. ChannelFactory는 Channel 및 이와 관련된 자원을 생성하고 관리하는 팩토리입니다. 이것은 모든 I/O 요청을 처리하고 모든 I/O를 수행하여 ChannelEvent를 생성합니다. Netty는 다양한 ChannelFactory 구현 클래스를 제공합니다. 이 예제는 서버 측 애플리케이션을 구현하고 있으므로, NioServerSocketChannelFactory가 사용되었습니다. 주목할 만한 또다른 사항은, 이 팩토리가 직접 I/O 스레드를 생성하지 않는다는 점입니다. 이는 생성자에 여러분이 지정한 스레드 풀로부터 스레드를 획득하도록 되어 있습니다. 그러므로, 여러분은 보안 관리자를 갖는 애플리케이션 서버와 같이 여러분의 애플리케이션이 실행되는 환경에서 스레드가 어떻게 관리되어야 하는지 보다 세밀하게 제어할 수 있습니다.

2. ServerBootstrap은 서버를 설정하는 보조 클래스입니다. 여러분은 Channel을 직접 사용하여 서버를 설정할 수 있습니다. 하지만, 이는 괴로운 과정이며 대부분의 경우, 그렇게 할 필요도 없습니다.

3. 여기에 DiscardServerHandler를 디폴트 ChannelPipeline에 추가하고 있습니다. 서버가 새로운 커넥션을 수용할 때마다, 새롭게 수용된 Channel을 위해 새로운 ChannelPipeline이 생성되고 여기에 추가된 모든 ChannelHandler는 이 새로운 ChannelPipeline에 추가됩니다. 이는 거의 얕은 복사 작업(shallow-copy operation)과 유사합니다. 모든 Channel들과 이들의 ChannelPipeline들은 동일한 DiscardServerHandler 인스턴스를 공유할 것이기 때문입니다.

4. 여러분은 또한 특정 Channel 구현에 대해 파라미터를 설정할 수 있습니다. 우리는 TCP/IP 서버를 만들고 있으므로, tcpNoDelay와 keepAlive같은 소켓 옵션을 설정하는 것이 가능합니다. 모든 옵션에 "child." 접두사가 붙어있는 점을 주목해보겠습니다. 이는 해당 옵션이 ServerSocketChannel의 옵션이 아니라 수용된 Channel에 적용될 것임을 의미합니다. 여러분은 다음과 같이 ServerSocketChannel을 설정할 수도 있습니다. bootstrap.setOption("reuseAddress", true);

5. 이제 준비가 다 되었습니다. 남은 일은 포트 바인딩과 서버를 시작시키는 것입니다. 여기에서, 우리는 컴퓨터에 있는 모든 NIC(Network Interface Card)의 8080 포트에 바인딩하고 있습니다. 여러분은 이제 (서로 다른 바인딩 주소를 사용하여) 여러분이 원하는 만큼 bind 메소드를 호출할 수 있습니다.



1.3. Looking into the Received Data

첫 서버를 만들었으므로, 이것이 실제로 동작하는지 테스트해 볼 필요가 있습니다. 이를 테스트 하기에 가장 쉬운 방법은 telnet 명령을 사용하는 것입니다. 예를 들어, 명령 프롬프트에서 "telnel localhost 8080"을 입력하고 무엇인가를 쳐볼 수 있습니다.


하지만, 서버가 제대로 동작하고 있다고 말할 수 있을까요? 이는 DISCARD 서버이기 때문에 우리는 실제로 이를 알 수가 없습니다. 어떠한 응답도 받지 못할 것입니다. 서버가 실제로 동작하고 있다는 것을 증명하기 위해, 서버가 수신 받은 내용을 출력하도록 수정해보겠습니다.


    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
        ChannelBuffer buf = (ChannelBuffer)e.getMessage();
        while(buf.readable()) {
            System.out.println((char)buf.readByte());
        }
    }

1. 소켓 전송에서 메시지 타입은 항상 ChannelBuffer라고 간주하는 것이 안전합니다. ChannelBuffer는 Netty에서 일련의 바이트들을 저장하는 기본적인 데이터 구조입니다. 이는 NIO의 ByteBuffer와 유사하지만, 사용하기에 더 쉽고 유연합니다. 예를 들어, Netty에서는 불필요한 메모리 복사 횟수를 줄여주기 위해 여러 ChannelBuffer를 조합하는 복합 ChannelBuffer를 사용할 수 있습니다.

비록 이것이 NIO ByteBuffer와 매우 닮았지만, API 레퍼런스를 참고해볼 것을 권합니다. Netty를 어려움없이 사용하려면 ChannelBuffer를 올바르게 사용하는 법을 아는 것이 매우 중요합니다.


여러분이 다시 telnet 명령을 실행하면, 여러분은 서버가 수신받은 내용을 출력하는 것을 보게 될 것입니다. DISCARD 서버의 전체 소스 코드는 배포판의 org.jboss.netty.example.discard에 있습니다.



1.4. Writing an Echo Server

지금까지, 우리는 아무 응답 없이 데이터를 사용하고만 있습니다. 그러나, 서버란 대개 요청에 대해 응답을 하도록 되어 있습니다. ECHO 프로토콜을 구현해 봄으로써, 클라이언트로 응답 메시지를 작성하는 법을 알아보겠습니다. 이 프로토콜은 수신 받은 데이터를 되돌려 전송합니다.


이전 섹션에서 구현했던 DISCARD 서버와의 유일한 차이는, ECHO 서버는 수신 받은 데이터를 콘솔에 출력하지 않고 되돌려 전송한다는 것입니다. 그러므로, 다시 messageReceived 메소드를 수정하는 것으로도 충분합니다.


    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
        Channel ch = e.getChannel();
        ch.write(e.getMessage());
    }

1. ChannelEvent 객체는 자신과 관련된 Channel에 대한 레퍼런스를 가지고 있습니다. 여기에서, 반환된 Channel은 MessageEvent를 수신했던 커넥션을 나타냅니다. 우리는 Channel을 얻고 이 객체의 write 메소드를 호출하여 원격 상대 쪽으로 무엇인가를 보내줄 수 있습니다.


여러분이 telnet 명령을 다시 실행하며, 여러분이 서버로 전송했던 내용을 서버가 다시 되돌려주는 것을 확인할 수 있을 것입니다.



1.5. Writing a Time Server

이번 섹션에서 구현할 프로토콜은 TIME 프로토콜입니다. 이 프로토콜은 어떠한 요청도 받지 않은 채 32비트 정수를 포함하고 있는 메시지를 전송하고 메시지가 일단 전송되면 커넥션이 사라진다는 점에서 이전 예제들과는 다릅니다. 이 예제에서, 여러분은 메시지를 구성하고 전송하는 법과 완료시 커넥션을 종료하는 방법에 대해 배울 것입니다. 


수신된 데이터는 무시하는 반면 커넥션이 생성되자 마자 메시지를 전송할 것이기 때문에, 이번에는 messageReceived를 사용할 수 없습니다. 대신, channelConnected를 재정의 해야 합니다. 다음은 이를 구현한 것입니다.


package org.jboss.netty.example.discard;

@ChannelPipelineCoverage("all")
public class TimeServerHandler extends SimpleChannelHandler {
    
    @Override
    public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) {
        Channel ch = e.getChannel();
        
        ChannelBuffer time = ChannelBuffers.buffer(4);
        time.writeInt(System.currentTimeMillis()/1000);
        
        ChannelFuture f = ch.write(time);
        
        f.addListener(new ChannelFutureListener() {
           public void operationComplete(ChannelFuture future) {
               Channel ch = future.getChannel();
               ch.close();
           } 
        });
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) {
        e.getCause().printStackTrace();
        e.getChannel().close();
    }
}

1. 설명했듯이, channelConnected 메소드는 커넥션이 생성될 때 호출될 것입니다. 여기에서 현재 시간을 초로 나타낸 32 비트 정수를 전송합니다.

2. 새로운 메시지를 전송하려면, 메시지를 포함하게 될 새로운 버퍼를 할당할 필요가 있습니다. 32비트 정수를 전송할 것이므로, 크기가 4바이트인 ChannelBuffer가 필요합니다. 새로운 버퍼 할당을 위해 ChannelBuffers 보조 클래스가 사용됩니다. buffer 메소드 뿐만 아니라, ChannelBuffers는 ChannelBuffer과 관련되어 유용한 메소드를 많이 제공합니다. 보다 자세한 정보는 API 문서를 참고하면 됩니다.

한편, 다음처럼 ChannelBuffers에 대해 static import를 사용하는 것도 좋은 생각입니다.

import static org.jboss.netty.buffer.ChannelBuffers.*;
...
ChannelBuffer dynamicBuf = dynamicBuffer(256);
ChannelBuffer ordinaryBuf = buffer(1024);

3. 평소처럼, 구축된 메시지를 씁니다. 그러나, 잠시만 flip 메소드는 어디에 있는 것일까요? NIO에서는 메시지를 전송하기 전에 ByteBuffer.flip() 메소드를 호출합니다. ChannelBuffer는 그러한 메소드를 가지고 있지 않습니다. 왜냐하면, 읽기 작업을 위한 포인터와 쓰기 작업을 위한 포인터를 각각 가지고 있기 때문입니다. ChannelBuffer로 무엇인가를 쓰면 쓰기 인덱스는 증가하지만 읽기 인덱스는 변하지 않습니다. 읽기 인덱스와 쓰기 인덱스는 각각 메시지가 시작되는 곳과 끝나는 곳을 나타냅니다.

반면, NIO 버퍼에서는 flip 메소드를 호출하지 않고서는 메시지 내용의 시작과 끝 위치를 알아내기 위한 명확한 방법을 제공하지 않습니다. 여러분이 버퍼에 대해 flip 호출하는 것을 잊어버리고 하지 않게 되면, 곤경에 처하게 될 것입니다. 왜냐하면, 어떠한 데이터도 전송되지 않거나 올바르지 않은 데이터가 전송될 것이기 때문입니다. Netty에서는 그러한 에러가 일어나지 않습니다. 왜냐하면, 서로 다른 작업에 대해 서로 다른 포인터를 가지고 있기 때문입니다. 여러분이 이에 익숙해지게 되면 작업이 훨씬 쉬워진다는것을 알게 될 것입니다.

여기에서 주목할 만한 또 다른 사항은 write 메소드가 ChannelFuture를 반환한다는 것입니다. ChannelFuture는 아직 발생하지 않은 I/O 오퍼레이션을 나타냅니다. Netty에서는 모든 오퍼레이션이 비동기이기 때문에, 이는 요청된 오퍼레이션이 아직 수행되지 않았을 수도 있음을 의미합니다. 예를 들어, 다음 코드는 메시지가 전송되기 전이라 할지라도 커넥션을 종료시킬 수 있습니다.

Channel ch = ...;
ch.write(message)
ch.close();

그러므로, write 메소드에 의해 반환된 ChannelFuture가 쓰기 작업의 완료를 통지한 후에 close() 메소드를 호출해야 합니다. close() 메소드는 즉시 커넥션을 종료하지 않을 수도 있다는 점을 유의하는 것이 좋습니다. 이 메소드는 ChannelFuture를 반환합니다.

4. 그러면 쓰기 요청이 완료되었을 때 이를 어떻게 통지 받을까요? 반환된 ChannelFuture에 ChannelFutureListener를 추가해 주면 됩니다. 여기에서 우리는 오퍼레이션이 완료되었을 때, Channel을 종료하는 새로운 익명 ChannelFutureListener를 생성하였습니다. 또는, 미리 정의된 리스너를 사용하여 코드를 간단하게 만들어 줄 수도 있습니다.

f.addListener(ChannelFutureListener.CLOSE);


1.6. Writing a Time Client

DISCARD, ECHO 서버와 달리, TIME 프로토콜의 경우 클라이언트가 필요합니다. 왜냐하면 사람은 32비트 바이너리 데이터를 달력 상의 날짜로 인식할 수 없기 때문입니다. 이번 섹션에서는 서버가 올바르게 동작하는지 확인하는 방법과 Netty로 클라이언트를 작성하는 방법에 대해 알아볼 것입니다.


Netty에서 서버와 클라이언트 간의 가장 크면서도 유일한 차이점은 서로 다른 Bootstrap과 ChannelFactory가 필요하다는 것입니다. 다음 코드를 살펴보겠습니다.


package org.jboss.netty.example.discard;

import java.net.InetSocketAddress;
import java.util.concurrent.Executors;

public class TimeClient {
    public static void main(String[] args) throws Exception {
        String host = args[0];
        int port = Integer.parseInt(args[1]);
        
        ChannelFactory factory = 
                new NioClientSocketChannelFactory(
                    Executors.newCachedThreadPool(),
                    Executors.newCachedThreadPool()
                );
        
        ClientBootstrap bootstrap = new ClientBootstrap(factory);
        
        TimeClientHandler handler = new TimeClientHandler();
        bootstrap.getPipeline().addLast("handler", handler);
        
        bootstrap.setOption("tcpNoDelay", true);
        bootstrap.setOption("keepAlive", true);
        
        bootstrap.connect(new InetSocketAddress(host, port));
    }
}


1. NioServerSocketChannelFactory 대신 NioClientSocketChannelFactory를 사용하여 클라이언트 측 Channel을 생성했습니다.

2. ClientBootstrap은 ServerBootstrap에 해당하는 클라이언트 측 요소입니다.

3. "child." 접두사가 존재하지 않는 점을 주목해보겠습니다. 클라이언트 측 SocketChannel은 부모를 갖지 않습니다.

4. bind 메소드 대신 connect 메소드를 호출해야 합니다.


보면 알 수 있듯이, 서버 측 시작 코드와 실제로 차이가 없습니다. ChannelHandler 구현은 어떨까요? 클라이언트는 서버로부터 32비트 정수를 받아 이를 사람이 인식 가능한 형태로 변환한 후, 변환된 시간을 출력하고 커넥션을 종료해야 합니다.


package org.jboss.netty.example.time;

import java.util.Date;

@ChannelPipelineCoverage("all")
public class TimeClientHandler extends SimpleChannelHandler {
    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
        ChannelBuffer buf = (ChannelBuffer)e.getMessage();
        long currentTimeMillis = buf.readInt()*1000L;
        System.out.println(new Date(currentTimeMillis));
        e.getChannel().close();
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) {
        e.getCause().printStackTrace();
        e.getChannel().close();
    }
}

매우 간단하며 서버 측 예제와 어떠한 차이점도 없습니다. 그러나, 이 핸들러는 종종 IndexOutOfBoundsException를 일으키며 동작하지 않는 경우도 있을 것입니다. 다음 섹션에서는 이러한 현상이 발생하는 이유에 대해 알아볼 것입니다.



1.7. Dealing with a Stream-based Transport

1.7.1. One small Caveat of Socket Buffer

TCP/IP와 같은 스트림 기반 전송에서, 수신된 데이터는 소켓 수신 버퍼에 저장됩니다. 불행히도, 스트림 기반 전송의 버퍼는 패킷으로 구성된 큐가 아니라 바이트로 구성된 큐입니다. 만약 여러분이 두 개의 메시지를 별도 두 패킷으로 전송한다 하더라도, 운영체제는 이를 두 개의 메시지가 아니라 단순히 바이트 덩어리로 취급한다는 것을 의미합니다. 그러므로, 읽어들인 내용이 원격 상대가 보낸 것과 정확히 일치한다고 보장되지 않습니다. 예를 들어, 운영체제의 TCP/IP 스택이 세 개의 패킷을 수신했다고 가정해보겠습니다.


+ - - - - + - - - - + - - - - +

 |   ABC  |   DEF   |   GHI   |

+ - - - - + - - - - + - - - - +


스트림 기반 프로토콜의 일반적인 특징으로 인해, 애플리케이션에서 이들을 다음과 같이 읽어 들일 가능성이 매우 높습니다.


+ - - - - + - - - - + - - - - +

 |  AB  |   CDEFG   |  H  |  I |

+ - - - - + - - - - + - - - - +


그러므로, 서버 측이든 클라이언트 측이든 상관없이 수신부는 수신받은 데이터를 애플리케이션 로직에서 쉽게 이해할 수 있는 하나 이상의 의미있는 프레임으로 조립해 주어야 합니다. 위 예제의 경우, 수신된 데이터는 다음과 같은 프레임이 되어야 합니다.


+ - - - - + - - - - + - - - - +

 |   ABC  |   DEF   |   GHI   |

+ - - - - + - - - - + - - - - +



1.7.2. The First Solution

이제 TIME 클라이언트 예제를 다시 살펴보겠습니다. 우리는 여기에서 똑같은 문제점을 가지고 있습니다. 32비트 정수는 매우 작은 양의 데이터이지만, 종종 단편화될 가능성이 존재합니다. 그러나, 문제는 단편화될 수 있다는 점이며, 단편화 가능성은 트래픽이 증가할수록 높아집니다.


가장 간단한 해결택은 내부 누적 버퍼를 생성하고 내부 버퍼로 4바이트가 수신될 때까지 대기하는 것입니다. 다음은 위 문제점 해결을 위해 수정된 TimeClientHandler입니다.


package org.jboss.netty.example.time;

import static org.jboss.netty.buffer.ChannelBuffers.*;
import java.util.Date;

@ChannelPipelineCoverage("one")
public class TimeClientHandler2 extends SimpleChannelHandler {
    private final ChannelBuffer buf = dynamicBuffer();
    
    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
        ChannelBuffer m = (ChannelBuffer)e.getMessage();
        buf.writeBytes(m);
        
        if(buf.readableBytes() >= 4) {
            long currentTimeMillis = buf.readInt()*1000L;
            System.out.println(new Date(currentTimeMillis));
            e.getChannel().close();
        }
    }
    
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) {
        e.getCause().printStackTrace();
        e.getChannel().close();
    }
}

1. 이번에, ChannelPipelineCoverage 어노테이션의 값으로 "one"이 사용되었습니다. 새로운 TimeClinet

Handler는 내부 버퍼를 유지해야 하기 때문에 다수의 Channel을 제공할 수 없습니다. 만약 TimeClientHandler의 한 인스턴스가 다수의 Channel(과 그 결과 다수의 ChannelPipeline)에 의해 공유된다면, buf의 내용은 손상될 것입니다.

2. 이 버퍼는 필요 시마다 자신의 크기를 증가시키는 ChannelBuffer입니다. 메시지의 길이를 모를 경우 이는 매우 유용합니다.

3. 우선, 수신된 모든 데이터는 buf로 누적되어야 합니다.

4. 그런 후, 핸들러는 buf가 충분한 데이터(예제의 경우 4바이트)를 가지고 있는지 검사한 후, 실제 비즈니스 로직을 처리해야 합니다. 충분한 데이터를 가지고 있지 않은 경우, Netty는 데이터가 더 수신될 때, messageReceived를 다시 호출할 것이며, 결국은 4바이트가 모두 누적될 것입니다.


수정될 곳이 더 있습니다. ClientBootstrap의 디폴트 ChannelPipeline에 TimeClientHandler 인스턴스를 추가했던 것을 기억하나요? 이는 동일한 TimeClientHandler 인스턴스가 여러 Channel을 처리하고 그 결과 데이터가 손상될 것임을 의미합니다. Channel 당 TimeClinetHandler 인스턴스를 생성하기 위해 ChannelPipelineFactory를 구현해야 합니다.


package org.jboss.netty.example.time;

public class TimeClientPipelineFactory implements ChannelPipelineFactory {
    public ChannelPipeline getPipeline() {
        ChannelPipeline pipeline = Channels.pipline();
        pipeline.addLast("handler", new TimeClientHandler());
        return pipeline;
    }
}

이제 TimeClient의 다음 행을 그 다음의 것으로 바꾸어 주면됩니다.


TimeClientHandler handler = new TimeClientHandler();
bootstrap.getPipeline().addLast("handler", handler);
bootstrap.setPipelineFactory(new TimeClientPipelineFactory());

처음 보면 약간 복잡해 보일지도 모릅니다. 사실, TimeClient는 오직 하나의 커넥션만 생성하기 때문에, 이렇게 특수한 경우, TimeClientPipelineFactory를 도입할 필요가 없는 것이 사실입니다.


그러나, 애플리케이션이 점점 더 복잡해지면, 거의 항상 ChannelPipelineFactory를 결국은 작성하게 될 것입니다. 왜냐하면, 이는 파이프라인 설정을 훨씬 더 유연하게 만들어주기 때문입니다.



1.7.3. The Second Solution

첫 번째 솔루션으로도 TIME 클라이언트가 갖는 문제점을 해결했지만, 수정된 핸들러는 그리 깔끔해 보이지 않습니다. 가변 길이 필드를 갖는 것처럼 다수의 필드들로 구성된 보다 복잡한 프로토콜을 생각해보면, 여러분이 구현한 ChannelHandler는 급속하게 유지 보수하기 힘들어질 것입니다.


여러분이 이미 눈치챘을지도 모르지만, ChannelPipeline에는 하나 이상의 ChannelHandler를 추가해 줄 수 있습니다. 그러므로, 여러분은 하나의 단일 ChannelHandler를 다수의 모듈로 쪼개어 어플리케이션의 복잡도를 줄여줄 수 있습니다. 예를 들어, 여러분은 TimeClientHandler를 두 개의 핸들러로 쪼개 줄 수 있습니다.


 - 단편화 이슈를 다루어주는 TimeDecoder

 - 최초의 간단한 TimeClientHandler


다행히도, Netty는 첫번째를 작성하는데 도움을 주는 확장 가능한 클래스를 제공해줍니다.


package org.jboss.netty.example.time;

public class TimeDecoder extends FrameDecoder {
    
    @Override
    protected Object decode(ChannelHandlerContext ctx, Channel channel, ChannelBuffer buffer) {
        if(buffer.readableBytes() < 4) {
            return null;
        }
        
        return bugger.readBytes(4);
    }
}

1. 이번에는 ChannelPipelineCoverage 어노테이션이 존재하지 않습니다. FrameDecoder는 이미 "one"을 갖는 어노테이션이 부여되어 있기 때문입니다.

2. 새로운 데이터가 수신될 때마다 FrameDecoder는 내부에 유지되고 있는 누적 버퍼를 가지고 decode 메소드를 호출합니다.

3. 만약 null이 반환되면, 이는 아직 충분한 데이터가 존재하지 않음을 의미합니다. 충분한 양의 데이터가 존재할때 FrameDecoder는 다시 호출할 것입니다.

4. 만약 null이 아닌 것이 반환되면, 이는 decode 메소드가 메시지를 성공적으로 디코딩했음을 의미합니다. FrameDecoder는 자신의 내부 누적 버퍼의 읽은 부분을 버릴 것입니다. 여러분은 다수의 메시지를 디코딩할 필요가 없다는 점을 염두합시다. FrameDecoder는 자신이 null을 반환할 때까지 디코더 메소드 호출을 유지할 것입니다.


만약 여러분이 호기심이 많은 사람이라면, 디코더를 훨씬 더 간단하게 만들어주는 ReplayingDecoder를 시험 삼아 사용해 보고 싶어할지도 모릅니다. 보다 자세한 정보는 API 문서를 찾아보는 것이 좋습니다.


package org.jboss.netty.example.time;

public class TimeDecoder2 extends ReplayingDecoder {
    @Override
    protected Object decode(
            ChannelHandlerContext ctx, Channel channel,
            ChannelBuffer buffer, VoidEnum state) {
        return buffer.readBytes(4);
    }
}


또한, Netty는 여러분이 대부분의 프로토콜을 매우 쉽게 구현할 수 있도록 해주는 디코더들을 제공하여, 여러분이 결과적으로 유지 보수가 힘든 단일 핸들러를 구현하는 일이 없도록 도와줍니다. 보다 자세한 예제는 다음 패키지를 살펴보면 됩니다.


 - 바이너리 프로토콜에 대한 org.jboss.netty.example.factorial

 - 텍스트 행 기반 프로토콜을 위한 org.jboss.netty.example.telnet



1.8. Speaking in POJO instead of ChannelBuffer

지금까지 우리가 살펴보았던 모든 예제에서는 ChannelBuffer를 프로토콜 메시지의 기본 데이터 구조로 사용하였습니다. 이번 섹션에서, 우리는 ChannelBuffer 대신 POJO를 사용하도록 TIME 프로토콜 클라이언트와 서버 예제를 개선해 볼것입니다.


ChannelHandler에서 POJO를 사용하는 경우의 장점은 명확합니다. 즉, ChannelBuffer로부터 정보를 추출하는 코드를 핸들러에서 분리시킴으로써, 핸들러의 유지보수와 재사용성이 더 좋아집니다. TIME 클라이언트와 서버 예제는 32 비트 정수만을 읽기 때문에, ChannelBuffer를 직접 사용하는 것은 큰문제가 되지는 않습니다. 하지만, 여러분이 실세계에서 사용하는 프로토콜을 구현할 때, 이들을 분리하는 것이 필요하다는 것을 알게 될 것입니다.


우선, UnixTime이라는 새로운 타입을 정의해보겠습니다.


package org.jboss.netty.example.time;

import java.util.Date;

public class UnixTime {
    private final int value;
    
    public UnixTime(int value) {
        this.value = value;
    }
    
    public int getValue() {
        return value;
    }
    
    @Override
    public String toString() {
        return new Date(value * 1000L).toString();
    }
}


우리는 이제 ChannelBuffer 대신 UnixTime을 반환하도록 TimeDecoder를 고칠 수 있습니다.


    @Override
    protected Object decode(
            ChannelHandlerContext ctx, Channel channel, ChannelBuffer buffer) {
        if(buffer.readableBytes() < 4) {
            return null;
        }
        
        return new UnixTime(buffer.readInt());
    }

1. FrameDecoder와 ReplayingDecoder는 어떠한 타입의 객체라도 반환할 수 있게 허용합니다. 만약 ChannelBuffer만을 반환하도록 제한되어 있다면, 우리는 ChannelBuffer를 UnixTime으로 변환하는 또 다른 ChannelHandler를 삽입해야 했을 것입니다.


수정된 디코더를 사용하면, TimeClientHandler는 ChannelBuffer를 더 이상 사용하지 않습니다.


    @Override
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) {
        UnixTime m = (UnixTime)e.getMessage();
        System.out.println(m);
        e.getChannel().close();
    }


훨씬 간단하고 우아해진 결과를 확인할 수 있습니다. 이와 똑같은 기법은 서버측에도 적용될 수 있습니다. 이번에는 TimeServerHandler를 수정해보겠습니다.


    @Override
    public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) {
        UnixTime time = new UnixTime(System.currentTimeMillis() / 1000);
        ChannelFuture f = e.getChannel().write(time);
        f.addListener(ChannelFutureListener.CLOSE);
    }


이제, 남은 것은 UnixTime을 다시 ChannelBuffer로 변환하는 ChannelHandler입니다. 이는 디코더를 작성하는 것보다 훨씬 간단합니다. 왜냐하면, 메시지를 인코딩 할 때, 패킷 단편화 및 조립 문제를 다룰 필요가 없기 때문입니다.


package org.jboss.netty.example.time;

import static org.jboss.netty.buffer.ChannelBuffers.*;

public class TimeEncoder extends SimpleChannelHandler {
    public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) {
        UnixTime time = (UnixTime)e.getMessage();
        
        ChannelBuffer buf = buffer(4);
        buf.writeInt(time.getValue());
        
        Channels.write(ctx, e.getFuture(), buf);
    }
}

1. 인코더의 ChannelPipelineCoverage 값은 대개 "all"입니다. 왜냐하면, 이 인코더는 상태정보를 갖지 않기 때문입니다. 사실, 대부분의 인코더들은 상태 정보를 갖지 않습니다.

2. 인코더는 쓰기 요청을 가로채기 위해 writeRequested 메소드를 재정의합니다. 여기에 있는 MessageEvent는 messageReceived에 지정되었던 것과 동일한 타입이지만, 다르게 해석된다는 점을 주의하는 것이 좋습니다. ChannelEvent는 이벤트가 흐르는 방향에 따라 업스트림 이벤트이거나 다운스트림 이벤트가 될 수 있습니다. 예를 들어, messageReceived에 대해 호출될 때는 업스트림 이벤트가 될 수 있고, writeRequested에 대해 호출될 때는 다운스트림 이벤트가 될 수 있습니다. 업스트림 이벤트와 다운스트림 이벤트 간의 차이점에 관해 더 자세히 알고 싶다면 API 문서를 참조하면 됩니다.

3. 일단 POJO를 ChannelBuffer로 변환하는 작업이 끝나게 되면, ChannelPipeline에 있는 이전 ChannelDownstreamHandler로 새로운 버퍼를 포워딩 해야 합니다. Channels는 ChannelEvent를 생성하고 전송하는 다양한 보조 메소드들을 제공합니다. 이 예제에서, Channels.write(...) 메소드는 새로운 MessageEvent를 생성하고 ChannelPipeline에 있는 이전 ChannelDownstreamHandler로 이를 전송합니다.

한편, Channels에 대해 static import를 사용하는 것은 좋은 아이디어입니다.


import static org.jboss.netty.channel.Channels.*;
...
ChannelPipeline pipeline = pipeline();
write(ctx, e.getFuture(), buf);
fireChannelDisconnected(ctx);


마지막으로 남은 작업은 서버측의 ChannelPipeline에 TimeEncoder를 넣는 것입니다. 이는 매우 쉬운 연습문제로 남겨두도록 하겠습니다.



1.9. Shutting Down Your Application

여러분이 TimeClient를 실행하면, 이 어플리케이션은 종료되지 않고 단지 아무것도 하지 않은 채로 계속 실행된 상태를 유지하고 있다는 것을 눈치챘을 것입니다. 완전한 StackTrace를 살펴보면, 한 두개의 I/O 스레드들이 실행되고 있는 것을 발견할 수 있을 것입니다. I/O 스레드를 종료하고 어플리케이션을 안전하게 종료시키려면, 여러분은 ChannelFactory에 의해 할당된 자원들을 해제해 주어야 합니다.


일반적인 네트워크 애플리케이션 종료 과정은 다음 세 단계로 이루어집니다.


1. 모든 서버 소켓을 종료한다.

2. 서버 소켓이 아닌 다른 모든 소켓들을 종료한다 (예를 들면, 클라이언트 소켓이나 수용된 소켓을 들 수 있다.)

3. ChannelFactory에 의해 사용된 모든 자원을 해제한다.


이러한 세가지 절차를 TimeClient에 적용하려면, TimeClient.main()에서 한 클라이언트 커넥션을 종료하고 ChannelFactory에 의해 사용되는 모든 자원들을 해제함으로써, 자신을 안전하게 종료할 수 있을 것입니다.


package org.jboss.netty.example.time;

public class TimeClient2 {
    public static void main(String[] args) throws Exception {
        ...
        ChannelFactory factory = ...;
        ClientBootstrap bootstrap = ...;
        ...
        ChannelFuture future = bootstrap.connect(...);
        future.awaitUninterruptible();
        if(!future.isSuccess()) {
            future.getCause().printStackTrace();
        }
        future.getChannel().getCloseFuture().awaitUninterruptibly();
        factory.releaseExternalResources();
    }
}

1. ClientBootstrap의 connect 메소드는 ChannelFuture를 반환합니다. 이것은 커넥션 시도가 성공 혹은 실패할 때 통지합니다. 이는 또한 커넥션 시도와 관련된 Channel에 대한 레퍼런스를 가지고 있습니다.

2. 커넥션 시도의 성공, 실패 여부를 판단하기 위해 반환된 ChannelFuture를 대기합니다.

3. 만약 실패했다면, 실패 이유를 알릴 수 있도록 실패 원인을 출력합니다. ChannelFuture의 getCause() 메소드는 연결 시도가 성공하지 못했거나 취소되었다면 실패 원인을 반환할 것입니다.

4. 커넥션 시도가 끝났으므로, Channel의 closeFuture를 대기함으로써 커넥션이 종료될때까지 대기할 필요가 있습니다. 모든 Channel은 사용자에게 종료를 통지하고 어떤 작업을 수행할 수 있도록 자신만의 closeFuture를 가지고 있습니다.

커넥션 시도가 실패했다 하더라도, closeFuture가 통지될 것입니다. 왜냐하면, 커넥션 시도가 실패했을 때 Channel은 자동으로 종료될 것이기 때문입니다.

5. 모든 커넥션이 이 지점에서 종료되었습니다. 남은 작업은 ChannelFactory에 의해 사용되는 자원을 해제하는 것입니다. 간단히 releaseExternalResources() 메소드를 호출하기만 하면 됩니다. NIO Selector, 스레드 풀을 비롯한 모든 자원들은 자동으로 종료될 것입니다.


클라이언트를 종료하는 것은 매우 쉽습니다. 하지만, 서버를 종료하는 것은 어떨까요? 여러분은 포트로부터 바인딩을 풀고 수용되어 열려 있는 모든 커넥션들을 닫아주어야 합니다. 이를 위해, 활성화 된 커넥션 목록을 추적하는 자료 구조가 필요한데, 이는 쉽지 않은 작업입니다. 다행히도 ChannelGroup이라는 해결책이 존재합니다.


ChannelGroup은 자바 컬렉션 API를 특수하게 확장한 것으로 열려 있는 Channel 집합을 나타냅니다. 어떤 한 Channel이 ChannelGroup에 추가되고 추가된 Channel이 닫히면, 닫힌 Channel은 자신의 channelGroup에서 자동으로 제거됩니다. 여러분은 또한 동일한 그룹에 있는 모든 Channel에 대해 한번에 오퍼레이션을 수행할 수도 있습니다. 예를 들면, 서버를 종료할 때, 한 ChannelGroup에 있는 Channel들을 닫을 수 있습니다.


열려 있는 소켓을 추적하려면, 전역 ChannelGroup인 TimeServer.allChannels에 새롭게 열린 Channel을 추가하도록 TimeServerHandler를 수정해 주어야 합니다.


    @Override
    public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) {
        TimeServer.allChannels.add(e.getChannel());
    }


ChannelGroup은 스레드에 안전합니다. 이제 모든 활성 Channel들의 목록이 자동으로 유지되므로, 서버를 종료하는 것은 클라이언트를 종료하는 것 만큼이나 쉽습니다.


package org.jboss.netty.example.time;

public class TimeServer {
    static final ChannelGroup allChannels = new DefaultChannelGroup("time-server");
    
    public static void main(String[] agrs) throws Exception {
        ...
        ChannelFactory factory = ...;
        ServerBootStrap bootstrap = ...;
        ...
        Channel channel = bootstrap.bind(...);
        allChannels.add(channel);
        waitForShutdownCommand();
        ChannelGroupFuture future = allChannels.close();
        future.awaitUninterruptibly();
        factory.releaseExternalResources();
    }
}


1. DefualtChannelGroup은 생성자 파라미터로 그룹의 이름을 필요로 합니다. 그룹 이름은 한 그룹과 다른 그룹을 구분하는데에만 사용됩니다.

2. ServerBootstrap의 bind 메소드는 지정된 로컬 주소에 바인딩 된 서버 측 Channel을 반환합니다. 반환된 Channel에 대해 close() 메소드를 호출하면 바인딩 된 로컬 주소로부터 Channel의 바인딩을 해제하게 됩니다.

3. 서버 측이든, 클라이언트 측이든 상관없이, ChannelGroup에 어떠한 타입의 Channel이라도 추가되거나 수용될 수 있습니다. 그러므로, 서버가 종료할 때 수용된 Channel들과 함께 바인딩된 Channel을 한 번에 닫을 수 있습니다.

4. waitForShutdownCommand()는 종료 신호를 대기하는 가상의 메소드입니다. 여러분은 특수한 권한을 갖는 클라이언트나 JVM 종류 후크로부터의 메시지를 대기할 수 있습니다.

5. 동일한 ChannelGroup에 있는 모든 채널들에 대해 동일한 오퍼레이션을 수행할 수 있습니다. 이 경우, 우리는 모든 채널들을 닫는데, 이는 바인딩된 서버 측 Channel의 바인딩이 풀리고 수용된 모든 커넥션들이 비동기적으로 닫힐 것임을 의미합니다. 모든 커넥션들이 성공적으로 닫힐 때를 통지하기 위해, 이것은 ChannelFuture와 유사한 역할을 지닌 ChnnelGroupFuture를 반환합니다.



1.10. Summary

이 장에서, Netty를 기반으로 완전히 동작하는 네트워크 애플리케이션을 작성하는 법에 대한 예를 통해 Netty를 살펴보았습니다. 여러분이 갖게 되었을지도 모르는 보다 많은 의문점들은 이후의 장과 이 장의 개선된 버전에서 다루어질 것입니다. community에서는 여러분의 질문과 여러분에게 도움을 주고 여러분의 피드백을 바탕으로 한 Netty의 지속적인 개선을 위한 아이디어를 항상 기다리고 있습니다.



Chapter 2. Architectural Overview




이 장에서, 우리는 Netty에서 제공되는 핵심 기능들이 무엇이며, 이들 핵심을 기반으로 완전한 네트워크 애플리케이션 개발 스택이 어떻게 구성되어 있는지 알아볼 것입니다. 위 다이어그램을 염두해 두고 이 장을 읽어나가면 좋습니다.



2.1. Rich Buffer Data Structure

Netty는 NIO ByteBuffer 대신 자신만의 버퍼 API를 사용하여 바이트를 표현합니다. 이러한 접근 방법은 ByteBuffer를 사용하는 것보다 훨씬 더 큰 장점을 가지고 있습니다. Netty의 새로운 버퍼 타입인 ChannelBuffer는 ByteBuffer의 문제점들을 해결하고 네트워크 애플리케이션 개발자의 요구를 충족시키기 위해 처음부터 설계되었습니다. 몇몇 멋진 기능들을 열거해보면 다음과 같습니다.


- 필요할 경우 자신만의 버퍼 타입을 정의할 수 있다.

- 내장된 복합 버퍼 타입에 의해 투명한 제로 복사(zero copy)가 수행된다.

- StringBuffer와 같이 필요시 용량이 증가하는 동적 버퍼 타입이 제공된다.

- flip() 메소드를 더이상 호출할 필요가 없다.

- 종종 ByteBuffer보다 더 빠르다.


보다 자세한 정보는 org.jboss.netty.buffer 패키지 설명 부분을 참고하면 됩니다.



2.2. Universal Asynchronous I/O API

자바에서 전통적인 I/O API는 서로 다른 전송 타입마다 서로 다른 타입과 메소드를 제공했습니다. 예를 들면, java.net.Socket과 java.net.DatagramSocket은 어떠한 공통 부모 타입도 가지고 있지 않기 때문에, 완전히 다른 방식으로 소켓 I/O를 수행합니다.


이러한 불일치는 한 전송 타입에서 다른 전송 타입으로 네트워크 애플리케이션 포팅 작업을 지루하고 어렵게 만듭니다. 애플리케이션의 네트워크 계층을 다시 작성하지 않고 더 많은 전송 타입을 지원해야만 할 때, 전송 타입 간 이식성 결여는 문제가 됩니다. 논리적으로, 수많은 프로토콜은 TCP/IP, UDP/IP, SCTP, 시리얼 포트 통신과 같이 하나 이상의 전송 타입에서 실행될 수 있습니다.


설상 가상으로, 자바 New IO(NIO) API는 기존 블록킹 IO(OIO) API와 호환되지 않으며, NIO 2(AIO) 역시 그러할 것입니다. 이러한 모든 API들은 설계 및 성능 측면에서 서로 다르기 때문에, 여러분은 종종 구현을 시작하기도 전에 애플리케이션이 의존할 API를 결정해야만 합니다.


예를 들어, 서비스 대상 클라이언트의 수가 매우 적으며 OIO를 사용하여 소켓 서버를 작성하는 것이 NIO를 사용하는 것보다 훨씬 쉽기 때문에, 여러분은 OIO를 가지고 구현하고 싶어할 지도 모릅니다. 하지만, 비즈니스가 기하 급수적으로 성장하여 서버가 동시에 수 만개의 클라이언트에게 서비스하기 시작했을때 여러분은 곤란한 상황에 처하게 될 것입니다. 처음부터 NIO를 사용했을 수도 있었겠지만, 빠른 개발을 저해하는 NIO Selector API의 복잡성으로 인해 구현하는데 훨씬 더 많은 시간이 소요될 수도 있습니다.


Netty는 Channel이라 불리는 일관된 비동기 I/O 인터페이스를 가지고 있습니다. 이는 점대점 통신에 필요한 모든 오퍼레이션들을 추상화합니다. 즉, 여러분이 Netty 전송 타입으로 애플리케이션을 작성했다면, 이 애플리케이션은 다른 Netty 전송 타입 상에서 실행될 수 있씁니다. Netty는 일관된 API를 통해 필수적인 전송 타입을 많이 제공합니다


- NIO 기반 TCP/IP 전송 타입 (org.jboss.netty.channel.socket.nio를 살펴보아라)

- OIO 기반 TCP/IP 전송 타입 (org.jboss.netty.channel.socket.oio를 살펴보아라)

- OIO 기반 UDP/IP 전송 타입

- 로컬 전송 타입 (org.jboss.netty.channel.local을 살펴보아라)


한 전송 타입에서 다른 전송 타입으로 전환하는 것은 다른 ChannelFactory 구현을 선택하는 것과 같이 대개 단지 몇 행의 코드 수정만을 필요로 합니다.


또한, 여러분은 아직 작성되지 않은 새로운 전송 타입도 사용할 수 있습니다. 예를 들면 생성자를 호출하는 몇 행을 바꿈으로써 직렬 포트 통신을 이용할 수 있습니다. 더욱이, 핵심 API를 확장해 자신만의 전송 타입도 작성할 수 있습니다. 왜냐하면 이것은 매우 확장성이 좋기 때문입니다.



2.3. Event Model based on the Interceptor Chain Pattern

잘 정의되고 확장성 좋은 이벤트 모델은 이벤트 구동 어플리케이션에 있어 필수적입니다. Netty는 I/O에 초점을 맞춘 잘 정의된 이벤트 모델을 가지고 있습니다. 또한 여러분은 기존 코드에 영향을 주지 않고 여러분만의 이벤트 타입을 구현할 수 있습니다. 왜냐하면 각각의 이벤트 타입은 엄격한 타입 계층 구조에 의해 서로 구분되기 때문입니다. 이는 다른 프레임워킁 대해 또 다른 차별 요소입니다. 많은 NIO 프레임워크는 이벤트 모델을 가지고 있지 않거나 매우 제한적인 개념을 가지고 있습니다. 그렇기에 여러분이 새로운 사용자 정의 이벤트 타입을 추가하려고 시도할 때, 이들은 기존 코드에 영향을 주거나 아예 확장을 허용하지 않습니다.


ChannelEvent는 ChannelPipeline에 있는 ChannelHandler들에 의해 처리됩니다. 파이프라인은 InterceptingFilter 패턴의 진화된 형태를 구현합니다. 이것은 이벤트 처리 방법과 파이프라인에 있는 핸들러들이 상호 작용하는 법에 대해 사용자로 하여금 완전히 제어할 수 있게 해줍니다. 예를 들면, 데이터가 소켓으로부터 읽혀질 때 무엇을 수행할지 정의할 수 있습니다.

public class MyReadHandler implements SimpleChannelHandler {
    public void messageReceived(ChannelHandlerContext ctx, MessageEvent evt) {
        Object message = evt.getMessage();
        // Do something with the received message.
        ...
        
        // And forward the event to the next handler.
        ctx.sendUpstream(evt);
    }
}


여러분은 또한 다른 핸들러가 쓰기를 요청했을 때 무엇을 할지 정의할 수 있습니다.

public class MyWriteHandler implements SimpleChannelHandler {
    public void writeRequested(ChannelHandlerContext ctx, MessageEvent evt) {
        Object message = evt.getMessage();
        // Do something with the message to be written.
        ...
        
        // And forward the event to the next handler.
        ctx.sendDownstream(evt);
    }
}


이벤트 모델에 대한 더 자세한 정보는 ChannelEvent와 ChannelPipeline에 대한 API 문서를 참고하면 됩니다.



2.4. Advanced Components for More Rapid Development

모든 타입의 네트워크 애플리케이션 구현을 이미 가능하게 해주는, 위에 언급된 핵심 컴포넌트를 기반으로, Netty는 개발 속도를 한층 더 높일 수 있게 해주는 일련의 고급 기능들을 제공합니다.


2.4.1. Codec framework

Section 1.8, "Speaking in POJO instead of ChannelBuffer"에 보여졌던 것처럼, 비즈니스로직으로부터 프로토콜 코덱을 분리하는 것은 항상 좋은 생각입니다. 그러나, 이러한 아이디어를 처음부터 구현시 몇몇 복잡한 사항이 존재합니다. 여러분은 메시지 단편화 문제를 다루어야만 합니다. 몇몇 프로토콜들은 다중 계층입니다. (즉, 다른 하위 레벨 프로토콜을 기반으로 합니다) 몇몇은 너무 복잡하여 단일 상태 머신(single state machine)으로 구현되지 않습니다.


결과적으로, 훌륭한 네트워크 애플리케이션 프레임워크는 유지보수가 뛰어난 사용자 코덱을 만들어주는 확장성 좋고 재사용 가능하며, 단위 테스트가 가능한 다중 계층 코덱 프레임워크를 제공해야 합니다.


Netty는 여러분이 단순하든 복잡하든, 바이너리이든 아니든 간에 상관없이 여러분이 프로토콜 코덱을 작성할 때 마주치게 될 대부분의 이슈를 해결할 수 있도록, Netty의 핵심 기능을 기반으로 다수의 기본적인 코덱과 고급 코덱을 제공합니다.


2.4.2. SSL/TLS Support

기존 블로킹 I/O와 달리, NIO에서 SSL을 지원하는 것은 쉬운 일이 아닙니다. 데이터를 암호화하거나 복호화 하기 위해 단순히 스트림을 감쌀 수는 없습니다. 대신 javax.net.ssl.SSLEngine을 사용해야만 합니다. SSLEngine은 SSL처럼 매우 복잡한 상태 머신입니다. 여러분은 암호 모음, 암호화 키 협상(혹은 재협상), 인증서 교환 및 유효성 검사와 같은 모든 가능한 상태들을 관리해야 합니다. 더욱이, SSLEngine은 일반적인 기대와 달리 스레드에 안전하지도 않습니다.


Netty에서 SslHandler는 SSLEngine의 모든 세부적인 사항들과 곤란한 점들을 처리합니다. 여러분이 할 일은 SslHandler를 설정하고 ChannelPipeline에 끼워넣는 것입니다. 이는 또한 여러분이 StartTLS와 같은 고급 기능들을 매우 쉽게 구현할 수 있게 해줍니다.


2.4.3. HTTP Implementation

HTTP는 인터넷에서 의심할 여지없이 가장 많이 사용되는 프로토콜입니다. 서블릿 컨테이너와 같이 HTTP를 구현하는 것은 이미 많이 존재합니다. 그러면, Netty는 자신의 핵심 기능을 바탕으로 HTTP를 지원하는 것일까요?


Netty의 HTTP 지원은 기존 HTTP 라이브러리들과는 매우 다릅니다. HTTP 메시지가 하위 레벨에서 어떻게 교환되는지에 대해 Netty는 완전히 제어할 수 있게 해줍니다. 이는 기본적으로 HTTP 코덱과 HTTP 메시지 클래스들의 조합이므로, 강제된 스레드 모델과 같은 제약이 없습니다. 즉, 여러분은 자신이 원하는 방식 그대로 동작하는 자신만의 HTTP 클라이언트나 서버를 작성할 수 있습니다. 여러분은 스레드 모델, 커넥션 생명 주기, 덩어리 인코딩 및 HTTP 명세에서 여러분에게 허용한 수많은 것들을 완전히 제어할 수 있습니다.


Netty는 개인화하는 능력이 매우 뛰어나기 때문에(high customizable nature), 여러분은 다음처럼 매우 효율적인 HTTP 서버를 작성할 수 있습니다.


- 영속적인 커넥션과 서버 푸시 기술을 필요로 하는 채팅 서머 (예: Comet)

- 전체 미디어가 스트리밍 될 때까지 커넥션을 지속적으로 열어두어야 하는 미디어 스트리밍 서버 (예: 2시간짜리 영화)

- 메모리 압박 없이 덩치 큰 파일 업로드를 허용하는 파일 서버 (예: 요청당 1GB 업로드)

- 수많은 제3업체 웹서비스에 비동기로 연결하는 확장성좋은 클라이언트


2.4.4. Google Protocol Buffer Integration

Google Protocol Buffers는 시간에 따라 변하는 매우 효율적인 바이너리 프로토콜을 빠르게 구현하기 위한 이상적인 솔루션입니다. ProtobufEncoder와 ProtobufDecoder를 사용하면, 여러분은 Google Protocol Buffers Compiler (protoc)에 의해 생성된 메시지 클래스들을 Netty 코덱으로 변환할 수 있습니다. 예제 프로토콜 정의로부터 여러분이 얼마나 쉽게 고성능의 바이너리 프로토콜 클라이언트와 서버를 작성할 수 있는지 보여주는 'LocalTime' 예제를 살펴보면 됩니다.


2.5. Summary

이 장에서는 기능적 관점에서 Netty의 전체적인 아키텍처를 살펴보았습니다. Netty는 단순하지만 강력한 아키텍처를 가지고 있습니다. Netty는 버퍼, 채널, 이벤트 모델이라는 세 개의 컴포넌트로 구성되며, 모든 고급 기능들은 이들 세 가지 핵심 컴포넌트를 기반으로 구축됩니다. 여러분이 이 세 가지 컴포넌트가 서로 어떻게 동작하는지 일단 이해하게 되면, 이 장에서 간단하게 다루어졌던 보다 고급 기능들을 이해하는 것은 어렵지 않을 것입니다.