OOME란 Out Of Memory Error의 첫글자를 따서 약어처럼 쓰는 전세계에서 통용되는 일종의 프로그래밍 언어다. 데이터 분석과 관련된 프로젝트 일을 하는 필자로서는 가장 보기 싫은 에러임과 동시에, 현장에서 발생하면 설명하기가 매우 까다로운 녀석임에 틀림이 없다. 특히, 메모리 누수가 없는 잘 만들어진 프로그램이라 할지라도 사용자에 의해 외부에서 입력된 데이터의 양과 적용 환경에 따라 얼마든지 이 에러가 발생할 수 있기 때문에, 우리 프로그래머들은 항상 OOME에 대해서 분석할 줄 알고, '내 프로그램은 죄가 없어요'라고 주장할 수 있는 근거를 만들 준비가 되어 있어야 한다. Brightics와 같은 분석 소프트웨어를 적용하다 보면 이런 메모리 관련 이슈를 심심치 않게 마주하게 된다. 이번 글에서는 빈번한 Java OOME 케이스와 그때마다 수행했던 기본적인 리소스 모니터링, 메모리 부족 원인 분석(Profiling)에 대해서 경험을 토대로 총망라해 보고자 한다.
OOME 케이스를 알아보기에 앞서 에러 메시지가 어떤 메모리 영역을 지칭하는 건지, 해당영역이 어떤 JVM Option에 의해 제어를 받는지 알 필요가 있다.
위 그림에서 제일 위에 있는 Heap과 Metaspace가 우리가 주로 관심있는 공간이다. 이 중에서 Metaspace가 Native Area(=Native Memory, Off-Heap, Non-heap, Direct Memory 등)인 것에 주목하자. Java 8부터 기존의 Permgen 메모리가 Metaspace로 바뀌면서 Native Memory에 할당되게 되었다. Native Memory는 Heap 영역의 바깥인 Off-Heap 공간을 의미하는 것으로 쉽게 시스템의 기본 메모리라고 생각하면 된다. Java 어플리케이션은 크게 위의 Heap과 Off-Heap 두 공간을 활용하여 동작하는데, 따라서 어플리케이션을 배포할 때 메모리 몇 GB를 할당해야 하는지 결정하기 위해서는 단순히 Xmx(Heap 메모리 최대치를 결정하는 Java 옵션) 값만 생각하면 OOME에 빠지기 쉽다. 실제로는 Xmx에 MaxMetaspace값을 더하고, 추가로 프로그램에서 NIO를 사용해 Native Memory를 직접 할당받는 로직을 고려해서 Heap + Native Memory 사용총량으로 할당을 해야 비교적 정확하다. 특히 컨테이너의 경우 계산을 좀 더 정확하게 해야 시스템에서 OOM killed되는 상황을 면할 수 있다. 추가로 Thread 수와 스택사이즈 등 고려할 사항이 조금 더 있지만 이 정도만 해도 기본적으로는 충분하니 여기선 논외로 해도 무방할 듯 하다. 특히, 최근에는 이 Off-Heap을 이용해 성능 향상을 하고 있는 어플리케이션들이 많아서 더 관심을 기울여야 한다. Netty, Spark, Cassandra, Ignite 등 이름만 들으면 알만한 여러 어플리케이션들과 이를 사용하는 파생된 수많은 프로젝트가 여기에 포함된다.
최근에는 Java로 만든 어플리케이션을 컨테이너로 배포하는 경우가 많은데, 일반적으로 컨테이너 자체에 대한 모니터링을 시행하면 Grafana로 본 아래 그림과 같이 프로세스가 최대 가용 메모리까지 점유해 놓고 시스템에 메모리를 다시 내어주지 않는 현상을 발견할 수 있다.
이 때문에 Java와 친숙하지 않는 사람들이 상황을 볼 땐, CPU는 하나도 안 쓰는데, 메모리는 반납도 안하고, 혹시 메모리 누수가 있는게 아니냐는 의심을 할 수 있는데, 그럴 땐 충분한 설명을 통해 공감대를 형성해 보도록 하자. 이 현상은 점유한 메모리를 시스템에 내어주고 다시 할당 받는 과정이 비싼 작업이다 보니, 성능 향상을 위해 Java에서 선택한 특별한 메모리 관리 중에 하나다. Java Heap Shrinkage로 검색하면 이와 관련한 많은 글들을 볼 수가 있는데, 간략히 설명하면 아래 그림과 같은 현상이다. 어느 순간부터 주황색 영역(Reserved Heap)이 감소하지 않고 유지되는 구간이 시작되는데, 이때부터 시스템에 메모리를 돌려주지 않고 계속 점유하고 있는 상태가 돼서 그림3과 같은 형태로 모니터링이 된다. 실제로는 파란색(Used Heap) 영역이 잘 감소하는지가 매우 중요하며, 그림5와 같이 OOME가 발생하는 경우 파란색 영역이 감소하지 않고 주황색 영역을 다 메운 상태로 유지되게 되는데 이것이 바로 OOME와 메모리 누수를 감별하는 척도가 된다.
여러가지 상황에서 OOME가 발생하지만 제일 빈번하게 일어나는 케이스로 필자가 뽑은 건 아래 세 가지다.
각각을 재현해 보자.
먼저 1번의 Java Heap Space 에러는 가장 기본적인 메모리 부족에 대한 에러로서, 가장 자주 일어난다. 그래서 그런지 인터넷의 OOME 사태 해결기의 대부분은 이 문제와 연관되어 있다. 일단 이 에러는 단순히 생성하고자 하는 오브젝트가 JVM의 Heap 메모리 가용 영역을 넘어설 경우 발생한다.
이 Code를 –Xmx256m 옵션을 주어 Heap 메모리가 부족한 환경에서 실행하면 Heapspace가 부족하다는 에러가 발생한다. 우리가 배열을 생성할 때 배열의 길이에 들어가는 인자를 사용자로부터 변수로 받아오는 경우 상한을 두고 메모리 한계를 꼭 생각해야 하는 이유다.
두번째는 GC Overhead Limit Exceeded이다. 이 에러는 GC(Garbage Collector)가 너무 빈번하게 일어나서 오버헤드가 걸렸다는 뜻인데, 근본적으로는 앞서 말했던 Heapspace가 부족한 것에서부터 시작한다고 볼 수 있다. GC가 동작하는 조건이 가용 메모리가 부족한 것으로부터 시작하기 때문이다. 정확히는 GC 작업을 하느라 전체 동작시간의 98%를 소비했는데도 불구하고, Heap 메모리를 2% 이하로 확보했을 경우 이 에러가 발생한다.
해당 작업을 –Xmx100m -XX:+UseParallelGC 옵션과 함께 실행하면 에러를 재현할 수 있다. 에러 메세지는 GC 오류이지만 거의 대부분 Heapspace가 실제로 부족하거나, 큰 메모리를 사용하게 되는 코드가 있거나, 메모리 누수를 유발하는 코드가 어딘가에 있다고 받아들이면 된다. 괜히 GC라는 에러 문구를 보고 GC Tuning을 시도하는 우를 범하지 않도록 하자. GC Tuning은 말 그대로 성능을 더 좋게 하려는 목적으로 접근하는 것이지 안 되는 작업을 되게 하려는 목적이 아니다.
세번째는 Metaspace 에러이다. Metaspace는 Java의 Classloader가 현재까지 로드한 Class들의 메타데이터가 저장되는 공간이다. Java계열의 언어들에서 이름이 다른 Anonymous Class를 다량 생성하거나, 실제로 Class가 많은데 메모리가 부족할 경우에 해당 에러가 발생한다. 일반적으로 Class를 무한정 생성하는 경우는 많이 없기 때문에 이 에러가 발생하면 메모리 할당량을 늘려주는 것으로 해결되지만, 간혹 나도 모르게 쓰고있는 3rd Party Lib들이 Class들을 양산하고 있을 수 있다. 주로 Scala, Kotlin 등이 제공하는 Command Line Compiler, REPL(Read Eval Print Loop)를 내부적으로 활용하거나, 이에 준하는 Janino같은 Runtime Compiler 또는 ScriptEngine을 사용한 어플리케이션, Javassist와 같은 Dynamic Class Generation을 활용한 어플리케이션을 긴 시간 동안 서비스할 때 많이 발생한다.
재현을 위해서 외부 라이브러리인 Javassist를 사용했다. 위 코드를 -XX:MaxMetaspaceSize=256m와 실행하면 Metaspace 에러를 발생시킬 수 있다.
이제 어디서 어떤 일이 벌어지는지 현상을 파악했으니, 이 오류에 대한 모니터링, 분석을 할 차례다. 다음 편에서 몇 가지 유틸리티를 활용하여 초보자들도 쉽게 따라 할 수 있는 메모리 프로파일링 기법에 대해 알아보도록 하자.
▶ 해당 콘텐츠는 저작권법에 의하여 보호받는 저작물로 기고자에 저작권이 있습니다.
▶ 해당 콘텐츠는 사전 동의없이 2차 가공 및 영리적인 이용을 금하고 있습니다.
삼성SDS 분석플랫폼Lab
삼성SDS 연구소 분석플랫폼Lab 소속이며, 소프트웨어 개발, 빅데이터 아키텍쳐, 머신러닝에 관한 지식과 업무 경험을 바탕으로 현재 삼성 SDS Brightics 솔루션 개발을 담당하고 있습니다.