슬기로운 개발생활

[Java] NIO 기반 입출력 및 네트워킹 - TCP 넌블로킹 채널

by coco3o
반응형

NIO 네트워크?

NIO를 이용해서 TCP 서버/클라이언트 애플리케이션을 개발하려면 블로킹, 넌블로킹, 비동기 구현 방식 중에서 하나를 결정해야 한다.

 

이 결정에 따라 구현이 완전히 달라지기 때문이다.

 

이번 절에서는 넌블로킹 방식만 설명하겠다.

 

[Java] NIO 기반 입출력 및 네트워킹 - TCP 블로킹 채널


 

TCP 넌블로킹 채널

ServerSocketChannel, SocketChannel은 블로킹(Blocking) 방식도 지원하지만 넌블로킹(non-blocking)방식도 지원한다.

 

이번 절에서는 넌블로킹 방식의 특징과 넌블로킹의 핵심 객체인 셀렉터(Selector)를 이해하고 채널을 넌블로킹 방식으로 사용하는 방법에 대해 알아보도록 한다.

 

블로킹 방식은 언제 클라이언트가 연결 요청을 할지 모르기 때문에 accept()에서 블로킹된다.

그리고 언제 클라이언트가 데이터를 보낼지 모르므로 read() 메소드는 항상 데이터를 받을 준비를 하기 위해 블로킹된다.

 

그렇기 때문에 ServerSocketChannel과 연결된 SocketChannel 당 하나의 스레드가 할당되어야 한다.

따라서연결된 클라이언트가 많을수록 스레드의 수가 증가하고 서버에 심각한 성능 문제를 유발할 수도 있다.

이 문제를 해결하기 위해 지금까지는 스레드풀(ExecutorService)을 사용했었다.

 

자바는 블로킹 방식의 또 다른 해결책으로 넌블로킹 방식을 지원한다.

넌블로킹 방식은 connect(), accept(), read(), write() 메소드에서 블로킹이 없다.

넌블로킹 방식에서 다음 코드는 클라이언트가 연결요청을 하지 않으면 무한 루프를 계속 돌게 된다.

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

그래서 넌블로킹은 이벤트 리스너 역할을 하는 셀렉터(Selector)를 사용한다.

넌블로킹 채널에 Selector를 등록해 놓으면 클라이언트의 연결 요청이 들어오거나 데이터가 도착할 경우,

채널은 Selector에 통보한다. 그럼 Selector는 해당 작업을 처리하게 된다.

 

Selector는 멀티 채널의 작업을 싱글 스레드에서 처리할 수 있도록 해주는 멀티플렉서(multiplexor)역할을 한다.

Selector의 동작 원리

  • 채널은 자신의 작업 유형을 키(SelectionKey)로 생성
  • 셀렉터의 관심 키셋에 키 등록
  • 셀렉터는 작업 처리 준비가 된 키를 선택
  • 선택된 키셋에 별도로 저장
  • 작업 스레드는 선택된 키셋에서 키를 하나씩 꺼냄

넌블로킹에서 작업 스레드를 꼭 하나만 사용할 필요는 없다. 채널 작업 처리 시 스레드풀을 사용할 수 있다.

작업 스레드가 블로킹되지 않기 때문에 적은 수의 스레드로 많은 양의 작업을 고속으로 처리할 수 있어

블로킹 방식보다 서버의 성능이 향상될 수 있다.

 

셀렉터 생성

Selector는 정적 메소드인 open() 메소드를 호출하여 생성한다. open() 메소드는 IOException이 발생할 수 있기 때문에 예외 처리가 필요하다.

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

Channel을 생성하고 주의할 점은 넌블로킹으로 설정된 것만 가능하기에 configureBlocking(false)를 해서 넌블로킹으로 만들어야 한다.

ServerSocketChannel serverSocket = ServerSocketChannel.open(); // Server Socket Channel 열고
serverSocket.configureBlocking(false); // Non-blocking 설정, 필수!!

 

각 채널은 register() 메소드를 이용해서 Selector에 등록하는데,

첫 번째 매개값은 Selector, 두 번째 매개값은 채널의 작업 유형이다.

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

다음은 두 번째 매개값으로 사용할 수 있는 SelectionKey의 상수들이다.

 

