19. GC 튜닝을 항상 할 필요는 없다

de_sj_awa·2021년 9월 10일
1

19. GC 튜닝을 항상 할 필요는 없다

자바의 GC 튜닝은 꼭 필요한 경우에 하는 것이 좋다. 그렇다면 WAS를 띄울 때 아무런 옵션 없이 띄워도 된다는 말인가? 그런 말이 절대 아니다. 기본적인 메모리 크기 정도만 지정하면 웬만한 사용량이 많지 않은 시스템에서는 튜닝을 할 필요가 없다는 말이다.

1. GC 튜닝을 꼭 해야 할까?

결론부터 이야기하면 Java 기반의 모든 서비스에서 GC 튜닝을 진행할 필요는 없다. 튜닝이 필요 없다는 이야기는 운영 중인 Java 기반 시스템의 옵션에는 기본적으로 다음과 같은 것들은 추가되어 있을 때의 경우다.

  • Xms 옵션과 -Xmx 옵션으로 메모리 크기를 지정했다.
  • -server 옵션이 포함되어 있다.

그리고 시스템의 로그에는 타임아웃 관련 로그가 남아있지 않아야 한다. 여기서 타임아웃은 다음과 같은 것들을 말한다.

  • DB 작업과 관련된 타임아웃
  • 다른 서버와의 통신시 타임아웃

왜 갑자기 타임아웃 이야기를 하는지 의아할 수도 있다. 그런데, 타임아웃 로그가 존재하고 있다는 것은 그 시스템을 사용하고 사용자 중 대다수나 일부는 정상적인 응답을 받지 못했다는 말이다. 그리고, 대부분 서로 다른 서버간에 통신 문제나 원격 서버의 성능이 느려서 타임아웃이 발생할 수도 있지만, 그 이유가 GC 때문일 수도 있다.

지금까지 이야기한 것을 정리하자면,

  • JVM의 메모리 크기도 지정하지 않았고,
  • Timeout이 지속적으로 발생하고 있다면

GC 튜닝을 하는 것이 좋다. 그렇지 않다면, GC 튜닝할 시간에 다른 작업을 하는 것이 더 낫다.

그런데 한 가지 꼭 명심해야 하는 점이 있다. GC 튜닝은 가장 마지막에 하는 작업이라는 것이다.

GC 튜닝을 왜 하는지 근본적인 이유를 생각해보자. Java에서 생성된 객체는 가비지 컬렉터(Garbage Collector)가 처리해서 지운다. 생성된 객체가 많으면 많을수록 가비지 컬렉터가 처리해야 하는 대상도 많아지고, GC를 수행하는 횟수도 증가한다. 즉, GC를 적게 하도록 하려면 객체 생성을 줄이는 작업을 먼저 해야 한다.

String 대신 StringBuilder나 StringBuffer를 사용하는 거나, 로그를 최대한 적게 쌓도록 하는 등 임시 메모리를 적게 사용하도록 하는 작업은 중요하다.

만약 애플리케이션 메모리 사용도 튜닝을 많이 해서 어느 정도 만족할 만한 상황이 되었다면 본격적으로 GC 튜닝을 시작하면 된다. GC 튜닝의 목적은 Old 영역으로 넘어가는 객체의 수를 최소화하는 것과 Full GC의 실행시간을 줄이는 것, 두 가지로 나누어 볼 수 있다.

Old 영역으로 넘어가는 객체의 수 최소화하기

Oracle JVM에서 제공하는 모든 GC는 Generational GC이다. 즉, Eden 영역에서 객체가 처음 만들어지고, Survivor 영역을 오가다가, 끝까지 남아 있는 GC는 Old 영역으로 이동한다. 간혹 Eden 영역에서는 만들어지다가 크기가 커져서 Old 영역으로 바로 넘어가는 객체도 있긴 하다. Old 영역의 GC는 New 영역의 GC에 비하여 상대적으로 시간이 오래 소요되기 때문에 Old 영역으로 이동하는 객체의 수를 줄이면 Full GC가 발생하는 빈도를 많이 줄일 수 있다. Old 영역으로 넘어가는 객체의 수를 줄인다는 말을 잘못 이해하면 객체를 마음대로 New 영역에만 남길 수 있다고 생각할 수 있지만, 그렇게는 할 수는 없다. 하지만 New 영역의 크기를 잘 조절함으로써 큰 효과를 볼 수는 있다.

Full GC 시간 줄이기

