SPRING&JAVA
[Netty] ByteBuf와 메모리 관리: 참조 카운트, 자동 관리, channelRead vs channelRead0
ultra_dev
2024. 7. 28. 23:56
목차
- Netty의 ByteBuf란?
- 1.1 ByteBuf의 주요 특징
- ByteBuf의 메모리 관리와 참조 카운트
- 2.1 주요 메서드 (retain, release, copy, slice, duplicate)
- 2.2 메모리 관리 사례
- 핸들러에서의 참조 관리
- 3.1 channelRead와 수동 관리
- 3.2 channelRead0와 자동 관리
- 3.3 channelRead와 channelRead0의 차이점 및 사용 사례
- 디코더와 예외 처리
- 4.1 디코더의 자동 참조 관리 (ByteToMessageDecoder)
- 4.2 디코더에서의 예외 처리
- ByteBuf의 자동 관리 사례
- 5.1 write() 및 writeAndFlush()
- 5.2 디코더(ByteToMessageDecoder)
- 5.3 SimpleChannelInboundHandler
- 5.4 DefaultPromise 및 Future/Listener
- 5.5 Pipeline의 핸들러 체인
- 5.6 Aggregator (예: HttpObjectAggregator)
1. Netty의 ByteBuf란?
ByteBuf는 Netty에서 데이터를 저장하고 조작하는 데 사용 되는 고성능 데이터 버퍼. Java의 기본 ByteBuffer를 대체하며, 여러 가지 추가 기능과 최적화를 제공
1.1 ByteBuf의 주요 특징
- 리더/라이터 인덱스:
- ByteBuf는 readerIndex와 writerIndex를 독립적으로 관리
- 데이터를 읽거나 쓸 때 자바의 ByteBuffer처럼 flip()을 호출할 필요 없이, 각각의 인덱스를 사용해 편리하게 작업할 수 있음
- 메모리 풀링(Pooling):
- Netty는 메모리 풀링 기능을 지원하며, 이를 통해 메모리 할당/해제 오버 헤드를 줄이고 성능을 최적화함
- 자동 확장:
- 필요에 따라 버퍼 크기를 자동으로 확장할 수 있음
- 참조 카운팅:
- ByteBuf는 참조 카운트(Reference Counting)를 기반으로 메모리를 관리. 이는 메모리 누수를 방지하고, 성능을 최적화하는 데 중요한 역할을 함
2. 메모리 관리와 참조 카운트
Netty의 ByteBuf는 참조 카운트를 기반으로 메모리를 관리. 이는 Java의 GC를 사용하는 ByteBuffer와 가장 큰 차이점 중 하나
2.1 주요 메서드
- retain():
- 참조 카운트를 1 증가 시킴
- 주로 비동기 작업에서 사용하며, 참조 카운트를 증가시키고 작업이 완료되면 release()를 호출해야 함
- release():
- 참조 카운트를 1 감소 시킴
- 참조 카운트가 0이 되면 메모리가 해제됨
- copy():
- 데이터를 복사하여 독립적인 ByteBuf 객체를 생성. 복사된 객체는 별도의 참조 카운트를 가지며, 원본 참조 카운트에 영향을 주지 않음
- slice() 및 duplicate():
- 기존 ByteBuf의 뷰(View)를 생성 하며, 원본과 참조 카운트를 공유
- 다만 readerIndex, writerIndex는 독립적
- slice() 예시
ByteBuf original = ctx.alloc().buffer(); original.writeBytes(new byte[]{1, 2, 3, 4, 5, 6}); // ByteBuf의 특정 범위를 슬라이싱 ByteBuf slice = original.slice(2, 3); // 2번 인덱스부터 3바이트 슬라이스 System.out.println(slice.readByte()); // 출력: 3 System.out.println(slice.readByte()); // 출력: 4 // 슬라이스된 뷰에서의 변경은 원본에 영향을 줌 slice.setByte(0, 99); // 슬라이스의 0번 인덱스를 99로 변경 System.out.println(original.getByte(2)); // 출력: 99 (원본 데이터 2번 인덱스 변경됨) // 참조 해제 slice.release(); original.release();
- duplicate() 예시
ByteBuf original = ctx.alloc().buffer(); original.writeBytes(new byte[]{1, 2, 3, 4, 5}); // 원본 ByteBuf를 복제 ByteBuf duplicate = original.duplicate(); // 원본과 복제 뷰의 데이터는 공유 System.out.println(duplicate.readByte()); // 출력: 1 duplicate.setByte(0, 99); // 복제 뷰에서 0번 인덱스 변경 System.out.println(original.getByte(0)); // 출력: 99 (원본 데이터 0번 인덱스 변경됨) // 인덱스는 독립적 System.out.println(original.readerIndex()); // 출력: 0 System.out.println(duplicate.readerIndex()); // 출력: 1 // 참조 해제 duplicate.release(); original.release();
참고) getBytes vs readBytes- getBytes는 readerIndex 변경 안하고 읽는 것 → 임의 위치에서 데이터 읽을 때 사용
- ex) 네트워크 프로토콜 처리 시, 데이터 구조를 참조만 해야 할 경우
- readBytes같은 건 readerIndex가 변경 되고, 순서대로 읽을 때 사용
- ex) 수신된 패킷 데이터를 순차적으로 읽으며 해석할 때
2.2 메모리 관리 사례
- 비동기 작업에서 retain() 사용:
- 비동기 작업에서 ByteBuf를 전달할 경우, 참조 카운트를 증가시켜야 함
- 비동기 작업을 실행하는 핸들러에서 처리하기 전에 참조를 해제하면 접근할 수가 없음
ByteBuf buf = (ByteBuf) msg; buf.retain(); // 참조 카운트 증가 executor.execute(() -> { try { process(buf); } finally { buf.release(); // 작업 완료 후 참조 해제 } });
- 비동기 작업에서 ByteBuf를 전달할 경우, 참조 카운트를 증가시켜야 함
- 복사본 생성:
- 원본 데이터를 안전하게 보호하기 위해 copy()를 사용하여 독립적인 복사본을 생성
ByteBuf copy = buf.copy(); process(copy); copy.release(); // 복사본 해제
- 슬라이싱과 참조 관리:
- 데이터의 일부분만 사용해야 할 경우 slice()를 사용하며, 참조 카운트를 신중히 관리해야 함
3. channelRead와 channelRead0의 차이점
3.1 channelRead
- 핸들러: ChannelInboundHandlerAdapter
- 참조 관리: 메시지(ByteBuf)의 참조 카운트를 수동으로 관리해야 함
- 유연성: 다양한 데이터 타입의 메시지를 처리할 수 있음
- 주의점 : fireChannelRead()로 다음 핸들러로 보낼 경우 관리 안해도 됨, 다음 핸들러에서 처리
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
process(buf);
} finally {
buf.release(); // 참조 해제
}
}
3.2 channelRead0
- 핸들러: SimpleChannelInboundHandler<T>
- 참조 관리: 메시지의 참조 카운트가 자동으로 관리됨
- 특정 데이터 타입: 제네릭 타입(T)을 사용하여 특정 데이터 타입을 처리하기 적합
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
int value = msg.readInt();
// 참조 카운트는 자동 관리됨
}
3.3 차이 요약
항목 | channelRead | channelRead0 |
참조 카운트 관리 | 수동 관리 (release() 호출 필요) | 자동 관리 |
데이터 타입 처리 | 모든 데이터 타입 처리 가능 | 특정 데이터 타입에 최적화 |
사용 예 | 복잡한 로직 처리, 다양한 메시지 처리 | 단순 메시지 처리, 타입 한정 메시지 처리 |
3.4 channelRead를 사용해야하는 경우
- 다양한 타입의 메시지를 처리해야 하는 경우
- channelRead0은 특정 타입만 처리할 수 있으므로, 다양한 타입의 메시지를 처리하려면 channelRead가 더 유연
- 메시지를 다음 핸들러로 전달해야 하는 경우
- channelRead0은 메시지를 소비한 후 참조를 해제하므로, 메시지를 파이프라인의 다음 핸들러로 전달하려면 적합하지 않음
4.1 디코더에서의 참조 관리
- 메시지 파싱 후 참조 자동 관리:
- 디코더에서는 Netty가 입력 ByteBuf의 참조 카운트를 자동으로 관리
- out.add()를 호출하면 디코더의 참조 카운트 관리가 끝나고, 출력된 메시지의 참조는 다음 핸들러로 넘어감
- 따라서, 디코더에서 release()를 호출할 필요가 없음
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 충분한 데이터를 기다림
if (in.readableBytes() < 4) {
return;
}
// 데이터 디코딩
int value = in.readInt();
out.add(value); // Netty가 자동으로 참조 카운트 관리
}
- retain()과 비동기 작업:
- 디코더에서 데이터를 비동기로 처리해야 하는 경우, 출력된 데이터(ByteBuf)를 비동기 핸들러에서 참조 카운트를 관리해야 됨
- 비동기로 처리하려는 메시지는 비동기 핸들러에서 retain()으로 참조를 유지하고, 비동기 작업이 끝난 후 release()를 호출해야 함
비동기 작업을 처리하는 핸들러 예시:
public class AsyncHandler extends SimpleChannelInboundHandler<ByteBuf> {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
msg.retain(); // 참조 카운트를 증가시켜 비동기 작업에서 안전하게 유지
executor.execute(() -> {
try {
// 비동기 작업 수행
System.out.println("Processing: " + msg.toString(CharsetUtil.UTF_8));
} finally {
msg.release(); // 작업 완료 후 참조 해제
}
});
}
}
4.2 디코더에서의 예외 처리
Netty의 디코더는 예외가 발생하면 입력 ByteBuf의 참조 카운트를 자동으로 해제 개발자는 예외 처리 시 참조 해제를 수동으로 호출할 필요가 없음
예외 처리 디코더 코드:
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
throw new IllegalArgumentException("Not enough data");
}
long value = in.readUnsignedInt();
out.add(value); // 예외 발생 시 Netty가 자동으로 참조 관리
}
핸들러와 디코더의 차이
항목 | 디코더 (ByteToMessageDecoder) | 핸들러 (ChannelInboundHandlerAdapter, SimpleChannelInboundHandler) |
참조 카운트 관리 | Netty가 참조 카운트를 자동으로 관리 (수동 release() 불필요) |
사용자가 직접 참조 카운트를 관리 (retain() 및 release() 필요) 단, 최종 핸들러까지 간 경우에는 자동으로 관리해줌 - 만약 fireChannelRead()로 보내지 않는다면 직접적으로 참조 카운트 해제 등의 관리 필요 - fireChannelRead()로 보내면 참조 관리 필요 없음. 다음 핸들러에서 처리 |
예외 처리 시 참조 관리 | 예외 발생 시 Netty가 자동으로 참조 해제 | 사용자가 직접 예외 처리 후 참조 해제를 관리해야 함 |
메시지 전달 방식 | out.add(decodedMessage)로 다음 핸들러로 전달 | ctx.fireChannelRead(msg)로 명시적으로 메시지를 전달해야 함 |
사용 목적 | 원시 데이터를 디코딩하여 고수준 데이터로 변환 | 모든 이벤트와 메시지 처리, 메시지 변환 및 전송 |
비동기 작업 시 처리 | 비동기로 처리하려면 출력 데이터를 핸들러에서 retain() 및 release() 필요 | 핸들러 자체에서 retain()과 release()를 직접 호출해야 함 |
- 참고
- 아웃바운드 핸들러는 write() 및 writeAndFlush() 호출 시 Netty가 참조 카운트를 자동으로 관리하므로, 수동으로 참조를 관리해야 하는 경우가 드뭄
5. ByteBuf 참조 자동 관리되는 케이스
Netty는 ByteBuf의 참조 카운트를 수동으로 관리할 필요가 없는 자동 관리 시나리오를 제공. 이 경우, 사용자가 직접 release()를 호출하지 않아도 됩니다. 아래는 대표적인 자동 관리 케이스
5.1 ChannelHandlerContext.write() 또는 writeAndFlush()
- write() 또는 writeAndFlush() 메서드 호출 시, Netty는 ByteBuf의 참조 카운트를 자동으로 관리
- 데이터가 송신 큐에 등록되고 나면 Netty가 내부적으로 참조를 해제
- 사용자는 명시적으로 release()를 호출할 필요가 없음
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
// 데이터를 처리한 후 클라이언트로 다시 전송
ctx.writeAndFlush(buf); // Netty가 참조를 자동으로 관리
// buf.release()를 호출할 필요 없음
}
주의: retain()을 호출한 경우에는 참조 카운트를 증가시키므로, 직접 release()를 호출해야 함
5.2 디코더(ByteToMessageDecoder)
- 디코더는 decode() 메서드에서 입력 버퍼(ByteBuf)를 사용한 뒤, 참조 카운트를 자동으로 관리
- 디코더에서 데이터를 파싱해 out.add()로 출력하면, Netty가 데이터의 참조 카운트를 관리
- 디코더에서 예외가 발생하더라도 Netty가 자동으로 참조를 해제
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if (in.readableBytes() < 4) {
return; // 충분한 데이터가 없으면 반환 (참조 자동 관리)
}
int value = in.readInt();
out.add(value); // 참조 카운트를 자동 관리
}
5.3 SimpleChannelInboundHandler
- SimpleChannelInboundHandler는 channelRead0() 메서드 호출 후 Netty가 메시지(ByteBuf)의 참조를 자동으로 해제
- 사용자는 메시지를 소비하기만 하면 되고, 직접 release()를 호출할 필요가 없음
public class MyHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
System.out.println("Message: " + msg.toString(CharsetUtil.UTF_8));
// msg는 Netty가 자동으로 해제
}
}
5.4 DefaultPromise 및 Future/Listener
- Netty의 비동기 작업에서 Future나 Promise를 사용하면 참조 관리가 자동으로 이루어짐
- 결과로 반환된 데이터가 ByteBuf인 경우, 완료된 후 참조 카운트를 자동으로 해제
ctx.writeAndFlush(buf).addListener(future -> {
if (future.isSuccess()) {
System.out.println("Write succeeded");
// 참조 카운트는 자동 관리
} else {
System.err.println("Write failed: " + future.cause());
}
});
5.5 Pipeline에서 핸들러 체인 타는 경우
- ChannelPipeline을 따라 메시지가 전달되면서, fireChannelRead()를 통해 다음 핸들러로 보내면 다음 핸들러에서 참조 관리해줘야 함
- 단, 핸들러에서 명시적으로 retain()을 호출한 경우에는 참조 카운트가 증가하니, release() 해줘야함
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
// 메시지가 다음 핸들러로 전달되며 자동 관리
ctx.fireChannelRead(msg);
// msg.release() 호출 필요 없음
}
5.6 Aggregator (예: HttpObjectAggregator)
- HttpObjectAggregator와 같은 Netty의 Aggregator는 자동으로 메시지의 참조를 관리
- Aggregator가 입력 ByteBuf 또는 메시지를 처리한 후 자동으로 해제하므로 추가 관리가 필요하지 않음
pipeline.addLast(new HttpObjectAggregator(1024 * 1024));
pipeline.addLast(new SimpleChannelInboundHandler<FullHttpRequest>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {
System.out.println("Full HTTP request received: " + msg.uri());
// msg는 Netty가 자동으로 해제
}
});
최종 자동 관리 목록
자동 관리 사례 | 설명 |
write() / writeAndFlush() | Netty가 송신된 ByteBuf의 참조를 자동으로 해제 |
ByteToMessageDecoder | decode()에서 입력 버퍼 처리 후 Netty가 참조를 자동으로 관리 |
SimpleChannelInboundHandler | channelRead0() 실행 후 Netty가 메시지를 자동 해제 |
DefaultPromise 및 Future | 비동기 작업 완료 후 참조 카운트를 자동으로 관리 |
Pipeline의 마지막 핸들러 처리 후 | 파이프라인 핸들러 체인을 통과한 메시지는 마지막 핸들러 처리 후 자동으로 해제됨 |
Aggregator (예: HttpObjectAggregator) | 메시지를 처리한 후 Netty가 참조를 자동으로 관리 |