ByteBuf 심화 — CompositeByteBuf & Slice
헤더와 바디를 합쳐서 하나의 패킷으로 보내야 하는데, 매번 새 버퍼에 복사해서 합치는 수밖에 없는 걸까?
이전 글에서 ByteBuf의 기본 구조와 Heap/Direct 차이를 다뤘는데, 이번에는 한 단계 더 들어가서 여러 ByteBuf를 복사 없이 합성 하는 CompositeByteBuf, 원본 데이터를 공유하는 slice()/duplicate(), 그리고 이것들을 조합한 Zero-copy 패턴을 정리해 보겠습니다.
CompositeByteBuf란
CompositeByteBuf는 여러 개의 ByteBuf를 하나의 ByteBuf처럼 보이게 합성 하는 가상 버퍼입니다. 핵심은 실제 바이트 데이터를 복사하지 않는다는 점입니다.
일반적으로 두 개의 ByteBuf를 합치려면 새 버퍼를 할당하고 데이터를 복사해야 합니다.
// 전통적인 방식 — 메모리 복사 발생
ByteBuf header = ...;
ByteBuf body = ...;
ByteBuf merged = Unpooled.buffer(header.readableBytes() + body.readableBytes());
merged.writeBytes(header); // 복사
merged.writeBytes(body); // 복사
CompositeByteBuf를 쓰면 이 복사를 완전히 생략할 수 있습니다.
// CompositeByteBuf — 복사 없이 합성
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);
// header와 body의 데이터를 복사하지 않고 참조만 보관
// 외부에서는 하나의 연속된 ByteBuf처럼 읽기 가능
내부 구조
CompositeByteBuf는 내부에 컴포넌트 리스트 를 유지합니다. 각 컴포넌트는 원본 ByteBuf에 대한 참조와 전체 버퍼 내에서의 오프셋 정보를 들고 있습니다.
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를 자동으로 업데이트 하라는 의미입니다.
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. 헤더 + 바디 합치기
프로토콜 구현에서 가장 흔한 패턴입니다. 헤더와 바디를 별도로 생성한 뒤, 전송 직전에 합칩니다.
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로 만들어서 조합할 때도 유용합니다.
// [길이(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 를 반환합니다. 자체 readerIndex와 writerIndex를 갖지만, 실제 데이터 메모리는 원본과 동일합니다.
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에 접근 가능하다는 점입니다.
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로 만든 뷰도 사용할 수 없게 됩니다.
ByteBuf original = ctx.alloc().buffer(256);
original.writeBytes("data".getBytes());
ByteBuf sliced = original.slice();
original.release(); // 원본 해제!
// 이후 sliced에 접근하면 → IllegalReferenceCountException 발생 가능
sliced.readByte(); // 위험!
안전하게 사용하려면 두 가지 방법이 있습니다.
// 방법 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), 데이터 크기만큼) |
| 참조 카운트 | 원본에 의존 | 독립 (별도 카운트) |
언제 뭘 쓰는지
// 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[] 감싸기
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를 생성합니다.
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도 감쌀 수 있습니다.
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 감싸기 | 원본 배열 직접 사용, 복사 없음 |
조합 예시 — 프로토콜 패킷 조립
실무에서 이 세 도구를 조합하는 전형적인 패턴을 보겠습니다.
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 데이터의 복사가 한 번도 발생하지 않음
}
조합 예시 — 수신 패킷 파싱
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도 죽습니다.
// 잘못된 패턴
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 입니다.
// 기본 — 최대 16개 컴포넌트
CompositeByteBuf composite = Unpooled.compositeBuffer();
// 명시적으로 설정
CompositeByteBuf composite = Unpooled.compositeBuffer(32);
컴포넌트가 maxNumComponents를 초과하면, 네티는 기존 컴포넌트들을 하나의 버퍼로 병합(consolidate) 합니다. 이때 메모리 복사가 발생하므로 Zero-copy의 이점이 사라집니다.
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() 호출
CompositeByteBuf를 java.nio.ByteBuffer로 변환해야 할 때 주의가 필요합니다. 여러 컴포넌트의 데이터를 하나의 연속된 ByteBuffer로 만들어야 하므로, 이 시점에서 메모리 복사가 발생합니다.
CompositeByteBuf composite = ...;
// nioBuffer() 호출 시 내부 데이터를 하나의 ByteBuffer로 복사
ByteBuffer nioBuffer = composite.nioBuffer();
// nioBufferCount()로 사전 확인 가능
if (composite.nioBufferCount() > 1) {
// 여러 개의 ByteBuffer로 분리해서 처리할 수도 있음
ByteBuffer[] buffers = composite.nioBuffers();
}
4. slice된 버퍼의 쓰기 제한
slice()로 만든 ByteBuf는 원본의 일부 영역만 참조하기 때문에, capacity를 초과하는 쓰기가 불가능 합니다. 자동 확장도 지원하지 않습니다.
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() — 를 살펴보았습니다. 공통된 원칙은 "복사하지 않고 참조로 해결한다" 는 것이고, 그 대가로 참조 카운팅과 생명주기 관리에 더 신경 써야 한다 는 점을 기억해 두면 좋겠습니다.