지난 시간에는 Spring MVC, Spring Web Flux를 알아보면서
Netty의 차이점이 뭔지 알아보았으며,
가장 쉬운 예시 코드를 통해서 학습을 했다. (지난 내용 여기 클릭!)
이번에는 4탄에서 논의하느라 놓친 Netty 의 Architecture에 대해 알아보고자 한다.
지난 예시에서 봤던 NioEventLoopGroup, ServerBootStrap, ChanelInitializer 들이나 예시들은... 6탄에서부터 확인해면 될 듯 하다 ㅠㅠ..
해당 글은 Javadoc 에서 소개하는 글을 io.netty.buffer 패키지를 중심으로 읽어보고, 핵심 클래스 구조들에 대해서 좀 더 알아보려고 한다.
참고로 3.x 버전까지는 뭔가 별도로 웹에서 볼 수 있게 정리를 해줬는데, (사이트 주소)
4.x버전 이상부터는 직접 javadoc을 봐야하는거 같다.
서로 비교를 하면서 보니 큰 맥락은 같으나 클래스명칭이 많이 바뀐걸로 보이기에,
필자는 4.x버전 기준으로 읽어보도록 하겟다.
그럼 다시 한 번 가... 보자고...! 할 수 있다!

0. 구조 리마인드
해당 글을 읽기 전에... 공식문서에서 다음의 구조를 기억하라고 한다.

