배경
실행중이던 서버가 갑자기 다운이 되었다. 로그를 확인해보니 java.lang.OutOfMemoryError: GC overhead limit exceeded 에러가 발생했다. 서버를 재기동해서 그냥 넘어가기는 했지만 다시 발생할 수 있는 문제이기 때문에 원인과 해결 방법을 정리해본다.
개발 환경
- Java 1.8
- Spring 5.3
- Oracle 11g
- MacOS
원인
GC는 더 이상 참조되지 않는 객체를 제거해주는 작업을 해주기 때문에 메모리 사용을 최적화하는데 도움을 준다. 하지만 GC가 너무 오랫동안 수행되면 실제 작업보다 많은 CPU를 사용하게 되어서 프로그램 실행이 느려지거나 중단될 수 있다.
OOM은 JVM이 GC를 수행하는데 너무 많은 시간을 소비해서 애플리케이션을 실행할 수 없게 되는 상황에 발생하는 에러이다. Java 문서에는 아래와 같이 나와있다.
CPU의 98% 이상이 GC에 소비되었지만 Heap 메모리의 2% 미만만 복구되는 경우 발생한다.
해결 방법
Heap Size 늘리기
JVM 옵션을 넣어 Heap Size를 늘릴 수 있다. 하지만 애플리케이션 코드상에 메모리 누수가 있는 경우 이 방법이 해결책이 되지는 않는다.
오류가 발생하는 시점을 잠깐 연기해줄 뿐이다. 따라서 애플리케이션의 메모리 사용량을 분석해 메모리 누수의 원인을 찾아내는 것이 중요하다.
java -Xmx2048m com.xyz.TheClassName
Heap Dump 분석
이 문제를 해결하기 위해서는 메모리 누수가 있는 코드를 찾아내야 한다.
힙 덤프(Heap Dump)란 힙 영역에서 활성화 되어 있는 스냅샷이 저장된 파일이다. 주소, 타입, 클래스 이름, 크기 등 인스턴스에 대한 자세한 정보와 인스턴스에 다른 객체에 대한 참조 여부를 알 수 있다.
이제 Heap Dump 파일을 분석해 메모리 누수 원인을 찾아 해결해보자
1. JVM에 아래 옵션을 추가해줘야 OOM이 발생했을 때 힙 덤프 파일이 생성된다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump
2. Eclipse MAT 설치
설치 방법은 아래 링크에 정리해두었다. (Mac OS 기준)
Eclipse Memory Analyzer - Eclipse MAT 설치 방법 (MacOS)
배경GC 에러로 인해 서버가 다운되어서 원인을 알아보기 위해 dump 파일을 분석해볼 필요가 있었다. Java - java.lang.OutOfMemoryError:GC overhead limit exceeded 원인실행중이던 서버가 갑자기 다운이 되었다
green-bin.tistory.com
3. Heap Dump 분석
Eclipse MAT으로 Heap Dump 파일을 분석해보자. MAT은 메모리 누수를 자동으로 탐지하는 기능을 제공해주고 있다.
[Report > Leak Suspects 실행]을 통해 메모리 누수가 의심되는 객체들을 자동으로 탐지하고 누수 가능성이 높은 곳의 경로를 알려준다.