Full GC의 수행 시간은 상대적으로 Young GC에 비하여 길다. 그래서 Full GC 실행에 시간이 오래 소요되면(1초 이상) 연계된 여러 부분에서 타임아웃이 발생할 수 있다. 그렇다고 Full GC 실행 시간을 줄이기 위해서 Old 영역의 크기를 줄이면 OutOfMemoryError가 발생하거나 Full GC 횟수가 늘어난다. 반대로 Old 영역의 크기를 늘리면 Full GC 수행 횟수는 줄어들지만 실행 시간이 늘어난다.

2. GC의 성능을 결정하는 옵션들

GC 옵션은 '누가 이 옵션을 썼을 때 성능이 잘 나왔대. 우리도 이렇게 적용하자.' 라고 생각하면 절대 안 된다. 왜냐하면, 서비스마다 생성되는 객체의 크기도 다르고 살아있는 기간도 다르기 때문이다.

두 대 이상의 서버에 GC 옵션을 다르게 적용해서 비교해 보고, 옵션을 추가한 서버의 성능이나 GC 시간이 개선된 때에만 옵션을 추가하는 것이 GC 튜닝의 기본 원칙이다.

다음 표는 성능에 영향을 주는 GC 옵션 중 메모리 크기와 관련된 옵션이다.

구분 옵션 설명
힙(heap) 영역 크기 -Xms JVM 시작 시 힙 영역 크기
힙(heap) 영역 크기 -Xmx 최대 힙 영역 크기
New 영역의 크기 -XX:NewRatio New 영역과 Old 영역의 비율
New 영역의 크기 -XX:NewSize New 영역의 크기
New 영역의 크기 -XX:SurvivorRatio Eden 영역과 Survivor 영역의 비율

이 중에서 가장 자주 사용되는 옵션은 -Xms, -Xmx, -XX:NewRatio 옵션이다. 특히 -Xms과 -Xmx는 필수로 지정해야 하는 옵션이다. 그리고 NewRatio 옵션을 어떻게 설정하느냐에 따라서 GC 성능에 많은 차이가 발생한다.

Perm 영역의 크기는 OutOfMemoryError가 발생하고, 그 문제의 원인이 Perm 영역의 크기 때문일 때에만 -XX:PermSize 옵션과 -XX:MaxPermSize 옵션으로 지정해도 큰 문제는 없다.

GC의 성능에 많은 영향을 주는 또 다른 옵션은 GC 방식이다. 다음 표는 GC 방식에 따라서 지정할 수 있는 옵션이다(JDK 6.0 기준).

구분 옵션 비고
Serial GC -XX:+UseSerialGC
ParallelGC -XX:+UseParallelGC
-XX:ParallelGCThreads=value
Parallel Compacting GC -XX:+UseParallelOldGC
CMS GC -XX:+UseConcMarkSweepGC
-XX:+UseParlNewGC
-XX:+CMSParallelRemarkEnabled
-XX:+CMSInitiatingOccupancyFraction=value
-XX:+UseCMSInitiatingOccupancyOnly
G1 -XX:+UnlockExperimentalVMOptions
-XX:+UseG1GC
JDK 6에서는 두 옵션을 반드시 같이
사용해야 함

G1 방식을 제외하고는, 각 GC 방식의 첫 번째 줄에 있는 옵션을 지정하면 GC 방식이 변경된다. GC 방식 중에서 특별히 신경쓸 필요가 없는 방식은 Serial GC다. Serial GC는 클라이언트 장비에 최적화되어 있기 때문이다.

3. GC 튜닝의 절차

GC를 튜닝하는 절차도 대부분의 성능 개선 작업과 크게 다르지 않다.

1) GC 상황 모니터링 : GC 상황을 모니터링하며 현재 운영되는 시스템의 GC 상황을 확인해야 한다.

2) 모니터링 결과 분석 후 GC 튜닝 여부 결정 : GC 상황을 확인한 후에는, 결과를 분석하고 GC 튜닝 여부를 결정해야 한다. 분석한 결과를 확인했는데 GC 튜닝에 소요된 시간이 0.1초 ~ 0.3초 밖에 안된다면 굳이 GC 튜닝에 시간을 낭비할 필요는 없다. 하지만 GC 수행 시간이 1~3초, 심지어 10초가 넘는 상황이라면 GC 튜닝을 진행해야 한다.

3) GC 방식/메모리 크기 지정

GC 튜닝을 진행하기로 결졍했다면 GC 방식을 선정하고 메모리의 크기를 지정한다. 이때 서버가 여러 대이면 서버에 GC 옵션을 서로 다르게 지정해서 GC 옵션에 따른 차이를 확인하는 것이 중요하다.

