IO는 아는데 NIO는 뭐야? 에서 JAVA 진영에서 지원하는 저수준의 논블로킹 IO 메커니즘에 대해서 알아보았다.
이번에는 이러한 저수준의 논블로킹 IO 방식이 어디서 추상화되어 사용되고 있는지 알아보고자 이 글을 작성한다.
혹시 만약 Java의 NIO에 대해서 개념이 생소한 것 같다고 생각되면, 위 글을 읽고 오는 것을 추천한다.
비동기/이벤트 기반의 네트워크 애플리케이션 프레임워크
빠르고 유지보수가 편한 고성능 네트워크 (UDP/TCP) 서버/클라이언트 프로그램을 구현할 수 있도록 다양한 API를 제공한다.
네티는 주로 톰캣과 비교를 많이하며, 톰캣과 네티를 함께 WAS의 양대산맥이라고 할 수 있다.
(한국인 개발자 이희승님께서 개발하셨다. 궁금하다면 여기를 읽어보면 좋을 것 같다.)

Netty는 자바의 NIO를 활용한 만큼, 네트워크 IO를 비동기적으로 수행할 수 있다.
물론 동기/비동기 방식을 설정으로 변경할 수 있긴 하다.
Non-Blocking 방식은 Java NIO를 기반으로 한 것이기 때문에 NIO와 별 차이가 없다.
Netty의 또 다른 특징은 이벤트 루프이다.
IO 작업을 이벤트 기반으로 처리하기 때문에,
발생하는 이벤트를 지속적으로 감지하기 위한 별도의 장치가 필요하다.
이것이 바로 이벤트 루프인 것이다.
조금 더 자세히 설명하자면 사용자의 요청을 수신하는 무한 루프 스레드가 존재하고,
이 스레드는 내부적으로 NIO의 Selector 를 사용하여 사용자의 요청이 받아들여졌는지를 매번 확인한다.
Selector는 여러 채널을 알고 있고, 각 채널에 READ/WRITE/ACCEPT 등의 이벤트가 발생하면 각 채널에 맞는 ChannelHandler 에게 이벤트를 전달한다.
위에서 설명한 이벤트 루프는, 말 그대로 이벤트를 대기하는 무한 루프를 의미한다.
또한 Netty에는 이러한 이벤트 루프를 그룹화한 EventLoopGroup이 존재한다.
이벤트 루프 여러 개를 묶어서 하나의 그룹으로 만들고,
그 하나의 그룹은 하나의 역할을 갖는다.
주로 Boss/Worker 그룹으로 나뉜다. 이는 Master-Slave 구조와 유사하다.
Boss가 클라이언트로부터 요청을 받으면 Channel을 생성하여 Worker 에게 넘긴다.
(Channel 은 클라이언트와 데이터를 주고 받는 통로이다.)
Boss
클라이언트 요청을 수신하는 그룹을 의미하며,
Client의 요청을 1대1로 전담하는 Channel을 지속적으로 생성한다.
새로운 클라이언트로부터 요청을 받으면, 이 클라이언트와 연결된 Channel 을 생성한다.
이후 이 Channel을 Worker 그룹에 넘겨준다.
// Boss (Master) - 주로 클라이언트 연결 요청 처리 담당
NioEventLoopGroup bossGroup = new NioEventLoopGroup(1); // 스레드 수 1개
Worker
Worker는 클라이언트의 요청을 처리하는 그룹을 의미한다.
Boss로부터 넘겨받은 Channel은 클라이언트와 데이터를 주고 받는 통로이다.
이 통로에는 이벤트가 발생할 수 있으며, 이 Channel에 발생한 이벤트를 처리하는 작업을
Worker 그룹이 담당한다.
// Worker - 데이터 송수신 담당
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
그림으로 그려보자면 아래와 같다.

이제 Netty의 구성요소에 대해 더 알아보자. EventLoopGroup 또한 Netty의 구성요소의 일부이다.
하지만 몇 가지 구성요소들이 더 있다.

