backend

토스가 겪은 Reactor Netty의 Memory Leak 이슈를 알아보자

downfa11 2025. 8. 18. 18:19

토스에서 제공하는 기술 블로그를 통해서 어떤 사고 과정을 거쳐서 문제를 해결하는지 권위자들의 시야를 엿볼 수 있어서 훔쳐보고(?) 커비처럼 빨아먹어야 한다.

 

 

토스 기술블로그 - https://toss.tech/article/reactor-netty-memory-leak

 

 

클러스터 안에는 수많은 서버들이 Spring WebClient를 통해 REST API로 통신해서 요청을 처리한다.

1. Spring Cloud Gateway의 Memory Leak 이슈 파악하기

해당 컨테이너에 지정한 메모리 상한을 사용한 총 메모리가 초과한 경우 OOMKilled 알림을 보낸다.

 

그런데 Gateway가 왜 OOM으로 죽어?

 

Gateway의 OOM 문제를 트러블 슈팅하는 과정으로 JVM 튜닝에 대한 이야기부터 시작한다.

 

토스에서는 메모리 할당에 드는 오버헤드를 줄이기 위해서 -XX:+AlwaysPreTouch JVM 옵션을 사용해서 Heap 영역만큼의 메모리를 미리 할당하고 시작한다. 이에 관해서 OOMKilledJVM의 Heap 영역 문제일 가능성은 거의 없다고 언급했는데, 이에 이해가 안돼서 따로 JVM에 대해서 공부를 했다.

 

+AlwaysPreTouch를 통해서 JVM 시작 시점에서 -Xmx 만큼 힙 영역을 OS로부터 미리 할당받아서 'Heap 부족으로 인한 OutOfMemoryError의 발생 가능성을 예측하기 쉬워지지만', 이는 힙 영역에 국한된다.

쉽게 말하자면, 이 옵션은 JVM로 하여금 "OS로부터 확정적으로 이만큼(-Xmx) 받아 왔으니 크기가 변하지 않을거야"라고 선언하는 셈이다.

 

반면에 OOMKilled는 k8s에서 할당된 총 Memory Limit을 초과해서 발생한건데, 해당 프로세스가 사용하는 다른 메모리(Resident Set Size) 전체를 감시한다.

 

문제의 원인을 늘어나지 않는 힙 영역이 아니라 Non-Heap 영역(Metaspace, Thread Stacks, JNI 등)으로 잡는 논리적 과정이 너무 흥미로웠다.

 

io.netty.buffer.ByteBuf 클래스에 대한 공식 문서

Java에서 기본적으로 제공하던 ByteBuffer를 개선한 Netty의 데이터 클래스로, byte 데이터를 효율적으로 처리하기 위해서 설계되었다.

 

기존 바이트 배열과 다르게 readerIndex, writerIndex 포인터를 통해 데이터를 읽고 쓰는 위치를 추적한다.

  • 0 ~ readerIndex 까지의 영역: 폐기 가능한(Discardable) Bytes
  • readerIndex ~ writerIndex 까지의 영역: 읽기 가능한(Readable) Bytes, 실제 데이터가 저장된 공간
  • writerIndex ~ capacity 까지의 영역: 쓰기 가능한(Writable) Bytes, 비어있는 공간

 

특히나 Zero-Copy를 지원하는 중요한 개념으로, Netty에서 HTTP 요청과 응답을 담는데 활용하기 때문에 짚고 넘어가는 거다.

  • slice(), dubplicate(): 기존 ByteBuf의 데이터를 복사하지 않고, 내부 저장소를 공유하는 객체를 새로 생성한다.
    이렇게 생성된 Buffer는 독립적인 인덱스들을 가지므로 원본 데이터와 별개로 읽고 쓰면서 메모리 복사를 피한다.
  • copy(): 메모리 복사를 통 모든 것을 복제한 새로운 ByteBuf를 생성한다.

 

ByteBuf의 메모리 할당 방식

실제 메모리를 어디에 할당할지에 따라서 구현체가 두 가지로 구분된다.

  • Heap Buffer(HeapByteBuf): JVM Heap 영역에 할당해서 GC가 자동으로 관리
  • Direct Buffer(DirectByteBuf): OS Native 메모리 영역에 할당해서 socket IO 등 OS 수준의 작업시 복서 오버헤드 생략

 

