Netty
고성능 네트워크 서버를 설계할 때 가장 먼저 맞닥뜨리는 한계는 스레드 기반 동시성 모델의 비효율성입니다. 요청마다 스레드를 생성하거나, 블로킹 I/O로 인해 스레드가 대기 상태에 머무르는 순간 시스템의 리소스는 급격히 소모되고, 예측 가능한 처리량을 유지하기 어려워집니다. 이 문제를 근본적으로 해결해 온 대표적인 프레임워크가 바로 Netty입니다.
Netty는 단순히 “비동기 네트워크 라이브러리”가 아니라, 논블로킹 I/O 기반의 이벤트 드리븐 아키텍처를 정교하게 구현한 네트워크 애플리케이션 프레임워크입니다. 많은 고성능 서버, 분산 시스템, 심지어 Spring WebFlux의 Reactor Netty조차도 Netty의 스레딩 모델과 이벤트 처리 방식을 기반으로 동작합니다.
Netty의 성능과 유연성은 겉보기보다 훨씬 구조적인 이유에서 나옵니다. 특히 Netty 내부에서 가장 핵심적인 역할을 수행하는 두 컴포넌트는 다음과 같습니다.
- EventLoop — 모든 I/O 이벤트와 작업을 스케줄링하고 실행하는 단일 스레드 실행 엔진
- Channel — 소켓 연결을 추상화하고 이벤트 흐름을 전달하는 네트워크 엔드포인트
이 두 가지는 Netty의 전체 동작 방식, 스레딩 모델, I/O 처리 흐름을 결정하는 가장 중요한 기반 요소입니다.
이어서 Netty에 대해 자세히 살펴보기 전에 다음 내용들을 미리 알고 있어야 합니다.
EventLoop

EventLoop는 Netty에서 I/O 이벤트 처리와 태스크 실행을 단일 스레드에서 담당하는 실행 단위이며, 그 기본 구현체로 NIOEventLoop입니다. 각 NIOEventLoop는 다음 세 가지 핵심 요소로 구성되어 있습니다.
- EventExecutor: 태스크를 실제로 실행하는 단일 스레드 기반 실행기
- TaskQueue: 실행 대기 중인 태스크를 저장하는 큐
- Selector: Java NIO의 I/O 멀티플렉싱을 담당하는 Selector
NIOEventLoop에서 처리되는 태스크(Task)는 크게 다음 두 종류로 구분됩니다.
I/O Task
I/O Task는 Channel이 register() 과정을 통해 Selector에 등록된 이후, Selector가 감지한 read, write, connect, accept 등의 I/O 준비 완료 이벤트를 처리하는 작업입니다. 이 이벤트가 발생하면 NIOEventLoop는 해당 Channel의 ChannelPipeline을 실행하여 관련 이벤트를 전파합니다.
Non I/O Task
Non I/O Task는 다양한 사용자 정의 작업으로 구성됩니다. 예를 들어 eventLoop.execute(Runnable) 를 통해 제출된 일반 작업, schedule() 로 예약된 지연 실행 작업 등이 모두 이 범주에 포함됩니다. NIOEventLoop는 TaskQueue에서 이러한 태스크를 가져와 동일 스레드 내에서 순차적으로 실행하기 때문에 작업의 순서가 보장됩니다.
또한 NIOEventLoop는 ioRatio 값을 통해 “I/O Task와 Non I/O Task에 얼마만큼의 시간을 배분할지”를 설정할 수 있습니다.
기본값은 50으로, I/O 처리와 Non I/O 처리에 동일한 비율로 시간을 사용합니다.
if (ioRatio == 100) {
try {
if (strategy > 0) {
processSelectedKeys();
}
} finally {
// Ensure we always run tasks.
ranTasks = runAllTasks();
}
}
NIOEventLoop는 생성자가 public 접근 제한자를 가지지 않기 때문에 직접 생성할 수 없으며, 반드시 NIOEventLoopGroup을 통해 생성해야 합니다.
EventLoopGroup

