Netty - ByteBuf

ByteBuf

 

Netty로 서버나 클라이언트 코드를 작성하다 보면 거의 반드시 마주치는 객체가 있습니다. 바로 ByteBuf입니다.
ChannelHandler에서 channelRead()를 구현할 때도, Encoder, Decoder를 만들 때도, 실제로 오가는 데이터는 모두 ByteBuf 형태로 전달됩니다.

 

처음 보면 Java NIO의 ByteBuffer와 비슷해 보이지만, 막상 다뤄보면 구조도 다르고 사용 방식도 크게 다르다는 걸 느끼게 됩니다. 특히 readerIndex, writerIndex, zero-copy 지원, 메모리 풀링 같은 개념은 ByteBuffer에는 없는 것들입니다.

 

그래서 이 글에서는 Netty ByteBuf가 무엇이고, 왜 굳이 ByteBuffer 대신 이런 별도의 타입을 제공하는지, 어떻게 생성하고 관리하는지, 그리고 ByteBufAllocator는 어떤 역할을 하는지까지 차근차근 정리해보려고 합니다.

 

NIO ByteBuffer의 한계

 

Netty의 ByteBuf를 이해하려면 먼저 Java NIO에서 제공하는 java.nio.ByteBuffer가 어떻게 생겼고, 어떤 제약을 가지고 있는지부터 짚고 넘어가는 것이 좋습니다. ByteBuffer 자체가 나쁜 API는 아니지만, 고성능 네트워크 서버를 구현하기에는 여러모로 불편한 부분이 많습니다. Netty는 이 불편함을 굉장히 치밀하게 보완한 추상화를 제공하고 있고, ByteBuf의 설계 대부분이 바로 ByteBuffer의 한계를 극복하는 데서 출발합니다.

 

1. 읽기/쓰기 포인터가 하나라서 flip()과 상태 전환이 번거롭다

ByteBuffer는 읽기와 쓰기를 모두 position 하나로 처리합니다. 쓰기 후 읽기로 전환하려면 flip()을 호출해야 하고, 다시 쓰기로 돌아가려면 clear()나 compact()가 필요합니다. 이 구조는 실수하기 쉽고, 데이터 흐름을 다루는 코드가 불필요하게 복잡해집니다.

 

Netty ByteBuf가 readerIndex와 writerIndex를 분리해놓은 이유가 바로 이 지점입니다. ByteBuf에서는 flip이 필요 없습니다.

 

2. capacity가 고정되어 있어 확장이 불가능하다

ByteBuffer는 크기를 늘릴 수 없기 때문에, 더 큰 데이터가 유입되면 새 버퍼를 만들고 기존 내용을 복사해야 합니다. 네트워크 서버처럼 다양한 크기의 메시지를 처리하는 환경에서는 이 재할당 과정이 비용과 지연을 일으키는 원인이 됩니다.

 

ByteBuf는 필요 시 자동 확장을 지원합니다.

 

3. 풀링이 없어서 대량 버퍼 할당 시 GC 부담이 크다

allocate()나 allocateDirect()로 만든 ByteBuffer는 GC 관리에 의존합니다. 특히 DirectBuffer는 해제 시점이 느리고, 네이티브 메모리를 오래 잡고 있어 부담이 큽니다. 하지만 ByteBuffer는 이를 풀링하거나 재사용하기 위한 표준 메커니즘을 제공하지 않습니다.

 

Netty는 ByteBufAllocator로 풀링 전략을 제공해 이런 GC 부담을 크게 줄입니다.

 

4. zero-copy를 활용하기 어렵다

ByteBuffer에도 slice(), duplicate() 같은 기능이 있지만 사용이 제한적이고 실전 프로토콜 처리에 써먹기에는 불편합니다. 작은 단위(헤더/바디)를 자르고 붙이는 네트워크 프레임 처리는 ByteBuffer만으로는 코드가 지저분해지는 경우가 많습니다.

 

 

Netty ByteBuf

 

ByteBuffer가 가지고 있는 구조적 한계를 보완하기 위해 Netty는 완전히 새로운 버퍼 모델인 ByteBuf를 설계했습니다. ByteBuf는 단순히 더 편한 ByteBuffer가 아니라, 네트워크 서버 환경에서 자주 발생하는 요구사항을 중심으로 기능이 정리된 고성능 버퍼 추상화입니다.

 