왜 Heap 영역이 아니라 Native 메모리가 증가할까?

 

 

Reactor Netty는 HTTP 통신상의 요청,응답에 여기서 DirectByteBuf를 이용해서 성능상의 이점을 보인다. 따라서 Native 메모리 영역에서 메모리 누수의 가능성이 생기는 것이다.

 

 

ByteBuf의 참조 카운팅 (Reference Counting)

Netty가 사용하는 ByteBuf 중에서 DirectBuffer는 Native 영역을 다루고 있기 때문에 GC의 관리 대상이 아니다.

따라서 개발자가 직접 메모리를 해제해줘야 하기 때문에 참조 카운팅(Reference Counting)이라는 수동 메모리 관리 기법을 도입했다.

 

 

HTTP 요청과 응답을 담는 버퍼(ByteBuf)를 항상 새로 생성하지 않는 대신, Buffer Pool을 두고 되돌려주는데 reference counter를 이용한다.

  • 처음 Buffer 할당받으면 reference counter=1
  • 동일한 Buffer를 다른 곳에서도 사용하면 .retain() 함수로 reference counter+=1
  • 사용이 끝난 Buffer는 .release() 함수를 통해 reference counter-=1
  • counter가 0이 되면 Buffer는 다시 Pool로 돌아가게 된다.

 

 

https://netty.io/wiki/reference-counted-objects.html

 

Netty.docs: Reference counted objects

Since Netty version 4, the life cycle of certain objects are managed by their reference counts, so that Netty can return them (or their shared resources) to an object pool (or an object allocator) as soon as it is not used anymore. Garbage collection and r

netty.io

 

 

모니터링 결과, 죽은 서버의 RSS(Residential Set Size) 메모리 지표는 꾸준히 우상향하는 중이었다. 여기부터는 JVM Heap 영역이 아니라, Native 메모리를 샅샅이 뒤져 범인을 찾아야 한다.

 

하지만 문제가 된 게이트웨이는 JNI나 JNA같이 네이티브 영역의 메모리를 쓰는 곳이 없어서 알기 어렵다.

  • JNA(Java Native Access): 다른 언어로 작성된 네이티브 라이브러리 함수를 자바로 직접 호출하도록 돕는 라이브러리
  • JNI(Java Native Interface)와의 차이점: 별도 코드를 작성 & 컴파일할 필요 없이 실행 시점에서 동적으로 호출

 

토스는 Active-Active 구조로 이중화된 데이터센터를 가지기에, 양쪽 데이터센터에 1:1 비율로 들어와야 정상인데, 한쪽 센터 RSS만 증가했다.

 

블로그 내용에서는 접근 로그를 살펴보니 해당 라우트에서 CacheRequestBody 필터를 찾을 수 있었고, 기본 제공하는 필터지만 캐싱 과정에서 발생한 해당 필터의 누수를 원인으로 지목했다.

 

https://github.com/spring-cloud/spring-cloud-gateway/pull/2842

 

Fixed memory leak of CacheRequestBodyGatewayFilter by wen-ys · Pull Request #2842 · spring-cloud/spring-cloud-gateway

Cached DataBuffer body in cacheRequestBodyAndRequest method is not released issue. Reference is missed because CACHED_REQUEST_BODY_ATTR key is overwritten to Java object. ServerWebExchangeUtils.ca...

github.com

 

 

 

해당 문제는 CacheRequestBodyGatewayFilterFactory가 메모리 누수를 야기한다는 Spring Cloud Gateway의 이슈로 현재 해결된 버전이 업데이트되고 있다.

 

ServerWebExchangeUtils.cacheRequestBodyAndRequest 메서드에서 캐싱된 DataBuffer의 Body 메모리가 해제되지 않고 남아서 누수(leak)가 발생한 것이다.

 

