본문 바로가기
빅테크 글 읽기

[Toss Tech] Java Native Memory Leak 원인을 찾아서

by sangyunpark99 2025. 4. 25.

이번 글은 Toss Slash 22에서 발표한 "Java Natvie Memory Leak 원인 찾아서"를 정리했습니다.

 

도입

 

OOM(Out Of Memory)오류 발생 Slack 메시지

 

토스 페이먼츠에서는 시스템에서 오류 가능성이 있어 보이는 항목들을 모니터링하고 있으며 Slack으로 메시지 연동을 하고 있습니다.

OOM Killer가 발동하면 system log에 로그가 남게 되고 이를 Slack으로 연동해둔 방식입니다.

 

OOM Killer

서버 내의 특정 프로세스가 과도하게 메모리를 사용하여, 시스템의 여유 메모리가 없는 상황을 가정합니다.

이때, OS에서 무언가의 연산을 할 때, 메모리가 필요할 수 있습니다. OS에서 더 이상 할당받을 메모리가 없다면, OS는 불안해질 수 있습니다. 이럴 경우 Linux OS에서는 시스템에서 과도하게 메모리를 사용하고 있는 프로세스를 희생하고 OS를 정상화하려고 합니다.

 

이러한 역할을 OOM Killer가 담당하고 있습니다.

일반적으로 Java로 서비스를 하는 경우엔, Java 프로세서가 가장 많은 메모리를 사용하는 경우가 많습니다. 따라서, OS에서 메모리가 부족할 경우 OOM Killer가 Java 프로세스를 죽이는 경우가 많습니다. 

 

문제 서버 상황

 

Java 버전은 11.0.03, Docker의 팟 MAX 메모리는 4기가를 할당하고, Java의 MAX Heap Size는 1.5기가를 할당했습니다. 

OOM Killer가 Java 프로세스를 죽였을 때에는 Java 프로세스의 메모리 사용량은 4GB 였습니다.

MAX Heap Size가 1.5GB인데, OS의 메모리가 없는 상황까지 생겼다면, 해당 프로세스는 2.5기가의 메모리가 힙 사이즈 외에 쓰이게 됩니다.  Java의 Metaspace 공간과 기타 메모리 공간을 감안하더라도 2.5기가의 알 수 없는 메모리 사용량은 이상한 수치입니다.

 

 

 

실제 사용량 측정

 

Java를 구동할 때 NativeMemoryTracking 옵션을 주면, jcmd 명령어를 통해서 자바 가상머신에서의 실제 메모리 사용량을 측정해볼 수 있습니다. 

문제가 되는 서버를 측정해보면, Heap 사이즈는 1.5GB, Class 공간은 190MB, Thread, Code, GC 등 자바 가상 머신의 전체 메모리 사용량은 2GB 정도였습니다.

 

프로세스가 시스템 메인 메모리를 할당 받아 쓰고 있는 메모리 수치를 TOP을 통해 알 수 있습니다.

TOP의 RSS의 수치가 그 수치 입니다. TOP에서 해당 프로세스를 조회해 보면 RSS는 2GB보다 훨씬 큰 3.1GB였습니다.

2GB랑 비교해보면, 약 1.1GB의 오차가 있습니다. 

 

OOM이 발생했을 때를 생각해보면, Java 프로세스의 메모리가 4기가까지 올라갔을 것입니다. 그렇다면 실제로는 2GB까지 오차가 발생했을 가능성이 있습니다.

 

RSS 수치로 나타낸 4GB에서 자바 가상 머신 통계로 나타낸 2GB를 빼면, 2GB가 남습니다.

이 정체를 알 수 없는 2GB는 Native Memory Leak일 가능성이 매우 높습니다.

 

Natvie Memory Leak

JVM에서 Native Memory Leak이 쉽게 나타날 수 있는 예상하는 부분은 아래와 같습니다.

 

1. JNI & JNA

JNI와 JNA는 C로 구현한 모듈을 Java에서 직접 호출하는 기능입니다. C 구현체에서 메모리 Leak이 생길 경우, JVM에서는 이를 인지할 수 없어서 Native Memory Leak의 여지가 있습니다.

확인 결과 문제의 서버는 JNI와 JNA를 쓰는 부분이 문제가 없었습니다.

 

2. DirectBuffer

DirectBuffer로 메모리를 할당하게 되면, 자바 가상 머신의 GC로 관리가 되지 않는 네이티브 메모리가 할당되게 됩니다.

DirectBuffer로 할당된 메모리는 앞서 나온 네이티브 메모리 트래킹 옵션의 결과에서 할당량을 조회할 수 있습니다.

문제가 되었던 서버에서 DirectBuffer의 양은 매우 적었습니다.  

 

3. JavaAgent 기반이 APM 툴

APM 툴은 보통 JavaAgent로 연동하며, instrument를 변경하는 형태로 동작하니 이 부분에서 사용되는 메모리는 없을까?에 대한 부분을 고려하지 않을 수 없었습니다.

해당 서버에서는 pinpoint만 사용하고 있었는데, 조사결과 특별한 문제가 없었습니다.

 

