본문 바로가기
Old Posts/Java

Java NIO - Selector

by A6K 2021. 1. 1.

'셀렉터(Selector)'는 하나의 스레드가 여러개의 채널을 관리할 수 있게해주는 NIO 컴포넌트다. NIO는 셀렉터라는 메커니즘을 제공하여 하나 이상의 NIO 채널들을 모니터링하고, 데이터 전송이 가능해지는 상황을 인지할 수 있게 해준다.

Java NIO - 셀렉터를 사용하는 이유

3개의 클라이언트 연결이 붙어 있는 서버 프로그램을 생각해보자. 서버에는 3개의 소켓채널(SocketChannel)이 열려있을 것이다. 클라이언트는 자기가 보내고 싶을 때 요청을 보내기 때문에 서버는 클라이언트의 요청을 기다려야한다.

가장 쉬운 방법은 클라이언트마다 스레드를 생성해서 붙여주는 방법이다. 각 스레드는 클라이언트와 연결된 채널에서 기다린다. 즉, 새로운 요청이 올 때까지 채널에 블로킹되어 있다. 서버 프로그램을 구현할 때 가장 쉬운 방식이다.

하지만 클라이언트가 수 만을 넘어 수 백만까지 올라간다면? 서버 프로그램에 엄청난 리소스 낭비를 초래한다. 각 스레드는 독립적인 스택 공간을 갖게된다. 각 스레드가 1MB 공간을 스택으로 사용한다고 했을 때, 100만개의 클라이언트를 처리하기 위해 스택 공간으로만 976GB를 사용하게 된다. 여기에 클라이언트 요청을 처리하기 위한 힙 영역을 별도다.

생성되는 스레드의 개수를 줄이면, 하나의 스레드가 여러개의 채널에 논블러킹 상태로 작업을 해야한다. 스레드는 주기적으로 채널에 데이터가 있는지 확인해야한다. 대부분의 시간동안 채널은 읽을 데이터가 없는 상태로 있을 것이다.

데이터가 들어올 때까지 주기적으로 채널 확인을 해야한다. 이런 식으로 확인하는 것을 폴링(polling)이라고 한다. 이 경우 CPU 타임을 낭비하는 경향이 있다. 

Java NIO에서는 Selector라는 컴포넌트를 두고 여러 채널을 등록할 수 있게 했다. 스레드는 셀렉터(Selector)에 등록된 채널 중 가용한 채널이 있는지 알 수 있다. 3개의 채널 중 하나라도 쓸 수 있는 상태가 되면 바로 알 수 있다. 이런 방식을 '멀티플렉싱(MultiMultiplexing)'이라고 한다.

셀렉터를 사용하기 위해서 추가로 설치하거나 셋업해야하는 작업은 없다. 이미 java.nio 패키지에 필요한 모든 클래스들이 있다. 그저 import만 잘해주면 된다. 

1. 셀렉터(Selector) 생성 및 채널 등록

Java NIO Selector를 사용하려면 우선 셀렉터 객체를 만들어야 한다.

Selector selector = Selector.open();

셀렉터 객체는 정적 메소드인 open()을 이용해서 만들 수 있다. 

channel.configureBlocking(false);

SelectionKey selectionKey = channel.register(selector, SelectionKey.OP_READ);

Java NIO Channel에 있는 register() 메소드를 이용해서 채널을 셀렉터에 등록할 수 있다. 셀렉터에 등록하기 위해서 채널은 반드시 SelectableChannel의 서브 클래스여야 한다. SelectableChannel 클래스는 configureBlocking() 메소드를 구현한다. 이 메소드는 채널을 논블러킹으로 만드는데, 채널을 셀렉터에 등록하기 위해서 채널은 반드시 논블로킹 모드로 설정되어 있어야 한다. 때문에 논블로킹으로 설정할 수 없는 FileChannel은 셀렉터와 함께 사용될 수 없다.

register() 메소드의 첫 번째 파라미터는 셀렉터 객체다. 
register() 메소드의 두 번째 파라미터는 interest set이다. 셀렉터에 등록된 채널에서 특정 이벤트가 발생할 때 깨어날 텐데, 어떤 이벤트가 발생하면 리턴할지를 설정하는 파라미터다.

