헤더와 바디를 합쳐서 하나의 패킷으로 보내야 하는데, 매번 새 버퍼에 복사해서 합치는 수밖에 없는 걸까?

이전 글에서 ByteBuf의 기본 구조와 Heap/Direct 차이를 다뤘는데, 이번에는 한 단계 더 들어가서 여러 ByteBuf를 복사 없이 합성 하는 CompositeByteBuf, 원본 데이터를 공유하는 slice()/duplicate(), 그리고 이것들을 조합한 Zero-copy 패턴을 정리해 보겠습니다.


CompositeByteBuf란

CompositeByteBuf여러 개의 ByteBuf를 하나의 ByteBuf처럼 보이게 합성 하는 가상 버퍼입니다. 핵심은 실제 바이트 데이터를 복사하지 않는다는 점입니다.

일반적으로 두 개의 ByteBuf를 합치려면 새 버퍼를 할당하고 데이터를 복사해야 합니다.

JAVA
// 전통적인 방식 — 메모리 복사 발생
ByteBuf header = ...;
ByteBuf body = ...;

ByteBuf merged = Unpooled.buffer(header.readableBytes() + body.readableBytes());
merged.writeBytes(header); // 복사
merged.writeBytes(body);   // 복사

CompositeByteBuf를 쓰면 이 복사를 완전히 생략할 수 있습니다.

JAVA
// CompositeByteBuf — 복사 없이 합성
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);
// header와 body의 데이터를 복사하지 않고 참조만 보관
// 외부에서는 하나의 연속된 ByteBuf처럼 읽기 가능

내부 구조

CompositeByteBuf는 내부에 컴포넌트 리스트 를 유지합니다. 각 컴포넌트는 원본 ByteBuf에 대한 참조와 전체 버퍼 내에서의 오프셋 정보를 들고 있습니다.

PLAINTEXT
CompositeByteBuf
├── Component 0: header (offset=0, length=12)
├── Component 1: body   (offset=12, length=256)
└── 총 readableBytes = 268

읽기 요청이 오면 → 인덱스로 어느 컴포넌트에 해당하는지 계산 → 해당 컴포넌트에서 읽기

readByte(15)처럼 인덱스로 접근하면, 내부적으로 오프셋을 계산해서 Component 1(body)의 3번째 바이트를 반환합니다. 사용하는 쪽에서는 이게 합성된 버퍼인지 아닌지 전혀 신경 쓸 필요가 없습니다.

addComponents의 true 파라미터

addComponents()를 호출할 때 첫 번째 인자로 true를 전달하는 것이 중요합니다. 이 플래그는 writerIndex를 자동으로 업데이트 하라는 의미입니다.

JAVA
CompositeByteBuf composite = Unpooled.compositeBuffer();

// true → writerIndex 자동 갱신 (권장)
composite.addComponents(true, header, body);
System.out.println(composite.readableBytes()); // header + body 크기

// false 또는 생략 → writerIndex 갱신 안 됨
CompositeByteBuf composite2 = Unpooled.compositeBuffer();
composite2.addComponents(header, body);
System.out.println(composite2.readableBytes()); // 0! writerIndex가 0이라 읽을 수 있는 데이터 없음

addComponents()에서 true를 빠뜨리면 readableBytes()가 0을 반환해서 데이터가 없는 것처럼 보이는 함정에 빠질 수 있습니다. 공부하다 보니 이 부분에서 많이 헷갈렸습니다.


언제 CompositeByteBuf를 쓰는가

1. 헤더 + 바디 합치기

프로토콜 구현에서 가장 흔한 패턴입니다. 헤더와 바디를 별도로 생성한 뒤, 전송 직전에 합칩니다.

JAVA
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
    MyMessage message = (MyMessage) msg;

    // 헤더 생성
    ByteBuf header = ctx.alloc().buffer(8);
    header.writeInt(message.getType());
    header.writeInt(message.getBodyLength());

    // 바디 생성 (이미 인코딩된 상태)
    ByteBuf body = message.getBody();

    // 복사 없이 합성
    CompositeByteBuf packet = ctx.alloc().compositeBuffer();
    packet.addComponents(true, header, body);

    ctx.write(packet, promise);
}

2. 프로토콜 프레이밍

길이 필드, 체크섬 등을 별도 ByteBuf로 만들어서 조합할 때도 유용합니다.

JAVA
// [길이(4바이트)] [페이로드] [체크섬(4바이트)]
ByteBuf lengthField = ctx.alloc().buffer(4);
lengthField.writeInt(payload.readableBytes());