Leak Suspects Report를 확인해보니 HashMap 객체에서 메모리 누수가 의심된다고 보고되었다. 대량의 데이터를 Map이나 ArrayList같은 자료구조에 저장할 때 메모리 누수가 많이 발생한다고 한다.
경로를 추적해보니 회원 정보 수정 로직에서 누수가 발생하고 있었고, 로직에서 필수 검색 조건이 누락되어 약 112만 개의 전체 회원 정보가 조회되는 문제가 확인 되었다. 필수 조건 검증 로직을 추가해서 해결할 수 있도록 해결 방안 문서를 작성해두었다.
예방하기
OOM은 보통 무분별하게 객체를 생성하거나 객체가 참조되고 있는 상태(Reachable 상태)를 유지할 때 발생하는게 주된 원인이다.
이런 문제를 예방하기 위해서는 코드를 작성할 때 메모리 누수를 고려해서 작성하는 것이 가장 중요하다.
사용한 리소스 잘 반납하기
DB 커넥트, FileStream 등 리소스를 사용한 후에 반드시 close()메서드로 닫아주어야 한다.
Java7 이상이라면 try-with-resources 구문을 사용하면 자동으로 닫아준다.
try (FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr)) {
return br.readLine();
}
Stream 사용하기
Stream은 파이프라이닝 기법을 통해 여러 연산을 연결하여 진행한다.
이러한 특징 때문에 중간 연산들을 저장하지 않아 메모리 사용을 최적화 할 수 있다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// for문 사용
int sum = 0;
for(int i = 0; i < numbers.length(); i++) {
int n = numbers[i];
if(n % 2 == 0) {
sum += n * 2;
}
}
// Stream 사용
int sum = numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.reduce(0, Integer::sum);
StrinbBuilder 사용하기
문자열을 연결할 때 String 객체를 생성해서 연결하는 대신 StringBuilder를 사용해서 메모리 사용을 최적화할 수 있다.
// 나쁜 예
String result = "";
for (int i = 0; i < 100; i++) {
result += i; // 매번 새로운 String 객체 생성
}
// 좋은 예
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append(i);
}
String result = sb.toString();
GC 로그 남기기
GC 이력을 로그 파일로 남겨두자. 아래는 Java 8 기준 옵션이다. Java 버전별로 설정 방식이 조금 다르니 찾아보자
-XX:+PrintGC -Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
- -XX:+PrintGC: 간단한 GC 정보 출력
- -XX:+PrintGCDetails: 상세한 GC 정보 출력
- -XX:+PrintGCDateStamps: GC 이벤트에 날짜와 시간 추가
- -Xloggc:<파일경로>: GC 로그를 지정된 파일에 저장
GC 튜닝하기
GC 튜닝은 복잡하고 신중한 접근이 필요한 작업이다. 대부분 코드 리팩토링을 통해서 메모리 성능을 개선할 수 있고, 이 방법이 GC 튜닝보다 더 안전하고 효과적인 방법이다. GC 튜닝은 나중에 학습할 예정이다.
마무리
실무에서 OOM 에러를 만나보기 힘들다는 이야기를 들었었는데, 난 굉장히 빨리 만나본 것 같다. Eclipse MAT의 용도와 사용법을 알게 됐고, 직접 heap dump 분석을 통해 원인을 찾아내는 귀중한 경험을 했다.
메모리 누수의 원인이 된 코드를 수정하지 않고 서버 재기동만으로 넘어간 부분이 마음에 걸린다. 충분히 재발할 수 있는 문제이기에 원인과 해결 방안에 대해 잘 문서화해둬야겠다.
참조
https://sematext.com/blog/java-garbage-collection-tuning/#parallel-garbage-collector
https://incheol-jung.gitbook.io/docs/q-and-a/java/heap-dump-feat.-oom
https://www.baeldung.com/java-gc-overhead-limit-exceeded
https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html
'나의 에러 일지' 카테고리의 다른 글
배경
실행중이던 서버가 갑자기 다운이 되었다. 로그를 확인해보니 java.lang.OutOfMemoryError: GC overhead limit exceeded 에러가 발생했다. 서버를 재기동해서 그냥 넘어가기는 했지만 다시 발생할 수 있는 문제이기 때문에 원인과 해결 방법을 정리해본다.
개발 환경
- Java 1.8
- Spring 5.3
- Oracle 11g
- MacOS
원인
GC는 더 이상 참조되지 않는 객체를 제거해주는 작업을 해주기 때문에 메모리 사용을 최적화하는데 도움을 준다. 하지만 GC가 너무 오랫동안 수행되면 실제 작업보다 많은 CPU를 사용하게 되어서 프로그램 실행이 느려지거나 중단될 수 있다.
OOM은 JVM이 GC를 수행하는데 너무 많은 시간을 소비해서 애플리케이션을 실행할 수 없게 되는 상황에 발생하는 에러이다. Java 문서에는 아래와 같이 나와있다.
CPU의 98% 이상이 GC에 소비되었지만 Heap 메모리의 2% 미만만 복구되는 경우 발생한다.
해결 방법
Heap Size 늘리기
JVM 옵션을 넣어 Heap Size를 늘릴 수 있다. 하지만 애플리케이션 코드상에 메모리 누수가 있는 경우 이 방법이 해결책이 되지는 않는다.
오류가 발생하는 시점을 잠깐 연기해줄 뿐이다. 따라서 애플리케이션의 메모리 사용량을 분석해 메모리 누수의 원인을 찾아내는 것이 중요하다.
java -Xmx2048m com.xyz.TheClassName
Heap Dump 분석
이 문제를 해결하기 위해서는 메모리 누수가 있는 코드를 찾아내야 한다.
힙 덤프(Heap Dump)란 힙 영역에서 활성화 되어 있는 스냅샷이 저장된 파일이다. 주소, 타입, 클래스 이름, 크기 등 인스턴스에 대한 자세한 정보와 인스턴스에 다른 객체에 대한 참조 여부를 알 수 있다.
이제 Heap Dump 파일을 분석해 메모리 누수 원인을 찾아 해결해보자
1. JVM에 아래 옵션을 추가해줘야 OOM이 발생했을 때 힙 덤프 파일이 생성된다.
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump
2. Eclipse MAT 설치
설치 방법은 아래 링크에 정리해두었다. (Mac OS 기준)
Eclipse Memory Analyzer - Eclipse MAT 설치 방법 (MacOS)
배경GC 에러로 인해 서버가 다운되어서 원인을 알아보기 위해 dump 파일을 분석해볼 필요가 있었다. Java - java.lang.OutOfMemoryError:GC overhead limit exceeded 원인실행중이던 서버가 갑자기 다운이 되었다
green-bin.tistory.com
3. Heap Dump 분석
Eclipse MAT으로 Heap Dump 파일을 분석해보자. MAT은 메모리 누수를 자동으로 탐지하는 기능을 제공해주고 있다.
[Report > Leak Suspects 실행]을 통해 메모리 누수가 의심되는 객체들을 자동으로 탐지하고 누수 가능성이 높은 곳의 경로를 알려준다.

