- Published on
Spring Boot 3 간헐적 500? Netty 메모리릭 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영에서 가끔씩만 500이 터지면 가장 괴로운 점은 재현이 어렵다는 것입니다. 특히 Spring Boot 3 기반 서비스에서 WebFlux 또는 Reactor Netty를 쓰는 경우, 로그에는 별 힌트가 없고 어느 순간부터 요청이 타임아웃되거나 500이 늘어나며, 재시작하면 잠깐 정상으로 돌아오는 패턴이 자주 나옵니다.
이런 증상은 애플리케이션 레벨 버그도 많지만, Netty 메모리(특히 Direct Memory) 압박 또는 ByteBuf 누수가 원인이면 “가끔 500”이라는 형태로 나타나기 쉽습니다. 이유는 간단합니다. 누수는 즉시 폭발하지 않고, 트래픽과 특정 경로를 밟는 요청이 누적되면서 임계점에서 갑자기 장애로 전환되기 때문입니다.
이 글은 Spring Boot 3 환경에서 간헐적 500을 Netty 메모리릭 관점으로 추적하는 방법을 단계별로 정리합니다.
- 어떤 지표를 보면 Netty/Direct Memory 문제인지 빠르게 감을 잡을 수 있는지
- ByteBuf leak detection을 운영에 가깝게 켜는 방법
- Reactor Netty의 관측 포인트(커넥션 풀, 이벤트루프, 타임아웃)
- 흔한 누수 패턴과 수정 예시
- 컨테이너 환경에서 메모리 한도와 Direct Memory의 관계
참고로, 가상 스레드 도입 이후 지연이나 교착 의심이 있다면 별도 관점이 필요합니다. 그 경우는 아래 글도 함께 보시면 원인 분리가 빨라집니다.
1) “가끔 500”이 Netty 메모리 문제일 때의 전형적 징후
다음 중 2개 이상이 동시에 보이면 Netty 메모리(특히 Direct Memory) 문제를 강하게 의심해볼 만합니다.
1-1. 에러 로그가 OutOfMemoryError: Direct buffer memory 또는 유사 패턴
대표적으로 아래와 같은 메시지가 보입니다.
java.lang.OutOfMemoryError: Direct buffer memoryio.netty.util.internal.OutOfDirectMemoryError: failed to allocate ...
다만 “가끔 500” 단계에서는 OOM이 아직 터지지 않아 로그가 빈약할 수도 있습니다. 그 대신 다음 현상이 먼저 옵니다.
1-2. 응답 지연 증가 후 타임아웃, 5xx 증가, 재시작하면 회복
Direct Memory가 부족해지면 Netty가 버퍼를 할당하지 못하거나, GC 및 네이티브 메모리 압박으로 전체가 느려지면서 타임아웃이 늘어납니다. 이때 상위 레이어에서는 500 또는 504처럼 보일 수 있습니다.
컨테이너 환경에서 504가 섞여 보인다면, 플랫폼 타임아웃과 애플리케이션 지연이 겹친 것일 수 있습니다.
1-3. Heap은 멀쩡한데 RSS가 계속 증가
JVM heap 사용량은 안정적인데, 프로세스 RSS가 꾸준히 증가한다면 네이티브 메모리(Direct Memory 포함) 누수를 의심합니다.
- Heap 그래프는 평평함
- 컨테이너 메모리 사용량은 계속 상승
- 어느 순간 OOMKilled 또는 응답 불능
2) Spring Boot 3에서 Netty 메모리 구조를 짧게 정리
Reactor Netty는 내부적으로 Netty의 ByteBuf를 사용하며, 성능을 위해 Direct Buffer(네이티브 메모리) 를 적극 활용합니다.
- Heap: JVM이 관리, GC 대상
- Direct Memory: 네이티브 영역, GC가 직접 회수하지 않음(참조 해제 후 Cleaner 등으로 정리)
- ByteBuf: 참조 카운팅(reference counting) 기반.
release()가 제대로 되지 않으면 누수
즉, “Heap 덤프 떠봤더니 별 거 없네?”가 충분히 가능한 케이스입니다.
3) 1차 분류: 지금 장애가 Direct Memory인지 확인하기
3-1. JVM 플래그로 NMT 켜기(가능하면 스테이징에서)
NMT(Native Memory Tracking)는 네이티브 메모리 사용량을 분류해줍니다. 비용이 있어 운영 상시 활성화는 부담일 수 있으니, 우선 재현 가능한 환경에서 켜는 것을 권합니다.
JAVA_TOOL_OPTIONS="-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions" \
java -jar app.jar
실행 중 아래로 요약을 봅니다.
jcmd <pid> VM.native_memory summary
여기서 Arena Chunk 또는 Internal 등 네이티브 영역이 비정상적으로 크면 Direct Memory 연관 가능성이 큽니다.
주의: 위 명령에서 <pid> 같은 부등호 표기는 MDX에서 빌드 오류가 날 수 있으니, 문서/블로그에서는 반드시 `<pid>`처럼 인라인 코드로 표기해야 합니다.
3-2. 컨테이너에서 RSS와 JVM 메모리 지표를 함께 보기
- 컨테이너 메모리(RSS)가 올라간다
- JVM heap은 안정적이다
이 조합이면 Direct Memory 또는 네이티브 누수 가능성이 높습니다.
3-3. Netty allocator 메트릭 확인
Micrometer가 붙어 있다면 Netty allocator 관련 지표를 노출할 수 있습니다(환경에 따라 자동 노출 여부가 다름).
- Pooled allocator 사용량
- Direct arena 사용량
지표가 없다면 다음 섹션의 leak detection으로 바로 넘어가도 됩니다.
4) 핵심: Netty ByteBuf leak detection 켜서 “누가 안 놓는지” 찾기
Netty는 leak detection 기능을 제공합니다. 다만 레벨이 높을수록 오버헤드가 커서 운영 상시 적용은 위험합니다. 현실적인 절차는 다음과 같습니다.
- 스테이징 또는 재현 가능한 환경에서
PARANOID로 원인 라인 확보 - 운영에서는 짧은 기간
ADVANCED또는SIMPLE로 증거 수집
4-1. leak detection 레벨 설정
JVM 옵션 또는 시스템 프로퍼티로 설정합니다.
JAVA_TOOL_OPTIONS="-Dio.netty.leakDetection.level=advanced" \
java -jar app.jar
가능한 값은 보통 아래입니다.
disabledsimpleadvancedparanoid
4-2. 로그에서 찾을 키워드
누수가 의심되면 Netty가 아래처럼 경고를 남깁니다.
LEAK: ByteBuf.release()ResourceLeakDetector관련 스택 트레이스
스택 트레이스에 “할당 지점”이 찍히므로, 어떤 코드 경로가 버퍼를 잡고 놓지 않는지 특정할 수 있습니다.
4-3. 운영에서 안전하게 쓰는 팁
advanced로 짧게(예: 10분 ~ 1시간)만 켜고 로그를 수집- 로그 볼륨이 커질 수 있으니 샘플링 또는 별도 라우팅 고려
- 장애 시간대에만 동적으로 켤 수 있으면 가장 좋지만, 일반적으로는 재기동이 필요합니다
5) 흔한 누수 패턴 5가지와 수정 예시
여기서부터는 “Spring Boot 3 + Reactor Netty”에서 실제로 자주 마주치는 실수들입니다.
5-1. DataBuffer 또는 ByteBuf를 수동으로 다루고 해제 누락
WebFlux에서 파일/바디를 다루다 보면 DataBuffer를 직접 만지는 코드가 생깁니다. 이때 join하거나 aggregate한 뒤 해제를 누락하면 누수로 이어질 수 있습니다.
나쁜 예(개념 예시):
public Mono<String> readBody(ServerRequest request) {
return request.bodyToMono(DataBuffer.class)
.map(buf -> {
byte[] bytes = new byte[buf.readableByteCount()];
buf.read(bytes);
// release 누락 가능
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
});
}
개선 예(반드시 해제):
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
public Mono<String> readBody(ServerRequest request) {
return request.bodyToMono(DataBuffer.class)
.map(buf -> {
try {
byte[] bytes = new byte[buf.readableByteCount()];
buf.read(bytes);
return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
} finally {
DataBufferUtils.release(buf);
}
});
}
핵심은 DataBufferUtils.release(...)를 통해 내부 Netty 버퍼가 반환될 수 있게 하는 것입니다.
5-2. WebClient 응답 바디를 끝까지 소비하지 않고 버리는 경우
WebClient에서 응답을 받고 .toBodilessEntity() 또는 .bodyToMono(Void.class)로 끝내지 않고, 중간에 구독을 끊거나 에러로 빠져 바디가 drain되지 않으면 커넥션 재사용과 버퍼 반환에 악영향이 생길 수 있습니다.
권장 패턴 예:
WebClient client = WebClient.builder().build();
public Mono<Void> callApi() {
return client.get()
.uri("https://example.com/api")
.retrieve()
.bodyToMono(String.class)
.timeout(java.time.Duration.ofSeconds(3))
.then();
}
에러 처리 시에도 바디 소비/정리가 되도록 exchangeToMono를 신중히 사용하고, 필요하면 바디를 읽어 버리거나 적절히 종료해야 합니다.
5-3. 커넥션 풀 과다/오설정으로 Direct Memory 압박
커넥션 풀이 너무 크게 열리면, 각 커넥션의 버퍼/큐가 누적되어 Direct Memory 사용량이 증가합니다. 특히 “가끔 500”은 트래픽 피크에서만 풀이 급격히 확장되는 패턴에서 잘 나타납니다.
Reactor Netty ConnectionProvider를 명시적으로 제한합니다.
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
ConnectionProvider provider = ConnectionProvider.builder("fixed")
.maxConnections(200)
.pendingAcquireMaxCount(500)
.pendingAcquireTimeout(java.time.Duration.ofSeconds(2))
.build();
HttpClient httpClient = HttpClient.create(provider)
.responseTimeout(java.time.Duration.ofSeconds(3));
포인트는 “무한 대기”를 만들지 않는 것입니다. 무한 대기는 이벤트루프 정체와 메모리 적체를 같이 부릅니다.
5-4. 이벤트루프 블로킹으로 누수처럼 보이는 적체 발생
정확히는 누수가 아니라 “해제가 늦어져서 누수처럼 보이는” 상황입니다. 이벤트루프 스레드에서 블로킹 작업(파일 IO, DB 동기 호출, 긴 CPU 작업)을 하면, 버퍼 반환과 네트워크 처리 자체가 지연됩니다.
- 이벤트루프 지연
- 응답 지연
- 타임아웃 증가
- 결과적으로 버퍼가 더 오래 잡혀 Direct Memory가 높아짐
블로킹 작업은 boundedElastic로 보내거나, 애초에 논블로킹 드라이버를 사용합니다.
Mono.fromCallable(() -> blockingCall())
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
5-5. 대용량 업로드/다운로드에서 무제한 in-memory 집계
대용량 payload를 한 번에 메모리에 올리는 방식은 Direct Memory와 Heap 모두에 부담입니다. 스트리밍 처리, 파일로 스풀링, 최대 크기 제한을 적용하세요.
6) “가끔 500”을 재현 가능하게 만드는 관측 포인트
메모리릭은 재현이 어려우니, 관측을 잘 설계해야 합니다.
6-1. 요청 단위 상관관계 ID로 누수 트리거 찾기
특정 API/특정 응답 크기/특정 헤더 조합에서만 누수가 난다면, 상관관계 ID로 요청을 추적해야 합니다.
X-Request-Id를 수신 또는 생성- WebClient outbound에도 전파
- leak detection 로그와 애플리케이션 로그를 같은 ID로 묶기
6-2. 메트릭: Direct Memory는 “순간 피크”도 중요
평균이 아니라 피크가 장애를 만듭니다.
- 컨테이너 메모리 피크
- Netty allocator 사용량 피크
- pending acquire 증가(커넥션 풀 대기열)
6-3. 부하 테스트는 “긴 시간”이 중요
누수는 단시간 TPS 테스트로 안 잡히는 경우가 많습니다.
- 낮은 TPS라도 1시간 이상
- 실제 운영과 유사한 응답 크기/에러 비율
- 특정 엔드포인트에 가중치 부여
7) 운영 대응: 완화책과 근본 해결을 분리하기
7-1. 즉시 완화책
- 커넥션 풀 상한 설정(폭주 방지)
- 타임아웃을 명시해 적체를 끊기
- 대용량 응답/요청에 상한 적용
- 필요 시 임시 롤백(최근 Netty/Reactor Netty 업그레이드가 있었다면 특히)
7-2. 근본 해결책
- leak detection으로 할당 지점 특정 후 수정
DataBuffer수동 처리 코드 제거 또는 해제 보장- 블로킹 작업 분리
- 커넥션 풀 설계 재검토
8) 체크리스트: 장애 보고서에 반드시 남길 것
간헐적 500을 “감”이 아니라 “증거”로 처리하려면, 아래를 남기면 재발 방지에 도움이 됩니다.
- 장애 시간대 RSS, heap, GC 로그 요약
-Dio.netty.leakDetection.level=advanced적용 여부와 로그 발췌jcmd로 본 NMT 요약(가능한 경우)- 문제가 난 엔드포인트, 평균/피크 응답 크기
- 커넥션 풀 설정값(
maxConnections, 대기열, 타임아웃) - 이벤트루프 블로킹 여부(스레드 덤프 또는 BlockHound 등)
마무리
Spring Boot 3에서 “가끔 500”이 반복되고, 재시작하면 일시적으로 회복된다면 Netty Direct Memory/ByteBuf 누수 또는 이벤트루프 정체로 인한 적체를 반드시 의심해야 합니다. 핵심은 두 가지입니다.
- Heap만 보지 말고 네이티브 메모리와 Netty 버퍼를 관측하기
- Netty leak detection으로 “누가 버퍼를 안 놓는지”를 스택 트레이스로 특정하기
위 절차대로 보면, 막연한 추측에서 벗어나 원인을 코드 라인 단위로 좁힐 수 있습니다.