SPRING&JAVA

[Netty] ByteBuf와 메모리 관리: 참조 카운트, 자동 관리, channelRead vs channelRead0

ultra_dev 2024. 7. 28. 23:56

목차

  1. Netty의 ByteBuf란?
    • 1.1 ByteBuf의 주요 특징
  2. ByteBuf의 메모리 관리와 참조 카운트
    • 2.1 주요 메서드 (retain, release, copy, slice, duplicate)
    • 2.2 메모리 관리 사례
  3. 핸들러에서의 참조 관리
    • 3.1 channelRead와 수동 관리
    • 3.2 channelRead0와 자동 관리
    • 3.3 channelRead와 channelRead0의 차이점 및 사용 사례
  4. 디코더와 예외 처리
    • 4.1 디코더의 자동 참조 관리 (ByteToMessageDecoder)
    • 4.2 디코더에서의 예외 처리
  5. 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의 주요 특징

  1. 리더/라이터 인덱스:
    • ByteBuf는 readerIndex와 writerIndex를 독립적으로 관리
    • 데이터를 읽거나 쓸 때 자바의 ByteBuffer처럼 flip()을 호출할 필요 없이, 각각의 인덱스를 사용해 편리하게 작업할 수 있음
  2. 메모리 풀링(Pooling):
    • Netty는 메모리 풀링 기능을 지원하며, 이를 통해 메모리 할당/해제 오버 헤드를 줄이고 성능을 최적화함
  3. 자동 확장:
    • 필요에 따라 버퍼 크기를 자동으로 확장할 수 있음
  4. 참조 카운팅:
    • ByteBuf는 참조 카운트(Reference Counting)를 기반으로 메모리를 관리. 이는 메모리 누수를 방지하고, 성능을 최적화하는 데 중요한 역할을 함

2. 메모리 관리와 참조 카운트

Netty의 ByteBuf는 참조 카운트를 기반으로 메모리를 관리. 이는 Java의 GC를 사용하는 ByteBuffer와 가장 큰 차이점 중 하나

2.1 주요 메서드

  1. retain():
    • 참조 카운트를 1 증가 시킴
    • 주로 비동기 작업에서 사용하며, 참조 카운트를 증가시키고 작업이 완료되면 release()를 호출해야 함
  2. release():
    • 참조 카운트를 1 감소 시킴
    • 참조 카운트가 0이 되면 메모리가 해제됨
  3. copy():
    • 데이터를 복사하여 독립적인 ByteBuf 객체를 생성. 복사된 객체는 별도의 참조 카운트를 가지며, 원본 참조 카운트에 영향을 주지 않음
  4. 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 메모리 관리 사례

  1. 비동기 작업에서 retain() 사용:
    • 비동기 작업에서 ByteBuf를 전달할 경우, 참조 카운트를 증가시켜야 함
      • 비동기 작업을 실행하는 핸들러에서 처리하기 전에 참조를 해제하면 접근할 수가 없음
    ByteBuf buf = (ByteBuf) msg;
    buf.retain(); // 참조 카운트 증가
    executor.execute(() -> {
        try {
            process(buf);
        } finally {
            buf.release(); // 작업 완료 후 참조 해제
        }
    });
    
    
  2. 복사본 생성:
    • 원본 데이터를 안전하게 보호하기 위해 copy()를 사용하여 독립적인 복사본을 생성
    ByteBuf copy = buf.copy();
    process(copy);
    copy.release(); // 복사본 해제
    
  3. 슬라이싱과 참조 관리:
    • 데이터의 일부분만 사용해야 할 경우 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가 참조를 자동으로 관리