본문 바로가기

Java/Network

[Java] NIO 기반 네트워킹 : TCP 넌블로킹(non-blocking) 채널

시작하며

IO기반의 네트워킹에서 TCP 통신을 위해 Socket과 ServerSocket 객체를 제공한다면 NIO기반의 네트워킹에서는 SocketChannel과 ServerSocketChannel을 제공한다. 사용방식은 Socket, ServerSocket과 거의 유사하다. IO기반의 TCP 통신에서는 연결을 요청하거나 수락하는 accept/connect 메서드, 읽고 쓰기 위한 read/write 메서드를 실행하면 블로킹이 일어나게 된다. 그렇기 때문에 이러한 메서드를 사용할 시 UI나 다른 작업의 블로킹을 막기위해 새로운 스레드를 생성하여 작업을 수행한다. 우리는 이러한 방식을 블로킹 방식이라고 한다. 반면 NIO기반의 TCP 통신의 경우 블로킹 방식외에도 메서드 실행시 블로킹이 일어나지 않는 넌블로킹 방식비동기 방식을 제공하는데 이번 포스팅에서는 넌블로킹 방식을 알아보고자 한다.

 

넌블로킹 방식

넌블로킹 방식은 이름대로 accept()/connect()/read()/write() 메서드를 소켓채널이 실행해도 블로킹이 일어나지 않는 방식이다. accept()/connect()의 경우 각각 클라이언트와 서버의 반응이 없으면 null을 리턴하고, read()/write()의 경우 읽고 쓸 데이터가 없을경우 바로 0을 리턴한다. 

 

그렇다면 accept()의 예로 생각해보자. 블로킹 방식의 경우 클라이언트의 연결 요청이 있을때 까지 accept()메서는 블로킹 되어 있지만 넌블로킹 방식은 즉시 null을 리턴해버린다. 즉 서버에서 클라이언트의 연결을 기다리는 코드를 구현하기 위해서는 accept() 메서드를 무한루프 환경에서 실행해야 한다.

while (true) { 
	SocketChannel socketChannel = serverSocketChannel.accept(); 
}

하지만 이렇게 코드를 구현할 경우 무한루프를 돌면서 계속해서 CPU를 소비하기 때문에 성능적인 면에서 좋지 않다. 이러한 문제를 해결하기위해 사용할 수 있는 것이 바로 셀렉터(selector)이다. 

 

셀렉터 메커니즘

셀렉터는 넌블로킹 채널에 등록할 수 있다. 각 소켓채널을 클라이언트가 요청한 사안(accept, read 등)을 셀렉터에게 알린다. 셀렉터는 해당 채널들을 선택해서 작업 스레드를 통해 즉시 작업을 처리하도록 한다. 셀렉터의 자세한 메커니즘은 다음과 같다.

 

  1. 채널은 셀렉터에 등록한 작업유형을 키(SelectionKey)로 생성하고, 함께 첨부할 첨부객체를 설정한다. 그후, 해당 키를 관심키셋(interest-set)에 등록한다(register / interestOps)
  2. 클라이언트가 처리를 요청한다.
  3. 관심키셋에 키(SelectionKey) 중에서 작업 처리가 준비된 키들을 선택된 키셋(selected-set)에 등록한다.(SelectedKey)
  4. 선택된 키셋에 있는 키들을 작업스레드가 가져와 해당 키에 대응하는 작업을 수행한다.

볼드체로 적힌 것들을 해당 단계를 수행하는 메서드들

 

 

※ 넌블로킹 채널에 대해서

중간 중간 언급되는 넌블로킹 채널이란 쉽게 말하면 넌블로킹 방식으로 작동하는 서버(NIO기반의 입출력에서 사용하는 채널)라고 보면 된다. 본 글에서는 넌블로킹 설정이된(configureBlocking(false)) ServerSocketChannel을 이용할 것이고, UDP 통신에 사용되는 DatagramChannel의 경우도 넌블로킹 방식으로 작동되므로 넌블로킹 채널이고 셀렉터를 등록할 수 있다.

 

셀렉터 생성과 등록

Selector는 자신의 정적 메소드인 open() 통해 생성할 수 있다. open()을 호출할 경우, IOException에 대한 예외처리가 필요하다.

try {
    Selector selector = Selector.open();
} catch (IOException e) {}

 

위에서도 언급했듯이 ServerSocketChannel은 기본적으로 블로킹 방식이므로 넌블로킹 채널로써 사용하기 위해서는 configureBlocking() 메서드를 호출하여 넌블로킹으로 설정하여야 한다.

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);

소켓서버의 경우도 기본적으로 블로킹 방식이므로 넌블로킹 채널로 사용하기 위해서는 configure() 메서드를 호출해 넌블로킹으로 설정해야 한다. 방식은 ServerSocketChannel과 동일하다. (코드는 생략)

 