CACHED_REQUEST_BODY_ATTR가 해당 객체를 Overwrite하는게 원인이라, RemoveCachedBodyFilterremove()를 통해 CACHED_REQUEST_BODY_ATTR를 제거하도록 해결했다. (Issues #2969)

 

public class RemoveCachedBodyFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).doFinally(s -> {
            Object attribute = exchange.getAttributes().remove(CACHED_REQUEST_BODY_ATTR);
            if (attribute != null && attribute instanceof PooledDataBuffer) {
                PooledDataBuffer dataBuffer = (PooledDataBuffer) attribute;
                if (dataBuffer.isAllocated()) {
                    if (log.isTraceEnabled()) {
                        log.trace("releasing cached body in exchange attribute");
                    }
                    dataBuffer.release();
                }
            }
        });
    }
}

 

하지만 이는 dataBuffer를 해제한다는 보장이 없기 때문에, cacheRequestBodyAndRequest를 여러 번 호출하는 과정에서 메모리 누수가 발생하고 결국 OutOfDirectMemoryError를 발생시킬 수 있다고 한다.

 

따라서 RemoveCachedBodyFilter 안에서 캐싱된 dataBuffer가 해제되었는지 확인하기 위해서 아래와 같이 release의 리턴값을 확인하는 과정이 추가되었다. (PR #2971)

public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).doFinally(s -> {
            Object attribute = exchange.getAttributes().remove(CACHED_REQUEST_BODY_ATTR);
            if (attribute != null && attribute instanceof PooledDataBuffer) {
                PooledDataBuffer dataBuffer = (PooledDataBuffer) attribute;
                if (dataBuffer.isAllocated()) {
                    if (log.isTraceEnabled()) {
                        log.trace("releasing cached body in exchange attribute");
                    }
                    while(!dataBuffer.release()) { 
                        // ensure proper release
                        // release() counts down until zero, will never be infinite loop
                    }
                }
            }
        });
}

 

 

추가적으로, CachedRequestBodyGatewayFilter를 직접적으로 사용하지 않는다면 - Dio.netty.allocator.type=unpooled를 통해서 직접적인 메모리 누수를 해결할 수 있다. (Issues #2408)

 

 

Spring Cloud Gateway의 메모리 누수 문제는 요청 Body를 캐싱하기 위해 Body를 가져올때 Reference counter가 올라갔지만, 저장된 Body를 잃어버리면서 내려주지 못해서 누수가 발생했다.

  • 중간 데이터를 잃어버리거나 처리 이후에 사용 완료를 알리는 release() 메서드 호출 누락
  • 참조 카운트가 줄어들지 않아서 ByteBuf는 아직 사용중이라 판단해서 점유중인 Native 메모리를 해제하지 않음
  • 위 과정이 누적되면서 결국 Native Memory Leak으로 OOMKilled 유발

 

 

2. WebClient Memory Leak 이슈 파악하기

Netty ResourceLeakDetector의 로그

LEAK: ByteBuf.release() was not called before it's garbage-collected

 

Spring WebClient를 생성하는 과정에서 ByteBuf가 GC되기 이전에 release()가 누락되어서 누수 발생

 

매 요청마다 커넥션을 맺는 오버헤드를 줄이기 위해서 Connection Pool을 사용하고 있었는데, Netty는 HTTP 요청 후 취소가 발생하면 Connection을 Connection Pool에 반납하지 않고 끊어버리도록 구현되었다.

 

이 과정에서 취소가 많이 발생하면 다른 요청들에서 커넥션을 ‘새로’ 맺어버리고 성능 저하로 이어졌는데 cancel이 발생해서 Mono를 정리할 때 ResponseBody를 명시적으로 release()하도록 수정해서 참조 카운팅 문제를 해결했다.

 

 

 

출처:

https://toss.tech/article/reactor-netty-memory-leak

 

Reactor Netty Memory Leak 이슈 탐방기

Spring Cloud Gateway와 Spring WebClient를 이용하면서 발생한 Memory Leak 이슈의 발생 원인과 해결 과정을 소개합니다.

toss.tech

 

https://netty.io/wiki/reference-counted-objects.html#wiki-h3-16

 

Netty.docs: Reference counted objects

Since Netty version 4, the life cycle of certain objects are managed by their reference counts, so that Netty can return them (or their shared resources) to an object pool (or an object allocator) as soon as it is not used anymore. Garbage collection and r

netty.io