셀렉터에서 사용할 수 있는 interest set은 총 4가지다. 이 들은 SelectionKey 클래스에 정의되어 있다.

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

OP_CONNECT는 채널이 다른 서버와 정상적으로 연결된 경우, OP_ACCEPT는 서버 소켓 채널에 새로운 연결이 들어왔을 경우, OP_READ는 읽을 데이터가 준비된 경우, OP_WRITE는 채널이 쓰기 준비가 된 경우를 의미한다.

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

SelectionKey들은 OR 연결을 할 수도 있다.

2. SelectionKey 객체

register() 메소드로 채널을 등록하면 SelectionKey 객체를 리턴 받게 된다. 이 객체에는 셀렉터에 등록된 채널에 대한 몇 가지 중요한 정보들이 있다.

2.1 interest set

셀렉터가 현재 이 채널에 관심있어 하는 interest set 값이 들어있다. 정수(int) 타입 값으로 SelectionKey 클래스의 interestOps() 메소드로 가져올 수 있다.

int interestSet = selectionKey.interestOps();

boolean waitForAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean waitForConnect = interestSet & SelectionKey.OP_CONNECT;
boolean waitForRead    = interestSet & SelectionKey.OP_READ;
boolean waitForWrite   = interestSet & SelectionKey.OP_WRITE;

반환된 interestSet 값에 AND 연산으로 특정 이벤트에 관심이 있는지 확인할 수 있다.

2.2 ready set

채널의 현재 가용한 연산을 리턴해준다. 마찬가지로 정수(int) 타입 값이며 SelectionKey 클래스의 readyOps() 메소드로 가져올 수 있다. 

int readySet = selectionKey.readyOps();

boolean isAcceptable  = readySet & SelectionKey.OP_ACCEPT;
boolean isConnectable = readySet & SelectionKey.OP_CONNECT;
boolean isReadable    = readySet & SelectionKey.OP_READ;
boolean isWritable   = readySet & SelectionKey.OP_WRITE;

마찬가지로 반환된 readySet 값에 AND 연산으로 특정 이벤트가 가용한지 여부를 확인 할 수 있다. SelectionKey에서 제공하는 별도의 메소드를 이용해서 확인할 수도 있다.

boolean isAcceptable = selectionKey.isAcceptable();
boolean isConnectable = selectionKey.isConnectable();
boolean isSelectable = selectionKey.isReadable();
boolean isWritable = selectionKey.isWritable();

2.3 Channel, Selector

SelectionKey와 관련된 채널과 셀렉터를 가져올 수 있다.

Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();

2.4 Attaching Objects

SelectionKey 객체에 또 다른 객체를 붙여넣을 수 (Attach) 있다. 채널에 대한 좀 더 자세한 정보를 담고 있는 객체라던지 채널을 좀 더 잘 확인할 수 있는 정보를 갖고 있는 객체를 붙여 넣을 수 있다.

key.attach(theObject);

Object attachedObj = key.attachment();

selectionKey 객체의 attach() 메소드를 이용해 SelectionKey 객체에 또 다른 객체를 붙여 넣을 수 있다. 나중에 attachment() 메소드를 이용해서 붙여진 객체를 가져올 수 있다.

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

혹은 register() 메소드를 이용해 채널을 셀렉터에 등록할 때, 붙여 넣을 객체를 파라미터로 명시할 수도 있다.

3. select()

셀렉터에 하나 이상의 채널을 등록한 다음 select() 메소드를 호출하면, 등록한 채널과 그 채널에 대한 interestSet이 만족할 때까지 기다렸다가 만족하는 이벤트가 발생하면 리턴한다.

셀렉터가 제공하는 select() 메소드는 대표적으로 다음과 같다.

  • int select()
  • int select(long timeout)
  • int selectNow()

select() 메소드는 적어도 하나의 채널이 준비될 때까지 블럭된다.
select(long timeout)은 timeout 시간까지만 블럭되었다가 리턴된다. timeout에 입력되는 시간은 milli second 단위다.
selectNow()는 논블러킹 모드로 바로 리턴한다.

반환되는 int 값은 register() 등록한 채널 중에 몇 개의 채널이 준비되었는지를 의미한다.

4. selectedKeys()