크게 보면 Core, Procotol Support, Transport Services로 나뉜다.
그리고 또 javadoc에 자세한 설명이 많으니 적극적으로 보길 권장하고 있다.
1. io.netty.buffer 요약 상세(summary description)
Netty의 javadoc이 정말 정성스럽게 작성되었다는 점을 느끼는데, package summary 가 너무 자세하다
이전 3.x버전에서는 ChannelBuffer라는 클래스를 NIO의 ByteBuffer대신에 사용했는데
4.x버전에서는 ByteBuf를 이용한다고 한다.
이전 버전과 동일하게 위의 장점과 동일하다.
희안하게 Netty는 NIO의 ByteBuffer를 사용하는게 아니라 Netty에서 제공하는 특별한 Buffer를 사용한다.
그런데 ByteBuffer를 사용하는거보다 ByteBuf를 사용함으로 많은 장점이 있다.
- 필요하면 자신만의 buffer type을 정의할 수 있다.
- 투명한 zero copy 가 built-in composite butter type에 의해 제공된다.
- 동적 버퍼 타입이 별도의 설치 없이 제공된다. StringBuffer처럼 필요하면 기능이 추가된다.
- flip() 메소드를 더이상 호출하지 않아도 된다.
- ByteBuffer보다 자주 더 빠르다.
이게 도대체 무슨 말인가?
이는 io.netty.buffer에 대한 package summary를 계속 읽어보면, 이해가 될 듯 하다.
2. 확장(Extensibility)
ByteBuf는 빠른 프로토콜 구현을 위해 최적화된 다양한 연산들을 제공한다.
예를 들어, ByteBuf는 unsigned 값, 문자열 접근, 그리고 버퍼 내에서 특정 바이트 시퀀스를 검색하는 등의 다양한 연산을 제공한다.
또한 기존 버퍼 타입을 래핑(wrapping)하거나 확장(extending)하여 편리한 접근자(accessor)를 추가할 수도 있다.
이때 커스텀 버퍼 타입은 호환되지 않는 새로운 타입을 도입하는 대신, 여전히 ByteBuf 인터페이스를 구현한다.
여기서 다양한 연산을 제공한다는게, 영어는 표현으로 rich하다~ 고 표현한다. (신기방기)
이에 대한 코드를 클로드에게 예시로 만들어달라고 부탁했다.
쭈욱 읽어보면 실제로 어떻게 쓰이는지 확인할 수 있다.
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.util.CharsetUtil;
import io.netty.util.ByteProcessor;
import java.nio.charset.StandardCharsets;
public class ByteBufOperationsExample {
public static void main(String[] args) {
System.out.println("=== ByteBuf 다양한 연산 예시 ===\n");
// ============================================
// 1. Unsigned Value (부호 없는 값) 처리
// ============================================
System.out.println("1. Unsigned Value 처리");
ByteBuf unsignedBuffer = Unpooled.buffer();
// 255를 쓰기 (내부적으로는 -1로 저장됨)
unsignedBuffer.writeByte(255);
// Unsigned로 읽기 - 255로 읽힘
int unsignedValue = unsignedBuffer.getUnsignedByte(0);
System.out.println("Unsigned로 읽기: " + unsignedValue); // 255
// Signed로 읽기 - -1로 읽힘
byte signedValue = unsignedBuffer.getByte(0);
System.out.println("Signed로 읽기: " + signedValue); // -1
unsignedBuffer.release();
System.out.println();
// ============================================
// 2. String (문자열) 처리
// ============================================
System.out.println("2. String 처리");
ByteBuf stringBuffer = Unpooled.buffer();
// 문자열 쓰기
stringBuffer.writeCharSequence("Hello Netty!", CharsetUtil.UTF_8);
// 문자열 읽기 방법 1: readCharSequence 사용
String text1 = stringBuffer.readCharSequence(5, CharsetUtil.UTF_8).toString();
System.out.println("읽은 문자열 (방법1): " + text1); // "Hello"
// 문자열 읽기 방법 2: 바이트 배열로 읽기
byte[] bytes = new byte[stringBuffer.readableBytes()];
stringBuffer.readBytes(bytes);
String text2 = new String(bytes, StandardCharsets.UTF_8);
System.out.println("읽은 문자열 (방법2): " + text2); // " Netty!"
stringBuffer.release();
System.out.println();
// ============================================
// 3. 특정 바이트 시퀀스 검색
// ============================================
System.out.println("3. 특정 바이트 시퀀스 검색");
ByteBuf searchBuffer = Unpooled.copiedBuffer("Hello World\nNetty Framework", CharsetUtil.UTF_8);
// 특정 바이트 찾기 - 'W' 문자의 위치 찾기
int indexW = searchBuffer.indexOf(0, searchBuffer.capacity(), (byte) 'W');
System.out.println("'W'의 위치: " + indexW); // 6
// 줄바꿈 문자 찾기
int newlineIndex = searchBuffer.indexOf(0, searchBuffer.capacity(), (byte) '\n');
System.out.println("줄바꿈(\\n)의 위치: " + newlineIndex); // 11
// ByteProcessor를 사용한 고급 검색 - 공백 문자 찾기
int spaceIndex = searchBuffer.forEachByte(ByteProcessor.FIND_ASCII_SPACE);
System.out.println("첫 번째 공백의 위치: " + spaceIndex); // 5
// HTTP 헤더 끝 패턴 찾기 예시 (\r\n\r\n)
ByteBuf httpBuffer = Unpooled.copiedBuffer(
"GET / HTTP/1.1\r\nHost: localhost\r\n\r\nBody",
CharsetUtil.UTF_8
);
int headerEndIndex = findHeaderEnd(httpBuffer);
System.out.println("HTTP 헤더 끝 위치: " + headerEndIndex); // 34
searchBuffer.release();
httpBuffer.release();
System.out.println();
// ============================================
// 4. Wrapping (래핑) - 기존 데이터를 감싸기
// ============================================
System.out.println("4. Wrapping (래핑)");
// 기존 바이트 배열을 ByteBuf로 감싸기 (복사 없이, Zero-Copy)
byte[] array = new byte[]{1, 2, 3, 4, 5};
ByteBuf wrappedBuffer = Unpooled.wrappedBuffer(array);
System.out.println("Wrapped 버퍼 읽기: " + wrappedBuffer.readInt()); // 16909060 (0x01020304)
// 여러 버퍼를 하나로 감싸기
ByteBuf buffer1 = Unpooled.copiedBuffer("Hello ", CharsetUtil.UTF_8);
ByteBuf buffer2 = Unpooled.copiedBuffer("World!", CharsetUtil.UTF_8);
ByteBuf compositeBuffer = Unpooled.wrappedBuffer(buffer1, buffer2);
String combined = compositeBuffer.toString(CharsetUtil.UTF_8);
System.out.println("합쳐진 버퍼: " + combined); // "Hello World!"
wrappedBuffer.release();
compositeBuffer.release();
System.out.println();
// ============================================
// 5. 종합 예시: 실제 프로토콜 파싱 시뮬레이션
// ============================================
System.out.println("5. 실제 프로토콜 파싱 예시");
// 가상의 프로토콜: [길이(1byte)][타입(1byte)][데이터]
ByteBuf protocolBuffer = Unpooled.buffer();
String message = "Test";
protocolBuffer.writeByte(message.length()); // 길이
protocolBuffer.writeByte(1); // 타입 (1 = TEXT)
protocolBuffer.writeCharSequence(message, CharsetUtil.UTF_8); // 데이터
// 파싱하기
int length = protocolBuffer.readUnsignedByte();
int type = protocolBuffer.readUnsignedByte();
String data = protocolBuffer.readCharSequence(length, CharsetUtil.UTF_8).toString();
System.out.println("파싱 결과 - 길이: " + length + ", 타입: " + type + ", 데이터: " + data);
protocolBuffer.release();
System.out.println();
// ============================================
// 6. Reader/Writer Index 예시
// ============================================
System.out.println("6. Reader/Writer Index 동작");
ByteBuf indexBuffer = Unpooled.buffer();
indexBuffer.writeInt(100);
indexBuffer.writeInt(200);
indexBuffer.writeInt(300);
System.out.println("Write 후 - writerIndex: " + indexBuffer.writerIndex()); // 12
System.out.println("Write 후 - readerIndex: " + indexBuffer.readerIndex()); // 0
int value1 = indexBuffer.readInt();
System.out.println("첫 번째 값 읽기: " + value1 + ", readerIndex: " + indexBuffer.readerIndex()); // 100, 4
int value2 = indexBuffer.readInt();
System.out.println("두 번째 값 읽기: " + value2 + ", readerIndex: " + indexBuffer.readerIndex()); // 200, 8
// get 메서드는 index를 이동시키지 않음
int peekValue = indexBuffer.getInt(8);
System.out.println("세 번째 값 Peek: " + peekValue + ", readerIndex: " + indexBuffer.readerIndex()); // 300, 8 (변화 없음)
indexBuffer.release();
}
/**
* HTTP 헤더 끝 패턴(\r\n\r\n)을 찾는 유틸리티 메서드
* 실제 HTTP 파싱에서 자주 사용되는 패턴
*/
private static int findHeaderEnd(ByteBuf buffer) {
for (int i = 0; i < buffer.readableBytes() - 3; i++) {
if (buffer.getByte(i) == '\r' &&
buffer.getByte(i + 1) == '\n' &&
buffer.getByte(i + 2) == '\r' &&
buffer.getByte(i + 3) == '\n') {
return i;
}
}
return -1;
}
}
```
## 실행 결과 예상:
```
=== ByteBuf 다양한 연산 예시 ===
1. Unsigned Value 처리
Unsigned로 읽기: 255
Signed로 읽기: -1
2. String 처리
읽은 문자열 (방법1): Hello
읽은 문자열 (방법2): Netty!
3. 특정 바이트 시퀀스 검색
'W'의 위치: 6
줄바꿈(\n)의 위치: 11
첫 번째 공백의 위치: 5
HTTP 헤더 끝 위치: 34
4. Wrapping (래핑)
Wrapped 버퍼 읽기: 16909060
합쳐진 버퍼: Hello World!
5. 실제 프로토콜 파싱 예시
파싱 결과 - 길이: 4, 타입: 1, 데이터: Test
6. Reader/Writer Index 동작
Write 후 - writerIndex: 12
Write 후 - readerIndex: 0
첫 번째 값 읽기: 100, readerIndex: 4
두 번째 값 읽기: 200, readerIndex: 8
세 번째 값 Peek: 300, readerIndex: 8
2. Transparent Zero Copy
네트워크 어플리케이션의 성능을 극도로 끌어올리기 위해 메모리 카피 작업의 수를 줄일 필요가 있다. (?)
일련의 버퍼들을 슬라이싱하고 결합하여 전체 메시지를 만들 수 있다.
Netty는 composite buffer를 제공하며, 이는 메모리 복사 없이 임의의 개수만큼 존재하는 버퍼들을 새로운 버퍼로 생성할 수 있게 해준다.
이게 도대체 무슨 말이냐...
왜 메모리 카피 작업 수가 네트워크 어플리케이션 성능에 영향을 주는지부터 이해가 안된다.
3. 하드웨어 구조 + 네트워크 요청 흐름
필자는 메모리 카피 작업 수가 네트워크 어플리케이션 성능에 영향을 주는지부터 이해하기 위해 그려보았다.
메모리 카피가 도대체 어디서 일어나는지 필자는 다시 구조를 뒤져보고 이를 정리하려고 한다.
왜냐하면 기억이 나지 않기 때문이다! (당당)