SelectionKey의 작업 목록

  • OP_ACCEPT : ServerSocketChannel의 연결 수락
  • OP_CONNECT : SocketChannel의 서버로 연결
  • OP_READ : SocketChannel의 데이터 읽기
  • OP_WRITE : Socket Channel의 데이터 쓰기

register()는 채널과 작업 유형 정보를 담고 있는 SelectionKey를 생성하고 Selector의 관심키셋에 저장한 후 해당 SelectionKey를 리턴한다.

다음은 ServerSocketChannel이 Selector에 자신의 작업 유형을 등록하는 코드이다.

SelectionKey selectionKey = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
SelectionKey selectionKey = socketChannel.register(selector, SelectionKey.OP_CONNECT);
SelectionKey selectionKey = socketChannel.register(SelectionKey.OP_READ);
SelectionKey selectionKey = socketChannel.register(SelectionKey.OP_WRITE);

주의할 점은 동일한 SocketChannel로 두 가지 이상의 작업 유형을 등록할 수 없다.

즉, register()를 두 번 이상 호출할 수 없다. 등록은 한 번만 하되, 작업 유형이 변경되면 이미 생성된 SelectionKey를 수정해야 한다.

 

register()가 리턴한 SelectionKey를 가져오고 싶다면 아래와 같이 언제든 가져올 수 있다.

SelectionKey key = socketChannel.keyFor(selector);

만약 작업을 변경하고 싶으면 아래와 가이 interstOps(작업) 이용해야 한다.

SocketChannel은 읽기와 쓰기가 가능할 수 있다.

selectionKey.interstOps(SelectionKey.OP_WRITE); // 작업 변경
selector.wakeup(); // 블로킹 된 select()를 리턴시키고 다시 실행

Selector를 구동하려면 select()메소드를 호출해야 하는데, select()는 관심키셋에 저장된 SelectionKey로부터 작업처리 준비가 되었다는 통보가 올때까지 대기(블로킹)한다.

  • select() : 최소한 하나의 채널이 작업 처리 준비가 될 때까지 블로킹한다.
  • select(long timeout) : select()와 동일하지만 주어진 시간동안만 블로킹한다.
  • selectNow() : 호출 즉시 리턴되며, 작업 처리가 준비된 채널이 있으면 채널 수를 리턴하고, 없으면 0을 리턴

주로 첫 번째 select()를 사용하는데 select()가 리턴되는 경우는 다음과 같다.

  • 채널이 작업 처리 준비가 되었다고 통보를 할 때
  • Selector의 wakeup() 메소드를 호출할 때
  • select()를 호출한 스레드가 인터럽트될 때

그리고 이제 선택된 키를 얻기 위해서는 selectedKeys()를 호출하여 Set 형태로 가져온다.

Set<SelectionKey> selectedKeys = selector.selectedKeys();

그리고 작업유형을 감지할 수 있는 boolean 타입 메소드도 지원한다.

  • isAcceptable() : 작업 유형이 OP_ACCEPT인 경우
  • isConnectable() : 작업 유형이 OP_CONNECT인 경우
  • isReadable() : 작업 유형이 OP_READ인 경우
  • isWritable() : 작업 유형이 OP_WRITE인 경우
  Thread thread = new Thread() {
        @Override
        public void run() {
            while (true) {
                try {
                    int keyCount = Selector.select(); // 작업 처리 준비된 키 감지
                    
                    if (keyCount == 0) {
                        continue;
                    }
                    
                    Set<SelectionKey> selectedKeys = selector.selectedKeys(); // 선택된 키셋 얻기
                    Iterator<SelectionKey> iterator = selectedKeys.iterator();
                    
                    while (iterator.hasNext()) {
                        SelectionKey selectionKey = iterator.next();
                        
                        if (selectionKey.isAcceptable()) { /* 연결 수락 작업 처리 */ }
                        else if (selectionKey.isReadable()) { /* 읽기 작업 처리 */}
                        else if (selectionKey.isWritable()) { /* 쓰기 작업 처리 */ }
                        
                        iterator.remove();
                    }
                } catch(Exception e) {
                    break;
                }
            }
        }
    };
 
    thread.start();

 

reference : '이것이 자바다' 19장

반응형

블로그의 정보

슬기로운 개발생활

coco3o

활동하기