읽기와 쓰기 흐름의 명확한 분리, 가변 크기 지원, zero-copy, 풀링 기반 메모리 관리 등 ByteBuffer로는 해결하기 어렵던 문제들을 실용적으로 해결합니다.

 

1. readerIndex / writerIndex 분리로 flip()이 필요 없는 구조

ByteBuf가 가장 먼저 눈에 띄는 특징은 읽기 포인터와 쓰기 포인터가 분리되어 있다는 점입니다. 읽을 때는 readerIndex, 쓸 때는 writerIndex를 사용하기 때문에, 모드 전환을 위한 flip이 존재하지 않습니다.

 

내부 데이터는 다음과 같은 형태로 유지됩니다.

 

이 구조 덕분에 데이터 수신 → 파싱 → 다시 기록하는 흐름이 자연스럽게 이어지고, ByteBuffer에서 흔히 발생하는 position이 꼬여서 데이터가 덮어써지는 문제가 사실상 사라집니다.

 

2. capacity 자동 확장 지원

ByteBuf는 필요하면 내부적으로 용량을 늘릴 수 있습니다. 데이터를 쓰다가 공간이 부족하면 ensureWritable() 호출을 통해 자동으로 확장되고, Netty가 적절한 크기의 새 버퍼를 생성하고 필요한 부분만 복사해줍니다.

 

이 덕분에 버퍼 크기를 얼마나 잡아야 할지 고민할 필요가 적고, 메시지 크기가 가변적인 네트워크 환경에서도 부드럽게 대응할 수 있습니다.

 

3. refCnt 기반의 명시적 메모리 관리

ByteBuf는 참조 카운트(refCnt) 기반으로 동작합니다. 더는 사용하지 않으면 release()를 호출해 메모리를 즉시 반환하거나 풀에 돌려보낼 수 있습니다. 특히 Direct 메모리처럼 GC에 의존하면 해제 시점이 예측되지 않는 영역에서는 이 명시적 메모리 관리가 큰 장점이 됩니다. 

 

ByteBuf는 Netty가 제공하는 Allocator와 함께 사용할 때 풀링 전략을 활용할 수 있습니다. 특히 PooledByteBufAllocator는 자주 사용되는 크기의 버퍼를 미리 확보해두고 필요할 때 즉시 재사용하는 방식으로 동작합니다. 

 

ByteBuf가 refCnt 기반으로 설계된 것도 바로 이 풀링 전략을 완성하기 위한 기반입니다. 사용이 끝난 버퍼는 release()를 통해 즉시 풀로 되돌아가고, 다시 필요할 때 매우 빠르게 할당되는 구조입니다.

 

4. zero-copy를 위한 다양한 뷰(view) 제공

ByteBuf는 slice(), duplicate(), retainedSlice() 같은 메서드로 내부 데이터를 복사 없이 잘라서 사용할 수 있습니다. 특히 수신한 패킷에서 헤더와 바디를 구분하거나, 여러 조각으로 나뉜 데이터를 논리적으로 하나처럼 처리할 때 유용합니다.

 

Netty는 아예 여러 ByteBuf를 하나로 묶는 CompositeByteBuf까지 제공해서 프레임 조립을 효율적으로 처리할 수 있습니다.

 

 

Heap / Direct ByteBuf

 

Netty ByteBuf는 내부 데이터가 어디에 저장되는지에 따라 몇 가지 구현체로 나뉩니다. 가장 기본이 되는 것은 JVM 힙 메모리를 사용하는 HeapByteBuf와 네이티브 메모리를 직접 사용하는 DirectByteBuf입니다. 두 종류는 구조와 메모리 접근 방식이 다르기 때문에 네트워크 서버에서 어떤 전략을 선택하느냐에 따라 성능이 달라질 수 있습니다.

 

HeapByteBuf – JVM 힙 기반의 일반적인 버퍼

HeapByteBuf는 내부적으로 단순한 byte[] 배열을 사용합니다. JVM 힙에 존재하기 때문에 생성 비용이 낮고 접근 속도도 빠르며, GC가 자동으로 해제 시점을 결정합니다.


