Spring Boot 애플리케이션 OOM(Out Of Memory) 원인 분석 및 해결

임원재·2025년 7월 16일
1

SpringBoot

목록 보기
18/19

문제 상황

프로젝트 진행 중, 서버가 자주 닫히는 현상이 발생했다. 서버가 닫힌 시간 즈음에 들어온 네트워크 패킷들이 평소보다 많았기에 t2.micro를 쓰던 필자에게는 서버가 작아서 그렇다고 수긍하게 만들었다.

서버가 닫히는 빈도가 점점 늘어나자, 원인을 찾기로 결정했다. 먼저, jvm내의 리소스를 조회하기 위해 visualVM이라는 프로그램으로 현재 서버에서 구동중인 애플리케이션을 모니터링하려고 했다.

꽉 찬 메타스페이스?

그라파나나 프로메테우스 조합의 훌륭한 모니터링 툴이 존재했지만, 이미 서버 내의 컨테이너가 4개 실행되고 있어 추가로 2개의 컨테이너를 실행하기에는 무리가 있다고 판단했다. 이에 로컬에서 실행하고, 서버에서 정보를 받아와 시각화하는 VisualVM을 사용하게 되었다.

다음과 같이 CPU, Memory 영역의 사용량 확인 가능했다. 하지만,,

JVM의 메타스페이스 영역이 포화상태임을 확인하였다. 힙은 문제없었으나 메타스페이스만 저렇게 꽉 찬다는게 이상하다고 느꼈다.
지금 이 상황이라면 수많은 클래스가 로드되고 힙에는 올라가지 않았다는 뜻인데,,, 이것만으로는 원인을 알기 힘들어 메타스페이스에 데이터를 로드하는 클래스 로더의 사용량을 조회하는 방법을 생각했다.

먼저 docker daemon에 접속한 뒤,

spring 애플리케이션이 구동중인 프로세스의 PID를 조회한다.
도커 컨테이너 내부에서는 항상 1인듯 하다.

그 후, jmap -clstats <PID>명령어를 통해 클래스로더 별 메타스페이스 사용량을 조회하였다.

다음과 같이, 클래스로더와 해당 클래스로더의 부모 클래스로더, 청크 사이즈, 블럭사이즈, 타입 정보를 확인할 수 있다.

org.springframework.boot.loader.launch.LaunchedClassLoader에서 비정상적인 사이즈 할당을 발견하였다.
해당 이름만으로는 구체적인 문제를 알 수 없었다... 대신, visualVM에서 Heap Dump를 통해 어떤 클래스로더가 어떤 클래스를 할당했는지 확인하고자 했다.

이에 ec2 docker daemon에서 생성된 .hprof파일을 scp명령어를 통해 로컬 컴퓨터로 복사하였고, 이를 visualVM에서 실행하였다.

해당 대시보드에서 Classes by Size of Instances 목록을 확인해보면,

byte[], int[], java.lang.String 등이 대부분의 사이즈를 차지함을 알 수 있었다. 스프링 애플리케이션의 힙 인스턴스를 분석해본 적이 없어서 이게 정상적인 상황인지 판단이 잘 서지 않았다.

byte[]형태의 인스턴스가 많아, 메모리에 로그가 쌓였나 예상하여 재시작했지만, 실행 시점부터 100% 할당률을 보여주었다.

힙에 어떤 클래스가 로드되었는지 확인하면 메타스페이스의 점유율 문제를 해결할 수 있지 않을까 생각하여 아래 명령어로 힙에 어떤 클래스가 로드되어있는지 확인하였다.

jmap -histo <pid>

하지만 오름차순으로 정렬되어있어 모든 객체가 출력되지 않는다.
이때

jmap -histo <PID> | sort -n -k2

정렬 명령어(sort)를 통해 가장 밑에 가장 힙을 많이 차지하는 객체를 확인할 수 있다.

