이전 글에서 볼 수 있듯이, 스레드풀을 이용한 서버에서 하나의 스레드에 하나의 입출력 요청을 할당한 이유는, Blocking I/O 모델에서는 입출력 요청에 대해 모든 데이터가 준비될 때까지 블록되어 다른 클라이언트의 요청을 받을 수 없었기 때문입니다. 이를 멀티플렉싱(입출력 다중화)를 통해 개선할 수 있습니다.
멀티플렉싱은 하나의 프로세스나 스레드를 통해서 여러 입출력을 처리할 수 있는 기술입니다. 커널(kernel)에서는 하나의 스레드를 통해서 여러 입출력을 처리할 수 있는 poll, epoll, select 등의 시스템 콜들을 제공합니다.
그렇다면 Blocking I/O와 멀티플렉싱의 차이를 살펴보기 위해 Blocking I/O의 흐름을 먼저 살펴보겠습니다.

즉, read 함수는 전체 1, 2번 과정 모두 완료되어야지만 시스템콜이 반환됩니다.
그렇다면 멀티플렉싱은 어떻게 동작할까요?

앞선 Blocking I/O에서는 하나의 스레드는 하나의 소켓에 대해 모든 데이터가 준비될때까지 대기를 합니다. 반면 멀티플렉싱은 하나의 스레드에서 여러 소켓들 중 사용가능한 소켓이 준비될 때까지 대기합니다. 즉, 하나의 스레드에서 여러 소켓들을 동시에 다룰 수 있게 됩니다.
그렇다면 멀티플렉싱을 이용해 서버를 어떻게 구축할 수 있을까요? 자바에서는 Java NIO 패키지에 멀티플렉싱을 구현한 셀렉터(selector)를 제공하며, 이를 통해 멀티플렉싱 서버를 쉽게 구현할 수 있습니다.
Java NIO의 다음과 같은 특징이 있습니다.
Java NIO에서 멀티플렉싱의 핵심인 셀렉터를 살펴보겠습니다.
셀렉터는 앞서 이야기했듯이 하나의 스레드에서 여러 채널을 관리할 수 있습니다.

서버에서 클라이언트와의 소켓 연결로 채널이 생성되면 해당 채널을 셀렉터에 등록하고 관리할 수 있습니다. 셀렉터는 셀렉터가 관리하는 채널들 중 하나 이상의 이벤트가 발생할 때까지 대기합니다.
Selector.open() 메소드를 통해서 셀렉터를 생성할 수 있습니다.
Selector selector = Selector.open();
ServerSocketChannel channel = ServerSocketChannel.open(); // 서버 소켓 체널 생성
channel.bind(new InetSocketAddress(SOCKET_SERVER_PORT));
channel.configureBlocking(false); // non blocking 모드로 변경
channel.register(selector, SelectionKey.OP_ACCEPT); // 채널을 셀렉터에 등록
셀렉터에 채널을 등록하기 위해서는 채널을 논 블로킹 모드로 전환해야 합니다. 그 후 채널의 register 메소드를 통해 셀렉터에 채널을 등록할 수 있습니다.
셀렉터에 채널을 등록할 때 수신하고자하는 이벤트를 설정해줘야 합니다. 이벤트는 네 가지 종류가 있으며 SelectionKey 클래스에 상수로 선언되어 있습니다.
둘 이상의 이벤트를 등록하려면 or 연산을 사용하면 됩니다.
int receivedEvent = SelectionKey.OP_READ | SelectionKey.OP_WRITE
셀렉터에 하나 이상의 채널을 등록한 뒤 select() 메소드를 통해 채널에 이벤트가 발생할 때까지 대기할 수 있습니다. select() 메소드는 이벤트를 수신할 때까지 블록킹이 되지만 selectNow() 메소드를 사용하면 준비된 채널이 있다면 즉시 반환하지만, 준비된 채널이 없다면 블로킹하지 않습니다.
셀렉터를 이용해서 준비가 완료된 채널이 발생하면, selectedKeys() 메소드를 통해 해당 채널의 집합을 받을 수 있습니다.
Set<SelectionKey> selectionKeys = selector.selectedKeys();
위 내용을 통해 셀렉터 기반 멀티플렉싱 서버를 작성하면 다음과 같습니다.
public class MultiplexingSocketServer {
private static final Integer SOCKET_SERVER_PORT = 8080;
private static final Map<SocketChannel, ByteBuffer> sockets = new ConcurrentHashMap<>();
public static void main(String[] args) {
try (ServerSocketChannel serverSocket = ServerSocketChannel.open();
Selector selector = Selector.open();
) {
serverSocket.bind(new InetSocketAddress(SOCKET_SERVER_PORT));
serverSocket.configureBlocking(false);
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
selectionKeys.forEach(MultiplexingSocketServer::dispatch);
selectionKeys.clear();
}
} catch (IOException e) {
...
}
}
private static void dispatch(SelectionKey key) {
try {
if (key.isValid()) {
switch (key.readyOps()) {
case SelectionKey.OP_ACCEPT -> handleAcceptEvent(key);
case SelectionKey.OP_READ -> handleReadEvent(key);
case SelectionKey.OP_WRITE -> handleWriteEvent(key);
}
}
} catch (Exception e){
closeSocket((SocketChannel) key.channel());
}
}
위 과정 모두 하나의 스레드에서 처리됩니다! 재밌지 않나요?
지금까지 셀럭터와 멀티플렉싱을 이용해서 다중 접속 서버를 구현해봤습니다. 하지만 이벤트를 수신하고 처리하는 과정을 더 효율적으로 만들 수 있을까요? Reactor 패턴을 통해 더 효율적으로 처리할 수 있습니다.
Reactor 패턴은 동시에 들어오는 여러 종류의 이벤트를 처리하기 위한 동시성을 다루는 디자인 패턴중 하나입니다. Reactor 패턴은 내부에서 이벤트가 발생하면 해당 이벤트를 처리할 헨들러에게 처리를 위임하는 패턴입니다. 또한 이 Reactor를 이벤트 루프라고 부릅니다.
이벤트 루프
이벤트 루프는 동시성을 제공하기 위한 프로그래밍 모델 중 하나로, 특정 이벤트가 발생할 때까지 대기하다가 이벤트가 발생하면 디스패치해 처리하는 방식으로 작동합니다.
이벤트 루프를 구현한 대표적인 프레임워크로는 Netty가 있습니다.
Reactor 패턴을 적용한 서버의 코드와 요청 처리를 나타낸 시퀀스 다이어그램은 다음과 같습니다.