ByteBuf checksum = ctx.alloc().buffer(4);
checksum.writeInt(calculateChecksum(payload));

CompositeByteBuf frame = ctx.alloc().compositeBuffer(3); // 최대 3개 컴포넌트
frame.addComponents(true, lengthField, payload, checksum);

3. 메모리 복사 최소화가 중요한 경우

대용량 데이터를 처리할 때, 수십 KB~수 MB 단위의 버퍼를 매번 복사하면 성능에 직접적인 영향을 줍니다. CompositeByteBuf는 이런 상황에서 복사 비용을 제거합니다.


slice()와 duplicate()

앞서 ByteBuf 기초 편에서 간단히 언급했지만, 이번에는 조금 더 깊이 들어가 보겠습니다.

slice() — 원본의 일부를 공유하는 뷰

slice()는 원본 ByteBuf의 일부 영역을 공유하는 새 ByteBuf 를 반환합니다. 자체 readerIndexwriterIndex를 갖지만, 실제 데이터 메모리는 원본과 동일합니다.

JAVA
ByteBuf original = Unpooled.copiedBuffer("Hello, Netty World!", StandardCharsets.UTF_8);

// 인자 없이 호출 — readableBytes 범위를 slice
ByteBuf sliced1 = original.slice();

// 범위 지정 — index 0부터 5바이트
ByteBuf sliced2 = original.slice(0, 5);  // "Hello"

// slice된 버퍼 수정 → 원본에도 반영
sliced2.setByte(0, 'h');
System.out.println(original.getByte(0)); // 'h' — 원본도 변경됨

duplicate() — 원본 전체를 공유하는 뷰

duplicate()는 원본 전체 영역을 공유하면서 독립된 인덱스 를 가지는 뷰입니다. slice()와의 차이는 전체 capacity에 접근 가능하다는 점입니다.

JAVA
ByteBuf original = Unpooled.copiedBuffer("Hello", StandardCharsets.UTF_8);

ByteBuf dup = original.duplicate();

// 인덱스 독립 — 원본의 readerIndex에 영향 없음
dup.readByte(); // dup의 readerIndex만 전진
System.out.println(original.readerIndex()); // 0 — 원본은 그대로

// 데이터 공유 — 수정하면 원본에도 반영
dup.setByte(0, 'h');
System.out.println((char) original.getByte(0)); // 'h'

참조 카운팅 주의

여기서 중요한 포인트가 있습니다. slice()duplicate()는 원본의 참조 카운트를 증가시키지 않습니다. 따라서 원본이 release()되면, slice나 duplicate로 만든 뷰도 사용할 수 없게 됩니다.

JAVA
ByteBuf original = ctx.alloc().buffer(256);
original.writeBytes("data".getBytes());

ByteBuf sliced = original.slice();

original.release(); // 원본 해제!

// 이후 sliced에 접근하면 → IllegalReferenceCountException 발생 가능
sliced.readByte(); // 위험!

안전하게 사용하려면 두 가지 방법이 있습니다.

JAVA
// 방법 1: retainedSlice() / retainedDuplicate() 사용
ByteBuf safeSlice = original.retainedSlice(); // 참조 카운트 +1
// 사용 후 반드시 release() 필요

// 방법 2: 수동으로 retain()
ByteBuf sliced = original.slice();
sliced.retain(); // 참조 카운트 +1
// 사용 후 반드시 release() 필요

retainedSlice()는 Netty 4.1에서 추가된 메서드입니다. slice() + retain()을 원자적으로 수행하므로, 가능하면 이쪽을 사용하는 것이 더 안전합니다.


copy() vs slice()

이 둘의 차이를 확실히 구분하는 것이 중요합니다.

구분slice()copy()
메모리 복사없음 (원본 공유)있음 (독립 복사본)
원본 수정 영향반영됨반영 안 됨
원본 해제 영향사용 불가영향 없음
성능빠름 (O(1))느림 (O(n), 데이터 크기만큼)
참조 카운트원본에 의존독립 (별도 카운트)

언제 뭘 쓰는지

JAVA
// slice() — 원본이 살아 있는 동안만 사용할 때
public void channelRead(ChannelHandlerContext ctx, ByteBuf msg) {
    ByteBuf header = msg.slice(0, 4);       // 헤더 4바이트만 뷰로 참조
    int length = header.readInt();
    ByteBuf body = msg.slice(4, length);    // 바디를 뷰로 참조
    processBody(body);                       // 이 메서드 안에서만 사용
    // msg가 해제되기 전에 모든 처리 완료
}

