Java GC 튜닝: 우리 앱의 멈춤 현상을 줄이는 실전 가이드

서비스를 운영하다 보면 서버 사양은 충분한데 이유 없이 응답이 느려지거나, 간헐적으로 시스템이 '얼어붙는' 현상을 마주하곤 합니다. 대부분의 경우 원인은 Stop-the-World(STW), 즉 가비지 컬렉션(GC)이 실행되는 동안 애플리케이션의 모든 스레드가 멈추는 현상에 있습니다.
현대적인 JVM은 매우 영리하게 메모리를 관리하지만, 트래픽이 몰리는 비즈니스 환경에서는 기본 설정만으로 부족할 때가 많습니다. 오늘은 단순한 이론을 넘어, 우리 서비스의 '버벅임'을 근본적으로 해결하기 위한 GC 튜닝의 깊은 곳을 살펴보겠습니다.
핵심 개념: 왜 내 앱은 멈추는가? (Deep Dive)
GC의 작동 원리와 비유
GC는 마치 **'대형 쇼핑몰의 야간 청소부'**와 같습니다. 쇼핑몰(메모리)에 손님(데이터)이 가득 차면, 청소부는 안전을 위해 출입문을 잠그고(Stop-the-World) 쓰레기를 치워야 합니다. 청소 시간이 길어질수록 밖에서 기다리는 손님들의 불만(Latency)은 커지게 되죠.
JVM 메모리는 크게 두 영역으로 나뉩니다:
- Young Generation: 금방 생성되고 사라지는 '단기 체류자' 구역. (Minor GC)
- Old Generation: 오래 살아남은 '장기 투숙객' 구역. (Major/Full GC)
대부분의 성능 문제는 Old Generation에서 발생합니다. 이곳은 덩치가 커서 청소하는 데 시간이 훨씬 오래 걸리기 때문입니다.
성능의 핵심 지표
- Throughput (처리량): 전체 시간 대비 애플리케이션 작업 시간의 비율.
- Pause Time (지연 시간): GC가 한 번 발생했을 때 시스템이 멈추는 시간. (튜닝의 주 타겟)
실전 예제: 주문 처리 시스템의 메모리 누수 방어
단순한 루프문이 아닌, 실제 이커머스 환경에서 흔히 발생하는 '대량 주문 데이터 처리' 시나리오를 통해 효율적인 메모리 사용법을 코드로 살펴보겠습니다.
Bad Case: 무분별한 객체 생성
// 매 요청마다 불필요한 대형 객체를 생성하여 Old 영역을 압박하는 사례
public List<OrderResponse> processOrders(List<OrderRequest> requests) {
// 1. 매번 새로운 리스트와 대량의 임시 String 객체 생성
List<OrderResponse> responses = new ArrayList<>();
for (OrderRequest req : requests) {
// 내부 로직에서 과도한 문자열 결합 (+ 연산자 등) 발생 시
// Heap 메모리에 수많은 단기 객체가 쌓여 Minor GC를 유발합니다.
String transactionId = "TX-" + UUID.randomUUID().toString() + "-" + req.getUserId();
responses.add(new OrderResponse(transactionId, "SUCCESS"));
}
return responses;
}
Good Case: 객체 재사용 및 메모리 친화적 설계
// 가비지 생성을 최소화하여 GC 부하를 줄이는 방식
public List<OrderResponse> processOrdersOptimized(List<OrderRequest> requests) {
// 2. 초기 용량(Capacity) 지정으로 내부 배열 확장(Resize) 비용 및 가비지 방지
List<OrderResponse> responses = new ArrayList<>(requests.size());
// 3. StringBuilder를 통한 문자열 효율적 관리 (Heap 낭비 방지)
StringBuilder sb = new StringBuilder(64);
for (OrderRequest req : requests) {
sb.setLength(0); // 기존 버퍼 재사용
String txId = sb.append("TX-")
.append(req.getUserId())
.append("-")
.append(System.currentTimeMillis())
.toString();
responses.add(new OrderResponse(txId, "SUCCESS"));
}
return responses;
}
트래블슈팅 팁 (Troubleshooting)
만약 java.lang.OutOfMemoryError: Java heap space가 빈번하다면, 무작정 힙 메모리($Xmx$)를 늘리기 전에 Heap Dump를 추출하세요. jmap이나 VisualVM을 통해 어떤 객체가 Old 영역의 80% 이상을 점유하고 있는지 확인하는 것이 튜닝의 첫걸음입니다.
주요 GC 알고리즘과 선택 기준 (Trade-offs)
어떤 청소부(GC)를 고용하느냐에 따라 앱의 성격이 달라집니다.
| GC 종류 | 특징 | 장점 | 단점 |
| G1 GC | 메모리를 Region 단위로 나누어 관리 (Java 9+ 기본) | 일정한 중지 시간(Pause Time) 보장 | 설정이 복잡하고 작은 힙에서는 비효율적 |
| ZGC | 8MB ~ 16TB까지 지원하는 초저지연 GC | 힙 크기에 상관없이 STW 1ms 미만 | CPU 사용량이 상대적으로 높음 |
추천하는 JVM 옵션 (G1 GC 기준)
지연 시간을 줄이고 싶다면 아래 옵션을 검토하세요.
# 힙 크기를 4G로 고정 (확장 시 발생하는 오버헤드 방지)
-Xms4g -Xmx4g
# G1 GC 사용 설정
-XX:+UseG1GC
# 최대 중지 목표 시간을 200ms로 설정 (JVM이 이를 맞추려 노력함)
-XX:MaxGCPauseMillis=200
# 로그 기록 (문제 분석의 핵심)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags
결론 및 요약
GC 튜닝은 단순히 옵션을 몇 개 바꾸는 과정이 아닙니다. 애플리케이션의 객체 생존 주기(Object Lifetime)를 이해하고 그에 맞는 전략을 세우는 과정입니다.
- 객체 생성 최소화: 불필요한 String 연산이나 Collection 확장을 피하세요.
- 적절한 알고리즘 선택: 저지연이 중요하다면 G1 GC나 ZGC를 적극 도입하세요.
- 지속적인 모니터링: $Xmx$와 $Xms$를 동일하게 설정하여 힙 크기 변화에 따른 성능 요동을 막으세요.
현재 운영 중인 서비스에서 GC 로그를 분석해 본 적이 있으신가요? 혹시 분석 과정에서 특정 시간대에만 Full GC가 튀는 현상을 발견했다면, 그 시간대의 배치 작업이나 로직을 점검해보는 것부터 시작해보시기 바랍니다.
더 정교한 튜닝이 필요하다면, 현재 사용 중인 JVM 버전과 힙 메모리 사용 패턴을 바탕으로 구체적인 로그 분석을 이어가는 것이 좋습니다. 구체적인 로그 데이터가 있다면 이를 기반으로 더 깊은 논의가 가능할 것입니다.