메모리 회전 정지: release()는 왜 비우지 못했나
JVM GC와 Netty 메모리 파편화의 상관관계 분석
운영 중인 서비스에서 며칠 간격으로 컨테이너가 OOMKilled(Exit Code 137)되는 장애를 겪었다.
특이한 점은 Heap 사용량이 안정적인데도 프로세스가 종료된다는 것이었다.
이 글은 해당 문제를 해결하기까지의 분석 과정과, 그 과정에서 얻은 핵심 인사이트를 정리한 내용이다.
1. 이상 현상: 특정 시간대에 반복되던 OOM 패턴
운영 중인 서비스에서 특이한 패턴의 장애가 발생했다.
- 매일 00시 전후로 컨테이너 OOMKilled 발생
- Heap 사용량은 안정적
- GC 로그에도 특이사항 없음
- RSS만 지속적으로 증가
특히 중요한 점은 장애 발생 시점이 항상 동일하다는 것이었다.
👉 즉 단순 누적 문제가 아니라 “특정 시간대에 트리거가 존재하는 구조”였다.
이후 확인 결과:
- 00시 스케줄러 실행
- 외부 API 1회 호출
- 해당 API 응답이 대용량 데이터
👉 이 지점에서 초기 가설이 형성되었다:“대용량 응답 처리 방식에 문제가 있는 것 아닐까?”
2. 1차 가설: “스트리밍으로 처리하면 해결되지 않을까?”
메모리 점유를 줄이기 위해 가장 먼저 시도한 접근은 스트리밍 처리였다.
“데이터를 나눠서 처리하면 메모리 사용량이 줄어들 것이다.”
webClient.post()
...
.retrieve()
.bodyToFlux(DataBuffer.class)
.collect(aggregationCollector)
당시에는 이 구조를 “Reactive 기반 스트리밍 처리”라고 생각했다.
3. 결과: 아무것도 바뀌지 않았다
하지만 결과는 동일했다.
- RSS 증가 패턴 유지
- OOM 발생 주기 동일
여기서 근본적인 질문이 생겼다.“우리는 정말 스트리밍을 하고 있었던 걸까?”
4. 재분석: 스트리밍이 아니라 ‘Aggregation’이었다
문제는 collect()였다.
- Flux<DataBuffer>로 나눠 받았지만
- 결국 모든 데이터를 메모리에 다시 모으고 있었다
즉, 구조적으로는:👉 Streaming이 아니라 ‘분할 수신 후 전체 Aggregation’
5. 왜 문제가 되었는가 (Netty Memory Deep Dive)
5.1 DataBuffer와 Direct Memory
Spring WebFlux의 DataBuffer는 구현체에 따라 Netty의 ByteBuf를 사용할 수 있으며, 이 경우 Direct Memory를 사용한다.
Netty는 성능을 위해 다음과 같은 방식으로 메모리를 관리한다.
- 큰 메모리 블록(Chunk)을 할당
- 이를 잘게 쪼개어 재사용
- reference counting 기반으로 lifecycle 관리
5.2 Fragmentation + Chunk Pinning
문제는 collect()로 인해 DataBuffer의 생명주기가 길어지는 데서 발생한다.
- DataBuffer는 처리 과정에서 release()가 호출되었지만
- aggregation 구조로 인해 전체 데이터 흐름의 생명주기가 길어졌고
- 그 결과 메모리 재사용(reuse) 타이밍이 지연됨
5.3 직관적 이해: “조각 하나가 전체 메모리 흐름을 붙잡는 구조”
이 문제를 직관적으로 이해하면 다음과 같다.
Netty의 memory allocator는 하나의 큰 memory region을 여러 조각으로 나누어 관리하며, 각 ByteBuf(DataBuffer)는 이 조각 중 일부를 참조하는 구조다.
중요한 점은 이 구조가 “즉시 해제”가 아니라 reference count 기반의 reuse 구조라는 것이다.
문제는 여러 DataBuffer가 하나의 흐름 안에서 함께 관리되면서 생긴다.
예를 들어 collect()와 같이 데이터를 모으는 구조에서는:
- 일부 DataBuffer는 처리 과정에서 release될 수 있지만
- 일부 buffer가 마지막까지 참조되면
- 해당 memory region은 완전히 반환되지 않고 reuse 가능한 상태로 남는다
즉,👉 “일부 buffer가 참조를 유지하는 동안, 같은 memory region의 reuse가 지연되는 상황”이 발생할 수 있다.
이 현상을 직관적으로 보면 다음과 같이 이해할 수 있다.
하나의 큰 memory block이 여러 조각으로 나뉘어 사용되는 구조에서 대부분의 조각이 해제되었더라도 일부 조각이 참조를 유지하고 있으면 해당 memory block은 즉시 재사용 가능한 상태로 돌아가지 못한다.
여기서 중요한 점은 이 상태가 “메모리가 영구적으로 점유되는 것”이 아니라 👉 “참조가 유지되는 동안 reuse 타이밍이 밀리는 현상”이라는 것이다.
6. 가설 검증: Heap을 줄였더니 왜 완화되었을까?
이제 이 구조에서 다음 실험을 진행했다.
실험
- Heap Size: 1GB → 750MB 축소
- 목적: GC 빈도 증가
결과
- 기존에는 일정 주기로 OOM이 발생했지만
- Heap 축소 이후에는 서버가 더 이상 OOM으로 종료되지 않음
현상 해석
이 결과를 단순히 “GC가 문제를 해결했다”고 보면 안 된다.
핵심은 다음이다.
- DataBuffer는 Java 객체로 감싸진 wrapper
- Heap GC 증가 → wrapper 객체가 더 빠르게 수거될 가능성이 증가
- 그 결과 참조 해제 시점이 앞당겨질 수 있음
결과적으로:👉 Chunk를 붙잡고 있던 마지막 참조가 더 빨리 해제될 수 있음
- 문제의 본질은 GC가 아니라
- “메모리 해제 타이밍(lifecycle)”
🎯 이 실험이 의미하는 바: 이 시스템은 메모리 크기 문제가 아니라 “생명주기와 타이밍에 의존하는 구조”였다.
7. 최종 해결: “진짜 스트리밍”으로 전환
근본 원인은 데이터를 메모리에 누적하는 구조였다. 이를 스트리밍 기반으로 전환했다.
DataBufferUtils.write(
webClient.post()
.uri(...)
.retrieve()
.bodyToFlux(DataBuffer.class),
path,
StandardOpenOption.CREATE
).block();
변환 포인트
- 데이터 aggregation 구조 제거
- DataBuffer를 중간에 누적하지 않고 즉시 소비하도록 변경
- 불필요한 retention(참조 유지) 구조 제거
- 데이터가 소비되는 시점에 맞춰 release가 발생하는 구조로 전환
결과
- Direct Memory 사용 패턴 안정화
- 주기적으로 발생하던 OOM이 재현되지 않음
💡 핵심 교훈
- 메모리 문제는 “크기”가 아니라 “생명주기”다 — 얼마나 많이 쓰느냐보다 언제 해제되느냐가 더 중요하다
- Reactive에서 Flux를 사용한다고 항상 스트리밍이 되는 것은 아니다 — 데이터를 모으는 순간 스트리밍의 장점은 사라진다
- Direct Memory는 GC의 직접적인 관리 대상은 아니지만 참조 객체의 생명주기에 영향을 받으며, GC는 해제 타이밍에 간접적인 영향을 준다
🚀 한 줄 결론
“문제는 메모리 부족이 아니라, 해제되지 않고 고여 있던 데이터 흐름이었다.”
🔥 마무리
우리는 스트리밍을 하고 있다고 생각했지만, 실제로는 모든 데이터를 메모리 안에 붙잡고 있었다.
yeop
참조
- https://netty.io/wiki/reference-counted-objects.html
- https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/
- https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Flux.html#collect-java.util.stream.Collector-
- https://netty.io/wiki/new-and-noteworthy-in-4.0.html#pooled-buffer