메모리 릭 탐지 & 디버깅
ByteBuf를 다 쓰고 나서release()를 호출해야 한다는 건 알겠는데, 실수로 빠뜨리면 어떻게 되고, 네티는 이걸 어떻게 잡아낼까?
네티는 ByteBuf의 메모리를 레퍼런스 카운팅 으로 관리합니다. retain()하면 카운트가 올라가고, release()하면 내려가서 0이 되면 메모리가 반환되는 구조입니다. 문제는 release()를 빠뜨리는 순간 메모리가 영원히 회수되지 않는다는 것입니다. 이번 글에서는 네티의 내장 릭 탐지기인 ResourceLeakDetector의 동작 원리부터, 흔한 릭 패턴과 디버깅 전략까지 정리해 보겠습니다.
메모리 릭의 원인 — 대부분 release() 누락
네티에서 메모리 릭이 발생하는 원인은 거의 하나입니다.
ByteBuf를 다 썼는데
release()를 호출하지 않은 것.
ByteBuf는 JVM의 GC가 아닌 ** 레퍼런스 카운팅 **으로 메모리를 관리합니다. 특히 Direct ByteBuf는 OS 네이티브 메모리에 할당되기 때문에, release()를 빠뜨리면 GC가 건드릴 수 없는 메모리가 계속 쌓입니다.
// 릭이 발생하는 코드
ByteBuf buf = ctx.alloc().buffer(256);
buf.writeBytes(data);
ctx.writeAndFlush(buf);
// write 실패 시 buf가 release 되지 않을 수 있음
// 안전한 패턴
ByteBuf buf = ctx.alloc().buffer(256);
try {
buf.writeBytes(data);
ctx.writeAndFlush(buf);
} catch (Exception e) {
buf.release(); // 실패 시 직접 release
throw e;
}
릭이 쌓이면 OutOfDirectMemoryError가 터지거나, 힙 메모리가 서서히 증가하다가 결국 OOM으로 프로세스가 죽습니다. 문제는 증상이 바로 나타나지 않고 서서히 드러나기 때문에 원인을 찾기 어렵다는 것입니다.
ResourceLeakDetector — 네티의 내장 릭 탐지기
네티는 이런 릭을 잡아내기 위해 ResourceLeakDetector를 기본으로 내장하고 있습니다. 별도 설정 없이도 동작하며, 릭이 감지되면 로그에 경고 메시지를 출력합니다.
동작 원리: PhantomReference + 샘플링
핵심 메커니즘은 PhantomReference와 ReferenceQueue 의 조합입니다.
ByteBuf가 생성될 때ResourceLeakDetector가 해당 객체에 대한PhantomReference를 만들어둔다ByteBuf가 GC에 의해 수거되면, 이PhantomReference가ReferenceQueue에 들어간다ResourceLeakDetector가ReferenceQueue를 확인해서,release()가 호출되지 않은 채로 GC된ByteBuf를 발견한다- 릭이 감지되면 로그에 경고 메시지와 스택트레이스를 출력한다
// PhantomReference를 사용하는 이유
// - WeakReference: GC 전에 참조 접근이 가능 → 릭 탐지 목적에 부적합
// - SoftReference: 메모리 압박이 있어야 수거 → 타이밍 예측 불가
// - PhantomReference: 객체가 확실히 수거된 후 알림 → 릭 탐지에 최적
PhantomReference는 객체가 finalize()까지 완료된 후에야ReferenceQueue에 들어갑니다. 즉, "이 객체는 이제 완전히 사라졌다"는 확실한 신호를 받을 수 있어서 릭 탐지에 최적인 것입니다.
모든 ByteBuf를 추적하면 성능 오버헤드가 크기 때문에, 기본적으로는 샘플링 을 합니다. 생성되는 ByteBuf 중 일부만 골라서 추적하는 방식입니다.
4가지 탐지 레벨
ResourceLeakDetector는 4가지 레벨을 제공합니다. 상황에 맞게 선택하면 됩니다.
DISABLED — 완전 비활성화
ResourceLeakDetector.setLevel(Level.DISABLED);
- 릭 탐지를 아예 하지 않는다
- 성능 오버헤드: 없음
- 프로덕션에서 릭이 없다고 확신할 때만 사용
SIMPLE — 기본 레벨
ResourceLeakDetector.setLevel(Level.SIMPLE);
- 약 1%의 ByteBuf를 샘플링 해서 추적
- 릭 발견 시 할당 위치만 알려줌 (중간 경유 경로 없음)
- 성능 오버헤드: ** 매우 낮음**
- 네티 기본값
ADVANCED — 상세 스택트레이스
ResourceLeakDetector.setLevel(Level.ADVANCED);
- 약 1%의 ByteBuf를 샘플링 (SIMPLE과 동일)
- 릭 발견 시 ** 할당 위치 + 접근 이력(스택트레이스)**을 함께 제공
- 성능 오버헤드: ** 보통**
- 릭의 원인을 추적할 때 유용
PARANOID — 전수 조사
ResourceLeakDetector.setLevel(Level.PARANOID);
- 100% 모든 ByteBuf를 추적 (샘플링 없음)
- 릭 발견 시 ** 할당 위치 + 접근 이력** 전부 제공
- 성능 오버헤드: ** 매우 높음**
- 개발/테스트 환경에서 릭을 절대 놓치고 싶지 않을 때 사용
레벨 비교 요약
| 레벨 | 샘플링 비율 | 스택트레이스 | 성능 오버헤드 | 권장 환경 |
|---|---|---|---|---|
| DISABLED | 0% | 없음 | 없음 | 릭 확인 완료된 프로덕션 |
| SIMPLE | ~1% | 할당 위치만 | 매우 낮음 | 프로덕션 (기본값) |
| ADVANCED | ~1% | 할당 + 접근 이력 | 보통 | 스테이징/QA |
| PARANOID | 100% | 할당 + 접근 이력 | 매우 높음 | 개발/테스트 |
릭 리포트 읽는 법
릭이 감지되면 네티는 다음과 같은 로그를 출력합니다.
SIMPLE 레벨의 리포트
ERROR io.netty.util.ResourceLeakDetector - 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: 0
SIMPLE 레벨에서는 "릭이 있다"는 사실만 알려주고, 어디서 발생했는지는 알기 어렵습니다. 접근 기록(access records)이 0개로 나옵니다.
ADVANCED/PARANOID 레벨의 리포트
ERROR io.netty.util.ResourceLeakDetector - 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.buffer.AdvancedLeakAwareByteBuf.writeBytes(AdvancedLeakAwareByteBuf.java:610)
com.example.MyHandler.channelRead(MyHandler.java:42)
io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(...)
...
#2:
io.netty.buffer.AdvancedLeakAwareByteBuf.readBytes(AdvancedLeakAwareByteBuf.java:502)
com.example.MyDecoder.decode(MyDecoder.java:88)
...
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:402)
io.netty.buffer.AbstractByteBufAllocator.buffer(AbstractByteBufAllocator.java:208)
com.example.MyHandler.channelRead(MyHandler.java:38)
...
리포트를 읽는 순서는 이렇습니다.
- "Created at" 부분부터 확인합니다. 이
ByteBuf가 ** 어디서 생성되었는지** 알 수 있습니다 - "Recent access records" 의
#1,#2, ...를 역순으로 따라갑니다. 해당ByteBuf를 마지막으로 건드린 코드 가 어디인지 파악합니다 - 마지막으로 접근한 코드 근처에서
release()가 빠진 경로를 찾습니다
보통 "Created at"에 나오는 핸들러와 "Recent access records"의 마지막 항목 사이 어딘가에서
release()가 누락된 것입니다.
흔한 릭 패턴 5가지
패턴 1: 핸들러에서 메시지를 삼키기
가장 흔한 패턴입니다. 인바운드 메시지를 처리했지만, 다음 핸들러로 전달하지도 않고 release()하지도 않는 경우입니다.
// 릭 발생
public class BadHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
// 메시지를 처리하고...
processData(buf);
// fire도 안 하고, release도 안 함 → 릭!
}
}
// 수정: 직접 release
public class GoodHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
processData(buf);
} finally {
buf.release(); // 확실하게 해제
}
}
}
파이프라인 끝에 있는 TailContext가 자동으로 release()를 해주지만, 중간 핸들러에서 메시지를 삼켜버리면 TailContext까지 도달하지 못합니다.
패턴 2: 예외 발생 시 release 누락
정상 경로에서는 release()를 잘 해두었지만, 예외가 터지면 건너뛰는 경우입니다.
// 릭 발생
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
String data = parseData(buf); // 여기서 예외 발생하면?
buf.release(); // 이 줄이 실행되지 않음 → 릭!
}
// 수정: try-finally로 보장
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
try {
String data = parseData(buf);
} finally {
buf.release(); // 예외가 터져도 반드시 실행
}
}
release()는 항상finally블록에 넣는 것을 습관으로 만들면 이 패턴을 피할 수 있습니다.
패턴 3: 코덱 디코딩 실패
ByteToMessageDecoder에서 디코딩에 실패했을 때, 입력 ByteBuf의 readerIndex를 전진시키지 않으면 같은 데이터로 계속 디코딩을 시도하면서 내부적으로 버퍼가 쌓이는 경우입니다.
// 문제가 될 수 있는 코덱
public class BadDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
return; // 데이터 부족 — 여기까지는 정상
}
int length = in.getInt(in.readerIndex()); // readerIndex를 전진시키지 않음
if (in.readableBytes() < length) {
return; // 계속 같은 상태로 반복될 수 있음
}
// readInt()로 readerIndex를 전진시켜야 함
int len = in.readInt();
ByteBuf frame = in.readBytes(len);
out.add(frame);
}
}
ByteToMessageDecoder는 decode()가 반환된 후 입력 버퍼를 자동으로 release 하지만, out에 추가된 객체들의 release는 다음 핸들러의 책임입니다.
패턴 4: 조건 분기에서 일부 경로 release 누락
if-else 분기 중 일부 경로에서만 release()를 잊는 패턴입니다.
// 릭 발생
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
if (isValid(buf)) {
ctx.fireChannelRead(buf); // 다음 핸들러로 전달 → OK
} else {
// 유효하지 않은 메시지는 버린다
// 그런데 release()를 안 함 → 릭!
log.warn("Invalid message received");
}
}
// 수정: 모든 분기에서 release 보장
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
if (isValid(buf)) {
ctx.fireChannelRead(buf); // 전달 — 다음 핸들러가 release 책임
} else {
buf.release(); // 버릴 때도 반드시 release
log.warn("Invalid message received");
}
}
모든 분기에서
ByteBuf가 어떻게 되는지 추적해야 합니다.fireChannelRead()로 넘기면 다음 핸들러 책임이고, 넘기지 않으면 현재 핸들러가release()해야 합니다.
패턴 5: ctx.write() 실패 시 msg release 누락
ctx.write()가 성공하면 네티가 메시지를 release 해줍니다. 하지만 write 자체가 실패하면 메시지가 release 되지 않을 수 있습니다.
// 릭 발생 가능
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
ByteBuf buf = ctx.alloc().buffer();
buf.writeBytes(encode(msg));
ctx.write(buf, promise);
// write 실패 시 buf가 릭될 수 있음
}
// 수정: write 실패 시 릴리스를 리스너로 보장
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
ByteBuf buf = ctx.alloc().buffer();
try {
buf.writeBytes(encode(msg));
ctx.write(buf, promise);
} catch (Exception e) {
buf.release(); // 인코딩 실패 시 직접 release
promise.setFailure(e);
}
}
ctx.write()가 반환하는 ChannelFuture에 리스너를 달아서, 실패 시 release하는 방법도 있습니다. 하지만 보통은 인코딩 과정의 예외를 try-catch로 잡는 것이 더 깔끔합니다.
디버깅 전략
릭이 의심될 때 체계적으로 접근하는 방법을 정리합니다.
1단계: PARANOID 모드 활성화
개발/테스트 환경에서는 PARANOID 레벨로 올려서 모든 ByteBuf를 추적합니다.
JVM 옵션으로 설정하는 방법:
# JVM 시작 인자로 추가
-Dio.netty.leakDetection.level=PARANOID
** 코드에서 설정하는 방법:**
// 애플리케이션 시작 시점에 설정
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
JVM 옵션 방식이 코드 수정 없이 환경별로 다르게 적용할 수 있어서 더 실용적입니다.
2단계: touch()로 접근 이력 추가
ResourceLeakDetector는 ByteBuf에 접근할 때마다 자동으로 기록을 남기지만, 직접 touch()를 호출하면 ** 커스텀 힌트 **를 남길 수 있습니다.
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf buf = (ByteBuf) msg;
buf.touch("MyHandler.channelRead() 진입"); // 커스텀 힌트
if (needsTransform(buf)) {
ByteBuf transformed = transform(buf);
transformed.touch("변환 완료, 원본 release 전"); // 변환 후에도 추적
buf.release();
ctx.fireChannelRead(transformed);
} else {
buf.touch("변환 불필요, 그대로 전달");
ctx.fireChannelRead(buf);
}
}
릭 리포트에 이 힌트들이 포함되어, 어떤 경로를 거쳤는지 훨씬 명확하게 파악할 수 있습니다.
3단계: ReferenceCountUtil 활용
직접 release()를 호출하는 대신 ReferenceCountUtil의 유틸리티 메서드를 사용하면 안전합니다.
import io.netty.util.ReferenceCountUtil;
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
// 메시지 처리
handleMessage(msg);
} finally {
// msg가 ReferenceCounted가 아니어도 안전하게 동작
ReferenceCountUtil.release(msg);
}
}
// safeRelease는 release 중 예외가 발생해도 무시
ReferenceCountUtil.safeRelease(msg);
// refCnt()로 현재 레퍼런스 카운트 확인
int count = ReferenceCountUtil.refCnt(msg);
ReferenceCountUtil.release()는 전달된 객체가 ReferenceCounted인지 먼저 확인하기 때문에, ByteBuf가 아닌 메시지를 받았을 때도 ClassCastException 없이 안전하게 동작합니다.
4단계: 단위 테스트에서 릭 검증
EmbeddedChannel을 사용하면 파이프라인을 테스트할 때 릭도 함께 검증할 수 있습니다.
@Test
public void testNoLeak() {
// PARANOID 레벨 설정
ResourceLeakDetector.setLevel(Level.PARANOID);
EmbeddedChannel channel = new EmbeddedChannel(new MyHandler());
ByteBuf input = Unpooled.copiedBuffer("test", CharsetUtil.UTF_8);
channel.writeInbound(input);
// 출력 확인
ByteBuf output = channel.readInbound();
assertNotNull(output);
output.release(); // 테스트에서도 release 필수
// finish()가 false면 큐에 남은 메시지 없음 → 릭 없음
assertFalse(channel.finish());
}
EmbeddedChannel.finish()는 채널을 닫으면서 큐에 남아있는 메시지가 있으면 true를 반환합니다. true가 반환되었는데 해당 메시지를 읽어서 release 하지 않으면 릭입니다.
5단계: 프로덕션 환경 전략
프로덕션에서는 PARANOID를 쓸 수 없으니, 다음과 같은 전략을 사용합니다.
- ** 기본 레벨(SIMPLE)을 유지 **하면서 릭 로그를 모니터링한다
- 릭 로그가 감지되면 ** 스테이징 환경에서 ADVANCED로 올려서** 재현한다
- Direct 메모리 사용량을 ** 메트릭으로 수집 **한다 (Micrometer 등 활용)
// Direct 메모리 사용량 확인 (디버깅용)
long usedDirectMemory = PlatformDependent.usedDirectMemory();
long maxDirectMemory = PlatformDependent.maxDirectMemory();
log.info("Direct memory: {} / {} MB",
usedDirectMemory / 1024 / 1024,
maxDirectMemory / 1024 / 1024);
핵심 정리
네티에서 메모리 릭은 거의 ByteBuf의 release() 누락이 원인이고, ResourceLeakDetector가 이를 탐지해줍니다. 정리하면 이렇습니다.
- ** 릭의 원인 **:
ByteBuf.release()미호출이 대부분 - ** 탐지 원리 **: PhantomReference로 GC 시점에 release 여부를 확인
- **4가지 레벨 **: DISABLED → SIMPLE(기본) → ADVANCED → PARANOID 순으로 정밀도와 오버헤드가 올라감
- ** 릭 리포트 **: "Created at"에서 할당 위치, "Recent access records"에서 마지막 접근 위치를 확인
- ** 흔한 패턴 **: fire 안 하고 삼키기, 예외 시 release 누락, 조건 분기 누락이 가장 많음
- ** 디버깅 핵심 **: 개발 환경에서
-Dio.netty.leakDetection.level=PARANOID설정,touch()로 경유 경로 추적,ReferenceCountUtil로 안전하게 release
한 줄로 요약하면,
release()는 항상finally블록에 넣고, 개발 환경에서는 PARANOID로 돌리는 것이 가장 확실한 릭 방지 전략입니다.