ChannelHandler
: Channel 에서 발생하는 READ/WRITE 등의 이벤트를 처리하는 이벤트 핸들러
NIO에서 Channel은 Client 와 Server가 주고받는 데이터들의 통로이다.
이는, IO의 InputStream, OutputStream과 정확하게 같은 역할을 수행한다.
대신 비동기/이벤트 기반으로 동작하기 때문에, 이벤트 핸들러가 필요하다.
그 이벤트 핸들러가 바로 ChannelHandler인 것이다.
채널의 종류에는 두 가지가 존재한다.
ChannelInboundHandler는 Channel로 들어오는 이벤트인 Inbound 이벤트를 처리하는 역할을한다.
이 인터페이스를 구현하면, 채널로 들어오는 이벤트를 처리하는 핸들러를 구현한 것이다.
public interface ChannelInboundHandler extends ChannelHandler {
void channelRegistered(ChannelHandlerContext var1) throws Exception;
void channelUnregistered(ChannelHandlerContext var1) throws Exception;
void channelActive(ChannelHandlerContext var1) throws Exception;
void channelInactive(ChannelHandlerContext var1) throws Exception;
void channelRead(ChannelHandlerContext var1, Object var2) throws Exception;
void channelReadComplete(ChannelHandlerContext var1) throws Exception;
void userEventTriggered(ChannelHandlerContext var1, Object var2) throws Exception;
void channelWritabilityChanged(ChannelHandlerContext var1) throws Exception;
void exceptionCaught(ChannelHandlerContext var1, Throwable var2) throws Exception;
}
ChannelOutboundHandler는 마찬가지로 Channel에서 나가는 이벤트인 Outbound 이벤트를 처리하는 역할을 수행한다.
public interface ChannelOutboundHandler extends ChannelHandler {
void bind(ChannelHandlerContext var1, SocketAddress var2, ChannelPromise var3) throws Exception;
void connect(ChannelHandlerContext var1, SocketAddress var2, SocketAddress var3, ChannelPromise var4) throws Exception;
void disconnect(ChannelHandlerContext var1, ChannelPromise var2) throws Exception;
void close(ChannelHandlerContext var1, ChannelPromise var2) throws Exception;
void deregister(ChannelHandlerContext var1, ChannelPromise var2) throws Exception;
void read(ChannelHandlerContext var1) throws Exception;
void write(ChannelHandlerContext var1, Object var2, ChannelPromise var3) throws Exception;
void flush(ChannelHandlerContext var1) throws Exception;
}
Channel에서는 READ/WRITE 등의 다양한 종류의 이벤트가 발생한다고 했다.
Channel에서 발생하는 이벤트는 두 가지 종류가 있다.
이 이벤트의 종류에 따라서, 이벤트 핸들러도 두 가지로 나뉜다.
클라이언트 -> 소켓 -> 애플리케이션
Inbound 이벤트는 애플리케이션으로 향하는 이벤트이다.
즉 데이터의 전달 방향이 클라이언트 -> 소켓 -> 애플리케이션일 때 발생하는 이벤트이다.
구체적인 이벤트는 아래와 같다.
애플리케이션 -> 소켓 -> 클라이언트
Outbound 이벤트는 소켓으로 향하는 이벤트이다.
데이터의 전달 방향이 애플리케이션 -> 소켓 -> 클라이언트일 때 발생하는 이벤트이다.
구체적인 이벤트는 아래와 같다.
파이프라인
: Channel에 붙어있는 Handler들의 연결리스트
Channel에는 Channel에서 발생하는 이벤트를 처리하는, ChannelHandler가 필요하다.
필요한 ChannelHandler는 여러 개일 수도 있는데, 이는 Pipeline으로 추상화되어있다.
Pipeline은 사실 Channel에 붙어있으며, ChannelHandler들의 연결리스트이다.
(ChannelHandler 는 Channel 에서 발생하는 이벤트를 처리하는 이벤트 핸들러를 말하며, 하나의 ChannelHandler 는 하나의 Channel 만 담당한다.)

파이프라인은 서블릿 필터처럼 순차적으로 실행되며, 이 핸들러들 중간에는 서비스의 비즈니스 로직을 수행하는 핸들러가 존재한다.
이때 핸들러의 순서가 중요한데, 서블릿 필터와 유사하다.
순서는 아래와 같다.
실제 코드는 아래와 같다.
public interface ChannelPipeline extends ChannelInboundInvoker, ChannelOutboundInvoker, Iterable<Map.Entry<String, ChannelHandler>> {
ChannelPipeline addFirst(String var1, ChannelHandler var2);
/** @deprecated */
@Deprecated
ChannelPipeline addFirst(EventExecutorGroup var1, String var2, ChannelHandler var3);
ChannelPipeline addLast(String var1, ChannelHandler var2);
/** @deprecated */
@Deprecated
ChannelPipeline addLast(EventExecutorGroup var1, String var2, ChannelHandler var3);
ChannelPipeline addBefore(String var1, String var2, ChannelHandler var3);
Netty가 클라이언트로부터 연결 요청을 받고, 데이터를 수신하여 응답을 하는 과정은 다음과 같다.

Netty를 새롭게 접하면서 Tomcat의 사고방식에서 벗어날 수 있어서 좋았다.
물론 Netty는 서버 뿐 아니라 클라이언트 애플리케이션에서도 사용된다.
이벤트와 Non-Blocking 방식으로 IO 작업을 수행하기 때문에 적은 자원으로 높은 처리량을 얻어야 하는 상황에서 선택한다면 아주 좋은 선택지라고 생각한다.
다음 포스팅에서는 Netty를 내장하고 있는 Spring WebFlux에 대해서 다뤄보겠다.
비동기와 논블로킹이 헷갈렸는데 한방에 이해됐어요
~~~👍🏽