이전 JVM 메모리 장애에서는 Heap 사용량이 정상인데도 RSS가 증가해 Pod가 재시작됐습니다. MultipartFile#getBytes()가 사용하는 NIO 임시 direct 버퍼의 캐시를 제한했고, 남은 RSS 증가는 glibc까지 조사했습니다.
이번에는 후속 과제였던 getBytes()를 다른 읽기 방식으로 바꾸면 메모리 문제도 해결되는지 확인했습니다. 핵심은 메서드 이름보다 버퍼 크기, 재사용 방식, 해제 시점이었습니다. 아래는 OpenJDK 21u의 Unix 계열 구현을 비교한 내용이며, 교체 후 운영 효과를 측정한 결과는 아닙니다.
Heap과 Native 버퍼 사용을 확인하기 위한 getBytes() 구현 읽기
당시 getBytes()는 ChannelInputStream.readAllBytes()를 통해 파일 전체를 Heap의 byte[]로 읽었습니다. 이 과정에서 파일을 먼저 임시 direct 버퍼에 읽고 Heap으로 복사했습니다. 결과가 Heap 배열이어도 읽는 동안에는 Native 메모리가 함께 필요했습니다. ChannelInputStream 구현, IOUtil 구현
따라서 getInputStream()으로 바꿔도 같은 스트림에서 readAllBytes()를 호출하면 전체 파일 적재와 임시 direct 버퍼 사용은 반복됩니다. 읽기용 메모리를 줄이려면 크기를 제한한 버퍼로 나눠 읽고, 처리한 내용을 계속 쌓아 두지 않아야 합니다.
읽기 중 메모리 사용을 줄이기 위한 버퍼 크기와 재사용 방식 비교하기
일반 파일을 읽는 세 경로를 비교했습니다. FileInputStream과 FileChannel 모두 블로킹 읽기이며, 사용하는 버퍼에 따라 중간 복사와 해제 방식이 달라집니다. FileChannel 문서, ByteBuffer 문서
FileInputStream.read(byte[]): Native 임시 버퍼를 거쳐 Heap 배열에 복사합니다. 한 번의 읽기 요청이 8KiB 이하면 스택 버퍼를 사용하고, 초과하면malloc()으로 할당합니다. 스택 버퍼는 함수 종료 시 수명이 끝나며, 동적 할당 버퍼는 함수 안에서free()합니다. 8KiB는 파일 전체 크기가 아니라 읽기 요청 길이 기준입니다. 임시 버퍼 구현FileChannel.read(Heap ByteBuffer): 임시 direct 버퍼를 거쳐 Heap 버퍼에 복사합니다. 사용한 임시 버퍼는 캐시에 보관하거나 해제합니다. IOUtil 구현FileChannel.read(direct ByteBuffer): 호출자가 준 Native 메모리에 읽으므로 Heap 버퍼로 옮기는 복사 단계가 없습니다. 호출자는 같은 버퍼를 재사용할 수 있지만, 읽기가 끝나도 버퍼는 해제되지 않습니다. ByteBuffer 문서
FileInputStream으로 바꾸면 NIO 임시 버퍼 캐시를 피할 수 있지만, Native 메모리 사용 자체가 없어지지는 않습니다. direct 버퍼도 반복 할당 비용이 있으므로 재사용 방식까지 비교해야 합니다.
또한 나눠 읽은 데이터를 마지막에 다시 모으거나 PDF 라이브러리가 전체를 적재하면 그만큼의 메모리는 여전히 필요합니다. 읽기 API뿐 아니라 후속 처리까지 확인해야 메모리를 줄였는지 판단할 수 있습니다.
RSS가 줄지 않는 이유를 찾기 위한 버퍼 해제와 OS 반환 구분하기
NIO 임시 direct 버퍼는 재사용할 수 있도록 캐시에 보관합니다. 반면 allocateDirect()로 만든 버퍼는 JVM에서 더 이상 참조하지 않는 것을 GC가 확인한 뒤 Cleaner가 Native 메모리를 해제합니다. Cleaner는 Heap에 있는 Java 객체로, JDK에 등록된 정리 작업을 실행합니다. 임시 버퍼 캐시, DirectByteBuffer 구현
캐시를 제한했는데도 RSS가 남았던 이유
기존에 적용한 -Djdk.nio.maxCachedBufferSize=262144는 캐시에 남길 개별 임시 direct 버퍼의 크기를 256KiB로 제한합니다.
- 256KiB 이하: 캐시에 빈자리가 있으면 보관해 재사용합니다.
- 256KiB 초과 또는 캐시가 가득 찬 경우: JDK가 Cleaner를 직접 실행해 해제합니다. GC를 기다리지 않습니다. 할당과 반환 구현
256KiB씩 나눠 읽는 설정은 아닙니다. 6MiB 임시 버퍼가 필요하면 그대로 할당해 읽고, Heap에 복사한 뒤 캐시에 남기지 않고 해제합니다. 읽는 동안의 할당량이나 결과 배열의 크기는 줄이지 않습니다.
이 조치 후 direct 버퍼 증가는 억제됐지만 RSS는 계속 증가했습니다. 그래서 NMT와 메모리 매핑, malloc/free 기록을 비교했습니다. 수집한 표본에서는 RSS 증가를 설명할 만한 미해제 메모리를 찾지 못했고, glibc가 해제한 공간을 보관하는 동작까지 조사했습니다. 기존 장애 조사
free()는 할당자에 공간을 반납하는 동작입니다. 그 공간이 OS로 돌아가는지는 조건에 따라 다릅니다.
- arena와 별도로
mmap한 블록:free()에서 매핑을 해제해 OS에 반환합니다. - arena 내부의 블록: 다음 할당에 재사용할 수 있도록 보관합니다. 반환 가능한 빈 페이지가 있고 해당 영역에 trim이 실행돼야 OS로 돌려줄 수 있습니다. glibc 구현, malloc_trim 문서
이 때문에 버퍼 해제가 정상이어도 RSS는 바로 줄지 않을 수 있습니다. 이후 할당자 비교를 거쳐 운영에는 MALLOC_ARENA_MAX=4를 적용했습니다. 큰 버퍼를 캐시에 남기지 않는 조치와, 해제된 공간이 RSS에 남는 문제를 줄이는 조치는 별도였습니다. 운영 적용 결과
소켓 통신의 메모리 사용을 확인하기 위한 버퍼 풀과 쓰기 대기량 검토하기
SocketChannel의 임시 direct 버퍼 사용 확인하기
OpenJDK 21u의 SocketChannel도 파일 읽기와 같은 IOUtil 경로를 사용합니다. Heap 버퍼는 임시 direct 버퍼를 경유하고, direct 버퍼는 해당 메모리로 I/O를 수행합니다. 소켓에서도 임시 버퍼 캐시와 버퍼 재사용 방식을 확인해야 하는 이유입니다. SocketChannelImpl, IOUtil
실제로 보고된 장애와 대응
- Twitter Finagle의 임시 버퍼 캐시: Heap 버퍼를 소켓에 쓰는 과정에서 큰 임시 direct 버퍼가 스레드별 캐시에 남아 Native 메모리가 증가했습니다. 캐시할 버퍼 크기를 제한했고, 이후 OpenJDK에도
jdk.nio.maxCachedBufferSize가 추가됐습니다. 이번 파일 읽기에서 확인한 것과 같은 종류의 문제입니다. 조사 기록, OpenJDK 변경 - Netty TCP 서버의 direct 메모리 부족: Netty 4.1.5에서 direct 메모리 부족이 보고돼, 유지보수자가 버퍼 풀의 arena 설정을 점검하도록 안내했습니다. 신고자는 설정 변경 후 예외가 사라졌다고 기록했지만 최종 원인은 확정하지 않았습니다. JDK 캐시 외에 프레임워크의 풀도 조사 대상이 됩니다. Netty 이슈
- Vert.x gRPC의 gzip 자원 해제 누락: Quarkus의 gRPC 수신 중 Heap 밖 메모리 증가와 OOMKilled가 보고됐습니다. gzip 처리 경로를 조사한 뒤, 압축 해제 스트림을 명시적으로 닫는 수정이 병합됐습니다. 메서드가 반환돼도 별도 자원을 닫지 않으면 Native 메모리가 남는 사례입니다. 조사 기록, 수정 PR
Netty는 NIO와 버퍼 관리를 어떻게 연결할까
Netty 4.1의 NIO 전송은 이벤트 처리로 읽기와 쓰기를 이어 가고, 버퍼 재사용과 쓰기 대기량 조절로 메모리를 관리합니다. 아래 그림은 버퍼 풀을 사용하는 경우의 흐름입니다.
- NIO 이벤트 처리: EventLoop는
Selector로 준비된 연결을 확인해 읽기와 쓰기를 처리합니다. 연결마다 대기용 스레드를 두는 대신 여러 연결의 I/O를 함께 관리합니다. NioEventLoop 구현 - 버퍼 재사용: 받은 데이터를
ByteBuf에 담아 처리기로 전달합니다. 사용이 끝나 참조 카운트가 0이 된 pooled 버퍼는 풀에 반환해 다음 I/O에 재사용합니다. 풀 반환이 곧 OS 메모리 반환은 아닙니다. 수신 처리, 참조 카운트와 버퍼 풀 - 쓰기 대기 중인 메모리: 한 번에 쓰지 못한 데이터는 대기 버퍼에 남고, 다시 쓸 수 있을 때 이어서 처리합니다. 이 데이터는 소켓 쓰기가 끝나기 전까지 메모리를 사용합니다. 소켓 쓰기 구현
- 쓰기 대기량에 따른 전송량 조절: Netty가
isWritable()로 알리는 상태를 보고, 애플리케이션이 새 데이터 추가를 멈추거나 재개해야 합니다. Netty의 쓰기 대기량 제어
Netty의 I/O용 버퍼는 지원 환경과 설정에 따라 direct 버퍼를 우선 사용합니다. 따라서 소켓의 메모리 문제를 조사할 때는 JDK 임시 버퍼 캐시와 함께 Netty 풀에 보관된 메모리와 쓰기를 기다리는 메모리도 구분해서 봐야 합니다. I/O 버퍼 선택
느린 수신자가 있을 때 EventLoop와 응답 버퍼 동작 구분하기
A, B, C가 각각 연결을 맺고 같은 EventLoop에 배정된 경우를 보겠습니다. 요청은 동시에 도착해도 한 스레드에서 각 연결의 읽기와 처리를 차례로 실행합니다. 그림의 A → B → C는 가능한 순서의 예시입니다. EventLoop 문서
C가 응답을 느리게 받으면 아직 쓰지 못한 데이터는 C의 대기 버퍼에 남습니다. EventLoop는 C의 쓰기를 기다리며 멈추지 않고 다른 연결의 I/O를 처리합니다. 따라서 사용이 끝나 풀에 반환한 버퍼와, 전송을 기다리며 계속 사용하는 버퍼를 구분해야 합니다. 아래는 요청 처리 자체가 EventLoop를 막지 않는 경우입니다. 이벤트 처리 구현
앞으로 검토할 내용
위 사례와 구현을 확인했지만, 이번 애플리케이션의 소켓 통신에서 같은 장애를 재현하거나 대응 효과를 측정한 것은 아닙니다. 다음에는 아래 항목을 확인하려고 합니다.
- 할당 경로: 사용하는 라이브러리의 임시 버퍼, 버퍼 풀, 압축 자원의 해제 책임을 확인합니다.
- 재사용과 대기량: 메시지 크기와 동시성을 바꾸고, 느린 수신자로 인해 쓰기 대기 버퍼가 쌓이는지도 확인합니다.
- 메모리와 처리 성능: 파일을 나눠 읽는 방식과 소켓 버퍼 재사용을 비교하고, 후속 처리의 전체 적재 여부와 RSS, 응답 시간을 함께 측정합니다.