EventLoopGroup은 여러 개의 EventLoop를 하나의 그룹 단위로 묶어 관리하는 컨테이너입니다. Netty에서 EventLoop는 단독으로 사용되지 않으며, 반드시 EventLoopGroup을 통해 생성·관리됩니다. 이 구조는 Netty의 스레드 모델이 일관성을 유지하고, 채널 분배(load balancing)를 자동화하기 위한 핵심 요소입니다.

public abstract class MultithreadEventLoopGroup extends ... {
private static final int DEFAULT_EVENT_LOOP_THREADS;
static {
DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt(
"io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
}
...
}
public NioEventLoopGroup(int nThreads) {
this(nThreads, (Executor) null);
}
NIOEventLoopGroup은 Java NIO 기반의 Selector를 내부적으로 사용하며, Netty에서 가장 널리 쓰이는 EventLoopGroup 구현입니다. 이 Group은 기본적으로 CPU 코어 수 × 2의 EventLoop를 생성하지만, 생성자를 통해 원하는 스레드 개수를 명시적으로 지정할 수도 있습니다.
아래에서는 NioEventLoopGroup을 생성한 뒤, EventLoop를 직접 선택하여 태스크를 실행하는 방식과 EventLoopGroup 자체에 태스크를 제출하는 방식을 순서대로 살펴보겠습니다.
1. EventLoop를 직접 선택하여 태스크 실행
public interface EventLoopGroup extends EventExecutorGroup {
/**
* Return the next {@link EventLoop} to use
*/
@Override
EventLoop next();
...
}
EventLoopGroup group = new NioEventLoopGroup();
EventLoop loop = group.next();
loop.execute(() -> {
System.out.println("태스크 실행");
});
2. EventLoopGroup에 태스크 제출
public abstract class AbstractEventExecutorGroup implements EventExecutorGroup {
...
public void execute(Runnable command) {
this.next().execute(command);
}
}
EventLoopGroup group = new NioEventLoopGroup();
group.execute(() -> {
System.out.println("태스크 실행");
});
이때 EventLoopGroup의 여러 EventLoop가 동시에 동작하기 때문에 전체 로그로 보면 태스크 실행 순서가 뒤섞여 보일 수 있습니다. 하지만 각 EventLoop는 단일 스레드로 동작하며 자신에게 전달된 태스크를 순차적으로 처리하기 때문에, EventExecutor(= EventLoop) 단위로 보면 실행 순서는 항상 보장됩니다.
Channel
EventLoop가 실행 엔진(Execution Unit)이라면, Channel은 Netty에서 네트워크 소켓 연결을 추상화한 엔드포인트(Endpoint) 입니다. 하나의 Channel은 반드시 단일 EventLoop에 바인딩되며, 해당 EventLoop는 Channel의 모든 I/O 이벤트를 전담합니다.
EventLoop는 단일 스레드 기반으로 동작하므로, 특정 Channel에 대한 I/O 이벤트 처리와 Task 실행은 항상 동일한 스레드에서 수행됩니다. 이를 통해 Channel 단위에서의 스레드 안전성과 일관된 실행 순서를 보장합니다.
여기서 주의해야 할 점은, Netty의 Channel은 Java NIO의 java.nio.channels.Channel과 다른 개념이라는 것입니다.
NIO Channel이 단순히 소켓 읽기/쓰기 기능만 제공하는 저수준 추상화라면, Netty의 Channel은 I/O 비동기 처리, Pipeline 연동, EventLoop 바인딩, LifeCycle 관리 등 훨씬 고수준의 기능을 포함하는 확장된 추상화입니다.
Channel은 다음과 같은 특징을 갖습니다.
- 내부적으로 Java NIO의 SocketChannel 또는 ServerSocketChannel을 래핑
- ChannelPipeline을 통해 이벤트 흐름을 처리
Netty는 다양한 환경을 위해 여러 구현체를 제공합니다. 그중 가장 일반적인 구현체가 NioServerSocketChannel과 NioSocketChannel입니다.
ChannelFuture
Netty의 모든 I/O 작업은 비동기(Non-Blocking) 으로 동작합니다. 따라서 Channel의 작업 결과는 즉시 완료되지 않으며, 그 결과를 나타내는 객체가 ChannelFuture입니다.
public interface ChannelFuture extends Future<Void> {
/**
* Returns a channel where the I/O operation associated with this
* future takes place.
*/
Channel channel();
@Override
ChannelFuture addListener(GenericFutureListener<? extends Future<? super Void>> listener);
@Override
ChannelFuture removeListener(GenericFutureListener<? extends Future<? super Void>> listener);
@Override
ChannelFuture sync() throws InterruptedException;
@Override
ChannelFuture await() throws InterruptedException;
}
Channel I/O 작업이 완료되면 isDone()이 true가 되며, 등록된 FutureListener가 있다면 해당 리스너가 자동으로 실행됩니다.
FutureListener는 다음과 같은 패턴으로 사용됩니다.
future.addListener(f -> {
if (f.isSuccess()) {
System.out.println("작업 성공");
} else {
f.cause().printStackTrace();
}
});
또한 ChannelFuture는 특정 Channel에서 수행된 I/O 작업의 결과를 표현하는 객체이므로, 어떤 Channel에 대한 작업인지 알아야 하기 때문에 내부적으로 Channel을 반드시 보관합니다.
sync()는 작업이 완료될 때까지 현재 스레드를 block하기 때문에, Netty 서버에서는 일반적으로 서버 소켓 채널이 닫힐 때까지 메인 스레드를 유지하는 용도로 자주 사용됩니다.
// 서버 부팅 후 완료될 때까지 대기
ChannelFuture bindFuture = bootstrap.bind(port).sync();
// 서버 채널이 닫힐 때까지 대기 (서버 유지)
bindFuture.channel().closeFuture().sync();
closeFuture()는 채널이 종료될 때 완료되는 Future를 반환하며, sync()를 호출하면 해당 Future가 완료될 때까지 — 즉, 채널이 실제로 닫히기 전까지 — 메인 스레드를 블로킹하여 서버가 계속 실행 상태를 유지하도록 합니다.
sync()와 await() 두 메서드 모두 작업이 완료될 때까지 현재 스레드를 block한다는 점은 동일합니다. 그러나 sync()는 작업이 실패한 경우 해당 예외를 즉시 throw하여 상위 호출자가 실패를 명확하게 감지할 수 있도록 하는 반면, await()는 단순히 작업이 완료될 때까지 기다리기만 하고, 작업 실패에 대한 예외를 자동으로 throw하지 않습니다.
ChannelPipeline

NioServerSocketChannel은 Netty가 제공하는 서버 소켓 채널(ServerSocketChannel)의 NIO 기반 구현체로,
내부에 Java NIO의 java.nio.channels.ServerSocketChannel을 보관하여 실제 accept·bind 등의 소켓 I/O 작업을 수행합니다.
상위 클래스인 AbstractChannel은 Channel이 생성될 때 ChannelPipeline을 함께 초기화하고 관리합니다.
이렇게 생성된 ChannelPipeline은 EventLoop에 의해 실행되며, 이는 I/O task의 일환으로 수행됩니다.

ChannelPipeline은 다양한 inbound 이벤트(channelActive, channelRead 등)를 전파하고, 동시에 다양한 outbound 작업(write, flush 등)을 수행합니다.

Pipeline 내부는 ChannelHandlerContext의 연속적인 노드 구조로 이루어져 있으며, 기본적으로 HeadContext와 TailContext가 포함됩니다. 각각의 ChannelHandlerContext는 LinkedList 형태로 구성되어 있어 next와 prev를 통해 양방향 접근이 가능합니다.
- 모든 inbound I/O 이벤트는 next 방향(head → tail) 으로 흐르고
- 모든 outbound I/O 작업은 prev 방향(tail → head) 으로 전파됩니다.
ChannelHandlerContext
ChannelHandlerContext는 각 ChannelHandler가 ChannelPipeline 안에서 어떤 방식으로 실행되고, 어떤 스레드에서 동작하며, 어떻게 다음 핸들러로 이벤트가 전달되는지를 결정하는 핵심 컴포넌트입니다.
다음은 ChannelPipeline에 Channelhandler를 등록하는 메서드의 소스코드 일부입니다.
public class DefaultChannelPipeline implements ChannelPipeline {
@Override
public final ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler) {
return internalAdd(group, name, handler, null, AddStrategy.ADD_LAST);
}
private ChannelPipeline internalAdd(EventExecutorGroup group, String name,
ChannelHandler handler, String baseName,
AddStrategy addStrategy) {
final AbstractChannelHandlerContext newCtx;
newCtx = newContext(group, name, handler);
...
}
private AbstractChannelHandlerContext newContext(EventExecutorGroup group, String name, ChannelHandler handler) {
return new DefaultChannelHandlerContext(this, childExecutor(group), name, handler);
}
}
ChannelPipeline에 addLast() 메서드를 사용하여 ChannelHandler를 추가하면 Netty는 해당 Handler를 감싸는 ChannelHandlerContext를 자동으로 생성하며, 각 Handler는 정확히 하나의 Context와 1:1로 연결됩니다.
이렇게 생성된 ChannelHandlerContext의 핵심 역할은 다음 네 가지입니다.
- 이벤트 전파 (Inbound / Outbound)
- 쓰레드 관리 (EventExecutor)
- Handler 실행
- Channel과 Pipeline 접근 포인트
1. 이벤트 전파
ChannelHandlerContext는 ChannelInboundInvoker와 ChannelOutboundInvoker를 구현한 인터페이스입니다.