// copy() — 원본과 독립적으로 보관해야 할 때
public void channelRead(ChannelHandlerContext ctx, ByteBuf msg) {
    ByteBuf snapshot = msg.copy();          // 독립 복사본 생성
    pendingMessages.add(snapshot);           // 나중에 처리하기 위해 보관
    // msg가 해제되어도 snapshot은 안전
}

핸들러 안에서 잠깐 참조만 할 거라면 slice(), 다른 스레드로 넘기거나 나중에 쓸 거라면 copy()가 기본 원칙입니다.


Unpooled.wrappedBuffer()

Unpooled.wrappedBuffer()는 기존 byte[] 배열이나 ByteBuffer를 ** 복사 없이 ByteBuf로 감싸는** 메서드입니다.

byte[] 감싸기

JAVA
byte[] data = new byte[]{0x01, 0x02, 0x03, 0x04};

// wrappedBuffer — 복사 없음, data 배열을 그대로 사용
ByteBuf wrapped = Unpooled.wrappedBuffer(data);

// 원본 배열 수정 → ByteBuf에도 반영
data[0] = 0x10;
System.out.println(wrapped.getByte(0)); // 0x10

// copiedBuffer와 비교 — 복사 발생
ByteBuf copied = Unpooled.copiedBuffer(data);
data[0] = 0x20;
System.out.println(copied.getByte(0)); // 0x10 — 복사 시점의 값 유지

여러 byte[]를 한번에 합성

wrappedBuffer()에 여러 배열을 전달하면, 내부적으로 CompositeByteBuf를 생성합니다.

JAVA
byte[] part1 = "Hello, ".getBytes();
byte[] part2 = "Netty!".getBytes();

// 내부적으로 CompositeByteBuf 생성 — 복사 없음
ByteBuf combined = Unpooled.wrappedBuffer(part1, part2);
// combined.readableBytes() == part1.length + part2.length

ByteBuffer 감싸기

java.nio.ByteBuffer도 감쌀 수 있습니다.

JAVA
ByteBuffer nioBuffer = ByteBuffer.allocateDirect(256);
nioBuffer.put("data".getBytes());
nioBuffer.flip();

// NIO ByteBuffer를 Netty ByteBuf로 변환 — 복사 없음
ByteBuf wrapped = Unpooled.wrappedBuffer(nioBuffer);

wrappedBuffer()는 외부에서 받은 데이터를 네티 파이프라인에 넣을 때 불필요한 복사를 제거하는 핵심 도구입니다. 다만 원본 배열이 외부에서 수정될 수 있다는 점은 항상 염두에 두어야 합니다.


Zero-copy 패턴 총정리

네티에서 말하는 "Zero-copy"는 OS 레벨의 sendfile() 같은 것과는 의미가 다릅니다. 네티의 Zero-copy는 JVM 내에서 바이트 데이터의 불필요한 메모리 복사를 피하는 패턴 을 뜻합니다.

세 가지 핵심 도구

도구역할Zero-copy 방식
CompositeByteBuf여러 버퍼를 합성참조만 보관, 데이터 복사 없음
slice() / duplicate()원본의 부분/전체 뷰메모리 공유, 새 할당 없음
Unpooled.wrappedBuffer()byte[]/ByteBuffer 감싸기원본 배열 직접 사용, 복사 없음

조합 예시 — 프로토콜 패킷 조립

실무에서 이 세 도구를 조합하는 전형적인 패턴을 보겠습니다.

JAVA
public ByteBuf assemblePacket(ChannelHandlerContext ctx,
                               int messageType,
                               byte[] payload) {

    // 1. wrappedBuffer — byte[] 복사 없이 ByteBuf 생성
    ByteBuf body = Unpooled.wrappedBuffer(payload);

    // 2. 헤더 생성
    ByteBuf header = ctx.alloc().buffer(8);
    header.writeInt(messageType);
    header.writeInt(payload.length);

    // 3. CompositeByteBuf — 헤더 + 바디를 복사 없이 합성
    CompositeByteBuf packet = ctx.alloc().compositeBuffer(2);
    packet.addComponents(true, header, body);

    return packet;
    // 전체 과정에서 payload 데이터의 복사가 한 번도 발생하지 않음
}

조합 예시 — 수신 패킷 파싱

