ByteBuf를 다 쓴 다음에 누가 메모리를 해제해야 할까? GC가 알아서 해 주는 거 아닌가?

이전 글에서 ByteBuf의 구조와 사용법을 살펴봤는데, 사실 ByteBuf를 제대로 쓰려면 반드시 알아야 할 것이 하나 더 있습니다. 바로 참조 카운팅(Reference Counting) 입니다. Java 개발자라면 GC가 메모리를 알아서 수거해 주는 환경에 익숙한데, 네티에서는 왜 직접 메모리 해제를 신경 써야 하는지부터 시작해 보겠습니다.


왜 참조 카운팅인가 — GC만으로 부족한 이유

Java의 GC는 JVM 힙 위의 객체 만 관리합니다. 문제는 네티의 ByteBuf가 힙만 사용하지 않는다는 점입니다.

Direct Buffer는 GC 밖의 영역

이전 글에서 Direct ByteBuf가 OS 네이티브 메모리에 할당된다는 것을 다뤘습니다. 네이티브 메모리는 JVM 힙 바깥이기 때문에 **GC가 직접 해제할 수 없습니다 **.

PLAINTEXT
┌──────────────────────────────────────┐
│            JVM 프로세스              │
│  ┌────────────────┐  ┌────────────┐ │
│  │   JVM 힙       │  │ 네이티브   │ │
│  │ (GC가 관리)    │  │ 메모리     │ │
│  │                │  │ (GC 범위X) │ │
│  │ Heap ByteBuf   │  │ Direct     │ │
│  │ 래퍼 객체들    │  │ ByteBuf    │ │
│  └────────────────┘  └────────────┘ │
└──────────────────────────────────────┘

물론 Java에는 Cleaner(과거 Finalizer)라는 메커니즘이 있어서, 힙의 래퍼 객체가 GC되면 네이티브 메모리도 해제됩니다. 하지만 여기에는 심각한 문제가 있습니다.

  • **GC 타이밍이 불확실하다 **: 래퍼 객체가 즉시 수거된다는 보장이 없습니다
  • ** 네이티브 메모리 압력을 GC가 감지하지 못한다 **: 힙은 여유로운데 네이티브 메모리가 꽉 찬 상황이 발생할 수 있습니다
  • ** 고성능 서버에서 치명적이다 **: 수십만 연결을 동시에 처리하는 서버에서 메모리 해제가 지연되면 OOM으로 직결됩니다

참조 카운팅의 핵심 아이디어

참조 카운팅은 단순합니다. "이 버퍼를 사용하는 곳이 몇 군데인지 숫자로 추적하고, 0이 되면 즉시 해제한다."

JAVA
// ByteBuf 할당 시 refCnt = 1
ByteBuf buf = ctx.alloc().buffer();  // refCnt: 1

// 다른 곳에서도 사용하려면 retain
buf.retain();                         // refCnt: 2

// 사용이 끝나면 release
buf.release();                        // refCnt: 1
buf.release();                        // refCnt: 0 → 메모리 즉시 반환

GC에 의존하지 않고, release()가 호출되어 refCnt가 0이 되는 순간 ** 결정론적으로** 메모리가 반환됩니다. 이것이 네티가 높은 처리량을 유지하는 비결 중 하나입니다.


retain()과 release() — refCnt 증감

ByteBufReferenceCounted 인터페이스를 구현합니다. 핵심 메서드는 딱 세 가지입니다.

JAVA
public interface ReferenceCounted {
    int refCnt();                    // 현재 참조 카운트 조회
    ReferenceCounted retain();       // refCnt + 1
    boolean release();               // refCnt - 1, 0이면 true 반환 + 메모리 해제
}

기본 동작 흐름

JAVA
// 1. 할당 — refCnt는 1에서 시작
ByteBuf buf = ctx.alloc().directBuffer(256);
System.out.println(buf.refCnt());  // 1

// 2. retain — 참조 카운트 증가
buf.retain();
System.out.println(buf.refCnt());  // 2

// 3. release — 참조 카운트 감소
buf.release();
System.out.println(buf.refCnt());  // 1

// 4. 마지막 release — 메모리 반환
boolean deallocated = buf.release();
System.out.println(deallocated);    // true (refCnt가 0이 됨)
System.out.println(buf.refCnt());   // 0

retain(increment)와 release(decrement)

한 번에 여러 카운트를 올리거나 내릴 수도 있습니다.

JAVA
buf.retain(3);    // refCnt += 3
buf.release(2);   // refCnt -= 2