ChannelInboundInvoker는 inbound I/O 이벤트를 다음 핸들러로 전파하는 기능을 정의한 인터페이스이며, Pipeline의 앞에서 뒤로(next 방향) 전달되는 이벤트 전파 메서드를 제공합니다.
public interface ChannelInboundInvoker {
/**
* A {@link Channel} was registered to its {@link EventLoop}.
*/
ChannelInboundInvoker fireChannelRegistered();
/**
* A {@link Channel} was unregistered from its {@link EventLoop}.
*/
ChannelInboundInvoker fireChannelUnregistered();
/**
* A {@link Channel} is active now, which means it is connected.
*/
ChannelInboundInvoker fireChannelActive();
/**
* A {@link Channel} is inactive now, which means it is closed.
*/
ChannelInboundInvoker fireChannelInactive();
/**
* A {@link Channel} received an {@link Throwable} in one of its inbound operations.
*/
ChannelInboundInvoker fireExceptionCaught(Throwable cause);
/**
* A {@link Channel} received an user defined event.
*/
ChannelInboundInvoker fireUserEventTriggered(Object event);
/**
* A {@link Channel} received a message.
*/
ChannelInboundInvoker fireChannelRead(Object msg);
/**
* Triggers an {@link ChannelInboundHandler#channelReadComplete(ChannelHandlerContext)}
*/
ChannelInboundInvoker fireChannelReadComplete();
/**
* Triggers an {@link ChannelInboundHandler#channelWritabilityChanged(ChannelHandlerContext)}
*/
ChannelInboundInvoker fireChannelWritabilityChanged();
}
반면 ChannelOutboundInvoker는 outbound I/O 작업을 이전 핸들러로 전파하는 기능을 정의한 인터페이스로, Pipeline의 뒤에서 앞으로(prev 방향) 전달되는 작업을 처리합니다.
public interface ChannelOutboundInvoker {
/**
* Request to bind to the given {@link SocketAddress} and notify the {@link ChannelFuture} once the operation
* completes, either because the operation was successful or because of an error.
*/
ChannelFuture bind(SocketAddress localAddress);
/**
* Request to connect to the given {@link SocketAddress} and notify the {@link ChannelFuture} once the operation
* completes, either because the operation was successful or because of an error.
*/
ChannelFuture connect(SocketAddress remoteAddress);
/**
* Request to connect to the given {@link SocketAddress} while bind to the localAddress and notify the
* {@link ChannelFuture} once the operation completes, either because the operation was successful or because of
* an error.
*/
ChannelFuture connect(SocketAddress remoteAddress, SocketAddress localAddress);
/**
* Request to disconnect from the remote peer and notify the {@link ChannelFuture} once the operation completes,
* either because the operation was successful or because of an error.
*/
ChannelFuture disconnect();
/**
* Request to close the {@link Channel} and notify the {@link ChannelFuture} once the operation completes,
* either because the operation was successful or because of
* an error.
*/
ChannelFuture close();
/**
* Request to deregister from the previous assigned {@link EventExecutor} and notify the
* {@link ChannelFuture} once the operation completes, either because the operation was successful or because of
* an error.
*/
ChannelFuture deregister();
}
앞서 ChannelPipeline에서 살펴보았던 inbound I/O 이벤트와 outbound I/O 이벤트의 흐름을 ChannelHandlerContext의 메서드를 사용하여 더 자세히 표현하면 다음과 같습니다.

