ByteBuffer로 데이터를 읽고 나서 다시 쓰려면 왜 flip()을 호출해야 할까? 그리고 버퍼 크기가 모자라면 왜 새로 만들어야 할까?

java.nioByteBuffer는 논블로킹 I/O의 핵심 도구이지만, 직접 써 보면 꽤 불편한 부분들이 있습니다. 네티는 이 불편함을 ByteBuf라는 자체 버퍼로 깔끔하게 해결했는데, 이번 글에서는 ByteBuffer의 한계부터 시작해서 ByteBuf의 구조와 사용 패턴까지 정리해 보겠습니다.


왜 ByteBuffer 대신 ByteBuf인가

java.nioByteBuffer는 채널 기반 I/O에서 데이터를 담는 핵심 컨테이너이지만, 실제로 사용하다 보면 세 가지 근본적인 불편함에 부딪힙니다.

1. flip() 필수 — 단일 포인터의 한계

ByteBufferposition이라는 하나의 포인터 로 읽기와 쓰기를 모두 처리합니다. 데이터를 쓰고 나면 position이 마지막으로 쓴 위치를 가리키고 있기 때문에, 그 상태에서 바로 읽으면 아무 데이터도 나오지 않습니다. 그래서 쓰기 → 읽기 전환 시 반드시 flip()을 호출 해서 position을 0으로 되돌리고, limit을 현재 position으로 옮겨야 합니다.

JAVA
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이 발생합니다. 결국 더 큰 버퍼를 새로 할당하고, 기존 데이터를 복사하는 코드를 직접 작성해야 합니다.

JAVA
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

ByteBufByteBuffer와 가장 크게 다른 점은 ** 읽기 포인터(readerIndex)와 쓰기 포인터(writerIndex)를 분리 **한 것입니다. 덕분에 flip() 없이도 쓰기와 읽기를 자유롭게 오갈 수 있습니다.

ByteBuf의 세 영역

PLAINTEXT
+-------------------+------------------+------------------+
|  이미 읽은 영역    |   읽기 가능 영역   |   쓰기 가능 영역   |
|  (discardable)    |  (readable)      |  (writable)      |
+-------------------+------------------+------------------+
0            readerIndex          writerIndex          capacity
  • ** 이미 읽은 영역** (0 ~ readerIndex): readByte() 등으로 이미 소비한 바이트들. discardReadBytes()로 정리 가능
  • ** 읽기 가능 영역** (readerIndex ~ writerIndex): 아직 읽지 않은 데이터. readableBytes()로 크기 확인
  • ** 쓰기 가능 영역** (writerIndex ~ capacity): 새 데이터를 쓸 수 있는 공간. writableBytes()로 크기 확인

flip()이 필요 없는 이유

ByteBufferposition 하나로 읽기와 쓰기를 전환하기 때문에 flip()이 필수였지만, ByteBuf는 ** 두 개의 독립된 인덱스 **를 사용하기 때문에 쓰기 후 바로 읽기가 가능합니다.

JAVA
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를 반환합니다.

JAVA
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를 반환합니다.

JAVA
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의 주요 읽기/쓰기 메서드와 파생 버퍼 패턴을 정리해 보겠습니다.

읽기/쓰기 기본 메서드

JAVA
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 계열 : 인덱스를 ** 변경하지 않고 특정 위치의 값을 읽거나 씀
JAVA
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에서 여러 개의 뷰나 복사본을 만들 수 있습니다.

JAVA
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()OO원본의 일부 영역만 참조할 때
duplicate()OO원본 전체를 다른 인덱스로 탐색할 때
copy()XO원본과 완전히 분리된 사본이 필요할 때

slice()duplicate()는 메모리를 공유하므로 메모리 효율이 좋지만, 원본이 해제되면 파생 버퍼도 사용할 수 없다는 점을 주의해야 합니다.


자동 확장 — ByteBuffer와의 결정적 차이

ByteBuffer는 생성 시 크기가 고정되어 초과하면 예외가 발생하지만, ByteBufmaxCapacity까지 자동으로 용량을 늘립니다.

JAVA
// 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는 내부적으로 capacitymaxCapacity 두 가지 개념을 가집니다.

  • capacity: 현재 할당된 버퍼 크기. 필요에 따라 늘어남
  • maxCapacity: 버퍼가 늘어날 수 있는 최대 한도. 기본값은 Integer.MAX_VALUE
JAVA
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 클래스는 풀링 없이 매번 새로운 버퍼를 할당합니다. 테스트나 간단한 유틸리티 코드에서 주로 사용합니다.

JAVA
// 힙 버퍼
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 — 풀링 버퍼 (권장)

네티의 채널 핸들러 안에서는 ChannelHandlerContextChannel에서 ByteBufAllocator를 가져와 버퍼를 할당하는 것이 권장됩니다. 기본 allocator는 ** 풀링(PooledByteBufAllocator)** 방식이라 버퍼를 재사용해서 GC 부담과 할당 비용을 줄여 줍니다.

JAVA
// 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.ByteBufferio.netty.buffer.ByteBuf
읽기/쓰기 포인터position 하나 (단일 포인터)readerIndex / writerIndex (이중 포인터)
모드 전환flip() 필수불필요
크기 변경고정 (초과 시 예외)maxCapacity까지 자동 확장
메모리 종류Heap / DirectHeap / Direct (동일)
풀링 지원없음PooledByteBufAllocator 기본 제공
레퍼런스 카운팅없음있음 (retain() / release())
파생 버퍼duplicate(), slice()duplicate(), slice(), copy() + 인덱스 독립
체이닝미지원쓰기 메서드 체이닝 가능
바이트 순서order()로 전환order()로 전환 (기본 Big Endian)

이번 글에서는 java.nio.ByteBuffer의 한계에서 출발해서 네티의 ByteBuf가 어떤 구조로 이 문제들을 해결하는지 살펴보았습니다. 핵심은 ** 이중 포인터 구조 **, ** 자동 확장 , ** 풀링 지원 이 세 가지입니다. 다음 글에서는 ByteBuf의 레퍼런스 카운팅과 메모리 누수 방지 패턴을 다루겠습니다.

댓글 로딩 중...