[트러블슈팅] OutOfMemoryError(OOM) 발생 원인 4가지와 해결 시나리오

지금까지 우리는 JVM의 딥한 아키텍처부터 메모리, 스레드, 데이터베이스 튜닝, 그리고 비동기 통신(NIO)까지 서버의 성능을 최적화하기 위한 다양한 이론과 기법들을 섭렵했습니다. 하지만 아무리 철저하게 준비해도 실제 운영(Production) 환경의 변수를 100% 통제할 수는 없습니다.

이번 포스팅부터는 대미를 장식할 ‘실전 트러블슈팅’ 시리즈를 시작합니다. 그 첫 번째 주제는 자바 개발자들의 가장 큰 악몽이자 서버를 강제로 종료시키는 치명적인 에러, java.lang.OutOfMemoryError (이하 OOM)입니다. OOM은 단순히 “메모리가 부족하다”는 하나의 에러가 아닙니다. 발생 원인에 따라 뒤에 붙는 메시지가 다르며, 그에 따른 해결책도 완전히 달라져야 합니다. 대표적인 4가지 OOM 유형과 실전 해결 시나리오를 분석해 보겠습니다.

1. java.lang.OutOfMemoryError: Java heap space

가장 빈번하게 발생하는 OOM 유형입니다. JVM의 힙(Heap) 영역에 더 이상 새로운 객체를 할당할 공간이 없을 때 발생합니다.

  • 발생 원인: 두 가지 경우가 있습니다. 첫째, 데이터베이스에서 대량의 쿼리 결과 리스트를 한 번에 메모리로 끌고 와서 거대한 JSON 파일 등으로 변환하려고 할 때처럼 ‘순간적인 과도한 메모리 요구’가 있을 때 발생합니다. 둘째, 다 쓴 객체를 리스트나 맵(Map) 같은 자료구조에 계속 담아두고 비우지 않아 가비지 컬렉터(GC)가 메모리를 회수하지 못하는 ‘메모리 누수(Memory Leak)’입니다.
  • 해결 시나리오: 단순히 힙 사이즈(-Xmx)를 늘리는 것은 임시방편일 뿐, 결국 다시 서버가 죽습니다. 이전 포스팅에서 다루었던 Eclipse MAT 도구를 사용해 힙 덤프(Heap Dump)를 분석하여 메모리를 쥐고 있는 진범(Retained Heap이 큰 객체)을 찾아야 합니다. 대량 데이터 처리 시에는 페이징(Paging) 처리나 스트림(Stream)을 활용해 메모리에 올라가는 데이터의 청크(Chunk) 크기를 줄이는 코드로 리팩토링해야 합니다.

2. java.lang.OutOfMemoryError: GC overhead limit exceeded

이 에러는 JVM이 애플리케이션 실행보다 쓰레기 청소(GC)에 너무 많은 시간을 허비하고 있다고 판단할 때 던지는 경고성 에러입니다.

  • 발생 원인: JVM이 전체 실행 시간의 98% 이상을 가비지 컬렉션(GC)에 소모하고 있는데, 정작 확보된 여유 메모리 공간은 2% 미만일 때 발생합니다. 즉, 힙 메모리가 가득 차서 계속 청소를 시도하지만, 객체들이 다 살아있는(Reachable) 상태라 청소가 불가능한 ‘극심한 힙 압박’ 상태입니다.
  • 해결 시나리오: 현상 자체는 Java heap space와 비슷하지만, 엄청난 CPU 리소스를 동반하는 Stop-The-World 상태를 지속적으로 유발하여 서버를 완전히 멈추게 만듭니다. 원인이 되는 대용량 캐시 데이터나 불필요한 객체 생성 루프를 프로파일링(VisualVM 등)으로 찾아 제거해야 합니다.

3. java.lang.OutOfMemoryError: Metaspace (또는 PermGen space)

클래스의 메타데이터(클래스 이름, 메서드 정보 등)가 저장되는 메모리 공간이 고갈되었을 때 발생합니다. (Java 8 이전에는 PermGen, 이후에는 Metaspace로 불립니다.)

  • 발생 원인: 동적으로 클래스를 생성하는 프레임워크(Spring의 CGLIB 프록시, Hibernate 등)를 과도하게 사용하거나, 톰캣(Tomcat) 같은 WAS 환경에서 애플리케이션을 재시작하지 않고 지속적으로 재배포(Hot Deployment)를 수행할 때 이전 클래스로더(ClassLoader)가 GC되지 않아 메모리 누수가 발생합니다.
  • 해결 시나리오: 일차적으로 JVM 시작 옵션에서 메타스페이스 최대 크기(-XX:MaxMetaspaceSize)를 넉넉하게 늘려줍니다. 근본적으로는 서버의 무중단 핫 디플로이 빈도를 줄이거나, 클래스로더 누수를 일으키는 외부 라이브러리의 버그 패치를 진행해야 합니다.

4. java.lang.OutOfMemoryError: unable to create new native thread

힙 메모리와는 무관하게, JVM이 운영체제(OS)로부터 새로운 스레드(Thread)를 생성할 자원을 할당받지 못할 때 발생합니다.

  • 발생 원인: 리눅스 OS 자체에 설정된 사용자당 최대 프로세스(스레드) 개수 제한을 초과했거나, JVM이 할당할 수 있는 물리적인 메모리(RAM) 용량 자체가 완전히 바닥났을 때 나타납니다.
  • 해결 시나리오: 먼저 리눅스 터미널에서 ulimit -u 명령어를 쳐서 스레드 생성 제한 수치가 너무 낮지 않은지 확인하고(보통 4096 이상 권장) /etc/security/limits.conf 파일에서 늘려줍니다. OS 제한 문제가 아니라면, 커넥션 풀이나 스레드 풀의 사이즈가 비정상적으로 크게 잡혀있는지 점검하고, 스레드 당 할당되는 스택 메모리 사이즈(-Xss, 보통 1MB)를 줄여서 더 많은 스레드가 생성될 수 있도록 튜닝합니다.

결론: 램(RAM) 증설은 해결책이 아니다

OOM이 터졌을 때 가장 초보적인 대응은 서버의 물리 메모리 용량을 돈을 들여 무작정 늘리는 것입니다. 하지만 메모리 누수가 있는 코드라면 16GB가 아니라 128GB의 램을 꽂아도 시간의 문제일 뿐 서버는 반드시 죽습니다. 에러 로그의 뒤에 붙은 정확한 발생 구간(Heap, Metaspace, Native Thread)을 파악하고, 데이터 분석에 기반한 원인 제거를 수행하는 것이 고연차 백엔드 엔지니어의 핵심 역량입니다.

메모리 장애를 마스터했으니, 다음 포스팅에서는 서버를 멈추게 만드는 또 다른 불청객, ‘[트러블슈팅] Java 프로세스 CPU 사용률 100% 지속 현상 디버깅 사례’를 통해 무한 루프와 스레드 경합을 잡아내는 실전 시나리오를 다루어 보겠습니다.

댓글 남기기