여기서 중요한 점은 정상적인 Inbound 이벤트 흐름에서는 보통 TailContext까지 이벤트가 도달하지 않는다는 것입니다. 일반적으로는 마지막 사용자 정의 ChannelHandlerContext에서 적절한 outbound 작업(write, flush 등)을 발생시키며 흐름이 종료되기 때문입니다.
만약 어떤 이유로 이벤트가 처리되지 않은 채 파이프라인 끝까지 전달되면, TailContext에서 onUnhandledInboundMessage()가 호출되며 메시지는 적절히 release되어 메모리 누수가 발생하지 않도록 정리됩니다.
Channel.Unsafe 는 Netty 내부에서만 사용되는 저수준(Low-level) I/O 인터페이스로, Channel의 실제 I/O 작업을 수행하는 핵심 엔진입니다.
2. 쓰레드 관리

각 ChannelHandlerContext는 반드시 하나의 ChannelHandler를 포함하며, 이는 Pipeline 내 Handler의 실행 위치를 나타내는 필수 구성 요소입니다. 반면 EventExecutor는 선택적이며, Handler를 등록할 때 별도의 EventExecutorGroup을 지정한 경우에만 생성됩니다. 별도로 지정하지 않으면 해당 Context는 Channel이 바인딩된 EventLoop를 그대로 실행 스레드로 사용합니다.
EventExecutor
ChannelHandlerContext는 기본적으로 Channel이 바인딩된 EventLoop 쓰레드에서 실행되지만, 필요하다면 별도의 EventExecutorGroup을 지정하여 특정 Handler만 다른 쓰레드풀에서 실행되도록 구성할 수 있습니다. 이는 Handler 내부에서 시간이 오래 걸리는 연산(블로킹 I/O, 데이터베이스 접근, 파일 처리 등)이 수행될 경우 EventLoop 쓰레드를 블로킹하지 않도록 하기 위한 설계입니다.
일반적으로 Handler의 콜백 메서드(channelRead, channelActive 등)는 모두 EventLoop 쓰레드에서 동작합니다.
따라서 Handler 내부에서 장시간 수행되는 연산이 발생하면 해당 EventLoop가 block되고, 동일한 EventLoop에 속한 다른 Channel들의 I/O 처리까지 지연되는 문제가 발생합니다.
이러한 상황을 방지하기 위해 Handler를 등록할 때 EventExecutorGroup을 함께 지정하면, 해당 ChannelHandlerContext는 EventLoop와 분리된 독립적인 스레드풀에서 이벤트를 처리하도록 구성됩니다.