현재 구조는 폰 노이만 구조와 현대 CPU 구조를 살짝 짬뽕한 구조이다.(양해 부탁...)
대부분 각자 구조만 공부하지, 크게 그리신 분이 없기도했고, 내가 불편해서 그려보았다.
물론 System Bus구조는 개념적인 구조일 뿐이고 실제 구조와는 동떨어진 것이라지만...
하여간 해당 구조에서 보이는 것들에 대해 요약을 하겠다.
하드웨어 구조 요약
- ALU: 정수·논리 연산을 실제로 계산하는 코어의 ‘계산기’.
- CU: 명령어를 해석해 실행 순서와 제어 신호를 내리는 ‘감독관’.
- L1I: 코어 바로 옆의 명령어 전용, 가장 작고 가장 빠른 캐시.
- L1D: 코어 바로 옆의 데이터 전용, 가장 작고 가장 빠른 캐시.
- L2: L1보다 크고 약간 느린 중간 캐시(대개 코어당).
- IMC: CPU 내부의 메모리 컨트롤러로 DRAM과 직접 통신·대역폭/지연을 관리.
- 시스템 버스: CPU·메모리·장치를 잇는 통로(현대는 PCIe·DMI 같은 포인트-투-포인트 링크 중심).
- 보조기억장치: 전원 꺼져도 남는 대용량 저장장치(SSD/HDD/NVMe)로 속도는 메모리보다 느림.
- 입출력장치: 키보드·마우스·네트워크·USB 등 외부와 데이터를 주고받는 장치.
- 메모리(DRAM): 실행 중인 프로그램과 데이터를 담는 휘발성 주기억장치.
레지스터 요약 설명
- PC (Program Counter) - 다음 실행할 명령어 주소 저장 (예: 0x1000번지 명령어 실행 중)
- AC (Accumulator) - 연산 결과 저장 (예: 10 + 20 = 30을 여기에 저장)
- IR (Instruction Register) - 현재 실행 중인 명령어 임시 저장 (예: "더하기" 명령어를 여기 저장)
- MAR (Memory Address Register) - 읽거나 쓸 메모리 주소 저장 (예: "0x1000번지 읽어줘" 요청)
- MBR (Memory Buffer Register) - 메모리에서 읽은/쓸 데이터 임시 저장 (예: 0x1000번지에서 읽은 값 42를 임시 보관)
- IOAR (I/O Address Register) - 통신할 I/O 장치 주소 저장 (예: 키보드는 0x60번, 네트워크는 0x300번)
- IOBR (I/O Buffer Register) - I/O 장치와 주고받을 데이터 임시 저장 (예: 키보드에서 받은 'A' 키를 임시 보관)
- FR (Flag Register) - 연산 결과 상태 저장 (예: 결과가 0인지, 음수인지, 오버플로우 났는지 비트로 표시)
CPU는 코어(ALU+레지스터+파이프라인), 캐시(L1→L2→L3), 메모리컨트롤러(IMC), PCIe루트가 하나의 칩 안에서 인터커넥트(링/메시)로 연결된 구조이다.
여태 다른것을 공부했지만... 사실 NIC는 PCIe 슬롯이 메인보드에 따로 있으며 우리 컴퓨터로는 NIC를 통해서 요청이 들어온다.
NIC는 또 온보드 NIC, 독립형 NIC로 또 나뉘며 Data Link Layer 에서 작동하는 네트워크 구성 요소이다.
즉, 물리적 장치와 네트워크 간의 다리 역할을 한다.
NIC 칩 내부 구조는 PHY, MAC, DMA 엔진 3개가 있다.
- PHY 칩: 전기 신호를 디지털 변환 (OSI 7 Layer 중 물리계층 역할)
- MAC 칩 : 프레임을 파싱, 주소를 필터링하며 CRC검증 진행 (OSI 7 Layer 중 데이터 링크 계층 역할)
- DMA 엔진 : CPU 개입없이 메모리에 직접 접근.
여기서 DMA 는 Direct Memory Access의 약자로, 말 그대로 CPU 개입없이 메모리에 직 접! 접근해서 붙여진 이름이다.
만약 대량의 데이터를 메모리에 전송한다면 CPU도 느려지기에 DMA를 통해 직접 메모리를 처리하면 여러번의 메모리 복사가 필요 없어져서 빨라진다.
DMA도입 이전에는 메모리 복사가 여러번 일어난다.
여기서 MSS의 명확한 byte크기라던지 그러한 것은 귀찮으니 그냥 임의로 하겠다.
1번의 복사
어플리케이션(User Mode)에서 우선 얼마를 보낼지 정한다.
즉, 코드상에서 byte를 얼마나 보낼지 코딩하는 것이다.
우리는 특정 사용자에게 1MB의 파일을 보내는데 코드상에서 8KB를 보낸다고 가정하자.
2번의 복사
kernel mode에서 커널 소켓 버퍼(kernel socket buffer)에 이 정보를 다시 담는다.
시스템 콜에 진입으로 해서 유저 영역의 메모리를 커널 영역의 메모리로 복사한다.
User Mode에서 받은 메모리는 8KB 이니 커널 소켓 버퍼도 8KB를 받는다고 하자.
3번의 복사
kernel mode의 TCP/IP 스택 위해 TCP 세그먼트를 생성한다.
TCP/IP 스택이란 커널에 이미 구현되어 있는 네트워크 프로토콜 처리 코드를 말한다. 여기서 데이터를 MSS 크기의 세그먼트로 분할한다.
MSS크기는 1KB로 커널 소켓 버퍼 크기보다 작다.
현재 커널 소켓 버퍼틑 크기가 8KB이니 총 8개의 패킷을 생성한다.
이 패킷들을 다시 Frame에 만들어서 RAM에 저장한다.
** MSS란? **
Maximum Segment Size로 TCP가 한 번에 전송할 수 있는 순수 데이터(payload)의 최대 크기이다.
MSS = MTU - IP 헤더(20바이트) - TCP 헤더(20바이트)
** MTU란? **
네트워크 인터페이스가 한 번에 전송할 수 있는 최대 바이트 수
그 후
kernel mode에서 NIC드라이버를 호출해서 네트워크로 전송한다.
즉, RAM에 있는 Frame정보를 읽고, 물리계층에서 디지털 신호를 전기신호로 변환해서 랜선으로 전송하는 것이다.
TCP/IP 스택 = Layer 3 (Network) + Layer 4 (Transport)으로 엄밀히 말하면 TCP/IP스택은 아니다
하지만 네트워크 스택에서 TCP/IP 스택이라고 하면 1~4를 다 아우른다.
여기서 헷갈리면 안되는게
Segment = TCP 데이터 단위(L4), Packet = IP 데이터 단위 (L3), Frame = Ethernet 데이터 단위 (L2)
으로 잘 기억하자.
데이터를 수신하면?
1. NIC RX FIFO → 커널 수신 버퍼(RAM)
- CPU PIO(Programmed I/O) 복사: 드라이버가 장치 레지스터/FIFO를 CPU로 읽어 RAM에 씀.
- (RX 디스크립터 링은 어느 RAM 버퍼를 쓸지를 관리하는 메타데이터 큐)
2. 프로토콜 처리 & 버퍼 조립
- 커널이 L3/L4 헤더를 해석하고 소켓에 전달할 sk_buff 를 구성.
- 필요 시 정렬/소형 패킷 최적화 등으로 추가 memcpy가 있을 수 있음(항상은 아님).
3. 커널 → 유저 앱이 recv() 하면 커널→유저 1회 복사가 이루어짐(보안 경계).
DMA를 도입하면?
- PIO: CPU가 장치 레지스터/FIFO를 직접 읽고/쓰며 데이터를 옮김
- DMA: 장치의 DMA 엔진이 호스트 RAM을 직접 읽고/쓰기
- 디스크립터 링: “어느 RAM 주소를 쓰고/읽을지”를 담은 메타데이터 큐(데이터 저장소 아님)
NIC 안의 DMA 엔진(하드웨어) 이, 드라이버가 미리 올려둔 RX 버퍼(호스트 RAM 페이지)의 물리 주소를 보고 PCIe로 직접 RAM에 써 넣는다.
이전(PI0)
1) NIC→커널: CPU가 PIO로 NIC RX FIFO에서 바이트를 읽어 RAM에 복사
2) 프로토콜 처리/정렬 과정에서 추가 복사 가능
3) 커널→유저: 복사 1회(보안 경계 - 직접 공유 금지이며 주소 공간이 달라서)
이후(DMA)
1) NIC→커널: 드라이버가 미리 올린 RX 버퍼 주소를 보고 NIC DMA가 RAM에 직접 write
2) 커널은 RAM 페이지를 참조로 붙여(sk_buff) 상위로 올림 → 대부분 무복사, 소형·정렬 때만 소량 복사
3) 커널→유저: 복사 1회(기본 경로에 남음)
효과(핵심)
1) CPU 바이트 읽기 루프 제거 → 인터럽트/컨텍스트 스위치 부담↓
2) 내부 메모리 트래픽↓: 참조 기반 조립로 대량 memcpy 회피
3) 처리량↑/지연↓: 높은 PPS에도 견고
DMA가 메모리에 적재를 하는 일을 하드웨어가 하기에, CPU 는 다른 일을 하다가 DMA를 통해 데이터가 다 적재되면 그때 일을 하면 된다. 그렇기에 빨라지는 것이다.
기존에 직접 관리를 하지 않아서 그렇다!
그리고 복사도 줄게 되는데 장치↔RAM 구간의 대용량 바이트 이동이 CPU memcpy에서 NIC의 DMA로 바뀌고,
커널 내부도 패킷 바이트를 다시 복사하지 않고 RAM 페이지에 대한 포인터만 소켓 버퍼에 붙여 올리므로
실제로 남는 큰 복사는 커널→유저 단계의 1회뿐이며(기본 소켓 경로),
필요하면 특수 경로로 이마저도 없앨 수 있어 처리량은 늘고 지연·CPU 사용률은 낮아진다.
4. 그래서, Netty랑 뭔상관?
이렇게 우리는 메모리복사의 주체를 DMA를 통해 CPU가 아니라 하드웨어가 하기에 성능이 좋아졌다.
그러면 Netty는 메모리 복사 어디를 줄인다는 건데?
근데 이제 쭈욱 글을 봐보니 이거... 내가 생각하던 그 하드웨어 부분의 메모리 복사가 아니었다. (오해 금물! ㅎㅎ)
Netty는 User Mode에서 생기는 불필요한 메모리 복사와 시스템콜을 줄여 네트워크 처리량과 지연을 더 좋게 만든다는 것이다.
이를 위한 핵심은 위에서 언급한 ByteBuf를 사용한 Transparent zero-copy 연산인 것이다.
위의 글을 다시 가져오겠다.
네트워크 어플리케이션의 성능을 극도로 끌어올리기 위해 메모리 카피 작업의 수를 줄일 필요가 있다. (?)
일련의 버퍼들을 슬라이싱하고 결합하여 전체 메시지를 만들 수 있다.
Netty는 composite buffer를 제공하며, 이는 메모리 복사 없이 임의의 개수만큼 존재하는 버퍼들을 새로운 버퍼로 생성할 수 있게 해준다.
예를 들어, 메시지 한 개는 두 부분으로 구성된다. (header 와 body)
모듈화된 어플리케이션에서, 두 부분은 다른 모듈들에 의해 생성될 수 있고 메시지가 보내질 떄 나중에 조립될 수 있다.
ByteBuffer를 사용하면, 새로운 큰 buffer를 생성하고 새로운 버퍼로 두 부분을 복사해야만 한다.
대안적으로, NIO에서 모음 쓰기 작업을 수행할 수 있지만, 하나의 버퍼보다는 오히려 ByteBuffer의 배열로 buffer들의 합치는것을 제한한다. (추상화를 깨고 복잡한 상태 관리를 하게 되는)
더욱이, NIO채널을 통해 읽거나 쓰지 않을것이라면 이건 쓸모가 없다.
// 합성 타입은 component type과 호환되지 않는다.
ByteBuffer[] message = new ByteBuffer[] { header, body };
대조적으로, ByteBuf는 그러한 경고를 가지고 있지 않는데 완전히 확장 가능하고 built-in 합성 버퍼 타입을 가지고 있기 때문이다.
아니 이게 도대체 무슨 말이냐...
다음의 예시 코드를 통해서 Java NIO의 문제점과, 이를 어떻게 해결했는지 직접 보도록 하겠다.
예시1 - ByteBuffer 와 ByteBuf 비교
ByteBuffer header = ByteBuffer.wrap(new byte[]{1, 2, 3});
ByteBuffer body = ByteBuffer.wrap(new byte[]{4, 5, 6, 7, 8});
// 문제 1: 합치려면 새 버퍼를 만들고 복사해야 함
ByteBuffer message = ByteBuffer.allocate(header.remaining() + body.remaining());
message.put(header);
message.put(body); // 메모리 복사 발생
// 문제 2: 또는 배열로 관리해야 함 (단일 버퍼처럼 다룰 수 없음)
ByteBuffer[] messageArray = new ByteBuffer[] { header, body };
ByteBuf header = Unpooled.buffer().writeBytes(new byte[]{1, 2, 3});
ByteBuf body = Unpooled.buffer().writeBytes(new byte[]{4, 5, 6, 7, 8});
// Zero Copy: 실제 데이터 복사 없이 합침
ByteBuf message = Unpooled.wrappedBuffer(header, body);
// 단일 버퍼처럼 접근 가능
byte firstByte = message.getByte(0); // 1
byte fifthByte = message.getByte(4); // 5 (body의 첫 바이트)
// 심지어 composite에 또 다른 버퍼 추가 가능
ByteBuf footer = Unpooled.buffer().writeBytes(new byte[]{9, 10});
ByteBuf messageWithFooter = Unpooled.wrappedBuffer(message, footer);
// 여전히 단일 버퍼처럼 동작
int readableBytes = messageWithFooter.readableBytes(); // 10
근데 필자는 기본적으로 ByteBuffer내부에서 정확히 어느 곳에서 복사가 일어나는지를 모르겠어서 찾아보고 의사 코드로
작성해 보았다. (클로드 고마워... ㅠㅠ)
예시 2 - 의사 코드 작성
// 1단계
// 의사코드: ByteBuffer.put()의 검증 로직
메소드 put(소스버퍼) {
// 1. 자기 자신을 복사하려는지 확인
if (소스버퍼 == 나자신) {
예외발생("자기 자신은 복사할 수 없음");
}
// 2. 읽기 전용 버퍼인지 확인
if (나는_읽기전용?) {
예외발생("읽기 전용 버퍼에는 쓸 수 없음");
}
// 3. 복사할 바이트 수 계산
int 복사할_바이트수 = 소스버퍼.limit - 소스버퍼.position;
// 4. 내 버퍼에 남은 공간 계산
int 남은_공간 = 나의limit - 나의position;
// 5. 공간이 부족한지 확인
if (복사할_바이트수 > 남은_공간) {
예외발생("버퍼 공간 부족");
}
// 6. 실제 복사 수행 (여기가 핵심!)
실제_메모리_복사_수행();
// 7. position 업데이트
나의position += 복사할_바이트수;
소스버퍼.position += 복사할_바이트수;
return 나자신;
}
// 2단계: 실제 메모리 복사 (putBuffer)
// 의사코드: putBuffer()의 핵심 로직
메소드 putBuffer(목적지위치, 소스버퍼, 소스위치, 복사바이트수) {
// 1. 소스 버퍼의 기반 정보 가져오기
Object 소스_기반_배열 = 소스버퍼.내부배열_또는_null();
// HeapByteBuffer: byte[] 배열 반환
// DirectByteBuffer: null 반환 (네이티브 메모리 사용)
// 2. 안전성 검증
assert (소스_기반_배열 != null) OR (소스버퍼는_다이렉트버퍼);
// 3. 목적지 버퍼의 기반 정보 가져오기
Object 목적지_기반_배열 = 나의_내부배열_또는_null();
assert (목적지_기반_배열 != null) OR (나는_다이렉트버퍼);
// 4. 메모리 주소 계산
long 소스_시작주소 = 소스버퍼.메모리주소 + 소스위치;
long 목적지_시작주소 = 나의_메모리주소 + 목적지위치;
long 복사_길이 = 복사바이트수;
// 5. 실제 메모리 복사 수행
try {
네이티브_메모리_복사(
소스_기반_배열, 소스_시작주소,
목적지_기반_배열, 목적지_시작주소,
복사_길이
);
} finally {
// 6. GC로부터 버퍼 보호
// 복사가 완료될 때까지 버퍼가 GC되지 않도록 보장
버퍼들을_GC로부터_보호(소스버퍼, 나자신);
}
}
6. 실제 복사 수행 메소드를 보면 메모리 주소를 계산해서 실제 메모리로 복사를 수행한다.
ScopedMemoryAccess.java 파일의 copyMemory 메소드를 타고 들어가면 native 코드로 작성된 copyMemory0 메소드가 있다.
이 native 코드는 JVM의 unsafe.cpp 파일에 구현되어 있으며, CPU에 최적화된 어셈블리 루틴 또는 C++ 버전(Copy::conjoint_memory_atomic)을 사용하여 바이트 단위로 메모리 복사를 수행한다.
이의 문제점은 앞서 언급했듯이, header와 body를 따로 받아서 조합해야 하는 경우 각 버퍼의 데이터를 새로운 버퍼로 복사해야 한다는 것이다.
즉, header와 body를 합치기 위해...
1. 새로운 큰 ByteBuffer를 생성하고
2. header의 데이터를 새 버퍼로 복사 (메모리 복사 1회)
3. body의 데이터를 새 버퍼로 복사 (메모리 복사 1회)
결과적으로 원본 데이터는 그대로 유지되고, 복사본이 새 버퍼에 생성되어 메모리 사용량이 2배가 되며 복사 작업으로 인한 CPU 시간도 낭비된다.
처음에 header, body를 받는데 메모리를 사용하고, 이걸 합쳐서 보내는데 메모리를 선언하기 때문이다.
하지만 Netty의 ByteBuf를 사용하면 이 비효율적인 문제가 해결된다.
예시 3 - ByteBuf 예시
ByteBuf header = Unpooled.wrappedBuffer(new byte[]{1, 2, 3}); // 3 bytes
ByteBuf body = Unpooled.wrappedBuffer(new byte[]{4, 5, 6, 7, 8}); // 5 bytes
// 합치기 - 복사 없이 참조만!
ByteBuf response = Unpooled.wrappedBuffer(header, body);
// 메모리 사용량:
// header 원본: 3 bytes
// body 원본: 5 bytes
// response: 참조만 저장 (거의 0 bytes)
// 합계: 8 bytes (복사본 없음!)
이것을 Transparent zero-copy라고 한다.
참조만 하지 실제 복사를 하지 않고(zero-copy) 내부를 신경 쓸 필요가 없으니(transparent)
합치면 transparent zero-copy 라고 한다.
5. Automatic Capacity Extention
해석을 해보면 자동 용량 확장인데...
뭔가 크기가 ArrayList혹은 StringBuilder마냥 add시에 capacity를 체크해서 array 길이를 늘려주는건가 싶기도 하다.
글을 읽어보니 이와 비슷하다.
많은 프로토콜들이 다양한 길이의 메시지들을 정의하는데, 메시지를 만들 떄 까지 메시지의 길이를 결정할 방법이 없고 또는 길이를 정확히 계산하기 어렵기 때문이다.
String을 사용할 때와 같은데, 종종 string 을 결정하는 길이를 측정하고 StringBuffer가 스스로 요청에 의해 확장하도록 한다.
뭐... 예상한 바가 맞다. 초기 capacity를 초과한 값이 들어온다면, 더 큰 capacity로 ByteBuf의 capacity를 재할당한다.
6. Better Performance
가장 자주 사용되는 ByteBuf 구현은 바이트 배열(즉, byte[])의 매우 얇은 래퍼다.
ByteBuffer와 달리, 복잡한 경계 검사와 인덱스 보정이 없기 때문에 JVM이 버퍼 접근을 최적화하기가 더 쉽다.
더 복잡한 버퍼 구현은 슬라이스되거나 합성된(composite) 버퍼에만 사용되며, 이것도 ByteBuffer만큼 잘 작동한다.
무슨말인지 필자는 하나도 이해가 안됏다.
ByteBuf가 byte[]의 매우 얇은 래퍼라는 말은 byte[] 를 단순하게 사용한다는 말이다.
그리고 경계검사는 배열 범위를 벗어나는지 확인하는 작업이고 인덱스 보정 작업이란 사용자가 요청한 인덱스를 실제 배열 인덱스로 변환하는 과정이다.
필요없는 과정들을 다 치우니 빨라질 수밖에 없다.
JVM 은 우리가 프로그램을 실행하면 최적화를 진행하는데, 코드가 복잡하면 최적화를 잘 해내지 못할 수 있기에
성능이 좋아진다는 것이다.
중간에 다른 길로(?) 세버려서 글이 엄청 길어졌다.
이번 시간에는 Rich Buffer Datastructure인 ByteBuf에 대해 알아보았다.
이 내용과는 사실 관련이 없어 보이긴 하지만, 네트워크에서 하드웨어까지 전반적인 흐름을 알아야
이 버퍼라는 것에 대해 좀 더 이해할 수 있을 것 같아서 그려보았다.
다음 시간에는 다른 예시를 좀 더 찾아보도록 하겠다.
출처
https://netty.io/4.1/api/io/netty/buffer/package-summary.html
'Spring > Spring Framework' 카테고리의 다른 글
| [Netty] Netty가 뭐에요? - 4탄 : Spring MVC 와 Spring Web Flux 그리고 Netty 예시 코드 탐방 (0) | 2025.10.07 |
|---|---|
| [Netty] Netty가 뭐에요? - 3탄 : Netty의 특징과 구조 및 NIO 테스트 (2) | 2025.09.28 |
| [Netty] Netty가 뭐에요? - 2탄 : Netty가 필요하기 까지 (2) | 2025.09.24 |
| [Netty] Netty가 뭐에요? - 1탄 : Netty 란? - 용어 정리 (3) | 2025.09.18 |