4) 결과 분석

GC 옵션을 지정하고 적어도 24시간 이상 데이터를 수집한 후에 분석을 실시한다. 운이 좋으면 해당 시스템에 가장 적합한 GC 옵션을 찾을 수 있다. 그렇지 않다면 로그를 분석해 메모리가 어떻게 할당되는지 확인해야 한다. 그 다음에 GC 방식/메모리 크기를 변경해 가면서 최적의 옵션을 찾아 나간다.

5) 결과가 만족스러울 경우 전체 서버에 반영 및 종료

GC 튜닝 결과가 만족스러우면 전체 서버에 GC 옵션을 적용하고 마무리한다.

다음에는 각 단계에 해야 하는 작업을 자세히 살펴보자.

1, 2 단계 : GC 상황 모니터링 및 결과 분석하기

운영 중인 WAS의 GC 상황을 확인하는 가장 좋은 방법은 jstat 명령어를 사용하는 것이다. 이 명령어를 사용해서 어떤 데이터를 봐야 하는지 알아보자.

다음 예제는 GC 튜닝을 안한 어떤 JVM의 상황이다.

$ jstat -gcutil 21719 1s
  S0   S1    E     O     P    YGC   YGCT  FGC  FGCT   GCTs
48.66 0.00 48.10 49.70 77.45 3428 172.623 3  59.050 231.673
48.66 0.00 48.10 49.70 77.45 3428 172.623 3  59.050 231.673

이 데이터를 보는 순서는 다음과 같다.

1) YGC와 YGCT의 값을 확인한다.

YGCT의 값을 YGC로 나누면 0.050초(50ms)라는 값이 나온다. 즉, Young 영역에서 GC가 수행되는데 평균 50ms가 소요되었다는 말이다. 이 정도면 Young 영역의 GC는 신경 쓰지 않아도 된다.

2) FGCT와 FGC의 값을 확인한다.

FGCT 값을 FGC로 나누면 19.68초라는 값이 나온다. 평균 19.68초가 소요되었다는 말이다. 세 번의 GC에서 모두 19.68초가 걸렸을 수도 있고, 두 번의 GC는 1초가 소요되고 한 번의 GC는 58초가 소요됐을 수도 있다. 그러나 어떤 경우이던 GC 튜닝이 필요한 경우라고 판단할 수 있다.

이렇게 GC의 상황을 jstat으로 간단하게 확인할 수도 있지만, -verbosegc 옵션으로 로그를 남겨 분석하는 것이 가장 좋다.

지금까지 이야기한 것을 정리해보자. GC가 수행되는 시간을 확인했을 때 결과가 다음의 조건에 모두 부합한다면 GC 튜닝이 필요 없다.

  • Minor GC의 처리 시간이 빠르다(50ms내외).
  • Minor GC의 주기가 빈번하지 않다(10초 내외).
  • Full GC의 처리 시간이 빠르다(보통 1초 이내).
  • Full GC의 주기가 빈번하지 않다(10분에 1회).

위에서 괄호 안에 있는 값은 절대값은 아니고 서비스의 상황에 따라 달라질 수 있는 값이다. Full GC 처리 속도가 0.9초가 나와도 만족하는 서비스가 있고, 그렇지 않은 서비스도 있기 때문이다. 따라서, 서비스의 특성에 따라 GC 튜닝 작업을 진행할 지 결정한다.

한 가지 주의할 점은, GC 상황을 확인할 때 Minor GC와 Full GC의 시간만 보면 안 된다는 점이다. GC가 수행되는 횟수도 확인해야 한다. 만약 New 영역의 크기가 너무 작게 잡혀 있다면 Minor GC가 밣생하는 빈도가 매우 높을 뿐만 아니라, Old 영역으로 넘어가는 객체의 개수도 증가하게 되어 Full GC 횟수도 증가하게 된다. 따라서 jstat 명령의 -gccapacity 옵션을 적용하여 각 영역을 얼마나 점유하여 사용하는지도 확인해야 한다.

3-1 단계 : GC 방식 지정

아제 튜닝에 들어가서 GC 방식과 메모리 크기를 지정하는 방법을 살펴보자. 먼저 이 절에서는 GC 방식을 지정하는 것에 대해서 알아보자.

GC 방식은 Oracle JVM을 기준으로 총 5가지가 있다. 그 중 Serial GC는 운영에서 사용하지 못한다. 그리도 JDK 7이 아니라면 G1를 제외해야 하므로 Parallel GC, Parallel Compacting GC, CMS GC의 3개 중에 하나를 선택해야 한다.