문자열 처리나 단순한 데이터 조작처럼 CPU에서만 머무르는 작업에서는 HeapByteBuf가 효율적입니다. 다만 I/O 작업에서는 커널로 데이터를 전달하기 위해 중간 복사가 필요한 경우가 많아, 네트워크 소켓을 통한 송수신에서는 DirectByteBuf보다 비효율적일 수 있습니다.

 

DirectByteBuf – 네이티브 메모리를 사용하는 버퍼

DirectByteBuf는 자바 힙 바깥의 OS 네이티브 메모리를 사용합니다. 네트워크 I/O에서는 이 방식이 유리한데, kernel과 user space 간 메모리 복사 과정을 줄일 수 있어 송수신 비용이 낮아지기 때문입니다.

 

다만 생성 비용이 상대적으로 크고, GC는 네이티브 메모리를 즉시 회수하지 않는다는 단점이 있습니다. Netty가 refCnt 기반 메모리 관리와 풀링 전략을 도입한 이유가 바로 Direct 메모리의 이런 특성 때문입니다. Netty는 이 버퍼를 적극적으로 풀링해 고성능 네트워크 I/O에 필요한 메모리 효율을 확보합니다.

 

 

ByteBuf 생성 방법

 

ByteBuf는 직접 생성자를 호출해서 만드는 형태가 아니라, Netty가 제공하는 Allocator를 통해 생성하는 방식이 일반적입니다. 이 구조는 단순히 객체를 만드는 책임을 분리하는 것뿐 아니라, 내부적으로 풀링 전략을 적용하거나 Heap/Direct 여부를 선택하기 위함이기도 합니다.

 

1. ChannelHandlerContext 사용

ChannelHandler 내부에서 가장 일반적인 생성 방식은 ctx.alloc()을 사용하는 것입니다.
여기서 반환되는 Allocator는 Channel이 생성될 때 이미 ChannelConfig에 설정된 값이며, 별도 설정을 하지 않으면 기본적으로 PooledByteBufAllocator가 사용됩니다.

 

따라서 아래 예제와 같이 buffer()를 호출하면 풀링된 ByteBuf(환경 설정에 따라 Direct 또는 Heap)가 생성되며, release()를 호출할 때까지 refCnt 기반으로 관리됩니다.

ByteBuf buf = ctx.alloc().buffer();

 

또한 필요한 경우에는 Heap 또는 Direct 버퍼를 명확히 지정해서 생성할 수도 있습니다.

ByteBuf heap = ctx.alloc().heapBuffer();
ByteBuf direct = ctx.alloc().directBuffer();

 

이 두 방식 모두 결국 ByteBufAllocator를 통해 버퍼를 만들기 때문에, 풀링 전략, refCnt 관리, 메모리 효율 측면에서 동일한 구조 안에서 동작합니다. 프로토콜 파싱처럼 CPU 내부에서만 처리한다면 heapBuffer가 나을 수 있고, 소켓 I/O로 바로 전송되는 데이터라면 directBuffer가 더 적합하지만, 대부분의 경우에는 그냥 buffer()를 사용하는 것이 가장 자연스럽습니다.

 

2. Unpooled 사용

풀링과는 관계없이 단독으로 버퍼를 만들고 싶거나, 테스트 코드처럼 간단한 상황에서는 Unpooled를 사용할 수 있습니다.

ByteBuf buf = Unpooled.buffer(128);
ByteBuf wrapped = Unpooled.wrappedBuffer(bytes);
ByteBuf copied = Unpooled.copiedBuffer("hi", CharsetUtil.UTF_8);

 

이 방식은 Netty의 메모리 풀링을 사용하지 않기 때문에 메모리 효율에서 손해가 있지만, 간단한 유틸리티 용도나 임시 데이터 변환에는 편리합니다.

 

ByteBuf는 생성된 순간부터 참조 카운트(refCnt)를 갖습니다. 즉, 생성 방식에 상관없이 사용이 끝나면 반드시 release()를 호출해 메모리를 반환해야 합니다.

'Spring > Webflux' 카테고리의 다른 글

Spring WebFlux  (1) 2025.12.09
Reactor operators  (0) 2025.12.08
Netty Server  (0) 2025.12.03
Netty - EventLoop와 Channel  (0) 2025.12.02
Spring portfolio  (0) 2025.11.27