이 경우 Netty는 이벤트 전파 시 직접 Handler를 실행하지 않고, 처리 로직을 executor.execute()를 통해 해당 스레드풀의 task queue에 위임합니다. EventLoop 스레드는 이 시점에서 Handler의 실행을 기다리지 않고 즉시 반환하여 다음 I/O 이벤트를 처리합니다. 다만 이후 이벤트가 다음 Handler로 전파될 때는, 그 다음 ChannelHandlerContext가 속한 Executor(EventLoop 또는 별도 Executor)에 따라 적절한 스레드에서 이어서 실행되도록 하여 이벤트 순서를 보장합니다.
이러한 스레드 관리 방식은 ChannelHandlerContext를 구현한 추상 클래스 AbstractChannelHandlerContext의 invokeChannelRead() 메서드를 살펴보면 확인할 수 있습니다.
abstract class AbstractChannelHandlerContext implements ChannelHandlerContext {
volatile AbstractChannelHandlerContext next;
volatile AbstractChannelHandlerContext prev;
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
final Object m = next.pipeline.touch(ObjectUtil.checkNotNull(msg, "msg"), next);
EventExecutor executor = next.executor();
if (executor.inEventLoop()) {
next.invokeChannelRead(m);
} else {
executor.execute(new Runnable() {
@Override
public void run() {
next.invokeChannelRead(m);
}
});
}
}
...
}
inbound 이벤트 전파(ChannelRead) 과정에서, 다음 ChannelHandlerContext의 EventExecutor가 EventLoop와 동일한지 여부를 확인한 뒤, 동일하지 않다면 Runnable로 래핑하여 executor의 task queue로 위임하는 동작을 수행함을 확인할 수 있습니다.
ChannelHandler
ChannelHandler는 Netty의 ChannelPipeline 내에서 I/O 이벤트를 처리하거나 I/O 작업을 수행하는 핵심 컴포넌트입니다.

