-
[SpringBoot] OOME, hprof 덤프 파일, 메모리 디버깅Programming/SpringBoot 2024. 7. 31. 16:20728x90
hprof 생성
hprof 덤프 파일을 생성하기 위해서는 JVM실행 옵션을 설정하면 됩니다.
인텔리제이를 사용중이므로 인텔리제이 옵션 설정으로 어플리케이션 실행시 적용되도록 해봅시다.
Edit Configurations > Modify options > add VM options
add VM options 버튼을 클릭하면 추가 입력 칸이 생깁니다.
입력 칸에 JVM 힙 메모리, OOME 발생시 덤프 파일을 생성하는 옵션, 덤프 파일 저장 경로를 입력합니다.
-Xmx50m -Dfile.encoding=UTF-8 -Dconsole.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=E:\dump
- Shallow Size: 객체를 저장하는 데에 사용된 메모리
- Retianed Size: 이 객체를 유지하는 데에 사용된 메모리 크기이다. 객체를 저장하는 데에 사용된 메모리 + 객체가 사용하고 있는 다른 객체를 저장하는 데에 사용된 메모리의 합으로 보면 된다. 이 크기는, GC으로 회수할 수 있는 메모리 크기로 볼 수 있다.
힙 메모리를 트러블 슈틸할 때는 Retained Size를 기준으로 정렬해서 상위에 나오는 Class위주로 디버깅하면 수월합니다.
인텔리제이로 hprof파일을 열면 메모리를 확인할 수 있습니다.
Summary도 제공해서 파악하기 쉽네요.
인델리제이로 메모리 디버깅
Profile ~ 버튼을 눌러 인텔리제이에서 제공하는 프로파일링으로 결과를 분석할 수 있습니다.
Profiler > CPU and Memory Live Charts
로컬 서버 애플리케이션 상태를 실시간으로 확인할 수도 있습니다.
메모리 분석1
OOME가 발생한 애플리케이션의 메모리를 분석하려고 합니다.
로그 기능을 유틸성 로그 클래스로 구현하였는데, 로그를 바로바로 파일로 쓰지 않고 ArrayList(메모리)에 모아서 들고 있다가 파일로 쓰도록 하는 기능을 구현하려고 했습니다. 파일로 쓰는 것은 IO가 계속 발생하고 CPU 작업이 수행되야 하기 때문에 비번하게 로그를 남기는 것은 문맥교환 관점에서 비효율적이라 판단하였습니다.
그렇지만 OOM이 발생하게 되었습니다.
OOM이 발생했던 순간의 Heap dump파일을 보면
LocalDateTime객체가 540276번 사용되고, 힙 메모리의 50MB중 38.9MB를 사용한 것을 확인할 수 있습니다.
LocalDateTime.now()함수를 사용하였는데 객체가 계속 생성되어 쌓이는 것도 확인할 수 있었습니다.
LocalDateTime.now() 메소드의 내부를 타고 들어가 보면 LocalDate, LocalTime 객체들을 사용하고 있습니다.
now() 메소드를 호출하여 사용한 만큼 메모리 사용량이 증가한 것이였습니다.
첫번째 시도
LocalDateTime.now() 메소드로 LocalDate, LocalTime객체들이 할당되어 ArrayList에 저장되기에 이를 해결하는 방법으로
LoalDateTime 타입으로 저장하지 않고 String으로 변환해서 저장하는 방법으로 코드를 수정하였습니다.
문제 코드
private static final ArrayList<LocalDateTime> arrayTimeList = new ArrayList<>();
수정 코드
private static final ArrayList<String> arrayLogList = new ArrayList<>();
하지만 여전히 OOME 에러가 발생했습니다.
메모리 분석2
다시 hprof 덤프파일을 통해 메모리 분석을 해보겠습니다.
LocalDateTime.now().toString()
위의 메소드로 String 타입으로 저장하도록 했지만 메모리 덤프에서도 String객체가 558,211번 호출되고, 힙 메모리의 50MB중 40.08MB이 사용되었습니다.
ArrayList.add() 연산에서 String을 저장하는데 이 때도 String객체가 계속 할당되어 리스트에 저장되고 해제되지 않아 발생한 문제였습니다.
OOME가 발생한 오류 메시지를 보면
add() -> grow() -> copyOf() 메소드가 호출된 것을 확인할 수 있습니다.
ArrayList는 initial capacity = 10이지만, 가변 길이입니다.
ArrayList가 꽉 찼을 때 약 1.5배 배열의 크기를 증가시켜주는 로직이 내부에 구현되어 있습니다.
add() 메서드 내부에 현재 크기가 모두 사용되었다면 grow()메소드를 호출합니다.
grow()메소드는 배열의 크기가 부족할 때 배열을 확장시켜주는 함수입니다.
newCapacity로 (이전 배열 크기) + (이전 배열 크기 / 2) = 1.5배 크기로 배열 확장하게 됩니다.
코드 상에는 Shift연산으로 구현되어 있습니다.
oldCapacity >> 1
Initial capacity 10에서 1개의 원소를 추가하게 되면 15로 늘어나게 됩니다.
1010(2) -> shift -> 0101(2) 로 10-> 15 로 계산됩니다.
ArrayList의 용량을 증가시키고 copyOf()메소드로 기존 데이터를 복사해 새로운 객체를 할당합니다 -> Array.newInstance()
ArrayList는 아래와 같이 static final변수로 선언되어 있습니다.
private static final ArrayList<String> arrayLogList = new ArrayList<>();
이 코드가 문제였습니다.
문제점
1. ArrayList를 static 으로 선언하여 GC의 대상이 되지 않는다.
2. ArrayList는 가변길이인데 원소를 계속 저장하기만 한다.
3. add() 하면 clear()를 호출하여 메모리 해제하는 로직이 없다.
ArrayList 를 static변수로 선언하여 데이터를 가지고 수집하고 있었는데 이는 잘못된 코드였습니다.
static은 애플리케이션이 실행 중인 런타임 시에 계속 메모리에 올라가 있기에 GC의 대상이 되지 않습니다. 더더욱 메모리 할당, 해제에 신경을 써야 하고 되도록 static에는 상태를 저장하는 변수를 선언하면 안됩니다.
해결
static 변수에 상태를 저장하면 안되지만 유틸성 클래스로 구현하였기에 ArrayList에 저장되는 원소의 사이즈를 체크하여 clear()를 호출하여 객체가 해제될 수 있게 수정하였습니다.
로그 기능을 유틸성 클래스로 구현하지 않고 다른 방식으로 개발할 수 있는지 고민해야겠습니다.
참고
https://codingdreamtree.tistory.com/44
https://romainefabula.tistory.com/89
https://americanopeople.tistory.com/428
https://mangkyu.tistory.com/336
728x90'Programming > SpringBoot' 카테고리의 다른 글
[SpringBoot] batchUpdate()를 활용한 bulkInsert (0) 2024.07.30 [Spring] ModelMapper MapStruct (0) 2024.07.02 [Spring] Transaction (0) 2024.06.20 [Spring] Spring에서 JSON 파라미터를 Java객체로 받기 (0) 2024.05.27 [Spring] Content-Type 'application/octet-stream' is not supported (0) 2024.05.27