실무에서 자주 쓰이지는 않지만, 하나의 버퍼를 여러 곳에 동시에 전달할 때 유용할 수 있습니다.

refCnt가 0인 버퍼에 접근하면?

JAVA
ByteBuf buf = ctx.alloc().buffer();
buf.release();  // refCnt: 0

// 이후 접근 시 예외 발생
buf.readByte();  // IllegalReferenceCountException: refCnt: 0

해제된 버퍼에 접근하면 IllegalReferenceCountException이 발생합니다. "이미 반환된 메모리에 접근하지 마라"는 안전장치인데, 이 예외가 보이면 release 타이밍이 잘못된 것입니다.


소유권 이전 규칙

참조 카운팅에서 가장 중요한 개념은 ** 소유권(ownership)** 입니다. 핵심 규칙은 단순합니다.

메시지를 마지막으로 소비하는 쪽이 release 책임을 진다.

핸들러 간 전달 시 규칙

파이프라인에서 ByteBuf가 핸들러를 따라 흐를 때, 소유권은 다음과 같이 이전됩니다.

PLAINTEXT
[핸들러 A] ──fireChannelRead(msg)──> [핸들러 B] ──fireChannelRead(msg)──> [핸들러 C]
  소유권 포기                          소유권 포기                         최종 소유자
                                                                         → release 책임

** 규칙 정리:**

  1. ** 전달하면 소유권 포기 **: ctx.fireChannelRead(msg)를 호출하면 소유권이 다음 핸들러로 넘어갑니다. 전달한 쪽에서 release하면 안 됩니다.
  2. ** 소비하면 release 의무 **: 메시지를 더 이상 전달하지 않고 직접 처리(소비)하는 핸들러가 release해야 합니다.
  3. ** 변환하면 원본 release**: 메시지를 다른 객체로 변환해서 전달하는 경우, 원본은 현재 핸들러가 release하고 새 객체의 소유권을 다음 핸들러로 넘깁니다.

소유권 이전 예시 — 변환 핸들러

JAVA
public class MyDecoder extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf in = (ByteBuf) msg;
        try {
            // 원본 ByteBuf에서 데이터를 읽어 새 객체 생성
            MyMessage decoded = decode(in);
            // 새 객체를 다음 핸들러로 전달 (소유권 이전)
            ctx.fireChannelRead(decoded);
        } finally {
            // 원본 ByteBuf는 이 핸들러가 소비했으므로 release
            in.release();
        }
    }
}

소유권 이전 예시 — 메시지를 그대로 전달

JAVA
public class LoggingHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 로그만 찍고 그대로 전달 — release 하지 않음!
        System.out.println("수신 메시지: " + msg);
        ctx.fireChannelRead(msg);  // 소유권을 다음 핸들러로 넘김
    }
}

여기서 in.release()를 호출하면 다음 핸들러가 이미 해제된 버퍼를 받게 되어 IllegalReferenceCountException이 발생합니다.


흔한 실수 패턴

참조 카운팅에서 벌어지는 실수는 크게 두 가지입니다. release를 빼먹거나, 두 번 하거나.

실수 1: release 누락 — 메모리 릭

JAVA
// BAD: 메시지를 소비했는데 release를 안 함
public class BadHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        // 데이터 처리
        processData(buf);
        // ❌ release도 안 하고 fireChannelRead도 안 함
        // → buf의 refCnt가 영원히 1로 남아 메모리 릭
    }
}
JAVA
// GOOD: 소비 후 반드시 release
public class GoodHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        try {
            processData(buf);
        } finally {
            buf.release();  // ✅ finally에서 확실하게 release
        }
    }
}

release 누락은 ** 가장 흔하면서 가장 발견하기 어려운 버그 **입니다. 당장은 아무 예외도 발생하지 않고, 서버가 시간이 지나면서 점점 메모리를 더 차지하다가 결국 OOM으로 죽습니다.

실수 2: 이중 release — IllegalReferenceCountException

JAVA
// BAD: 전달한 메시지를 또 release
public class DoubleReleaseHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        ctx.fireChannelRead(buf);  // 소유권 이전
        buf.release();             // ❌ 이미 소유권을 넘겼는데 release
        // → 다음 핸들러에서 IllegalReferenceCountException
    }
}

소유권을 넘긴 뒤에 release하면, 실제로 메모리가 해제되는 시점은 다음 핸들러가 release할 때인데, 이때 refCnt가 이미 0이라 예외가 터집니다.

예외 경로에서의 릭

놓치기 쉬운 포인트가 하나 더 있습니다. ** 예외가 발생했을 때 **의 경로입니다.