그렇다면 어떻게 정해야 할까? 가장 좋은 방법은 3가지를 다 적용해 보는 것인데, 일반적으로 CMS GC는 다른 Parallel GC보다 작업 속도가 빠르다. 그렇다면 그냥 CMS GC만 적용하면 된다고 생각하겠지만, CMS GC가 항상 빠른 것은 아니다. 일반적인 CMS GC의 Full GC 처리 시간을 빠르지만, Concurrent mode failure가 발생하면 다른 Parallel GC보다 느려진다.

Concurrent mode failure에 대해서 좀 더 알아보자

Parallel GC와 CMS GC의 가장 큰 차이점은 압축(compaction) 작업 여부이다. 압축 작업은 메모리 할당 공간 사이에 사용하지 않는 빈 공간이 없도록 옮겨서 메모리 단편화를 제거하는 작업이다.

Parallel GC 방식에서는 Full GC가 수행될 때마다 압축 작업을 진행하기 때문에 시간이 많이 소요된다. 하지만, Full GC가 수행된 이후에는 메모리를 연속적으로 지정할 수 있어 메모리를 더 빠르게 할당할 수 있다.

반대로 CMS GC는 기본적으로 압축 작업을 수행하지 않기 때문에 당연히 속도가 빠르다. 하지만, 압축 작업을 수행하지 않으면 디스크 조각 모음을 실행하기 전의 상태처럼 메모리에 빈 공간이 여기저기 생긴다. 그렇기 때문에 크기가 큰 객체가 들어갈 수 있는 공간이 없을 수도 있다. 예를 들어, Old 영역에 남아 있는 크기가 300MB인데도 10MB짜리 객체가 연속적으로 들어갈 공간이 없을 수도 있다는 말이다. 그럴 때 Concurrent mode failure라는 경고가 발생하면서 압축 작업을 수행한다. 그런데, CMS GC를 사용할 때는 압축 시간이 다른 Parllel GC보다 더 오래 소요된다. 그래서 오히려 더 문제가 될 수 있다.

결론적으로, 운영 중인 시스템 특성에 따라 적합한 GC 방식이 다르므로 해당 시스템에 최적인 방식을 찾아야 한다. 운영 중인 서버가 6대 정도 있다면, 2대씩 각 옵션을 동일하게 지정하고 -verbosegc 옵션을 추가한 후 결과를 분석하는 방법을 추천한다.

3-2 단계 : 메모리 크기

여기서 말하는 메모리 크기는 JVM의 시작 크기(-Xms)와 최대 크기(-Xmx)를 말한다. 메모리 크기와 GC 발생 횟수, GC 수행 시간의 관계는 다음과 같다.

  • 메모리 크기가 크면,
    - GC 발생 횟수는 감소한다.
    - GC 수행 시간은 길어진다.

  • 메모리 크기가 작으면,
    - GC 발생 횟수는 짧아진다.
    - GC 수행 시간은 증가한다.

메모리 크기를 크게 설정할 것인지, 작게 설정할 것인지에 대한 정답은 없다. 서버 자원이 좋은 시스템이라서 메모리를 10GB로 설정해도 Full GC가 1초 이내에 끝난다면 10GB로 지정해도 된다. 하지만, 대부분의 서버는 메모리를 10GB 정도로 설정하면 Full GC 시간이 10-30초 정도 소요된다. 물론 이 시간은 객체의 크기가 어떻게 되어 있느냐에 따라서 달라진다.

그렇다면 메모리 크기를 어떻게 설정해야 할까? GC 튜닝 이전에 현재 상황을 모니터링한 결과를 바탕으로 Full GC가 발생한 이후에 남아 있는 메모리의 크기를 봐야 한다. 만약 Full GC 후에 남아 있는 Old 영역의 메모리가 300MB 정도라면 300MB(기본 사용) + 500MB(Old 영역용 최소) + 200MB(여유 메모리)를 감안하여 Old 영역만 1GB 정도로 지정하는 것이 좋다. 그래서 3대 정도의 운영 서버가 있다면, 서버 한 대는 1GB로, 다른 한 대는 1.5GB로, 또 다른 한 대는 2GB 정도로 지정한 후 결과를 지켜본 다음 결정한다.