Leak Suspects Report를 확인해보니 HashMap 객체에서 메모리 누수가 의심된다고 보고되었다. 대량의 데이터를 Map이나 ArrayList같은 자료구조에 저장할 때 메모리 누수가 많이 발생한다고 한다.
경로를 추적해보니 회원 정보 수정 로직에서 누수가 발생하고 있었고, 로직에서 필수 검색 조건이 누락되어 약 112만 개의 전체 회원 정보가 조회되는 문제가 확인 되었다. 필수 조건 검증 로직을 추가해서 해결할 수 있도록 해결 방안 문서를 작성해두었다.
예방하기
OOM은 보통 무분별하게 객체를 생성하거나 객체가 참조되고 있는 상태(Reachable 상태)를 유지할 때 발생하는게 주된 원인이다.
이런 문제를 예방하기 위해서는 코드를 작성할 때 메모리 누수를 고려해서 작성하는 것이 가장 중요하다.
사용한 리소스 잘 반납하기
DB 커넥트, FileStream 등 리소스를 사용한 후에 반드시 close()메서드로 닫아주어야 한다.
Java7 이상이라면 try-with-resources 구문을 사용하면 자동으로 닫아준다.
try (FileReader fr = new FileReader(path);
BufferedReader br = new BufferedReader(fr)) {
return br.readLine();
}
Stream 사용하기
Stream은 파이프라이닝 기법을 통해 여러 연산을 연결하여 진행한다.
이러한 특징 때문에 중간 연산들을 저장하지 않아 메모리 사용을 최적화 할 수 있다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// for문 사용
int sum = 0;
for(int i = 0; i < numbers.length(); i++) {
int n = numbers[i];
if(n % 2 == 0) {
sum += n * 2;
}
}
// Stream 사용
int sum = numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * 2)
.reduce(0, Integer::sum);
StrinbBuilder 사용하기
문자열을 연결할 때 String 객체를 생성해서 연결하는 대신 StringBuilder를 사용해서 메모리 사용을 최적화할 수 있다.
// 나쁜 예
String result = "";
for (int i = 0; i < 100; i++) {
result += i; // 매번 새로운 String 객체 생성
}
// 좋은 예
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append(i);
}
String result = sb.toString();
GC 로그 남기기
GC 이력을 로그 파일로 남겨두자. 아래는 Java 8 기준 옵션이다. Java 버전별로 설정 방식이 조금 다르니 찾아보자
-XX:+PrintGC -Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
- -XX:+PrintGC: 간단한 GC 정보 출력
- -XX:+PrintGCDetails: 상세한 GC 정보 출력
- -XX:+PrintGCDateStamps: GC 이벤트에 날짜와 시간 추가
- -Xloggc:<파일경로>: GC 로그를 지정된 파일에 저장
GC 튜닝하기
GC 튜닝은 복잡하고 신중한 접근이 필요한 작업이다. 대부분 코드 리팩토링을 통해서 메모리 성능을 개선할 수 있고, 이 방법이 GC 튜닝보다 더 안전하고 효과적인 방법이다. GC 튜닝은 나중에 학습할 예정이다.
마무리
실무에서 OOM 에러를 만나보기 힘들다는 이야기를 들었었는데, 난 굉장히 빨리 만나본 것 같다. Eclipse MAT의 용도와 사용법을 알게 됐고, 직접 heap dump 분석을 통해 원인을 찾아내는 귀중한 경험을 했다.
메모리 누수의 원인이 된 코드를 수정하지 않고 서버 재기동만으로 넘어간 부분이 마음에 걸린다. 충분히 재발할 수 있는 문제이기에 원인과 해결 방안에 대해 잘 문서화해둬야겠다.
참조
https://sematext.com/blog/java-garbage-collection-tuning/#parallel-garbage-collector
https://incheol-jung.gitbook.io/docs/q-and-a/java/heap-dump-feat.-oom
https://www.baeldung.com/java-gc-overhead-limit-exceeded
https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html