JAVA
public void channelRead(ChannelHandlerContext ctx, ByteBuf msg) {
    // slice — 헤더와 바디를 복사 없이 분리
    ByteBuf header = msg.slice(0, 8);
    int type = header.readInt();
    int length = header.readInt();

    ByteBuf body = msg.slice(8, length);

    // body를 다음 핸들러로 전달
    // 원본 msg가 이 핸들러에서 해제되지 않으므로 안전
    ctx.fireChannelRead(body.retain()); // retain()으로 참조 카운트 보호
}

주의사항

Zero-copy 패턴은 성능에 좋지만, 주의할 점도 있습니다.

1. slice한 ByteBuf의 생명주기

앞서 말했듯이 slice()는 원본의 참조 카운트를 늘리지 않습니다. 원본이 먼저 해제되면 slice도 죽습니다.

JAVA
// 잘못된 패턴
ByteBuf sliced;
{
    ByteBuf temp = ctx.alloc().buffer();
    temp.writeBytes(data);
    sliced = temp.slice();
    temp.release(); // 원본 해제!
}
sliced.readByte(); // IllegalReferenceCountException!

// 올바른 패턴
ByteBuf sliced;
{
    ByteBuf temp = ctx.alloc().buffer();
    temp.writeBytes(data);
    sliced = temp.retainedSlice(); // 참조 카운트 +1
    temp.release(); // 원본 해제해도 sliced는 안전
}
sliced.readByte(); // 정상 동작
sliced.release();  // 사용 후 반드시 해제

2. CompositeByteBuf의 maxNumComponents

CompositeByteBuf는 컴포넌트 수에 상한이 있습니다. 기본값은 16 입니다.

JAVA
// 기본 — 최대 16개 컴포넌트
CompositeByteBuf composite = Unpooled.compositeBuffer();

// 명시적으로 설정
CompositeByteBuf composite = Unpooled.compositeBuffer(32);

컴포넌트가 maxNumComponents를 초과하면, 네티는 기존 컴포넌트들을 하나의 버퍼로 병합(consolidate) 합니다. 이때 메모리 복사가 발생하므로 Zero-copy의 이점이 사라집니다.

JAVA
CompositeByteBuf composite = Unpooled.compositeBuffer(4); // 최대 4개

// 5번째 추가 시 → 기존 컴포넌트들을 하나로 병합 (복사 발생)
for (int i = 0; i < 5; i++) {
    composite.addComponent(true, Unpooled.wrappedBuffer(new byte[]{(byte) i}));
}
// 내부적으로 consolidation이 발생해서 컴포넌트 수가 줄어듦

루프에서 반복적으로 addComponent()를 호출하는 패턴이라면, maxNumComponents를 넉넉하게 설정하거나 다른 접근 방식을 고려하는 것이 좋습니다.

3. CompositeByteBuf에서 nioBuffer() 호출

CompositeByteBufjava.nio.ByteBuffer로 변환해야 할 때 주의가 필요합니다. 여러 컴포넌트의 데이터를 하나의 연속된 ByteBuffer로 만들어야 하므로, 이 시점에서 메모리 복사가 발생합니다.

JAVA
CompositeByteBuf composite = ...;

// nioBuffer() 호출 시 내부 데이터를 하나의 ByteBuffer로 복사
ByteBuffer nioBuffer = composite.nioBuffer();

// nioBufferCount()로 사전 확인 가능
if (composite.nioBufferCount() > 1) {
    // 여러 개의 ByteBuffer로 분리해서 처리할 수도 있음
    ByteBuffer[] buffers = composite.nioBuffers();
}

4. slice된 버퍼의 쓰기 제한

slice()로 만든 ByteBuf는 원본의 일부 영역만 참조하기 때문에, capacity를 초과하는 쓰기가 불가능 합니다. 자동 확장도 지원하지 않습니다.

JAVA
ByteBuf original = Unpooled.buffer(100);
original.writeBytes(new byte[50]);

ByteBuf sliced = original.slice(0, 10); // 10바이트 영역만 참조
sliced.writerIndex(10);
sliced.writeByte(0x01); // IndexOutOfBoundsException — capacity(10) 초과

이번 글에서는 네티의 Zero-copy를 구성하는 세 가지 핵심 도구 — CompositeByteBuf, slice()/duplicate(), Unpooled.wrappedBuffer() — 를 살펴보았습니다. 공통된 원칙은 "복사하지 않고 참조로 해결한다" 는 것이고, 그 대가로 참조 카운팅과 생명주기 관리에 더 신경 써야 한다 는 점을 기억해 두면 좋겠습니다.

댓글 로딩 중...