public class Reactor implements Runnable {
private final Selector selector;
private final ServerSocketChannel serverSocketChannel;
public Reactor(int port) throws IOException {
this.selector = Selector.open();
this.serverSocketChannel = ServerSocketChannel.open();
this.serverSocketChannel.bind(new InetSocketAddress(port));
this.serverSocketChannel.configureBlocking(false);
SelectionKey selectionKey = this.serverSocketChannel.register(this.selector, SelectionKey.OP_ACCEPT);
selectionKey.attach(new AcceptHandler(this.selector, this.serverSocketChannel));
}
@Override
public void run() {
try {
while (true) {
this.selector.select();
Set<SelectionKey> selected = this.selector.selectedKeys();
selected.forEach(this::dispatch);
selected.clear();
}
} catch (IOException e) {
LOGGER.severe("error occur when attempt to run reactor, error message: " + e.getMessage());
}
}
private void dispatch(SelectionKey key) {
if(key.attachment() instanceof Handler handler){
handler.handle();
}
}
}
앞서 멀티플렉싱을 사용한 서버와 처리 방식이 매우 비슷하지 않나요? 맞습니다. 이벤트루프는 멀티플렉싱을 바탕으로 구현된 개념이기 때문입니다.
멀티플렉싱부터 이벤트 루프까지 살펴보며, 이벤트 루프 기반 서버에서 블록하면 안되는 이유를 유추해볼 수 있습니다.
이벤트 루프는 메인 스레드에서 동작하며, 발생한 이벤트에 대해서 차례대로 처리합니다. 즉, 이벤트를 처리할 때 블록된다면 전체적인 요청 처리가 지연되게 됩니다.
마찬가지로 CPU 집약 처리가 발생하는 경우 요청을 처리하는데 지연이 발생하여 전체적인 요청 처리가 지연될 수 있습니다.
이러한 문제가 발생할 경우 별도의 스레드 또는 스레드풀을 만들어 지연되는 작업을 처리하여 메인 스레드가 지연되지 않도록 해야합니다.
참고
https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part3
https://engineering.linecorp.com/ko/blog/author/%EA%B9%80%EC%A2%85%EB%AF%BC