GC 튜닝을 왜 하는가 ?
자바에서 생성된 객체는 JVM의 가비지 컬렉터(Garbage Collector)가 자동으로 관리하고 제거한다. 이 덕분에 개발자는 메모리 해제에 직접 관여하지 않아도 되지만, GC의 자동 처리 자체가 항상 성능에 최적화되어 있지 않다. 애플리케이션에서 객체 생성과 소멸이 빈번해질 수록 GC가 처리해야 할 대상은 빠르게 증가하고, 그에 따라 GC 수행 횟수 또한 자연스럽게 늘어난다.
문제는 GC가 실행되는 동안 애플리케이션의 모든 스레드가 멈추는 Stop-The-World (STW) 구간이 발생한다는 점이다. 이 STW 시간이 길어지거나 자주 발생하면, 애플리케이션은 정상적으로 동작하고 있음에도 사용자 입장에서는 응답 지연, 타임아웃, 서비스 장애로 체감한다.
이러한 이유로, GC가 애플리케이션의 성능 병목으로 작용하지 않도록 실제 서비스 환경에 맞게 동작을 조정하는 과정이 바로 GC 튜닝이다.
GC 튜닝의 목표
GC 튜닝이란, 성능 저하를 유발하기 쉬운 GC 동작을 애플리케이션 특성과 서버 자원에 맞게 최적화하는 것을 의미한다. GC 튜닝의 핵심 목표는 Minor GC보다 Stop-The-World 시간이 훨씬 긴 Major(Full) GC를 어떻게 관리할 것인가에 있다.
1) old 영역으로 넘어가는 객체의 수 최소화하기
- old 영역의 크기는 young 영역의 크기보다 훨씬 크다. 따라서 old 영역의 GC는 young 영역의 GC에 비해 상대적으로 시간이 오래 소요되기 때문에, 애초에 old 영역으로 이동하는 객체의 수를 줄이면 Full GC가 발생한느 빈도를 많이 줄일 수 있게 된다. 즉, young 영역의 크기를 잘 조절하여 old 영역으로 넘어가는 빈도를 줄이면 큰 효과를 볼 수 있다.
2) Full GC 시간 줄이기
- Full GC의 실행 시간은 상대적으로 Minor GC에 비해 길기 때문에, old 영역의 크기를 적절하게 설정하는 것도 하나의 방법이다. 그렇다고 old 영역의 크기를 무작정 줄여버리면 `OutOfMemoryError`가 발생하거나 Full GC 횟수가 늘어날 수 있다. 반대로 old 영역의 크기를 늘리면 Full GC 횟수는 줄어들지만 실행 시간이 늘어나게 된다.
GC 튜닝 시작
1️⃣ GC 상황 모니터링
앞서 그라파나를 통해서 이상 신호를 확인했다.
- HTTP Duration (MAX)에서 빠른 응답 속도를 유지하다가 특정 지점에서 말도 안되게 높게 치솟을 것을 확인했다.
- Pause Durations 지표에서 응답 시간이 지연된 시점과 정확히 일치하게 Major GC가 발생한 것을 확인했습니다. 이는 힙 덤프가 수행되면서 STW 현상이 발생한 것이다.


그래서 서버에 '설정'이 어떻게 되어있는지 확인해보기 위해 ssh 터널링을 통해 컨테이너 내부로 들어가서 `jcmd`를 확인했다.
다음과 같은 명령어를 작성하면
docker exec <컨테이너_ID> jcmd 1 VM.flags

다음과 같이 나오는 것을 확인할 수 있었다.
하나씩 살펴보면,
1) 가비지 컬렉션 방식 : Serial GC 사용 `-XX:+UseSerialGC`
- Serial GC는 적은 메모리 환경에서 사용되지만, 단 하나의 스레드만 사용하여 메모리를 청소한다.
- 이 방식은 힙 덤프처럼 무거운 작업을 할 때 서버 전체를 멈추는 STP 현상을 만든다.
2) 힙 메모리 크기 : 약 240MiB `-XX:MaxHeapSize=251658240`
- 현재 애플리케이션의 최대 힙 메모리가 약 240MB로 설정되어 있다.
- 스프링 부트 앱을 가동하기에는 살짝 빠듯한 크기라고 할 수 있다.
- 메모리가 여유롭지 않으면 GC가 더 자주 발생한다.
3) 코드 캐시 : `-XX:ReservedCodeCacheSize=251658240`, `-XX:+SegmentedCodeCache`
- 최적화된 코드를 저장하는 공간을 약 240MB로 잡아두었다.
- 자바는 실행 속도를 높이기 위해 JIT(Just-In-Time) 컴파일러를 사용한다. 이는 자주 실행되는 코드를 기계어로 번역해서 보관해두는 '창고'이다. 똑같은 코드를 실행할 때 매번 해석하지 않고, 창고에서 이미 번역된 기계어를 바로 꺼내 쓰기 때문에 실행 속도가 비약적으로 빨라진다.
4) 초기 설정의 비효율성 : `-XX:InitialHeapSize=16777216` (약 16MB)
- 시작 시 메모리는 16MB인데, 최대는 240MB이다.
- 애플리케이션이 실행되면서 메모리가 더 필요하면, JVM은 OS에게 '메모리 좀 더 줘'라고 요청하며 야금야금 크기를 키운다. 그러나 이 '메모리 크기 조정' 작업 자체가 CPU를 소모하고 애플리케이션에 부하를 준다.
- 이것은 그라파나에서 확인할 수 없는 부분이다. 그라파나는 애플리케이션이 완전히 켜지고 네트워크가 연결된 이후부터 데이터를 수집한다. 그렇기 때문에 지표에서는 JVM이 OS로부터 현재 확보한 양을 보여주지, 자바 애플리케이션이 처음 켜질 때 들고 시작하는 메모리의 양, 초기 설정값을 조회할 수 없다.
설정값을 확인한 다음, 실제로 메모리가 어떻게 차오르고 GC가 일어나는지 숫자로 확인하기 위해 `jstat`을 실행했다.
아래 명령어를 실행하면
docker exec <컨테이너_ID> jstat -gcutil -t 1 1000
다음과 같이 나온다.