다음과 같이 [B라는 객체와, String 객체, ConcurrentHashMap등 이 메모리를 차지함을 알 수 있다.

한 줄 씩 메모리 누수 관련 구글링을 진행하였더니 , io.github.classgraph가 .jar전체를 스캔하며 수만 개의 class, zipEntry 등을 로딩하여 많은 클래스를 힙에 로드한다는 것을 알게 되었다.

이에 classgraph가 어느 라이브러리에서 사용되는지 확인하기 위해

./gradlew dependencies > deps.txt

를 통해 의존성 트리를 txt파일에 넣고, classgraph를 검색했다.

몇천줄이 넘어가 모든 로그를 보여줄 수 없지만,

+--- org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2  
|    +--- org.springdoc:springdoc-openapi-starter-webmvc-api:2.0.2  
|    |    +--- org.springdoc:springdoc-openapi-starter-common:2.0.2  
|    |    |    +--- org.springframework.boot:spring-boot-autoconfigure:3.0.0 -> 3.4.4 (*)  
|    |    |    \--- io.swagger.core.v3:swagger-core-jakarta:2.2.7  
|    |    |         +--- org.apache.commons:commons-lang3:3.12.0 -> 3.17.0  
|    |    |         +--- org.slf4j:slf4j-api:1.7.35 -> 2.0.17  
|    |    |         +--- io.swagger.core.v3:swagger-annotations-jakarta:2.2.7  
|    |    |         +--- io.swagger.core.v3:swagger-models-jakarta:2.2.7  
|    |    |         |    \--- com.fasterxml.jackson.core:jackson-annotations:2.14.0 -> 2.18.3 (*)  
|    |    |         +--- org.yaml:snakeyaml:1.33 -> 2.3  
|    |    |         +--- jakarta.xml.bind:jakarta.xml.bind-api:3.0.0 -> 4.0.0  
|    |    |         |    \--- jakarta.activation:jakarta.activation-api:2.1.0 -> 2.1.3  
|    |    |         +--- jakarta.validation:jakarta.validation-api:3.0.0 -> 3.0.2  
|    |    |         +--- com.fasterxml.jackson.core:jackson-annotations:2.14.0 -> 2.18.3 (*)  
|    |    |         +--- com.fasterxml.jackson.core:jackson-databind:2.14.0 -> 2.18.3 (*)  
|    |    |         +--- com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.14.0 -> 2.18.3  
|    |    |         |    +--- com.fasterxml.jackson.core:jackson-databind:2.18.3 (*)  
|    |    |         |    +--- org.yaml:snakeyaml:2.3  
|    |    |         |    +--- com.fasterxml.jackson.core:jackson-core:2.18.3 (*)  
|    |    |         |    \--- com.fasterxml.jackson:jackson-bom:2.18.3 (*)  
|    |    |         \--- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.14.0 -> 2.18.3 (*)  
|    |    \--- org.springframework:spring-webmvc:6.0.2 -> 6.2.5 (*)  
|    +--- org.webjars:swagger-ui:4.15.5  
|    +--- org.webjars:webjars-locator-core:0.52 -> 0.59  
|    |    \--- org.slf4j:slf4j-api:2.0.13 -> 2.0.17  
|    \--- io.github.classgraph:classgraph:4.8.149

맨 아래 io.github.classgraph가 존재함을 확인할 수 있다.
다름아닌 이 의존성을 가진 라이브러리는 바로 springdoc-openapi-starter-webmvc-ui즉, swagger였다.

swagger에서는 API 문서를 자동으로 만들기 위해, @RestController를 비롯한 모든 클래스를 스캔하는것으로 예상할 수 있다. 이 과정에서 너무 많은 클래스 정보를 메타데이터에 적재하여 점유율 문제가 발생해 OOM이 터지는 것으로 예측하였다.

springdoc과 classgraph를 키워드로 구글링한 결과,

ClassGraph raised OOM error in my project #2050

위 링크를 찾아낼 수 있었다.
필자와 같은 상황으로 OOM(Out Of Memory)이 발생하며, swagger가 원인이라고 밝혔다.
하지만 이슈에 답한 개발자는 swagger의 문제가 아니고 classgraph 자체의 기능 문제라고 했다.

위 개발자가 시도한 swagger의 전체 스캔범위를 부분만 스캔하도록 설정하여 스캔의 타깃 클래스를 줄여보고자 했다.

해결

swagger 스캔 범위 지정

스웨거로 하여금 전체 스캔을 하지 않고

springdoc:  
  packages-to-scan: com.itstime.xpact

다음과 같이 패키지를 지정하여 해당 패키지 내부에서만 클래스 스캔이 이루어지도록 할 수 있다.

MetaSpace 크기 조절

메타스페이스가 포화되어 OutOfMemoryError: Metaspace가 발생한 원인을 파악하였고, 이를 방지하기 위해 메타스페이스의 최대 크기도 늘리기로 결정하였다.

"XX:MetaspaceSize=128M", "-XX:MaxMetaspaceSize=256M"

위 옵션을 JAR 실행 시 함께 지정하면, 초기 메타스페이스 크기와 최대 허용 크기를 설정할 수 있다. 현재 128M에서 부족하기에 최댓값을 2배로 늘려주었다.

이 설정을 통해 클래스 메타데이터를 위한 메모리 공간이 충분히 확보할 수 있고, swagger의 스캔 범위를 설정하여 OOM 발생 가능성을 줄이고자 했다.

느낀 점

단순히 메타스페이스의 크기를 늘려 문제를 해결할 수 있었지만, JVM의 상태를 확인하고 원인을 찾는 과정에서 다양한 명령어와 방법을 통해 JVM 메모리 구조에 대한 이해와 클래스 로딩 상황을 분석하는 경험을 얻을 수 있었다.

0개의 댓글