해당 부분들에서도 문제를 찾을 수 없어서 Linux OS의 Process 레벨로 살펴보게 됩니다.

 

더 큰 범위에서 문제 찾기

1. 프로세스 조사

 

첫 번째로 시도한 방법은 프로세스 메모리를 dump하고 dump된 파일 내에 포함된 문자열을 뽑아 힌트를 얻고자 했습니다.

메모리 dump는 pmap, smpas, gdb를 통해서 알 수 있으며, dump된 파일에서 문자열을 알아내는 건 strings 명령어를 통해서 쉽게 뽑아낼 수 있습니다. 하지만, 이부분에서도 찾아볼 수 없었습니다.

 

 

두 번째로 시도한 방법은 memory profile 툴을 적용해 보는 것이였습니다.

Linux에서 기본적으로 메모리를 할당할 때는 malloc이라는 라이브러리를 사용합니다 . 이 malloc을 jemalloc으로 변경하게 되면 jmealloc에서 제공하는 프로파일링 툴을 통해서 어떤 모듈에서 얼마나 메모리를 쓰는지 볼 수 있습니다.

 

 

jemalloc이 적용되고 난 뒤, 문제의 상황의 메모리 할당 상황을 다이어그램으로 볼 수 있습니다.

이 다이어그램을 통해 C2Compiler에서 프로세스 메모리의 90% 이상인 1.9GB를 사용하고 있다는 점을 미루어 보았을때, C2 Compiler가 원인이 되는 것 같습니다. 

 

C2 Complier의 Memory Leak을 확인하기 위해서 openjdk의 bug tracking system을 살펴보니 C2 Complier에 메모리 릭이 발생했다는 이슈가 리포팅 된 건도 보이게 됩니다.

 

 

C2 Compiler

 

Java파일을 컴파일 하면 Class파일이 생깁니다.

Class 파일을 OS가 구동하려면, 기계어가 컴파일 해야 하는데 이 컴파일은 JIT Compiler가 하게 됩니다.

 

Java에서 JIT Compiler는 level0 에서 level4까지 5단계로 나눠집니다.

이 중 마지막 5단계에 해당하는 것이 level4 컴파일러가 C2 입니다.

 

 

 

C1 Compiler는 JIT Compiler의 최적화를 줄이되, 빠르게 컴파일되는 컴파일러이며, 이런 특성은 클라이언트 레벨에서 많이 사용합니다.

클라이언트에서는 앱을 띄우면, 바르게  뜨는 것이 중요하니 많은 최적화보다는 빠르게 컴파일되어 구동 시간을 줄이는 것이 중요합니다.

 

C2 Compiler는 구동 시간은 많이 느리지만,  JIT Compiler의 최적화를 많이 하여, 연산할 때는 빠른 연산을 할 수 있는 컴파일러입니다.

이러한 특성은 서버에서 적합합니다. 구동되는데 시간이 걸리더라도 한번 구동되면 오랫동안 서비스 하여 많은 양의 연산을 하는 서버에 적합할 것입니다. 이러한 이유로 서버들은 보통 C2Compiler를 사용합니다.

 

Java에서는 TiredStopAtLevel이라는 옵션을 주면, JIT Compiler의 레벨을 선택할 수 있습니다.

 

 

C2Compiler가 문제의 진짜 원인인지 확인을 하기 위해서 C2Compiler대신 C1Compiler로 적용해보는 것입니다.

C1Compiler를 적용하니 문제가 발생하지 않았습니다. 그 대신 예상했던 대로 CPU 사용률이 40% 대에서 70%까지 올라가게 됩니다. 이는 최적화가 상대적으로 약하기 때문에 발생한 일입니다.

 

문제를 방지하고 CPU 사용률을 낮추는 방법

CPU 사용률을 낮추려면 C2 Compiler를 사용할 수 밖에 없습니다.

따라서, JDK 버전을 JDK11 최신 버전으로 그리고 JDK 17 버전으로 변경했지만 문제가 해결되지 않았습니다.

 

 

그러던 중, Open JDK의 Gral Compiler를 사용할 수 있다는 것을 알게 되었습니다.

이 기능을 쓰기 위해선 JVM 내에서 실험적인 기능을 쓰겠다는 옵션을 넣어야 하며, JVM CI Compiler 옵션을 주게 되면 Gral JIT Compiler로 적용할 수 있습니다.

 

실험적인 옵션이므로 선택하기에 좀 고민이 있었지만, Toss 페이먼츠에서는 이미 Kubernates를 통한 HA를 확보하고 있었으며, 카나리 배포와 Blue/Green 배포 시스템이 구축되어 있기에 문제가 발생하더라도 영향도를 최소화할 수 있으며, 빠른 롤백이 가능하기 때문에 적용해 보게 됩니다.

 

해당 옵션을 적용해보니 70%에서 40%로 떨어지게 됩니다.

 

 

마무리

트러블 슈팅에서 중요한건 정답보다 문제 원인의 실마리를 찾아가는 과정이 중요합니다.

이 과정의 시간이 짧으면 짧을수록 좋습니다. 

문제의 원인을 찾는 데는 실마리가 보일때 급물살을 타게 됩니다.