출력된 각 칼럼의 의미와 상태를 분석해보면,
| 항목 | 현재 값 | 의미 분석 |
| S0/S1 | 0.00/18.77 | Survivor 영역의 사용률이다. 현재 한쪽만 사용 중인 정상적인 상태이다. |
| E(Eden) | 96.48-99.17 | 새로운 객체가 들어갈 공간이 거의 다 찼다. 곧 Minor GC가 발생할 직전임 알 수 있다. 실제로 조금 이따가 0으로 떨어졌다. |
| O(Old) | 51.94 | Old 영역 사용률이다. 약 절반 정보 차 있어 아직은 여유가 있지만, Serial GC 특성 상 이 영역을 치울 때 큰 부하가 발생한다. |
| M(Meta) | 99.23 | Metaspace 사용률이다. 거의 꽉 찬 것처럼 보이지만, Metaspace는 필요시 스스로 확장되므로 현재로서는 안정적인 상태이다. |
| YGC/YGCT | 1935/70.709 | 애플리케이션 구동 후 Minor GC가 1,935번 발생했고, 총 70.7초를 사용했다. |
| FGC/FGCT | 31/291.830 | Full GC가 31번 발생했는데, 총 291.8초를 사용했다. 한 번 발생할 때마다 평균 9.4초동안 서버를 멈췄다는 뜻이다. |

- Minor GC 수행 시간 = YGCT/YGC = 70.709/1935 = 0.037...초
- Full GC 수행 시간 = FGCT/FGC = 291.830/31 = 9.414...초
✅ 결론
- Full GC 한 번 당 평균 9.4초가 소요되고 있다. 이는 일반적인 권장 기준(약 1초)보다 9배 느린 상태이다.
- 힙의 크기가 최대 240MB로 제한된 상태에서, Serial GC 기반의 단일 스레드 엔진이 Old 영역 전체를 반복적으로 훑는 구조가 되어 STW 시간이 길어지는 현상이 관측된다. 이는 현재 설정된 GC 방식이 운영 환경에 부적합함을 명확히 보여준다.
- 애플리케이션 구동 과정에서 초기 힙 크기(16MB)와 최대 힙 크기(240MB)의 차이가 매우 커, JVM 메모리 부족 시마다 OS에게 추가 메모리를 요청하여 힙 리사이징을 반복해야 한다. 이로 인해 불필요한 CPU 사용이 발생한다.
- JVM은 회수 가능한 객체가 남아있는지 확인하기 위해 내부적으로 힙 구조를 재분석하는 (Heap Dump Initiated GC) 단계가 수행된다. 그러나 Serial GC 특성상 단일 스레드로 힙 전체를 스캔하면서 긴 Stop-The-World 시간이 발생했다.
2️⃣ GC 튜닝하기
우리는 Docker로 컨테이너화하여 Blue-Green 무중단 배포를 진행하고 있다. 현재 서버 사양과 배포 방식의 특성을 고려하여 기존의 비효율적인 설정을 다음과 같이 최적화하였다.
environment:
- JAVA_OPTS=-Xms128m -Xmx256m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
- -Xms128m -Xms256m (메모리 고정)
초기힙 크기 (-Xms)를 128MB로 설정한다. JVM이 힙 크기를 조절할 때 발생하는 리사이징 오버헤드를 원천 차단한다. - -XX:+UseG1GC (GC 엔진 교체)
기존의 SerialGC 대신 G1(Garbage First) GC를 사용한다. Serail GC는 단일 스레드로 동작하여 청소 시 서버를 아주 길게 멈추게 하지만 (STW), G1 GC는 힙을 구역으로 나우어 여러 스레드가 동시에 청소한다. 이를 통해 중단 시간을 획기적으로 줄일 수 있다. - -XX:MaxGCPauseMillis=200 (응답 시간 목표)
GC로 인해 서버가 멈추는 시간을 최대 0.2초 이내로 해달라고 JVM에게 목표치를 전달한다. - -XX:+HeapDumpOnOutOfMemoryError (완전한 자동 진단)
평소에는 덤프를 생성하지 않다가, 메모리가 고갈되어 애플리케이션이 종료되기 직전(`OutOfMemoryError`)에만 자동으로 힙 덤프 파일을 생성하도록 설정했다.
참고
JVM GC 작동 원리와 GC 튜닝 실전 가이드 (WITH Spring Boot)
👨💻 실무 자바 개발자라면 반드시 알아야 할 가비지 컬렉션 핵심 개념부터 Spring Boot 애플리케이션 성능 최적화까지! 메모리 누수와 성능 저하 문제를 해결하는 GC 튜닝 완벽 가이드 JVM 메모
notavoid.tistory.com
☕ 가비지 컬렉션 GC 튜닝 절차 맛보기
Garbage Collection 튜닝 자바(Java)가 C 언어에 비해 속도 차이가 나는 이유는 아키텍쳐 설계, 즉 JVM에 있는데, 미리 바이너리 코드로 컴파일되는 C언어에 비하여, 자바는 바이트 코드라는 중간 단계 컴
inpa.tistory.com
'프로젝트 > 끼니콩' 카테고리의 다른 글
| 대용량 데이터 (csv파일) db에 넣기 with JdbcTemplate -2 (0) | 2025.12.19 |
|---|---|
| [k6] 부하테스트 (0) | 2025.11.04 |