select() 메소드가 리턴하면 하나 이상의 채널이 준비되었다는 의미다. 이 때, 그 채널로의 접근은 selected key set을 통해 할 수 있다. selected key set은 selector.selectedKeys() 메소드를 호출해서 얻을 수 있다.

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

셀렉터에서 selectedKeys()를 리턴하면 준비된 채널에서 가지고 있는 이 SelectionKey 객체들을 가져오게 된다. 이 SelectionKey 객체에는 위에서 봤던 정보들이 있다.

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

Iterator<SelectionKey> iterator = selectedKeys.iterator();

// SelectedKeys 순회
while(iterator.hasNext()) {
    
    SelectionKey key = iterator.next();

    // 준비된 채널이 어떤 이벤트에 준비가 되었는지 확인
    if(key.isAcceptable()) {
        // ... 실행 코드 
    } else if (key.isConnectable()) {
        // ... 실행 코드 
    } else if (key.isReadable()) {
        // ... 실행 코드 
    } else if (key.isWritable()) {
        // ... 실행 코드     }

    // 처리가 끝났으면 삭제
    iterator.remove();
}

SelectionKey 객체에서 channel() 메소드를 이용해 준비된 채널을 가져올 수 있다.

서버와 소켓 채널로 연결된 클라이언트 프로그램을 생각해보자. 서버로 요청을 보내고 응답을 기다리고 있는 상태라고 하자. 클라이언트는 서버에서 응답이 오기를 셀렉터를 통해 기다릴 수 있다. SelectionKey.OP_READ 를 interest set에 설정하고 select()에서 기다리게 된다.

서버에서 100바이트 만큼의 데이터가 도착한 상황에서 헤더에 해당하는 20바이트만 우선 읽었다고 생각해보자. 소켓 버퍼에 아직 80바이트가 남아있는 상황에서 첫 번째 select()가 끝나고 두 번째 select()를 실행하면 어떻게 될까? 정답은 바로 리턴된다. 소켓 버퍼에 데이터가 남아있는 상황이기 때문에 바로 읽을 수 있는 상황이기 때문이다. 간단하게 말하면 '레벨 트리거링(Level Triggering)'이다.

흥미로운 점은 select() 메소드의 리턴값이다. selectedKeys()를 가져와서 처리한 다음 iterator에서 삭제를 해주는 코드가 마지막에 있다. 이 코드를 생략하고 select() 메소드를 실행하면 리턴 값이 0이 된다. 아마도 select() 메소드의 리턴값은 새롭게 추가된 채널 이벤트의 개수를 의미하는 것 같다. (JavaDoc에도 적혀있다. The number of keys, possibly zero, whose ready-operation sets were updated)

5. wakeUp()

스레드가 select() 메소드를 호출하면 준비된 채널이 나타날 때까지 블럭된다. 이렇게 블럭된 스레드를 깨워주는 것이 wakeUp() 메소드의 역할이다. 아무래도 멀티 스레드 환경에서 사용하면 좋다.

만약 select() 메소드에서 기다리고 있는 스레드가 없는 상황에서 wakeUp()을 실행했다면, 다음 select() 하는 스레드는 관심있는 이벤트가 준비되지 않아도 바로 깨어나게 된다.

6. close()

셀렉터를 닫는다. 셀렉터에 있는 selectionKey들을 무효화(invalidate)시킨다. 열렸던 채널들도 닫힌다.

7. 셀렉터 사용 예제

Selector selector = Selector.open();

channel.configureBlocking(false);

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);


while(true) {

  int readyChannels = selector.selectNow();

  if(readyChannels == 0) continue;
  
  Set<SelectionKey> selectedKeys = selector.selectedKeys();

  Iterator<SelectionKey> iterator = selectedKeys.iterator();

  // SelectedKeys 순회
  while(iterator.hasNext()) {
    
    SelectionKey key = iterator.next();

    // 준비된 채널이 어떤 이벤트에 준비가 되었는지 확인
    if(key.isAcceptable()) {
        // ... 실행 코드 
    } else if (key.isConnectable()) {
        // ... 실행 코드 
    } else if (key.isReadable()) {
        // ... 실행 코드 
    } else if (key.isWritable()) {
        // ... 실행 코드     }

    // 처리가 끝났으면 삭제
    iterator.remove();
}

 

댓글