'트래픽이 많이 몰리는 이벤트가 예정되어 있을 경우, 힙 구성을 어떻게 설정해야 할까?'
이 질문을 보고 바로 생각난 건 Young Generation의 크기를 증가시키는 것이었다. 과연 맞을까?
맞다면 비율은 어떻게 설정해야 할까? 아니면 어떤 다른 방법이 있을까?
제대로 고민해 보기 전에, 힙의 구조와 GC에 대해 간단히 정리해 보자.
모든 클래스, 인스턴스 및 배열의 메모리가 동적으로 할당되는 런타임 데이터 영역으로 모든 쓰레드가 접근할 수 있다. JVM이 시작될 때 생성되며, 할당된 객체는 자동 저장소 관리 시스템(가비지 컬렉터)에 의해 회수된다.
위 그림처럼 힙 영역은 크게 두 가지 영역으로 나뉜다.
새롭게 생성되거나 생성된 지 얼마 안 된 객체들이 할당되는 영역으로 대부분의 객체가 생성되었다가 곧바로 사라진다. 어떤 곳에서도 참조하지 않는 객체는 GC(가비지 컬렉션) 대상이 되는데 해당 영역의 GC를 Minor GC라고 부른다.
Young Generation에서 발생하는 GC로 다음 세 가지 영역에서 실행된다.
영역 | 설명 |
---|---|
Eden | 새로 생성된 객체가 위치하며, 정기적인 GC 후 살아남은 객체들은 Survivor 영역으로 전달된다. |
Survivor0/Survivor1 | 한 번 이상의 GC에서 살아남은 객체가 위치하는 영역으로, 둘 중 하나는 꼭 비어 있어야 한다. |
Minor GC는 다음 과정을 반복하며 실행된다.
객체가 생성되면 Eden 영역에 위치하며, Eden 영역이 가득 차면 Minor GC가 실행된다.
GC에서 생존한 객체들은 Survivor 0 영역으로 이동하고 age 값이 1씩 증가한다.
다시 Eden 영역이 가득 차면 Minor GC가 발생하고, 살아남은 객체들은 Survivor 1으로 이동한 후 age가 1씩 증가하게 된다.
age란?
객체가 살아남은 횟수를 의미하는 값이며, Object Header에 기록된다. 만일 age가 임계 값에 도달하면 Old Generation으로 이동하는 Promotion이 발생한다.
Minor GC에서 생존해 age가 임계 값에 도달한 객체는 Old Generation으로 이동한다. 오래 사용되는 객체들이 모이는 곳으로 Young Generation에 비해 큰 공간을 할당한다.
Old Generation에서 발생하는 GC로 실행 과정은 다음과 같다.
age가 임계 값에 도달한 객체를 대상으로 Promotion이 발생한다.
계속되는 Promotion에 의해 Old Generation의 공간이 부족하게 되면 Major GC가 발생한다.
힙의 내부 구조와 각 영역에서 발생하는 GC에 대해 간단히 알아보았다. 이제 많은 트래픽이 몰리는 상황에서 어떤 문제가 발생할 것 같은지 생각해 보자.
앞서 살펴본 힙의 구조는 일반적인 상황의 설정이다. 만약 많은 트래픽이 예정된 상황에서 이런 설정을 유지하면 어떻게 될까?
먼저 Young Generation을 크게 할당하지 않는 이유에 대해 생각해 볼 필요가 있다. 아래 그래프의 X축은 오브젝트 수명, Y축은 참조되고 있는 객체들의 총 바이트 크기다.
이미지 출처: https://docs.oracle.com/en/java/javase/17/gctuning/garbage-collector...
위 그래프를 통해 알 수 있듯이, 대부분의 객체들은 생성되고 곧바로 사용하지 않는 상태가 된다.
만약 Eden 영역을 크게 할당할 경우, 사용하지 않는 객체들로 인해 메모리 공간을 낭비하게 된다. 따라서 작은 공간을 할당하는 이유는 사용하지 않는 객체를 메모리에서 빠르게 제거하기 위함이다.
반면 Old Generation의 경우 오래 사용되는 객체들이 모여있기 때문에 GC가 실행되어도 제거되는 객체가 적다. 따라서 GC 빈도수를 줄이기 위해 큰 공간을 사용하는 것을 알 수 있다.
트래픽이 몰리게 되면 평소보다 많은 객체들이 빠르게 생성된다. 때문에 Young Generation을 크게 할당해도 사용하지 않는 객체들이 메모리에 존재하는 시간이 길지 않다. 오히려 공간을 늘리지 않으면 Minor GC 빈도수가 증가함에 따라 성능이 저하될 수도 있다.
이와 같은 이유로 Young Generation의 크기를 증가시키는 방법을 고려해 볼 수 있다.
G1GC는 1MB ~ 32MB 크기의 Region을 통해 점진적으로 GC를 실행한다.
GC 실행 방식에는 차이가 있지만, 마찬가지로 Region의 크기가 바뀌지 않으면 많은 트래픽에 의해 GC 빈도수가 증가하게 된다.
G1GC는 GC의 빈도수를 줄이기 위해 Region의 크기를 증가시키는 방법을 고려해 볼 수 있다.
두 영역의 비율은 어떻게 설정해야 할까?
트래픽이 많을 거라고 예상할 수는 있지만 정확하게 예측할 수는 없다. 또 애플리케이션마다 구조가 다르기 때문에 한 번에 정의할 수 없는 부분이라고 생각한다.
실제로 비율과 관련해서 알아보면, Young Generation과 Old Generation의 크기를 알맞게 조정하는 것은 중요하지만 방식에는 트레이드오프가 있다고 한다.
각 영역의 비율을 줄이면 GC의 빈도수가 많아지고, 늘리면 GC의 지연 시간이 길어진다. 때문에 수치를 조절해가며, 테스트와 모니터링을 통해 애플리케이션에 맞는 최적의 설정을 찾는 것이 중요하다.
지연 시간이란?
애플리케이션의 응답 속도로, GC pauses로 인해 증가한다.
많은 트래픽을 처리하기 위해 힙 크기를 조정하는 이유는 GC의 성능을 향상시키기 위해서다. 그렇다면 성능 향상을 위한 다른 방법은 없을까?
아래는 Uber 기술 블로그의 일부분이다.
'Preserving the reliability and performance of our internal data services required tuning the GC parameters and memory sizes and reducing the rate at which the system generated Java objects.'
원문 링크
원문을 보면 Uber는 성장과정에서 겪은 다양한 메모리 문제를 해결하기 위해, 메모리 크기를 조정하고 시스템에서 Java 객체를 생성하는 속도를 줄였다고 한다.
또, Minor GC의 지속 시간은 힙 크기보다 GC에서 살아남은 객체들의 영향이 크다고 한다. 즉, Eden 영역에서 Survivor 영역으로 복사되거나 Old 영역으로 Promotion 되는 등의 작업을 줄임으로써 GC의 성능을 높일 수 있다.
이처럼 객체 생성을 줄이는 것 역시 성능 향상을 위해 고려해야 하는 부분이다.
객체 생성을 줄일 수 있는 몇 가지 방법에 대해 간단히 알아보자. 자세한 내용은 이곳에서 볼 수 있다.
컬렉션과 이하 구현체들은 내부적으로 배열을 사용한다. 때문에 초기 크기를 벗어나면 새로운 배열을 생성하는데, 이 과정에서 기존 배열은 GC의 대상이 된다. 따라서 예측 가능하다면 컬렉션의 크기를 생성 시에 직접 설정하는 것이 좋다.
또, 메소드에서 컬렉션을 생성하는 것보다 매개 변수로 받아 불필요한 생성을 지양하는 것이 좋다.
통신하려는 데이터 크기가 클 때, JVM 내부에 한 번에 할당하려고 한다면 OutOfMemoryErrors가 발생할 수 있다. 어떻게 할당되어도 결국 큰 공간을 차지하기 때문에 스트림으로 나누어 전송하는 것이 좋다.
InputStream의 경우 내부적으로 버퍼를 통해 일정 크기만큼만 조회한다. 따라서 InputStream을 사용해 가비지 생성을 최소화하고, OutOfMemoryErrors를 방지할 수 있다.
가비지 컬렉터는 컨테이너가 불변 객체를 필드로 가지고 있으면, 하위의 불변 객체들은 GC 대상에서 빠르게 제외한다. 해당 컨테이너가 참조하는 불변 객체들은 처음에 할당된 상태 그대로 참조되고 있기 때문이다. 따라서 불변 객체를 활용하면 GC의 실행 시간을 줄일 수 있다.
힙은 동적으로 객체가 생성되고 사라지는 곳으로, Young Generation과 Old Generation으로 구분한다. 각 영역에서는 Minor GC, Major GC가 실행된다.
대부분의 객체들이 곧바로 사용하지 않는 상태가 되기 때문에 Young Generation이 작은 공간을 가지지만, 트래픽이 몰릴 경우엔 객체가 빠르게 생성되기 때문에 크기를 증가시키는 것을 고려해 볼 수 있었다.
힙 구성 비율은 애플리케이션의 상황에 맞춰서 적합한 설정을 찾아야 한다.
GC 성능을 향상시키기 위해 객체 생성을 줄이는 것 역시 중요하다.
'하나를 만들더라도 제대로 만들어야 한다.'는 말을 많이 들었지만, 제대로 만드는 방법에 대해 고민해 본 적은 없었던 것 같다. 이번 주제를 통해 '제대로'에 한 걸음 다가간 것 같다.
https://logonjava.blogspot.com/2015/08/java-g1-gc-full-gc.html
https://mangkyu.tistory.com/120
https://sigridjin.medium.com/weekly-java...
https://www.uber.com/en-KR/blog/jvm-tuning-garbage-collection/
개발자로서 성장하는 데 큰 도움이 된 글이었습니다. 감사합니다.