JAVA
// BAD: 예외 발생 시 release가 실행되지 않음
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    String data = buf.toString(CharsetUtil.UTF_8);
    doSomethingThatMightThrow(data);  // ❌ 여기서 예외 발생 시 release 누락
    buf.release();
}

// GOOD: try-finally 패턴
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    ByteBuf buf = (ByteBuf) msg;
    try {
        String data = buf.toString(CharsetUtil.UTF_8);
        doSomethingThatMightThrow(data);
    } finally {
        buf.release();  // ✅ 예외가 발생해도 반드시 실행
    }
}

참조 카운팅을 다룰 때는 ** 항상 try-finally** 패턴을 습관화하는 것이 좋습니다.


SimpleChannelInboundHandler의 자동 release

앞에서 본 것처럼 매번 try-finally로 release하는 건 번거롭고 실수하기 쉽습니다. 네티는 이 문제를 해결하기 위해 SimpleChannelInboundHandler를 제공합니다.

내부 동작 원리

SimpleChannelInboundHandlerchannelRead() 내부를 간략히 보면 이렇습니다.

JAVA
// SimpleChannelInboundHandler 내부 (간략화)
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    boolean release = true;
    try {
        if (acceptInboundMessage(msg)) {
            @SuppressWarnings("unchecked")
            I imsg = (I) msg;
            channelRead0(ctx, imsg);  // 사용자가 오버라이드하는 메서드
        } else {
            release = false;
            ctx.fireChannelRead(msg);  // 타입이 안 맞으면 다음 핸들러로 전달
        }
    } finally {
        if (release) {
            ReferenceCountUtil.release(msg);  // ✅ 자동 release
        }
    }
}

** 핵심 포인트:**

  • 타입이 매칭되면 channelRead0() 실행 후 ** 자동으로 release**합니다
  • 타입이 매칭되지 않으면 다음 핸들러로 전달하고 release하지 않습니다 (소유권 이전)
  • channelRead0()에서 ** 예외가 발생해도** finally 블록에서 release가 보장됩니다

사용 예시

JAVA
public class MyHandler extends SimpleChannelInboundHandler<ByteBuf> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        // ByteBuf 타입인 메시지만 이 메서드로 들어옴
        // 처리 로직 작성
        String data = msg.toString(CharsetUtil.UTF_8);
        System.out.println("수신: " + data);
        // ✅ release를 직접 호출할 필요 없음 — 프레임워크가 알아서 해 줌
    }
}

주의: channelRead0에서 메시지를 retain해야 하는 경우

자동 release가 편리하지만, 메시지를 channelRead0() 이후에도 계속 사용해야 하는 상황이 있습니다. 예를 들어 비동기 작업에 메시지를 넘겨야 할 때입니다.

JAVA
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
    // 비동기 작업에 메시지를 넘기려면 retain 필수
    msg.retain();  // refCnt + 1

    executor.submit(() -> {
        try {
            processAsync(msg);
        } finally {
            msg.release();  // 비동기 작업 완료 후 release
        }
    });
    // channelRead0 반환 후 프레임워크가 release → refCnt - 1
    // 비동기 작업에서 release → refCnt - 1 → 0 → 메모리 반환
}

retain() 없이 비동기 작업으로 넘기면, channelRead0() 반환 즉시 release되어 비동기 작업이 해제된 버퍼를 접근하게 됩니다.


코덱에서의 참조 카운팅

ByteToMessageDecoder의 누적 버퍼

ByteToMessageDecoder는 TCP의 바이트 스트림을 메시지 단위로 잘라내는 디코더인데, 내부적으로 ** 누적 버퍼(cumulation)** 를 관리합니다.

PLAINTEXT
수신 데이터:  [패킷1 일부] → [패킷1 나머지 + 패킷2] → [패킷3]

누적 버퍼:    [패킷1 일부]
              [패킷1 전체 | 패킷2]   ← 이전 데이터 + 새 데이터
              [패킷3]                ← 패킷1,2는 디코딩 후 정리됨

디코더가 수신한 ByteBuf는 누적 버퍼에 합쳐지고, decode() 메서드에서 완전한 메시지를 꺼내면 해당 부분은 정리됩니다. ** 입력으로 들어온 ByteBuf의 release는 디코더가 알아서 처리합니다.**

디코더 실패 시 릭 가능성

문제는 decode() 메서드 안에서 예외가 발생할 때입니다.

