Skip to content
hello tis
Go back

[26.10.15] 학습: 알툴즈 AI API의 파일 읽기 방식별 메모리 사용 비교하기

이전 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 문서

파일 읽기 방식별 Native 버퍼와 Heap 버퍼 사이의 데이터 흐름

FileInputStream으로 바꾸면 NIO 임시 버퍼 캐시를 피할 수 있지만, Native 메모리 사용 자체가 없어지지는 않습니다. direct 버퍼도 반복 할당 비용이 있으므로 재사용 방식까지 비교해야 합니다.

또한 나눠 읽은 데이터를 마지막에 다시 모으거나 PDF 라이브러리가 전체를 적재하면 그만큼의 메모리는 여전히 필요합니다. 읽기 API뿐 아니라 후속 처리까지 확인해야 메모리를 줄였는지 판단할 수 있습니다.

RSS가 줄지 않는 이유를 찾기 위한 버퍼 해제와 OS 반환 구분하기

NIO 임시 direct 버퍼는 재사용할 수 있도록 캐시에 보관합니다. 반면 allocateDirect()로 만든 버퍼는 JVM에서 더 이상 참조하지 않는 것을 GC가 확인한 뒤 Cleaner가 Native 메모리를 해제합니다. Cleaner는 Heap에 있는 Java 객체로, JDK에 등록된 정리 작업을 실행합니다. 임시 버퍼 캐시, DirectByteBuffer 구현

임시 버퍼의 캐시 보관과 해제, 호출자 direct 버퍼의 Cleaner 실행, glibc의 OS 반환 조건

캐시를 제한했는데도 RSS가 남았던 이유

기존에 적용한 -Djdk.nio.maxCachedBufferSize=262144는 캐시에 남길 개별 임시 direct 버퍼의 크기를 256KiB로 제한합니다.

256KiB씩 나눠 읽는 설정은 아닙니다. 6MiB 임시 버퍼가 필요하면 그대로 할당해 읽고, Heap에 복사한 뒤 캐시에 남기지 않고 해제합니다. 읽는 동안의 할당량이나 결과 배열의 크기는 줄이지 않습니다.

이 조치 후 direct 버퍼 증가는 억제됐지만 RSS는 계속 증가했습니다. 그래서 NMT와 메모리 매핑, malloc/free 기록을 비교했습니다. 수집한 표본에서는 RSS 증가를 설명할 만한 미해제 메모리를 찾지 못했고, glibc가 해제한 공간을 보관하는 동작까지 조사했습니다. 기존 장애 조사

free()는 할당자에 공간을 반납하는 동작입니다. 그 공간이 OS로 돌아가는지는 조건에 따라 다릅니다.

이 때문에 버퍼 해제가 정상이어도 RSS는 바로 줄지 않을 수 있습니다. 이후 할당자 비교를 거쳐 운영에는 MALLOC_ARENA_MAX=4를 적용했습니다. 큰 버퍼를 캐시에 남기지 않는 조치와, 해제된 공간이 RSS에 남는 문제를 줄이는 조치는 별도였습니다. 운영 적용 결과

소켓 통신의 메모리 사용을 확인하기 위한 버퍼 풀과 쓰기 대기량 검토하기

SocketChannel의 임시 direct 버퍼 사용 확인하기

OpenJDK 21u의 SocketChannel도 파일 읽기와 같은 IOUtil 경로를 사용합니다. Heap 버퍼는 임시 direct 버퍼를 경유하고, direct 버퍼는 해당 메모리로 I/O를 수행합니다. 소켓에서도 임시 버퍼 캐시와 버퍼 재사용 방식을 확인해야 하는 이유입니다. SocketChannelImpl, IOUtil

실제로 보고된 장애와 대응

Netty는 NIO와 버퍼 관리를 어떻게 연결할까

Netty 4.1의 NIO 전송은 이벤트 처리로 읽기와 쓰기를 이어 가고, 버퍼 재사용과 쓰기 대기량 조절로 메모리를 관리합니다. 아래 그림은 버퍼 풀을 사용하는 경우의 흐름입니다.

OS의 읽기와 쓰기 준비 상태를 EventLoop가 처리합니다. 수신 처리와 소켓 쓰기가 끝난 버퍼는 참조 카운트에 따라 풀에 반환해 재사용하며, 쓰기 대기량 신호를 받은 애플리케이션은 새 데이터 추가를 조절합니다.

Netty의 I/O용 버퍼는 지원 환경과 설정에 따라 direct 버퍼를 우선 사용합니다. 따라서 소켓의 메모리 문제를 조사할 때는 JDK 임시 버퍼 캐시와 함께 Netty 풀에 보관된 메모리와 쓰기를 기다리는 메모리도 구분해서 봐야 합니다. I/O 버퍼 선택

느린 수신자가 있을 때 EventLoop와 응답 버퍼 동작 구분하기

A, B, C가 각각 연결을 맺고 같은 EventLoop에 배정된 경우를 보겠습니다. 요청은 동시에 도착해도 한 스레드에서 각 연결의 읽기와 처리를 차례로 실행합니다. 그림의 A → B → C는 가능한 순서의 예시입니다. EventLoop 문서

C가 응답을 느리게 받으면 아직 쓰지 못한 데이터는 C의 대기 버퍼에 남습니다. EventLoop는 C의 쓰기를 기다리며 멈추지 않고 다른 연결의 I/O를 처리합니다. 따라서 사용이 끝나 풀에 반환한 버퍼와, 전송을 기다리며 계속 사용하는 버퍼를 구분해야 합니다. 아래는 요청 처리 자체가 EventLoop를 막지 않는 경우입니다. 이벤트 처리 구현

세 사용자의 요청이 동시에 도착해 같은 EventLoop에서 A, B, C 순서로 처리되는 예시입니다. A와 B의 사용이 끝난 버퍼는 풀로 돌아가지만, 느린 C에 아직 쓰지 못한 응답은 대기 버퍼에 유지됩니다. EventLoop는 다른 연결의 I/O를 계속 처리합니다.

앞으로 검토할 내용

위 사례와 구현을 확인했지만, 이번 애플리케이션의 소켓 통신에서 같은 장애를 재현하거나 대응 효과를 측정한 것은 아닙니다. 다음에는 아래 항목을 확인하려고 합니다.


Share this post:

Previous Post
[26.10.16] ClickHouse 100억 건 적재를 위한 수집 조정하기
Next Post
[26.10.14] 알툴즈 AI API의 단계적 전환을 위한 API와 데이터 변경 범위 정하기