ByteBuf 기초 — Netty의 버퍼
ByteBuffer로 데이터를 읽고 나서 다시 쓰려면 왜flip()을 호출해야 할까? 그리고 버퍼 크기가 모자라면 왜 새로 만들어야 할까?
java.nio의 ByteBuffer는 논블로킹 I/O의 핵심 도구이지만, 직접 써 보면 꽤 불편한 부분들이 있습니다. 네티는 이 불편함을 ByteBuf라는 자체 버퍼로 깔끔하게 해결했는데, 이번 글에서는 ByteBuffer의 한계부터 시작해서 ByteBuf의 구조와 사용 패턴까지 정리해 보겠습니다.
왜 ByteBuffer 대신 ByteBuf인가
java.nio의 ByteBuffer는 채널 기반 I/O에서 데이터를 담는 핵심 컨테이너이지만, 실제로 사용하다 보면 세 가지 근본적인 불편함에 부딪힙니다.
1. flip() 필수 — 단일 포인터의 한계
ByteBuffer는 position이라는 하나의 포인터 로 읽기와 쓰기를 모두 처리합니다. 데이터를 쓰고 나면 position이 마지막으로 쓴 위치를 가리키고 있기 때문에, 그 상태에서 바로 읽으면 아무 데이터도 나오지 않습니다. 그래서 쓰기 → 읽기 전환 시 반드시 flip()을 호출 해서 position을 0으로 되돌리고, limit을 현재 position으로 옮겨야 합니다.
ByteBuffer buffer = ByteBuffer.allocate(256);
// 쓰기
buffer.put("hello".getBytes());
// 이 시점에서 position=5, limit=256
buffer.flip(); // 읽기 모드로 전환: position=0, limit=5
// 읽기
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
flip()을 빠뜨리면 데이터를 읽지 못하거나 엉뚱한 위치를 읽게 되는데, 이 실수가 생각보다 자주 발생합니다.
2. 크기 고정 — 한 번 정하면 끝
ByteBuffer는 생성 시 할당한 크기를 변경할 수 없습니다. 처음에 1024바이트로 만들었는데 그보다 큰 데이터가 들어오면 BufferOverflowException이 발생합니다. 결국 더 큰 버퍼를 새로 할당하고, 기존 데이터를 복사하는 코드를 직접 작성해야 합니다.
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put(new byte[11]); // BufferOverflowException 발생!
3. 모드 전환의 복잡함
flip(), clear(), compact(), rewind() 등 상태 전환 메서드가 여러 개 있고, 어떤 상황에서 어떤 메서드를 써야 하는지 헷갈리기 쉽습니다. 특히 ** 부분적으로 읽은 후 다시 쓰기를 이어가려면 compact()를 써야 하는데 **, clear()와 혼동하면 아직 읽지 않은 데이터를 덮어쓰게 됩니다.
ByteBuffer는 "저수준 제어가 가능하다"는 장점이 있지만, 실무에서 반복적으로 사용하기엔 실수 가능성이 너무 높은 API 설계입니다. 네티의ByteBuf는 이 문제들을 구조적으로 해결합니다.
ByteBuf 구조 — readerIndex / writerIndex / capacity
ByteBuf가 ByteBuffer와 가장 크게 다른 점은 ** 읽기 포인터(readerIndex)와 쓰기 포인터(writerIndex)를 분리 **한 것입니다. 덕분에 flip() 없이도 쓰기와 읽기를 자유롭게 오갈 수 있습니다.
ByteBuf의 세 영역
+-------------------+------------------+------------------+
| 이미 읽은 영역 | 읽기 가능 영역 | 쓰기 가능 영역 |
| (discardable) | (readable) | (writable) |
+-------------------+------------------+------------------+
0 readerIndex writerIndex capacity
- ** 이미 읽은 영역** (0 ~ readerIndex):
readByte()등으로 이미 소비한 바이트들.discardReadBytes()로 정리 가능 - ** 읽기 가능 영역** (readerIndex ~ writerIndex): 아직 읽지 않은 데이터.
readableBytes()로 크기 확인 - ** 쓰기 가능 영역** (writerIndex ~ capacity): 새 데이터를 쓸 수 있는 공간.
writableBytes()로 크기 확인
flip()이 필요 없는 이유
ByteBuffer는 position 하나로 읽기와 쓰기를 전환하기 때문에 flip()이 필수였지만, ByteBuf는 ** 두 개의 독립된 인덱스 **를 사용하기 때문에 쓰기 후 바로 읽기가 가능합니다.
ByteBuf buf = Unpooled.buffer(256);
// 쓰기 — writerIndex만 전진
buf.writeBytes("hello".getBytes());
// readerIndex=0, writerIndex=5
// 바로 읽기 — readerIndex만 전진 (flip() 불필요)
byte b = buf.readByte();
// readerIndex=1, writerIndex=5
Heap ByteBuf vs Direct ByteBuf
ByteBuf는 메모리를 어디에 할당하느냐에 따라 두 가지로 나뉩니다. 각각 장단점이 뚜렷해서, 상황에 맞게 선택하는 것이 중요합니다.
Heap ByteBuf (힙 버퍼)
JVM 힙 메모리에 할당 되는 버퍼입니다. 내부적으로 byte[] 배열을 감싸고 있어서 hasArray()가 true를 반환합니다.
ByteBuf heapBuf = Unpooled.buffer(256); // 힙 버퍼 생성
if (heapBuf.hasArray()) {
byte[] array = heapBuf.array(); // 내부 배열 직접 접근 가능
int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
int length = heapBuf.readableBytes();
// array, offset, length로 데이터 처리
}
- **장점 **: GC가 메모리를 관리하므로 해제 걱정이 적고, 할당/해제 속도가 빠름
- ** 단점 **: 소켓 I/O 시 JVM 힙 → 커널 버퍼로 복사가 한 번 더 필요 (내부적으로 임시 Direct 버퍼를 거침)
Direct ByteBuf (다이렉트 버퍼)
OS의 네이티브 메모리에 할당 되는 버퍼입니다. JVM 힙 바깥에 존재하므로 hasArray()가 false를 반환합니다.
ByteBuf directBuf = Unpooled.directBuffer(256); // 다이렉트 버퍼 생성
if (!directBuf.hasArray()) {
int length = directBuf.readableBytes();
byte[] array = new byte[length];
directBuf.getBytes(directBuf.readerIndex(), array); // 복사해서 사용
// array로 데이터 처리
}
- **장점 : 소켓 I/O 시 커널 버퍼로 ** 직접 전달 가능 (제로 카피에 가까움), 네트워크 전송에 유리
- ** 단점 **: 할당/해제 비용이 힙보다 높고, GC 대상이 아니라서 명시적 해제가 필요
언제 뭘 쓰는지
| 상황 | 권장 버퍼 | 이유 |
|---|---|---|
| 소켓에서 읽은 데이터를 바로 소켓으로 전송 | Direct | 힙 복사 없이 커널로 직접 전달 |
| 데이터를 애플리케이션 로직에서 가공·변환 | Heap | 배열 접근이 빠르고 GC 관리 편리 |
| 네티 내부 I/O 처리 | Direct (기본값) | ctx.alloc().buffer()는 기본적으로 Direct 할당 |
실무에서는 네티의
ChannelHandlerContext.alloc()이 상황에 맞게 풀링된 버퍼를 반환하므로, 대부분의 경우 직접 Heap/Direct를 고르기보다ctx.alloc().buffer()를 사용하는 것이 권장됩니다.
ByteBuf 사용 패턴
ByteBuf의 주요 읽기/쓰기 메서드와 파생 버퍼 패턴을 정리해 보겠습니다.
읽기/쓰기 기본 메서드
ByteBuf buf = Unpooled.buffer(256);
// 쓰기 — writerIndex 전진
buf.writeByte(65); // 1바이트: 'A'
buf.writeInt(42); // 4바이트: 정수 42
buf.writeBytes("netty".getBytes()); // 가변 길이
// 읽기 — readerIndex 전진
byte b = buf.readByte(); // 1바이트 읽기
int i = buf.readInt(); // 4바이트 읽기
// 읽을 수 있는 바이트 수 확인
int readable = buf.readableBytes();
get/set vs read/write
ByteBuf에는 두 종류의 접근 메서드가 있습니다.
- **
read/write계열 **: 인덱스를 ** 자동으로 전진 **시킴 - **
get/set계열 : 인덱스를 ** 변경하지 않고 특정 위치의 값을 읽거나 씀
ByteBuf buf = Unpooled.buffer(256);
buf.writeInt(100);
buf.writeInt(200);
// get — readerIndex 변경 없음
int val = buf.getInt(0); // index 0에서 4바이트 읽기, readerIndex 그대로
// set — writerIndex 변경 없음
buf.setInt(0, 999); // index 0에 값 덮어쓰기, writerIndex 그대로
파생 버퍼 — slice(), duplicate(), copy()
하나의 ByteBuf에서 여러 개의 뷰나 복사본을 만들 수 있습니다.
ByteBuf original = Unpooled.copiedBuffer("Hello, Netty!", StandardCharsets.UTF_8);
// slice() — 원본 메모리를 공유하는 부분 뷰
ByteBuf sliced = original.slice(0, 5); // "Hello"
// sliced를 수정하면 original에도 반영됨
// duplicate() — 원본 메모리를 공유하는 전체 뷰
ByteBuf duplicated = original.duplicate();
// 인덱스는 독립적이지만, 데이터는 공유
// copy() — 완전히 독립된 복사본
ByteBuf copied = original.copy();
// copied를 수정해도 original에 영향 없음
| 메서드 | 메모리 공유 | 인덱스 독립 | 용도 |
|---|---|---|---|
slice() | O | O | 원본의 일부 영역만 참조할 때 |
duplicate() | O | O | 원본 전체를 다른 인덱스로 탐색할 때 |
copy() | X | O | 원본과 완전히 분리된 사본이 필요할 때 |
slice()와duplicate()는 메모리를 공유하므로 메모리 효율이 좋지만, 원본이 해제되면 파생 버퍼도 사용할 수 없다는 점을 주의해야 합니다.
자동 확장 — ByteBuffer와의 결정적 차이
ByteBuffer는 생성 시 크기가 고정되어 초과하면 예외가 발생하지만, ByteBuf는 maxCapacity까지 자동으로 용량을 늘립니다.
// ByteBuffer — 고정 크기
ByteBuffer nioBuffer = ByteBuffer.allocate(10);
nioBuffer.put(new byte[11]); // BufferOverflowException!
// ByteBuf — 자동 확장
ByteBuf buf = Unpooled.buffer(10); // 초기 capacity=10
buf.writeBytes(new byte[11]); // capacity가 자동으로 확장됨
System.out.println(buf.capacity()); // 10보다 큰 값 (64 등)
확장 동작 방식
ByteBuf는 내부적으로 capacity와 maxCapacity 두 가지 개념을 가집니다.
- capacity: 현재 할당된 버퍼 크기. 필요에 따라 늘어남
- maxCapacity: 버퍼가 늘어날 수 있는 최대 한도. 기본값은
Integer.MAX_VALUE
ByteBuf buf = Unpooled.buffer(10, 100); // 초기 capacity=10, maxCapacity=100
buf.writeBytes(new byte[50]); // capacity가 자동 확장
buf.writeBytes(new byte[60]); // maxCapacity(100) 초과 → 예외 발생
쓰기 연산 시 남은 공간(writableBytes())이 부족하면, 네티가 내부적으로 더 큰 버퍼를 할당하고 기존 데이터를 복사합니다. 이 과정이 자동으로 이루어지기 때문에 개발자가 크기 관리를 신경 쓸 필요가 줄어듭니다.
ByteBuf 생성 방법
ByteBuf를 생성하는 방법은 크게 두 가지로 나뉩니다.
1. Unpooled 유틸리티 — 비풀링 버퍼
Unpooled 클래스는 풀링 없이 매번 새로운 버퍼를 할당합니다. 테스트나 간단한 유틸리티 코드에서 주로 사용합니다.
// 힙 버퍼
ByteBuf heapBuf = Unpooled.buffer(256);
// 다이렉트 버퍼
ByteBuf directBuf = Unpooled.directBuffer(256);
// 기존 바이트 배열을 감싸는 버퍼 (복사 없음)
ByteBuf wrappedBuf = Unpooled.wrappedBuffer(new byte[]{1, 2, 3});
// 문자열로부터 생성
ByteBuf copiedBuf = Unpooled.copiedBuffer("hello", StandardCharsets.UTF_8);
2. ByteBufAllocator — 풀링 버퍼 (권장)
네티의 채널 핸들러 안에서는 ChannelHandlerContext나 Channel에서 ByteBufAllocator를 가져와 버퍼를 할당하는 것이 권장됩니다. 기본 allocator는 ** 풀링(PooledByteBufAllocator)** 방식이라 버퍼를 재사용해서 GC 부담과 할당 비용을 줄여 줍니다.
// ChannelHandler 안에서
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 풀링된 버퍼 할당 (기본적으로 Direct)
ByteBuf buf = ctx.alloc().buffer(256);
// 풀링된 힙 버퍼
ByteBuf heapBuf = ctx.alloc().heapBuffer(256);
// 풀링된 다이렉트 버퍼
ByteBuf directBuf = ctx.alloc().directBuffer(256);
}
| 방식 | 풀링 여부 | 사용 상황 |
|---|---|---|
Unpooled.buffer() | X | 테스트, 유틸리티, 핸들러 밖 |
Unpooled.directBuffer() | X | 테스트, 유틸리티, 핸들러 밖 |
ctx.alloc().buffer() | O | 핸들러 내부 (권장) |
ctx.alloc().heapBuffer() | O | 핸들러 내부에서 힙 버퍼가 필요할 때 |
핸들러 안에서
Unpooled로 생성하면 풀링의 이점을 못 누리고, 매번 새로 할당/해제하게 됩니다. 가능하면ctx.alloc()을 사용하는 습관을 들이는 게 좋습니다.
ByteBuffer vs ByteBuf 비교 표
마지막으로, 두 버퍼의 차이를 한눈에 비교해 보겠습니다.
| 항목 | java.nio.ByteBuffer | io.netty.buffer.ByteBuf |
|---|---|---|
| 읽기/쓰기 포인터 | position 하나 (단일 포인터) | readerIndex / writerIndex (이중 포인터) |
| 모드 전환 | flip() 필수 | 불필요 |
| 크기 변경 | 고정 (초과 시 예외) | maxCapacity까지 자동 확장 |
| 메모리 종류 | Heap / Direct | Heap / Direct (동일) |
| 풀링 지원 | 없음 | PooledByteBufAllocator 기본 제공 |
| 레퍼런스 카운팅 | 없음 | 있음 (retain() / release()) |
| 파생 버퍼 | duplicate(), slice() | duplicate(), slice(), copy() + 인덱스 독립 |
| 체이닝 | 미지원 | 쓰기 메서드 체이닝 가능 |
| 바이트 순서 | order()로 전환 | order()로 전환 (기본 Big Endian) |
이번 글에서는 java.nio.ByteBuffer의 한계에서 출발해서 네티의 ByteBuf가 어떤 구조로 이 문제들을 해결하는지 살펴보았습니다. 핵심은 ** 이중 포인터 구조 **, ** 자동 확장 , ** 풀링 지원 이 세 가지입니다. 다음 글에서는 ByteBuf의 레퍼런스 카운팅과 메모리 누수 방지 패턴을 다루겠습니다.