이전 글에서 tomcat은 Default로 NIO Connector
를 제공하고 이는 클라이언트의 연결을 Non-Blocking
하게 처리할 수 있음을 의미합니다.
Tomcat의 Nio Connector는 내부적으로 Java의 NIO를 사용하기에, 이번시간엔 JAVA의 NIO에 대해 자세히 알아보고 Tomcat은 어떻게 NIO를 통해 동작하는지 알아보려 합니다.
java.nio의 정식 명칭은 New Input/Output 의 약자로 기존 표준 I/O를 보완하기 위해 Java J2SE 1.4와 함께 도입되었으며, 이후 NIO2가 Java SE 7과 함께 출시되었습니다.
많이들 BIO/NIO를 Blocking IO / Non-Blocking IO로 생각하고 쓰고 있지만 실제 I/O와 NIO(New I/O)
가 맞으며, 이를 이해하기 쉽게 하기위해 기존 I/O의 특징인 Blocking과 비차단 I/O인 New I/O를 BIO와 NIO로 표현하는것으로 생각됩니다.
실제 NIO API의 일부(Ex : File API)의 경우 실제 차단(Block)하게 동작하기 때문에 NIO를 Non-Blocking IO로 생각하는 것은 오해의 소지가 있다고 보여집니다.
그럼 왜 우리는 NIO를 써야 할까요? 실제 우리는 이미 파일을 읽거나 파일에 쓰기 위해 이미 Java의 IO Package가 있기에 NIO Package의 사용이유가 궁금할 수 있습니다.
궁극적으로 Java의 NIO Package를 사용하는 가장 큰 이유는 기존의 I/O Package보다 나은 멀티 스레딩 기능을 제공하는것에 있습니다. 멀티 스레딩이란 기본적으로 하나의 프로그램에 동시에 여러개의 일을 수행할 수 있도록 하는 것을 의미합니다.
기존의 I/O Package의 기능을 재작성하고 수정하는 것보단 새로운 Package를 만드는것이 더 쉽기에 Java는 NIO라는 새로운 Package를 만들었습니다.
그리고 이러한 NIO Package는 일반적으로 우리가 인식하고 있는것 처럼 파일을 읽거나 쓰는 동안 쓰레드를 차단하지 않는 Non-Blocking한 IO로 쓰이고 있습니다.
즉 NIO Package는 파일을 다른 스레드로 읽으며 동시에 다른 스레드로 이동하여 파일에 대한 권한을 얻을 수 있는 방식으로 개선된 기능을 제공하여 멀티스레딩한 기능을 제공할 수 있습니다.
그럼 Java docs에서의 NIO package는 어떻게 설명되고 있을까요?
데이터 컨테이너인 버퍼를 정의하고 다른 NIO 패키지에 대한 개요를 제공한다. 입니다 이렇게만 봐선 무슨 의미인지 모르겠으니 더 살펴볼 필요가 있습니다.
NIO API의 핵심 추상화는 아래 4가지의 기능입니다.
- 데이터 컨테이너인
Buffers
- 문자셋과 관련 바이트와 유니코드 문자 사이를 변환하는 Decoders와 Encoders
- I/O 작업을 수행할 수 있는 엔티티에 대한 연결을 나타내는 다양한 유형의
Channle
- 멀티플레싱, 논 블로킹 I/O 기능을 정의하는 선택 가능한 채널을 포함하는
Selector
와 SelectKey
NIO엔 많은 클래스와 컴포넌트가 있지만 가장 중요한 3가지는 Chnnel, Buffer, Selector 입니다. 이 3가자의 핵심 컴포넌트에 대해 알아보도록 하겠습니다.
Java docs에 정의된 Cahnnel
을 먼저 알아보겠습니다.
NIO Channel은 파일 소켓과 같은 I/O 작업을 수행할 수 있는 엔티티에 대한 연결을 나타내는 채널을 정의하고, 다중화된 비차단 I/O 작업을 위한 셀렉터를 정의합니다.
즉 다중화된 비차단 I/O(Non-Blocking)를 지원하며, 쉽게 말해 데이터가 흘러다니는 통로를 우리는 NIO Channles 라고 합니다.
데이터가 흘러다니는 통로는 기존 I/O(흔히 우리가 말하는 BIO)에서는 Stream
를 생각해 볼 수 있습니다. 많이봤던 InputStream, OutPutStream
이 그 예입니다. 앞선 글에서도 말했지만 기존 I/O의 Stream은 Blocking하게 동작한다는 특징이 있습니다.
그럼 우리가 알아보려는 NIO Channel과 Stream은 어떻게 다를까요?
NIO 채널은 기존 I/O의 Streams와 유사합니다.(ex InputStream, OutPutStream) 그러나 단방향인 기존 I/O와 다르게 양방향입니다. 즉 기존 I/O Stream의 경우 입력과 출력을 위해 각각 InputStream과 OutpuStream이 필요하지만, Channel의 경우 유사한 데이터 통로지만 양방향으로 둘 의 구분이 없습니다.
또한, Channel은 Stream과 다르게 기본적으로 항상 버퍼를 읽거나 버퍼에서 쓰이는 구조이며, Blocking 방식과 Non-Blocking방식 모두 가능합니다. 즉 많은 스레드를 사용하지 않고 효과적으로 재사용이 가능합니다.
아래는 NIO에서 주로 중요한 채널에 대한 구현체입니다.
- FileChannel : 파일에서 데이터를 읽고 파일로 전송합니다.
- DatagramChannel : UDP 네트워크를 통해 데이터를 읽고 쓸 수 있습니다.
- SocketChannel : TCP 네트워크를 통해 데이터를 읽고 쓸 수 있습니다.
- ServerSocketChannel : 웹 서버처럼 들어오는 TCP 연결을 수신 대기할 수 있으며, 들어오는 각 연결에 대해 소켓채널이 생성됩니다.
NIO의 Buffer
을 보기전에 Buffer가 무슨 역할을 하는지 짚고 넘어가겠습니다.
Buffer는 흔히 임시로 데이터를 담아둘 수 있는 일종의 큐입니다. 바이트 단위의 데이터가 입력될 시에, Stream은 이를 즉시 전송하게 되는데 이 행동이 디스크 접근 혹은 네트워크 접근같은 오버헤드가 발생하여 비효율적인 방법입니다.
이를 Buffer는 중간에서 입력에 대한 내용을 모아서 한번에 출력하는 방식으로 I/O의 성능 향상을 하는 역할을 합니다.
NIO Buffer는 위에서 설명한 내용과 같이 채널에서 버퍼로 읽히고 버퍼에서 채널로 쓰이며
, Channel과 상호 작용할 때 사용됩니다. Buffer를 사용하여 데이터를 읽고 쓰는 것은 일반적으로 아래와 같이 4단계 프로세스로 진행됩니다.
- Buffer에 데이터 쓰기
- buffer.flip() 호출
- 버퍼에서 데이터 읽기
- buffer.clear() 또는 buffer.cmompact()
사용자가 버퍼에 데이터를 쓰면, 버퍼는 사용자가 쓴 양이 얼마나 되는지 추적합니다.
데이터를 읽을 시에는 flip()
메서드 호출을 통해 쓰기 모드에서 읽기 모드로 전환합니다. 읽기 모드에선 버퍼에 기록된 모든 데이터를 읽을 수 있습니다.
이후 버퍼를 다시쓰기위해 비워야하는 과정이 필요하며 이때는 clear()
메소드로 전체 버퍼를 지우거나, compact()
메소드를 통해 이미 읽은 데이터만 지울 수 있습니다. 이때 읽지 않은 부분은 버퍼의 시작부분으로 이동하며, 읽지않은 다음 데이터에 추가 데이터가 버퍼에 기록됩니다.
아래는 FileChannel과 ByteBuffer를 사용하여 파일을 복사하는 간단한 코드입니다.
public static void main(String[] args) {
String sourceFile = "/Users/owen-um/project/test/source.txt"; // 원본 파일 이름
String destinationFile = "/Users/owen-um/project/test/destination.txt"; // 복사 대상 파일 이름
try (FileInputStream fis = new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(destinationFile);
FileChannel sourceChannel = fis.getChannel();
FileChannel destinationChannel = fos.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1KB 버퍼
while (sourceChannel.read(buffer) != -1) {
buffer.flip(); // 버퍼를 읽기 모드로 전환
destinationChannel.write(buffer); // 버퍼의 데이터를 대상 파일로 복사
buffer.clear(); // 버퍼를 비우고 다시 쓰기 모드로 전환
}
System.out.println("파일 복사 완료");
} catch (IOException e) {
e.printStackTrace();
}
}
여기서 신기한 점은 flip() 메소드를 호출하지 않을 경우 파일이 정상적으로 복사되지 않는 것을 확인할 수 있습니다.
flip() 메소드는 버퍼의 시작이자 데이터의 시작인 0으로 Position이 이동하고 데이터의 끝 부분에 Limit이 위치하게 해줍니다. Limit을 Position위치로 바꾸고, Position을 0으로 바꾸는 작업을 통해 데이터를 정상적으로 읽을수 있도록 해주는데 이 과정이 빠져서 생기는 문제입니다.
추가로 clear()를 하지 않을 경우 버퍼가 지워지지 않아 계속해서 원본파일의 데이터가 무한으로 복사되는 장면을 보실 수 있습니다.
그럼 Buffer방식을 이해하기 위해 알아둬야할 Buffer의 세가지 속성을 간단하게 알아보겠습니다.
메모리 블록인 버퍼는 고정된 크기를 가지며 이를 capacity(용량)
이라고 합니다. 우리는 용량 만큼의 바이트를 버퍼에 쓸 수 있으며 만일 버퍼가 가득 찰 경우 추가 데이터를 쓰기위해 데이터를 읽거나, clear 하여 비워야합니다.
버퍼에 데이터를 쓸경우 특정위치에 데이터를 쓰게 되며, 초기에 이 Position(위치)
는 0입니다. 만일 데이터가 기록되면 position은 위의 그림처럼 데이터를 넣을 다음 쉘을 가르킵니다. 참고로 positon의 경우 (capacity - 1)까지 가질 수 있습니다.
버퍼의 데이터를 읽을 경우 position이 가르키는 위치에서 읽으며, 만일 flip() 메소드를 통해 쓰기 -> 읽기 모드로 전환할 시 Position은 0으로 초기화 됩니다. Position으로 부터 버퍼의 데이터를 읽으면 다음 position으로 이동하여 읽을 수 있습니다.
버퍼에 데이터를 쓸 경우 Limit
은 얼마나 데이터를 쓸 수 있는지를 의미하며, 이는 버퍼의 Capacity(용량)과 같은 의미입니다.
버퍼의 데이터를 읽을 경우 Limit은 버퍼의 데이터를 얼마나 읽을 수 있는지를 의미합니다. 따라서 읽기모드 전환시 Lmit은 쓰기 모드의 쓰기 Position으로 설정되며, 이는 쓴 만큼의 바이트를 읽을 수 있음을 의미합니다.
Tip
Position과 Limit의 의미는 버퍼가 읽기/쓰기 모드인지에 따라 달려있습니다. 다만 용량은 항상 같은 크기로
읽기/쓰기모드에 영향이 없습니다.아래의 그림은 쓰기와 읽기 모드에서의 Position과 Limit이 의미하는 것을 보여줍니다. 각 모드에 따라 두 위치가 유동적으로 움직입니다.
이번엔 NIO의 Selector(선택기)에 대해 알아보도록 하겠습니다. NIO의 Selector는 하나 이상의 NIO Channel의 인스턴스를 검사하고 읽기/쓰기 등의 작업을 수항핼 준비가 된 채널을 결정합니다.
이렇게 되면 단일 스레드에서 여러 채널을 관리할 수 있기때문에 여러 네트워크 연결을 관리할 수 있습니다.
그럼 선택기의 장점은 무엇일까요?
위에서 설명했듯이, 단일 스레드에서 여러 채널을 처리 및 관리하여 여러 네트워크 연걸을 관리할 수 있다는 것에 있습니다.
스레드 간의 전환은 OS에 많은 비용을 야기하며, 리소스를 활용하게 되는데, 단일 스레드에서 여러 채널을 관리한다면, 이러한 성능적 이점을 가져올 수 있는 것입니다. 또한 이러한 점이 앞선 글의 내용에서 Asynchronous/Non-Blocking한 통신을 위한 하나의 구조가 되는 것이죠.
아래는 Selector에서 Channel을 관리하고 그에따른 실행 동작을 제어하는 서버측의 간단한 예제 코드입니다.
public static void main(String[] args) throws IOException {
Selector selector = Selector.open(); //Selector 객체를 생성합니다.
/**
* 선택기가 채널을 모니터링 하기위해 채널을 등록하며 해당 채널은 Non-Blocking이어야 합니다.
* 이는 FileChannel의 경우 선택기와 함께 사용이 불가능함을 말합니다.(Non-Blocking하게 전환 불가)
*/
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // Channel을 생성합니다.
serverSocketChannel.configureBlocking(false); //비차단 모드로 Channel을 설정합니다..
serverSocketChannel.bind(new InetSocketAddress("localhost", 8081));
/**
* SelectionKey 는 관심 세트로 채널에서 선택기를 통해 어떤 이벤트를 수신 대기할지를 의미합니다.
* SelectionKey.OP_CONNECT : 클라이언트가 서버에 연결을 시도할 때.
* SelectionKey.OP_ACCEPT : 서버가 클라이언트의 연결을 수락하는 경우
* SelectionKey.OP_READ : 서버가 채널에서 읽을 준비가 되었을 때.
* SelectionKey.OP_WRITE : 서버가 채널에 쓸 준비가 되었을 때.
*
* @return selectionKey 선택 가능한 채널이 선택기에 등록되었음을 나타냅니다.
*/
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //Channel을 Selector에 등록합니다
ByteBuffer buffer = ByteBuffer.allocate(256); //ByteBuffer를 생성합니다.
while (true) {
selector.select(); //준비된 채널을 선택합니다.(등록한 이벤트에 대해 하나 이상의 채널이 준비될 떄까지 기다립니다.)
Set<SelectionKey> selectionKeys = selector.selectedKeys(); // Selector에 등록된 SelectionKey의 리스트를 가져옵니다.
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isAcceptable()) {
SocketChannel client = serverSocketChannel.accept(); //클라이언트로부터 연결을 수락합니다.
client.configureBlocking(false); // 소켓채널 객체를 비차단 모드로 설정합니다.
client.register(selector, SelectionKey.OP_READ); // 읽기 작업을 위해 Selector에 등록합니다.
}
if (key.isReadable()) {
SocketChannel client = (SocketChannel) key.channel();
int read = client.read(buffer);
if (read == -1) {
client.close();
System.out.println("클라이언트의 메시지가 없습니다.");
} else {
buffer.flip();
client.write(buffer);
buffer.clear();
}
}
}
iterator.remove();
}
}
간단하게 정리하자면, NIO는 Channel 이라는 I/O 통로를 통해 통신하며 이 Channel은 Buffer를 사용해 통신합니다.
또한 Selector(선택기)를 통해 하나의 스레드에서도 여러 Channel을 모니터링하여 관리하여 기존의 Thread Per Request 방식의 스레드가 많이 소비되는 단점을 개선할 수 있습니다.
그렇다면 무조건 NIO만이 답일까요?
결론 부터 말하면 그렇지는 않습니다. NIO의 경우 입출력 처리가 올래 걸리는 작업에대해 NIO의 특징인 스레드의 재사용이 힘들어져 효율적인 측면에서 좋지못할 수 있습니다.
대용량 데이터처리의 경우에도 버퍼를 사용하는 NIO의 할당 크기에 문제가 있으며, 모든 입출력 작업에 버퍼를 무조건 사용해야 하기 때문에 문제가 있습니다.
정리하자면, NIO의 경우 불특정 다수의 클라이언트를 연결하나 그 처리 작업이 오래 걸리지 않는경우에 유용하다고 할 수 있습니다.
그렇다면 Tomcat의 NIO는 어떻게 동작하며, 어떤 방식으로 사용자의 요청을 처리 할까요?
Tomcat의 NIO 구현은 주로 Connector 구성 요소에 있습니다. 우선 Tincat의 Connector는 주로 브라우저에서 보낸 TCP 연결 요청에 대한 수신하고 각각에 대한 Request / Response 객체를 생성 합니다.
이후 요청을 처리하기 위한 Thread를 생성하고 생성된 요청객체와 응답 객체를 요청을 처리하는 Thread에 전달하는 역할을 수행합니다. 요청을 처리하는 Thread는 Container Component의 역할입니다.
이러한 Connector는 Http11NioProtocol, Mapper, CoyoteAdapter 3가지로 구성되며, Http11NioProtocol에는 NioEndpoint와 Http11ConnectionHandler가 포함되어 있습니다.
NioEndpoin는 Http11NioProtocol에서 소켓을 수신하며 처리하는 모듈이고, Http11ConnectionHandler는 연결을 위한 프로세스 입니다.
그중 NioEndpoint는 주로 소켓 요청 리스너 스레드인 Acceptor와 Socket Nio Poller Thread 그리고 request processing Thread pool(요청 처리 스레드 풀) 을 구현합니다.
그럼 NioEndpoint를 구성하고 있는 각각에 대해 알아보도록 하겠습니다.
소켓 스레드를 수신하는 역할
을 합니다. 소켓 채널 객체를 가져오고 NioChannel객체에 캡슐화 합니다.
이후 NioChannel객체를 PollerEvent 객체로 캡슐화하여 PollerEvent Queue에 push합니다.
Acceptor와 Poller Thread는 큐를 통해 통신하며, Acceptor는 이벤트 큐의 발행자고, Poller는 소비자가 됩니다.
아래는 Acceptor 와 NioEndpint의 코드 부분이며 간단한 설명이 포함되어 있습니다.
[Acceptor.java]
try {
// Loop until we receive a shutdown command
while (!stopCalled) { //shutdown command가 발생하지 않는한 루프를 돌며 소켓을 검색합니다.
.
.
.
// Configure the socket
if (!stopCalled && !endpoint.isPaused()) { //stop이나 pause 상태가 아니면 소켓설정을 진행합니다.
// setSocketOptions() will hand the socket off to
// an appropriate processor if successful
if (!endpoint.setSocketOptions(socket)) { //소켓설정
endpoint.closeSocket(socket);
}
} else {
endpoint.destroySocket(socket);
}
[NioEndpoint.java]
@Override
protected boolean setSocketOptions(SocketChannel socket) {
NioSocketWrapper socketWrapper = null;
try {
// Allocate channel and wrapper
NioChannel channel = null; //SocketChannle 인자를 NioChannel객체로 감쌉니다.
if (nioChannels != null) {
channel = nioChannels.pop();
}
.
.
.
// Set socket properties
// Disable blocking, polling will be used
socket.configureBlocking(false); // 소켓 Non-Blocking 설정을 합니다.
if (getUnixDomainSocketPath() == null) {
socketProperties.setProperties(socket.socket());
}
socketWrapper.setReadTimeout(getConnectionTimeout()); //readTimeout을 설정합니다.
socketWrapper.setWriteTimeout(getConnectionTimeout()); //connectionTimeout을 설정합니다.
socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests());
poller.register(socketWrapper); // Poller에 SocketChannle을 등록합니다.
return true;
Selector를 관리하며, 이벤트 큐인 SynchronizedQueue<PollerEvent>를 가지고 있습니다
. Poller는 NIO 구현의 메인 스레드로 먼저 이벤트 큐의 Consumer로써 큐에서 PollerEvent 객체를 가져옵니다. 가져온 객체의 Channel을 OP_READ 이벤트와 함께 메인 셀렉터에 등록합니다.
이후 메인 셀렉터는 데이터를 읽을 수 있는 소켓을 탐색하여 선택 작업을 수행하고, Woker Thread Pool에서 사용 가능한 Woker Thread를 가져와 소켓을 Worker에 전달합니다.
[Poller.java]
public class Poller implements Runnable {
private Selector selector;
private final SynchronizedQueue<NioEndpoint.PollerEvent> events = new SynchronizedQueue<>();
//Acceptor에서 생성한 PollerEvent가 addEvent를 통해 PollerEvent Queue에 들어갑니다.
private void addEvent(NioEndpoint.PollerEvent event) {
events.offer(event);
if (wakeupCounter.incrementAndGet() == 0) {
selector.wakeup();
}
}
@Override
public void run() {
// Loop until destroy() is called
while (true) {
boolean hasEvents = false;
try {
if (!close) {
hasEvents = events(); //루프를 돌며 등록된 이벤트가 있는지 확인합니다.
.
.
.
Iterator<SelectionKey> iterator =
keyCount > 0 ? selector.selectedKeys().iterator() : null;
// Walk through the collection of ready keys and dispatch
// any active event.
while (iterator != null && iterator.hasNext()) { //준비된 키 컬렉션을 통해 루프를 돕니다.
SelectionKey sk = iterator.next();
iterator.remove();
NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
// Attachment may be null if another thread has called
// cancelledKey()
if (socketWrapper != null) {
//Channel이 있을경우 해당 key에 대한 process를 실행합니다.
processKey(sk, socketWrapper);
}
}
.
.
.
protected void processKey(SelectionKey sk, NioEndpoint.NioSocketWrapper socketWrapper) {
try {
if (close) {
socketWrapper.close();
} else if (sk.isValid()) {
if (sk.isReadable() || sk.isWritable()) {
if (socketWrapper.getSendfileData() != null) {
processSendfile(sk, socketWrapper, false);
} else {
unreg(sk, socketWrapper, sk.readyOps());
boolean closeSocket = false;
// Read goes before write
//Channle의 SelectionKey가 OP_READ모드일 경우
if (sk.isReadable()) {
if (socketWrapper.readOperation != null) {
if (!socketWrapper.readOperation.process()) {
closeSocket = true;
}
} else if (socketWrapper.readBlocking) {
synchronized (socketWrapper.readLock) {
socketWrapper.readBlocking = false;
socketWrapper.readLock.notify();
}
} else if (!processSocket(socketWrapper, SocketEvent.OPEN_READ, true)) {
closeSocket = true;
}
}
//Channel의 SelectionKeyrk OP_WRITE인 경우며, 응답에 사용됩니다.
if (!closeSocket && sk.isWritable()) {
if (socketWrapper.writeOperation != null) {
if (!socketWrapper.writeOperation.process()) {
closeSocket = true;
}
} else if (socketWrapper.writeBlocking) {
synchronized (socketWrapper.writeLock) {
socketWrapper.writeBlocking = false;
socketWrapper.writeLock.notify();
}
} else if (!processSocket(socketWrapper, SocketEvent.OPEN_WRITE, true)) {
closeSocket = true;
}
}
if (closeSocket) {
socketWrapper.close();
}
}
}
} else {
// Invalid key
socketWrapper.close();
}
} catch (CancelledKeyException ckx) {
socketWrapper.close();
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
log.error(sm.getString("endpoint.nio.keyProcessingError"), t);
}
}
Poller로 부터 전달 받은 socket을 SocketProcessor 객체에 캡슐화 합니다.
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
try {
if (socketWrapper == null) {
return false;
}
SocketProcessorBase<S> sc = null;
if (processorCache != null) {
sc = processorCache.pop();
}
if (sc == null) {
//createSocketProcessor를 통해 SocketProcessor가 생성됩니다.
sc = createSocketProcessor(socketWrapper, event);
} else {
sc.reset(socketWrapper, event);
}
Executor executor = getExecutor();
if (dispatch && executor != null) {
// 생성된 socketProcessor를 실행합니다.
executor.execute(sc);
이후 Http11ConnectionHandler에서 Http11NioProcessor 객체를 가져와 CoyoteAdapter 로직을 호출합니다.(해당 부분은 이전 포스팅에 설명되어 있습니다.)
Worker 스레드에서는 소켓에서 http 요청 읽기를 완료한 후 이를 HttpServletRequest 객체로 분석하여 해당 서블릿에 디스패치하고 로직을 완료한 다음 소켓을 통해 클라이언트에게 응답을 보냅니다.
결국 정리하자면 NIO Connector의 구조는 아래와 같습니다.
- Acceptor에서 소켓을 Accpect 후 PollerEvent Queue에 Publish합니다.
- Poller는 새로운 Channel들을 Selector에 등록합니다.
- Poller는 PollerEvent Queue를 Subscrive하여 소켓을 획득하고 Selector를 활용하여 활성 가능 상태의 Channel 들을 한 번에 processing합니다.
- Porcessing된 Channel은 각 Worker Thread에게 할당 되어 작업이 저리되고 반환됩니다.
https://www.programmersought.com/article/80284949590/
https://colevelup.tistory.com/39
https://www.programmersought.com/article/1699692284/
https://jenkov.com/tutorials/java-nio/index.html
https://www.baeldung.com/java-nio-selector