JAVA
public class MyDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
        int length = in.readInt();
        // BAD: 여기서 ByteBuf를 할당했는데 예외 발생 시 릭
        ByteBuf frame = ctx.alloc().buffer(length);
        in.readBytes(frame);

        // 이 줄에서 예외 발생 시 frame이 release되지 않음
        MyMessage msg = parseFrame(frame);  // ❌ 예외 발생!
        frame.release();  // 이 줄에 도달하지 못함
        out.add(msg);
    }
}
JAVA
// GOOD: try-finally로 안전하게 처리
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    int length = in.readInt();
    ByteBuf frame = ctx.alloc().buffer(length);
    try {
        in.readBytes(frame);
        MyMessage msg = parseFrame(frame);
        out.add(msg);
    } catch (Exception e) {
        frame.release();  // ✅ 예외 시 직접 할당한 버퍼 release
        throw e;
    }
}

ByteToMessageDecoder가 관리하는 누적 버퍼는 디코더가 알아서 정리하지만, decode() 안에서 직접 할당한 버퍼 는 개발자가 책임져야 합니다.

exceptionCaught에서의 채널 닫기

디코더에서 복구 불가능한 예외가 발생하면, 채널을 닫아서 누적 버퍼를 포함한 모든 리소스가 정리되도록 하는 것이 안전합니다.

JAVA
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    cause.printStackTrace();
    ctx.close();  // 채널 닫기 → 파이프라인의 모든 핸들러 정리 + 누적 버퍼 해제
}

touch() 메서드 — 디버깅용 참조 추적

참조 카운팅 버그를 디버깅할 때, "이 버퍼가 어디서 retain되고 어디서 release됐는지"를 추적하고 싶을 때가 있습니다. touch() 메서드는 이를 위한 디버깅 도구입니다.

기본 사용법

JAVA
ByteBuf buf = ctx.alloc().buffer();

// 각 처리 지점마다 touch()로 힌트를 남김
buf.touch("할당 직후");

// ... 핸들러 A에서
buf.touch("핸들러 A에서 처리");

// ... 핸들러 B에서
buf.touch("핸들러 B로 전달됨");

touch()는 **프로덕션 환경에서는 아무런 동작도 하지 않습니다 **(no-op). ResourceLeakDetector의 레벨이 ADVANCED 또는 PARANOID일 때만 의미가 있습니다.

ResourceLeakDetector 레벨 설정

네티는 참조 카운팅 릭을 탐지하기 위한 내장 도구인 ResourceLeakDetector를 제공합니다.

JAVA
// 프로그래밍 방식으로 설정
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);

// 또는 JVM 옵션으로 설정
// -Dio.netty.leakDetection.level=PARANOID
레벨샘플링 비율touch() 추적용도
DISABLED0%X릭 탐지 완전 비활성화
SIMPLE1%X기본값. 릭 여부만 알려줌
ADVANCED1%O릭 위치 + touch 기록 제공
PARANOID100%O모든 버퍼 추적. 개발/테스트 전용

릭 탐지 로그 예시

릭이 감지되면 다음과 같은 로그가 출력됩니다.

PLAINTEXT
LEAK: ByteBuf.release() was not called before it's garbage-collected.
See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
#1:
    io.netty.handler.codec.ByteToMessageDecoder.channelRead(...)
    hint: 핸들러 B로 전달됨
#2:
    com.example.MyHandler.channelRead(...)
    hint: 핸들러 A에서 처리

ADVANCED 이상 레벨에서는 touch()로 남긴 힌트와 함께 호출 스택을 보여주므로, 어디서 release가 빠졌는지 빠르게 찾을 수 있습니다.

개발 단계에서는 PARANOID 레벨로 설정해서 모든 릭을 잡고, 프로덕션에서는 SIMPLE(기본값)로 두는 것이 일반적입니다.


정리

참조 카운팅의 핵심 규칙을 한 줄로 요약하면 이렇습니다.

"마지막으로 사용하는 쪽이 release한다."

상황release 책임
메시지를 소비하고 전달하지 않음현재 핸들러가 release
ctx.fireChannelRead(msg)로 전달다음 핸들러가 release
메시지를 변환해서 새 객체 전달원본은 현재 핸들러가 release
SimpleChannelInboundHandler 사용프레임워크가 자동 release
비동기 작업으로 전달retain() 후 비동기 작업 완료 시 release

실무에서 기억할 포인트:

  • release는 ** 항상 try-finally** 패턴으로 감싸기
  • SimpleChannelInboundHandler를 활용하면 실수를 크게 줄일 수 있음
  • 개발 중에는 ResourceLeakDetectorPARANOID로 설정해서 릭을 조기에 발견하기
  • 릭이 의심되면 touch()로 버퍼의 이동 경로를 추적하기
댓글 로딩 중...