셀렉터를 등록하기 위해서는 register() 메서드를 이용하면된다.

SelectionKey selectionKey = serverSocketChannel.register(Selector s, int ops);

register() 메서드는 등록한 키를 리턴하며, 첫번째 매게변수는 등록할 셀렉터, 두번째 매게변수는 작업유형을 의미하는 SelectionKey이 열겨상수(OP_ACCEPT / OP_CONNECT / OP_READ / OP_WRITE)를 넣어준다.

 

키를 등록하면 함께 객체를 첨부해 줄 수 있는데 객체를 첨부할때는 attach() 메서드를 사용하면된다. 그리고 첨부된 객체를 리턴받고 싶다면 attachement()메서드를 사용하면된다.

selectionKey.attach(object ob);
Object ob= selectionKey.attachment;

 

셀렉터를 등록할때 주의할 점은 등록은 단 한번만 가능하다는 것이다. 즉, 하나의 채널에서 register 메서드는 한번만 호출이 가능하다. 그렇기 때문에 만약 작업의 유형이 변경되면 다시 등록하는것이 아니라 작업유형을 변경해주어야 한다. 이를 구현하기위해 채널로부터 키를 얻어오기 위해 채널의 keyFor() 메서드를 사용해주고, 얻어온 key의 메서드인 interestOps() 메서드를 통해 키의 작업유형을 변경해 주면 된다.

SelectionKey key = socketChannel.keyFor(selector);
key.interestOps(SelectionKey.OP_WRITE);

매게변수로 keyFor() 메서드는 셀렉터를, interestOps() 메서드는 변경할 새로운 작업 유형을 갖는다.

 

선택된 키셋과 select() 메서드

등록을 통해 관심키셋에 저장된 키를 선택된 키셋으로 옮기기 위해서는 Selector의 메서드인 select()를 호출하면 된다. select() 메서드는 작업 처리 준비가 완료된 SelectionKey를 선택된 키셋으로 옮겨 작업 스레드를 통해 작업을 처리한다. 이때 만약 작업 처리 준비가 완료된 SelectionKey가 없을 경우, select() 메서드는 블로킹 된다. select()는 블로킹이 되므로 UI및 이벤트를 처리하는 코드와 함께 작성되면 안되고, 별도의 스레드에서 처리되어야 한다.

 

select()가 블로킹되지 않고 리턴하는 경우는 3가지인데, 

  1. 채널이 작업 준비가 되었다는 통보를 할때
  2. Selector의 wakeup() 메서드를 호출할때,
  3. select()를 호출한 스레드가 interrupt 되었을때

이때, 1.의 경우 처음 SelectionKey를 등록하는 경우를 제외하고는 통보할 수 없으므로 SocketChannel의 작업 유형을 수정할 시에는 수정 후 반드시 2.의 경우 처럼 wakeup() 메서드를 실행하여야 select() 메서드가 리턴을 한다.

selectionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();

 

작업 처리

select() 메서드는 선턱샌 키셋의 (작업 처리 준비가 완료된) SelectionKey의 개수를 리턴한다. 그러므로 select()의 리턴값이 0이상인 경우에만 작업 처리를 진행한다.

 

작업 처리를 하기 위해서는 먼저 선택된 키셋을 Set의 형태로 받아햐 하는데 이를 위해 Selector의 selectedKeys() 메서드를 사용하면된다. 선택된 키셋을 받은 후에는 각 원소(SelectionKey)들의 작업 유형을 감지해 해당 유형에 맞는 작업을 처리해야 한다. 각 작업유형을 감지한는 메서드는 다음과 같다.

 

메서드 설명
isAcceptable() 작업 유형이 OP_ACCEPT면 TRUE를 리턴
isConnectable() 작업 유형이 OP_CONNECT면 TRUE를 리턴
isReadable() 작업 유형이 OP_READ면 TRUE를 리턴
isWritable() 작업 유형이 OP_WRITE면 TRUE를 리턴

 

다음은 작업을 처리하는 예제이다.

Thread thread = new Thread() {
    @Override
    public void run() {
    	while (true) {
            try {
            	int keyCnt = selector.select();
                if (keyCnt == 0) { continue; } 	// 작업할 키가 없으면 스킵
                
                // 선택된 키셋 Set형태로 리턴
                Set<SelectionKey> selectedKeys = Selector.selectedKey();
                Iterator<SelectionKey> iter = selectedKeys.iterator();
                
                // 작업 유형에 따라 작업 처리
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                    if ( key.isAcceptable ) { // 연결 수락 작업 처리 }
                    if ( key.isConnectable ) { // 연결 요청 작업 처리 }
                    if ( key.isReadable ) { // 읽기 작업 처리 }
                    if ( key.isWritable ) { // 쓰기 작업 처리 }
                    
                    iter.remove();
                }
            } catch (Exception e) { break; }
        }
    }
};

thread.start();