핸들러는 크게 Inbound 처리용, Outbound 처리용, 그리고 양방향 모두를 처리할 수 있는 Duplex Handler로 구성됩니다.
- ChannelInboundHandler
→ Channel에서 발생하는 inbound I/O 이벤트를 처리 - ChannelOutboundHandler
→ Channel로 전달되는 outbound I/O 작업(write, flush 등)을 가로채어 처리 - ChannelDuplexHandler
→ inbound/outbound 인터페이스 모두 구현한 복합 핸들러
이 모든 핸들러들은 ChannelPipeline에 삽입되며, 각 이벤트는 파이프라인을 따라 순서대로 전파됩니다.
ChannelInboundHandler
ChannelInboundHandler는 inbound I/O 이벤트를 처리하기 위한 인터페이스입니다.
즉, 소켓에서 들어오는 이벤트들이 도착했을 때 호출되며, 이벤트 흐름은 Pipeline의 앞에서 뒤로 전파됩니다.

| 메서드 | 설 명 |
| channelRegistered() | Channel이 EventLoop에 등록될 때 호출 |
| channelUnregistered() | EventLoop에서 제거될 때 호출 |
| channelActive() | Channel이 active 상태(connected)로 전환될 때 호출 |
| channelInactive() | Channel이 inactive 상태가 되고 close될 때 호출 |
| channelRead() | 소켓으로부터 메시지를 읽을 준비가 완료되었을 때 호출 |
| channelReadComplete() | 모든 메시지를 읽은 후 호출 |
| userEventTriggered() | 사용자 정의 이벤트가 트리거된 경우 |
| channelWritabilityChanged() | Channel의 쓰기 가능 여부(writability)가 변경되었을 때 → 딱 한 번만 호출되는 중요한 이벤트 |
ChannelOutboundHandler
ChannelOutboundHandler는 outbound I/O 작업을 가로채서 처리하는 핸들러입니다. 즉, write, flush, connect 등 소켓으로 나가는 작업에 개입할 수 있습니다. Outbound 이벤트는 Pipeline의 뒤에서 앞으로 전파됩니다.

| 메서드 | 설 명 |
| bind() | ServerSocketChannel bind 요청 시 호출 |
| connect() | SocketChannel connect 요청 시 호출 |
| disconnect() | disconnect 요청 시 호출 |
| deregister() | EventLoop에서 deregister 된 경우 호출 |
| read() | read 요청이 발생했을 때 호출 |
| write() | write 작업을 가로챌 때 호출 → 메시지를 변경하거나 변환하여 다음 핸들러로 전달 가능 |
| flush() | flush 요청이 실행될 때 호출 |
| close() | Channel이 닫힐 때 호출 |
'Spring > Webflux' 카테고리의 다른 글
| Reactor operators (0) | 2025.12.08 |
|---|---|
| Netty - ByteBuf (2) | 2025.12.03 |
| Netty Server (0) | 2025.12.03 |
| Spring portfolio (0) | 2025.11.27 |
| Reactive Programing (0) | 2025.11.25 |