Netty 내부 구조 — EventLoop, ChannelPipeline, ByteBuf
Spring WebFlux, gRPC, Reactor Netty, Cassandra, Elasticsearch transport. JVM 생태계에서 비동기 네트워크 라이브러리를 찾으면 거의 예외 없이 그 아래에는 Netty가 있습니다. 이 글은 Netty 4.1을 기준으로 EventLoop, ChannelPipeline, ByteBuf 세 축이 한 바이트의 소켓 데이터를 어떻게 사용자 핸들러까지 운반하는지를 살펴봅니다.
NIO만으로는 부족했던 이유
Java NIO는 Selector로 다중화된 비동기 IO를 가능하게 했지만, 라이브러리 개발자에게 그대로 노출된 API는 거칠었습니다.
Selector.select()호출과SelectionKey이벤트 디스패치를 직접 작성해야 합니다.ByteBuffer는flip(),compact(),clear()처럼 위치(position)와 한계(limit)를 사람이 신경 써야 합니다.- 한 스레드가 셀렉터를 돌리는 동안 부가 작업(타이머, 사용자 태스크)을 같이 굴리는 스케줄링은 직접 짜야 합니다.
- 잘 알려진 epoll의 100% CPU 버그(빈 셀렉터가 즉시 깨어나는 현상)는 라이브러리 차원에서 회피해야 합니다.
Netty는 이 모든 부담을 사용자로부터 거둬 갑니다. 그 추상화의 중심에 EventLoop, ChannelPipeline, ByteBuf가 있습니다.
전체 그림
flowchart LR
subgraph "EventLoopGroup (Boss)"
BL[NioEventLoop]
end
subgraph "EventLoopGroup (Worker)"
EL1[NioEventLoop 1]
EL2[NioEventLoop 2]
EL3[NioEventLoop N]
end
BL -->|accept| EL1
BL -->|accept| EL2
BL -->|accept| EL3
EL1 --- C1[Channel A]
EL1 --- C2[Channel B]
EL2 --- C3[Channel C]
C1 --> P1[ChannelPipeline]
P1 --> H1[HeadContext]
H1 --> HD1[Decoder]
HD1 --> HD2[Business Handler]
HD2 --> T1[TailContext]
P1 -.alloc.-> BUF[PooledByteBufAllocator]
다이어그램의 세 묶음이 이 글에서 다룰 부분입니다.
NioEventLoop: 한 개의 자바 스레드 위에Selector와 태스크 큐를 묶어 둔 단일 스레드 실행기ChannelPipeline: 한 Channel에 묶인 양방향 링크드 리스트로, 인바운드와 아웃바운드 이벤트를 핸들러 사이에 흘려보내는 인터셉터 체인PooledByteBufAllocator: jemalloc 변종 알고리즘으로 직접 메모리 단편화를 줄이고 할당 비용을 상수에 가깝게 만드는 버퍼 풀
EventLoop — 한 스레드, 두 가지 일
단일 스레드라는 강한 보장
NioEventLoop는 SingleThreadEventLoop의 NIO 구현체입니다. 한 EventLoop는 정확히 한 자바 스레드에 묶이고, 한 번 묶인 후에는 그 스레드를 떠나지 않습니다. 그리고 한 Channel은 등록되는 순간 정확히 하나의 EventLoop에 영구 바인딩됩니다.
이 제약은 동시성 설계를 극도로 단순하게 만듭니다.
- 같은 Channel의 핸들러는 항상 같은 스레드에서 호출됩니다. 따라서 핸들러 내부 상태에
synchronized나volatile이 필요 없습니다. - 사용자 코드는
channel.eventLoop().execute(...)로 안전하게 자신을 EventLoop 스레드에 예약할 수 있습니다. - 외부 스레드에서
channel.write(...)을 호출하면, Netty가 알아서 작업을 EventLoop 큐로 옮깁니다.
run() 루프의 구조
NioEventLoop.run()은 무한 루프입니다. 매 반복마다 두 가지 일을 한 차례씩 처리합니다.
Selector.select()로 IO 이벤트 수집- 태스크 큐에 쌓인
Runnable실행
루프의 진입부는 selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())로 시작합니다. 이 전략 호출은 세 가지 결과 중 하나를 돌려줍니다.
- 큐에 처리할 태스크가 있으면
selectNow()로 IO 준비 키를 즉시 수확하고 IO 단계로 진입합니다 (블로킹 없음). - 큐가 비어 있으면
SelectStrategy.SELECT를 돌려주고, 다음 스케줄 태스크 마감까지select(curDeadlineNanos)로 블로킹합니다. - 외부 wakeup이 들어오면 즉시 깨어납니다.
ioRatio — IO와 태스크의 시간 배분
EventLoop는 IO만 굴리지 않습니다. 사용자 핸들러가 executor.execute(...)로 던진 비-IO 작업, 스케줄러가 시간이 된 타이머 태스크, 셀렉터 키 처리가 모두 한 스레드 위에서 돌아갑니다. 이들 사이의 시간 분배를 결정하는 손잡이가 ioRatio 필드입니다.
- 기본값은
50. IO에 쓴 시간만큼 태스크 실행에도 시간을 할당합니다. 100이면 모든 IO 키를 우선 처리한 뒤 태스크 큐를 비웁니다 (시간 측정 비용을 아낍니다).- 그 외 값에서는 IO 시간
ioTime을 측정한 다음runAllTasks(ioTime * (100 - ioRatio) / ioRatio)로 태스크를 제한 시간 동안만 실행합니다.
IO가 많은 프로세스는 ioRatio를 100에 가깝게, 사용자 비즈니스 로직이 무거우면 50 또는 그 이하로 낮춥니다.
두 종류의 태스크 큐
SingleThreadEventLoop는 두 개의 태스크 큐를 들고 있습니다.
taskQueue: 사용자 코드와 IO 처리에서 흘러들어오는 일반 태스크.tailTasks: 한 번의 EventLoop 반복(IO + 일반 태스크 실행)이 끝나는 시점에 추가로 실행되는 후처리 태스크.
스케줄된 태스크(schedule(...))는 별도 우선순위 큐에서 관리되다가 마감 시각이 되면 taskQueue로 이동합니다. 이 분리 덕분에 EventLoop는 매 반복마다 "IO 처리 → 일반 태스크 → tail 태스크" 3단계를 반복하는 단순한 흐름을 유지합니다.
기본 큐 구현은 JCTools의 MpscQueue 계열입니다. 다중 외부 스레드가 한 EventLoop 스레드에 태스크를 던지는 패턴(Multi-Producer, Single-Consumer)에 최적화되어 있어 락이 필요 없습니다.
epoll 100% CPU 버그 회피
JDK의 Selector.select()가 아무 키도 준비되지 않았는데 즉시 깨어나는 현상이 알려져 있습니다. 이 상황이 반복되면 EventLoop 스레드가 100% CPU를 태우는 회전 루프에 빠집니다.
Netty는 selectCnt로 깨어난 횟수를 세고, SELECTOR_AUTO_REBUILD_THRESHOLD(기본 512)를 넘기면 새 Selector를 만들어 모든 키를 재등록합니다. 이 재구축은 사용자 코드를 거치지 않고 라이브러리 내부에서 일어납니다.
wakeup의 비용 줄이기
외부 스레드가 EventLoop에 새 태스크를 던지면, EventLoop는 그 사실을 즉시 알아야 합니다. JDK의 Selector.wakeup()은 셀렉터에 등록된 파이프 또는 eventfd에 바이트를 써서 깨우는데(JDK 17 이전 Linux는 파이프 기반 1바이트, JDK 17 이후 Linux는 eventfd 기반 8바이트), 이 자체가 시스템 콜이라 비싸지 않은 호출은 아닙니다.
Netty는 nextWakeupNanos AtomicLong으로 깨어 있는 상태를 추적합니다.
AWAKE(-1L): 이미 깨어 있음 —wakeup()호출 불필요NONE(Long.MAX_VALUE): 무기한 대기 중 — 깨워야 함- 그 외 값: 그 시각까지 대기 중 — 마감 전이라면 깨워야 함
외부 스레드는 wakeup()을 호출하기 전 이 값을 CAS로 확인해 불필요한 시스템 콜을 차단합니다.
EventLoopGroup과 Channel 할당
Boss와 Worker의 분업
서버는 보통 두 개의 EventLoopGroup을 띄웁니다.
- Boss 그룹:
ServerSocketChannel을 들고accept()만 담당. 보통 스레드 1개로 충분합니다. - Worker 그룹: 수락된 연결 각각의 IO 처리. 기본 스레드 수는
2 * 코어 수입니다.
NioEventLoopGroup은 MultithreadEventLoopGroup의 구현체로, 내부에 EventLoop 배열을 갖고 새 Channel이 등록되면 EventExecutorChooserFactory가 라운드 로빈 등으로 EventLoop를 골라 줍니다.
등록은 종신 계약
한 번 eventLoop.register(channel)이 호출되면, 그 Channel은 평생 그 EventLoop의 스레드에서만 IO를 처리받습니다. 두 가지 결과가 따라옵니다.
- 부하 분산은 새 Channel을 받는 시점에만 일어납니다. 이미 받은 Channel을 다른 EventLoop로 옮길 수 없습니다.
- 하나의 Channel이 극단적으로 바쁘면 그 EventLoop만 포화될 수 있습니다. 이런 워크로드는 Channel 단위 분할이나 비즈니스 처리를 별도
EventExecutorGroup으로 분리하는 식으로 해결합니다.
ChannelPipeline — 양방향 인터셉터 체인
두 개의 센티넬
DefaultChannelPipeline은 Channel이 생성될 때 함께 만들어집니다. 생성 시점에 두 개의 센티넬 노드가 자리잡습니다.
HeadContext: 인바운드 이벤트의 출발점이자 아웃바운드 이벤트의 종착점.Channel.Unsafe를 통해 실제 소켓 IO를 호출합니다.TailContext: 인바운드 이벤트의 종착점이자 아웃바운드 이벤트의 출발점.
초기 상태에서 head.next = tail, tail.prev = head입니다. 사용자가 추가하는 핸들러는 모두 이 두 노드 사이에 들어갑니다.
addLast의 네 줄
pipeline.addLast(handler)는 핸들러를 AbstractChannelHandlerContext로 감싼 뒤 tail 직전에 끼워 넣습니다. 핵심 동작은 다음 네 줄로 요약됩니다.
AbstractChannelHandlerContext prev = tail.prev;
newCtx.prev = prev;
newCtx.next = tail;
prev.next = newCtx;
tail.prev = newCtx;
이 갱신은 파이프라인 자체의 락을 잡고 일어나므로, 핸들러 추가는 IO가 한창 흐르는 중에도 안전합니다. checkMultiplicity가 @Sharable이 아닌 핸들러의 중복 등록을 막아 줍니다.
인바운드 vs 아웃바운드의 흐름 방향
이벤트는 종류에 따라 흐르는 방향이 다릅니다.
flowchart LR
SOCK[Socket Recv]
SOCK --> H[HeadContext]
H --> D1[Decoder]
D1 --> D2[Business Handler]
D2 --> T[TailContext]
OUT[user.write] --> T2[TailContext]
T2 --> E2[Encoder]
E2 --> H2[HeadContext]
H2 --> SOCK2[Socket Send]
- 인바운드 이벤트(
channelRead,channelActive,exceptionCaught등)는 Head에서 출발해 Tail 방향으로 흐릅니다.ctx.fireChannelRead(msg)가 다음 인바운드 핸들러로 전달하는 메서드입니다. - 아웃바운드 이벤트(
write,flush,bind,connect)는 Tail에서 출발해 Head 방향으로 흐릅니다.ctx.write(msg)가 이전 아웃바운드 핸들러로 전달합니다.
HeadContext는 인바운드와 아웃바운드 인터페이스를 모두 구현합니다. 모든 아웃바운드 이벤트의 종착점이 결국 Head이고, 거기서 Unsafe를 통해 소켓에 바이트를 씁니다.
핸들러 제거의 원자성
핸들러는 실행 중에도 제거될 수 있습니다. atomicRemoveFromHandlerList는 두 줄로 핸들러를 빼냅니다.
prev.next = next;
next.prev = prev;
다만 제거 직후 다음 이벤트가 이 핸들러를 그냥 지나치는지, 마지막으로 한 번 더 호출되는지는 핸들러 상태(ADD_COMPLETE vs REMOVE_COMPLETE)로 가드됩니다.
예외 전파 — exceptionCaught의 흐름
핸들러에서 던진 예외는 인바운드 이벤트의 한 종류로 취급되어 파이프라인을 따라 흐릅니다.
- 인바운드 핸들러가 던진 예외는
ctx.fireExceptionCaught(cause)로 다음 인바운드 핸들러에 전달됩니다. 어떤 핸들러도 잡지 않으면 결국TailContext.exceptionCaught가 경고 로그를 남기고release()로 메시지 자원을 정리합니다. - 아웃바운드 동작 중 발생한 예외는 그 동작에 결부된
ChannelPromise의setFailure(cause)로 전달됩니다. 사용자는promise.addListener(...)로 실패를 비동기 콜백으로 잡을 수 있습니다.
즉 인바운드 실패는 "흘러간다", 아웃바운드 실패는 "약속이 깨진다" 두 갈래로 분리됩니다. 이 둘을 혼동해 exceptionCaught 한 곳에서 모든 오류를 잡으려 하면 아웃바운드 실패를 놓치게 됩니다.
ChannelOutboundBuffer — write가 즉시 flush되지 않는 이유
channel.write(msg)를 호출해도 소켓에 바이트가 즉시 나가지 않습니다. 한 번 더 호출이 필요합니다.
channel.writeAndFlush(msg);
그 이유는 Channel 안쪽에 ChannelOutboundBuffer라는 단일 연결 큐가 있기 때문입니다.
addMessage, addFlush, flush
ChannelOutboundBuffer는 세 개의 포인터로 큐를 관리합니다.
unflushedEntry: write로 들어와 아직 flush 대상이 아닌 항목들의 시작flushedEntry: flush 요청은 받았지만 아직 소켓에 안 나간 항목들의 시작tailEntry: 큐의 마지막
흐름은 다음과 같습니다.
flowchart LR
W[write] -->|addMessage| U[unflushedEntry]
F[flush] -->|addFlush| FL[flushedEntry]
FL -->|EventLoop write event| FF[forceFlush]
FF -->|SocketChannel.write| KERN[Kernel Send Buffer]
write(msg)는addMessage(msg)로 항목을unflushedEntry뒤에 추가합니다.flush()는addFlush()로unflushedEntry포인터를flushedEntry쪽으로 옮깁니다. 단지 포인터 이동입니다.- 실제 소켓 write는 EventLoop가 다음 IO 단계에서
ch.unsafe().forceFlush()를 호출할 때 일어납니다.
왜 한 단계 미루는가
- 배치: 여러 번의
write()를 한 번의gathering write시스템 콜에 묶어 처리할 수 있습니다. - 백프레셔:
ChannelOutboundBuffer는 누적된 바이트 수를 추적하고,writeBufferWaterMark임계를 넘으면Channel.isWritable()을 false로 떨어뜨립니다. 사용자 핸들러는 이 값을 보고 추가 write를 늦출 수 있습니다. - EventLoop 친화성: 외부 스레드가
write()를 호출했더라도 실제 소켓 호출은 EventLoop 스레드에서 일어나므로, 다중 스레드의 소켓 동시 접근이 원천 차단됩니다.
워터마크와 백프레셔
ChannelOutboundBuffer는 큐에 쌓인 미전송 바이트 수를 항상 추적합니다. 이 수가 Channel.config().getWriteBufferHighWaterMark()를 넘으면 Netty는 channel.isWritable()을 false로 떨어뜨리고 channelWritabilityChanged 인바운드 이벤트를 발생시킵니다.
이후 누적 바이트가 getWriteBufferLowWaterMark() 아래로 내려가면 다시 true로 복귀하고 같은 이벤트가 한 번 더 발생합니다. 기본값은 high 64KB, low 32KB입니다.
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (ctx.channel().isWritable()) {
ctx.writeAndFlush(transform(msg));
} else {
// 큐가 꽉 찼다. 소스에 backpressure를 전파하거나 메시지를 버린다.
}
}
Reactor Netty는 이 신호를 Reactive Streams의 request(n) 흐름과 연결해 publisher가 보내는 속도를 자동으로 조절합니다.
ByteBuf — 자바 위에 얹은 jemalloc
NIO ByteBuffer와의 차이
Netty의 ByteBuf는 NIO ByteBuffer와 거의 같은 일을 하지만, 사용자에게 보이는 차이가 큽니다.
- 읽기 인덱스와 쓰기 인덱스를 분리:
readerIndex와writerIndex가 따로 있어flip()같은 호출이 필요 없습니다. - 자동 확장:
writableBytes가 부족하면 내부 배열이 늘어납니다. - 참조 카운팅:
retain()/release()로 명시적으로 수명 관리. - 풀링:
PooledByteBufAllocator로 같은 크기의 버퍼를 재활용해 GC 부담을 거의 0으로 만듭니다.
PooledByteBufAllocator의 구조
기본 할당기는 jemalloc 변종입니다. 메모리는 다음 계층으로 쪼개집니다.
flowchart TB
A[PooledByteBufAllocator]
A --> HA[Heap Arena]
A --> DA[Direct Arena]
HA --> CK1[PoolChunk 16MB]
HA --> CK2[PoolChunk 16MB]
CK1 --> PG1[Page 8KB]
CK1 --> PG2[Page 8KB]
PG1 --> SP1[PoolSubpage]
SP1 --> EL[Equal-size blocks]
- Arena: 락 경합을 줄이기 위한 분할. 기본 개수는 heap, direct 각각
2 * 코어 수입니다. - PoolChunk: Arena 내부의 큰 메모리 블록. Netty 4.1.x 기본 16MB이며 시스템 프로퍼티로 조정 가능 (버전에 따라 4MB 또는 16MB). 청크 안에서 페이지 단위로 buddy 알고리즘이 돕니다.
- Page: 청크를 쪼갠 단위. 기본 8KB.
- PoolSubpage: 페이지보다 작은 할당을 처리하기 위해 페이지를 같은 크기 블록으로 다시 잘라 둔 슬랩.
큰 할당은 buddy로, 작은 할당은 슬랩으로 처리하는 하이브리드입니다. jemalloc의 핵심 아이디어와 같습니다.
PoolThreadCache — Arena 경합도 줄이기
Arena를 여러 개로 쪼개도, 한 Arena 안에서는 여전히 락이 있습니다. 그래서 Netty는 스레드별로 작은 캐시(PoolThreadCache)를 둡니다.
- 스레드가 자주 할당하는 크기는 thread-local 큐에서 lock 없이 꺼냅니다.
- 캐시에 없을 때만 Arena의 free list에 들어가 락을 잡습니다.
EventLoop 스레드는 평생 같은 스레드이므로, 이 캐시의 적중률이 매우 높습니다.
참조 카운팅 — Java GC를 우회하는 대가
ByteBuf는 ReferenceCounted를 구현합니다. refCnt가 0이 되는 순간 메모리가 풀에 반환됩니다.
- 새로 할당된 버퍼는
refCnt = 1. retain()은 카운터를 1 올립니다.release()는 카운터를 1 내리고, 0이 되면 회수합니다.
규칙은 한 줄로 요약됩니다.
어떤 메서드가
ByteBuf를 받아 소비했다면, 그 메서드가release()책임을 집니다.
이 책임 전가는 ChannelPipeline에서도 동일합니다. 인바운드 핸들러가 ctx.fireChannelRead(msg)로 다음 핸들러에 넘기면 책임도 함께 넘어갑니다. 핸들러가 msg를 소비하고 더 전달하지 않으면 그 핸들러가 ReferenceCountUtil.release(msg)로 풀어 줘야 합니다.
SimpleChannelInboundHandler가 자동으로 release를 호출해 주는 이유가 이 규약입니다.
슬라이스와 듀플리케이트의 함정
slice()나 duplicate()는 새 버퍼를 만들지 않고 같은 메모리를 가리키는 뷰만 만듭니다. 그래서 둘은 부모와 카운터를 공유합니다.
slice()로 만든 뷰를retain()하면 부모의 카운터가 올라갑니다.- 뷰만
release()하고 부모를 release하지 않으면 누수가 일어납니다.
Netty가 메모리 누수를 잡기 위해 ResourceLeakDetector를 따로 가지고 있는 이유입니다. 기본 모드는 일부 할당에 대해 호출 스택을 기록하고, GC 시점에 release되지 않은 채로 회수된 버퍼를 감지합니다. 운영 환경에서는 -Dio.netty.leakDetection.level=PARANOID로 모든 버퍼를 추적하는 것도 가능하지만 비용이 큽니다.
한 바이트의 여정
지금까지 본 조각들을 합쳐, 클라이언트가 보낸 1바이트가 어떤 경로로 사용자 핸들러까지 도달하는지 정리합니다.
flowchart TB
K[Kernel TCP Recv Queue]
K -->|epoll_wait wakeup| S[NioEventLoop.select]
S -->|processSelectedKeys| R[Unsafe.read]
R -->|ByteBufAllocator| B[Pooled ByteBuf]
B -->|pipeline.fireChannelRead| H[HeadContext]
H --> D[Decoder]
D --> BH[Business Handler]
BH -->|writeAndFlush| OUT[Outbound Pipeline]
OUT --> HT[HeadContext]
HT -->|Unsafe.write| OB[ChannelOutboundBuffer]
OB -->|forceFlush| KO[Kernel TCP Send Queue]
흐름의 마디마다 Netty가 무엇을 책임지는지가 분명해집니다.
- 커널 → JVM:
Selector가 readable 이벤트를 알리면,Unsafe.read()가 풀에서ByteBuf를 꺼내 소켓을 읽어 채웁니다. - JVM 내부: 파이프라인이 인바운드 방향으로 디코더와 핸들러를 차례로 통과시킵니다. 모든 호출은 같은 EventLoop 스레드입니다.
- 응답: 핸들러가
writeAndFlush를 부르면,write는ChannelOutboundBuffer에 항목을 쌓고,flush는 포인터만 옮깁니다. - JVM → 커널: 다음 IO 단계에서 EventLoop가
forceFlush를 호출해 모은 항목을 한 번의 시스템 콜로 내려 보냅니다.
마치며
Netty는 비동기 IO 라이브러리가 풀어야 할 세 가지 문제를 각각 다른 추상화로 떼어 놓았습니다. 스레드 관리는 EventLoop가, 처리 단계의 조합은 ChannelPipeline이, 메모리는 ByteBuf 풀이 책임집니다. 셋이 모두 같은 스레드 모델 위에서 협력하기 때문에, 한 Channel 안의 코드는 동시성 부담 없이 작성할 수 있는 대신, 그 EventLoop 한 스레드를 절대 막아서는 안 된다는 규칙이 따라옵니다.
이 규칙을 어기면 발생하는 증상(전체 처리량 급감, 다른 Channel의 응답 지연)도, 이를 견디기 위한 도구(EventExecutorGroup 분리, 백프레셔 워터마크, leak detection)도 모두 위 구조의 자연스러운 귀결입니다.
참고자료
- Netty Project (GitHub)
- NioEventLoop.java (4.1 branch)
- DefaultChannelPipeline.java (4.1 branch)
- Netty 4.1 API: EventLoop
- Netty 4.1 API: EventLoopGroup
- Netty 4.1 API: NioEventLoopGroup
- Netty 4.1 API: ChannelPipeline
- Netty 4.1 API: PooledByteBufAllocator
- Netty 4.1 API: Channel.Unsafe
- Netty 4.1 API: AbstractChannel
- Netty.docs: User guide for 4.x
- Netty.docs: Reference counted objects
- Netty.docs: New and noteworthy in 4.0