이렇게 지정하면 이론적으로 생각했을 때는 GC가 Old 영역 1GB > 1.5GB > 2GB 순서로 빠르므로, 결국 1GB일 때 GC가 제일 빠르다고 볼 수 있을 것이다. 하지만 그렇다고 1GB일 때 Full GC가 1초 걸리고, 2GB일 때는 2초 걸린다고 보장할 수 없다. 서버의 성능에 따라 다르고 객체의 크기에 따라서 시간이 달라지기 때문이다. 그러므로 측정 데이터 셋을 최대한 많이 만들어 모니터링을 통해서 확인하는 것이 가장 좋은 방법이다. 1GB일 때 1초가 소요되면, 1.5GB일 때 1.2초가 소요되는 경우도 있다. 만약 0.2초의 성능 저하는 무시할 수 있다면, 1GB일 때보다 1.5GB일 때 Full GC가 수행되는 빈도가 적어지게 되므로 전반적인 Full GC 수행 시간은 적어지게 된다.

메모리 크기를 지정할 때 해야 하는 것이 한 가지 더 있다. 바로 NewRatio다. NewRatio는 New 영역과 Old 영역의 비율이다. -XX:+NewRatio=1로 지정하면 (New 영역):(Old 영역)의 비율은 1:1이 된다. 만약 1GB라면 (New 영역):(Old 영역)은 500MB:500MB가 된다. NewRatio가 2이면 (New 영역):(Old 영역)이 1:2가 된다. 즉, 값이 커지면 커질수록 Old 영역의 크기가 커지고 New 영역의 크기가 작아진다.

별 것이 아닌 것으로 생각할 수 있지만, NewRatio 값은 GC의 전반적인 성능에 많은 영향을 준다. New 영역의 크기가 작으면 Old 영역으로 넘어가는 메모리 양이 많아져 Full GC도 잦아지고 시간도 오래 걸린다.

단순하게 생각하면 NewRatio 값을 1로 주었을 때 최고의 상황이 될 것 같지만 경험상 newRatio의 값이 2나 3일때의 전반적인 GC 성능이 더 좋았다. 정확하게는 TPS가 더 높게 나왔다. 하지만, 이 결과는 객체의 크기 및 생성 주기에 따라 달라지기 때문에 자신이 운영하는 서비스의 상황에 맞는 값을 찾는 작업을 수행하는 것이 가장 중요하다.

GC 튜닝을 가장 빨리 진행하는 방법은 무엇일까? 성능 테스트로 결과를 비교하는 것이 가장 빠르게 검토 결과를 얻을 수 있는 방법이다. 동일한 서비스를 제공하는 운영 서버의 대수가 많다면 서버마다 옵션을 다르게 지정하고 상황을 모니터링하면 된다. 하지만, 이렇게 설저한 후에는 적어도 하루나 이틀 정도 데이터가 쌓인 후에 보는 것이 바람직하다. 하지만 성능 테스트를 통해서 GC 튜닝을 하면 빠른 시간에 결과를 얻을 수 있다. 그런데 문제는 운영 상황과 동일하게 부하를 줄 수 있는 환경을 구성해야 하는 작업이 쉽지 않다는 점이다. 그리고 부하를 주는 URL과 같은 요청 비율도 운영과 동일해야 한다. 그러나 이렇게 정확하게 부하를 주는 것은 전문 성능 테스터도 쉽지 않고, 준비하는데 오히려 더 많은 시간이 소요될 수 있다. 시간이 오래 걸리더라도 운영에 적용하고 결과를 기다리는 것이 더 간단하고 편하다.

4단계 : GC 튜닝 결과 분석

GC 옵션을 적용하고, -verbosegc 옵션을 지정한 다음에 tail 명령어로 로그가 제대로 쌓이고 있는지 확인해야 한다. 만약 옵션을 잘못 지정해서 로그가 안 쌓이면, 시간만 허비하게 된다. 로그가 잘 쌓이고 있다면, 하루 혹은 이틀 정도의 데이터가 축정된 후 결과를 확인해 보자. 축적도니 로그는 로컬 PC로 옮긴 다음에 HPJMeter로 분석하는 것이 가장 쉽다.

분석할 때는 다음의 사항을 중심으로 살펴보는 것이 좋다. 이는 우선 순위 별로 나열되어 있다.

  • FullGC 수행 시간
  • MinorGC 수행 시간
  • Full GC 수행 간격
  • MinorGC 수행 간격
  • 전체 Full GC 수행 시간
  • 전체 Minor GC 수행 시간
  • 전체 GC 수행 시간
  • Full GC 수행 횟수
  • Minor GC 수행 횟수

GC 옵션을 결정하는 데 가장 큰 비중을 차지하는 것은 첫 번째 항목인 Full GC 수행 시간이다.

참고

  • 자바 성능 튜닝 이야기
profile
이것저것 관심많은 개발자.

0개의 댓글