JVM과 성능¶
🎯 이 장에서 배우는 것¶
- JVM 구성 원리(클래스 로딩·실행 엔진·GC)
- 힙·스택·메서드 영역과 객체가 사는 곳
- GC 로그를 읽고 메모리 튜닝
단계: 3단계 — 고급·품질 · 앞 장: java-study-ch09 · 다음 장: java-study-ch11
따라 하는 법: 위에서 아래로 읽으며 코드를 직접 쳐본다. 10.0→10.1→10.2 순으로 읽고, 11.90 JVM 워크북 문제로 굳힌다. 깊이: entity-jvm.
참고 프로젝트: 본문 곳곳에 나오는 실무 저장소 이야기는 필자의 실무 프로젝트 참고 사례일 뿐, 실습에 필요하지 않습니다. 직접 실행해 보는 예제는 단일 파일(
javac/java)로 돌아갑니다. 본문에 나오는demo는 6장 6.1에서 start.spring.io로 만든 Spring Boot 실습 프로젝트를 가리킵니다.
10.0 JVM 기초 가이드 1: 개념과 구성 원리¶
🎯 목표: JVM의 구성 원리(클래스 로딩·실행 엔진·런타임 영역)를 이해한다.
개요¶
이 문서는 JVM을 처음 구조적으로 이해하려는 독자를 위한 첫 번째 가이드입니다. JVM은 단순히 "자바를 실행하는 프로그램"이 아니라, 자바 코드를 바이트코드로 실행하고 메모리와 클래스 로딩, 실행 최적화를 관리하는 런타임 환경입니다.
Spring Boot 애플리케이션을 다룰 때도 결국 JVM 위에서 프로그램이 실행됩니다. 그래서 클래스가 언제 로딩되는지, 객체가 어느 영역에 놓이는지, 왜 가비지 컬렉션이 필요한지 이해해야 메모리 문제와 성능 문제를 제대로 읽을 수 있습니다.
참고 — 실무 프로젝트의 런타임은 어떻게 생겼는가¶
참고로 필자의 실무 저장소는 Java 21을 사용하고 있고, 실행 이미지는 eclipse-temurin:21-jre-alpine 위에서 동작합니다. 즉 이 장은 추상적인 JVM 소개이면서 동시에 실제 서비스가 올라가는 런타임을 읽는 기초이기도 합니다.
아래는 그 실행 이미지 설정의 일부입니다. JAVA_OPTS에 담긴 -XX:... 형태가 JVM 실행 옵션인데, 지금은 컨테이너 메모리 한계에 맞춰 힙 상한을 잡는 옵션 정도로만 읽으면 됩니다 (각 옵션은 10.2에서 자세히 다룹니다).
FROM eclipse-temurin:21-jre-alpine
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
예상 결과
이런 실무 프로젝트는 JVM 위에서 JAR를 실행하고,
컨테이너 메모리 한계를 의식하는 옵션과 함께 기동된다.
즉 JVM 문서는 책 밖의 이론이 아니라 실제 운영 방식과 바로 연결된다.
(demo를 같은 방식으로 만들면 JAR 이름은 `demo-0.0.1-SNAPSHOT.jar`가 된다.)
왜 JVM을 먼저 알아야 하는가¶
Java는 운영체제 위에서 바로 실행되지 않습니다. javac가 소스 코드를 .class 바이트코드로 컴파일하고, java 명령이 JVM을 시작해서 그 바이트코드를 읽고 실행합니다.
이 구조 덕분에 Java는 운영체제 차이를 어느 정도 감추면서 같은 프로그램을 여러 환경에서 실행할 수 있습니다. 다만 그 대가로 실행 과정이 한 번 더 추상화되기 때문에, 문제가 생겼을 때 JVM의 구조를 모르면 원인을 잘못 짚기 쉽습니다.
1. 소스 코드에서 실행까지의 흐름¶
Java 프로그램의 기본 흐름은 아래처럼 이해하면 됩니다.
여기서 중요한 점은 .class 파일이 기계어가 아니라 JVM이 이해하는 명령 집합인 바이트코드라는 것입니다. 운영체제가 직접 실행하는 파일이 아니라, JVM이 읽고 실행해야 하는 중간 산출물입니다.
2. 스펙과 구현을 구분해서 봐야 한다¶
JVM을 공부할 때 가장 먼저 잡아야 하는 기준은 명세와 구현을 구분하는 습관입니다.
- JVM Specification: JVM이 어떤 런타임 데이터 영역과 동작 규칙을 가져야 하는지 정의합니다.
- HotSpot 같은 구현체: 그 명세를 실제 제품으로 구현한 JVM입니다.
예를 들어 문서에서는
Method Area를 JVM의 런타임 데이터 영역으로 설명하지만, HotSpot에서는 이를 메타스페이스 같은 구현 형태로 관리합니다. 즉, 책과 블로그에서 나오는 용어가 모두 틀린 것은 아니지만, 같은 층위의 설명은 아닙니다.
3. JVM 런타임 데이터 영역¶
JVM 스펙은 런타임 데이터 영역을 크게 스레드별 영역과 공유 영역으로 나눠 설명합니다.
3.1 스레드마다 따로 가지는 영역¶
PC Register
현재 실행 중인 JVM 명령의 위치를 가리키는 정보입니다. 스레드가 여러 개일 때 각 스레드가 어디까지 실행했는지 구분해야 하므로 스레드마다 따로 가집니다.
Java Virtual Machine Stack
메서드 호출이 쌓이는 영역입니다. 메서드가 호출될 때 프레임이 생기고, 메서드가 끝나면 프레임이 사라집니다. 지역 변수, 매개변수, 중간 계산 값이 여기에 놓입니다.
이 예제에서 left, right, result 같은 값은 메서드 실행 프레임과 함께 다뤄집니다.
Native Method Stack
JVM 바깥의 네이티브 코드를 호출할 때 사용하는 영역입니다. 일반적인 입문 단계에서는 깊게 다루지 않아도 되지만, JVM이 Java 코드만 완전히 고립된 채로 실행되는 것은 아니라는 점을 보여줍니다.
3.2 여러 스레드가 함께 보는 영역¶
Heap
객체와 배열이 생성되는 대표적인 영역입니다. new로 만든 객체는 보통 힙에 놓입니다.
위 코드에서 실제 객체와 배열은 힙에 생성됩니다. 반면 메서드 안에서 그 객체를 가리키는 참조값은 스택 프레임에서 다뤄집니다.
Method Area
클래스 메타데이터, 필드와 메서드 정보, 런타임 상수 풀 같은 구조를 저장하는 공유 영역입니다. 초보 단계에서는 "클래스 수준 정보가 올라가는 곳" 정도로 이해하면 충분합니다.
단, 여기서 다시 한 번 강조할 점은 Method Area는 스펙 용어이고, HotSpot의 Metaspace는 구현 용어라는 점입니다.
4. 클래스 로딩은 한 번에 끝나는 작업이 아니다¶
JVM은 프로그램 시작 시 모든 클래스를 통째로 올리는 것이 아니라, 필요한 시점에 클래스를 로딩하고 연결하고 초기화합니다.
핵심 단계는 세 가지입니다.
4.1 Loading¶
클래스의 바이트코드를 찾아 메모리에 읽어들입니다.
4.2 Linking¶
읽어 온 클래스가 유효한지 검증하고, 필요한 메모리 구조를 준비하고, 심볼 참조를 해석합니다.
4.3 Initialization¶
static 필드의 실제 초기값을 반영하고, static 초기화 블록을 실행합니다. (참고 코드 — static 초기화 시점을 보여주는 개념 예시입니다.)
public class Config {
static int maxSize = 100;
static {
System.out.println("Config initialized");
}
}
이 단계 구분을 이해하면 static 초기화 시점, 클래스 간 순환 참조, 애플리케이션 시작 속도 문제를 더 정확하게 볼 수 있습니다.
5. 실행 엔진은 바이트코드를 실제 실행으로 바꾼다¶
JVM은 바이트코드를 그대로 들고 있는 것만으로는 아무 일도 하지 못합니다. 실제 실행은 실행 엔진이 담당합니다.
5.1 인터프리터¶
바이트코드를 읽으면서 순차적으로 실행합니다. 초기 실행이 빠르게 시작된다는 장점이 있습니다.
5.2 JIT 컴파일러¶
반복적으로 실행되는 코드를 감지해 기계어 수준으로 최적화합니다. 그래서 Java는 "무조건 느리다"고 단정할 수 없습니다. 처음에는 해석하면서 실행하다가, 자주 호출되는 경로는 더 빠르게 돌릴 수 있도록 바뀝니다.
이 구조 때문에 Java 성능을 볼 때는 단순히 코드 한 줄만 보는 것이 아니라, 실행 빈도와 워밍업 이후의 상태를 함께 봐야 합니다.
6. 가비지 컬렉션은 힙 메모리 관리의 일부다¶
가비지 컬렉션은 "메모리를 다 알아서 해준다"는 마법이 아닙니다. 더 이상 참조되지 않는 객체를 식별하고 회수하는 메커니즘입니다.
여기서 먼저 구분해야 할 것은 아래 둘입니다.
StackOverflowError: 메서드 호출이 너무 깊어져 스택이 넘친 경우OutOfMemoryError: 힙이나 메타스페이스 등 필요한 메모리를 확보하지 못한 경우 즉, 메모리 문제라고 해서 모두 같은 문제가 아닙니다. 어떤 런타임 영역에서 발생했는지 먼저 봐야 합니다.
GC 자체의 종류와 튜닝은 다음 장에서 더 자세히 다룹니다.
7. Spring 개발자에게 JVM이 중요한 이유¶
Spring Boot 프로젝트를 실행하면 아래 현상이 모두 JVM과 연결됩니다.
- 애플리케이션 시작 시 수많은 클래스가 로딩됩니다.
- 빈 생성 과정에서 객체가 많이 만들어집니다.
- JSON 직렬화, 로그 처리, 컬렉션 변환 중에 임시 객체가 계속 생깁니다.
- 테스트 실행과 운영 실행에서 메모리 사용 패턴이 달라집니다. 따라서 Spring을 쓴다고 해서 JVM을 몰라도 되는 것이 아니라, 오히려 프레임워크가 많은 일을 대신해 주기 때문에 JVM 관점의 관찰력이 더 중요해집니다.
자주 헷갈리는 지점¶
- JVM은 Java 언어 자체가 아니라 Java 프로그램의 실행 환경입니다.
.class파일은 운영체제가 직접 실행하는 파일이 아니라 JVM이 읽는 바이트코드입니다.- 지역 변수와 객체가 모두 같은 메모리 위치에 있는 것은 아닙니다.
Method Area와Metaspace는 같은 층위의 용어가 아닙니다.- 가비지 컬렉션이 있다고 해서 메모리 문제가 자동으로 사라지는 것은 아닙니다.
공식 문서 기준으로 더 보면 좋은 자료¶
- The javac Command
- The java Command
- JVM Spec Chapter 2: Run-Time Data Areas
- JVM Spec Chapter 5: Loading, Linking, and Initializing
✏️ JVM 기초 가이드 1 직접 해보기¶
간단한 프로그램의 실행 과정을 클래스 로딩→실행 순으로 설명해 보라.
실습 순서
- 파일 생성 — 아무 작업 디렉터리(예:
~/jvm-practice)에HelloJvmDemo.java를 만듭니다. 단일 파일 실습이므로 package 선언 없이 씁니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다.
-
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
javac HelloJvmDemo.java && java HelloJvmDemo— 추가할 때마다 다시 실행해 출력을 확인합니다.
정리¶
JVM은 자바 코드를 실행하는 중간 계층이 아니라, 클래스 로딩, 메모리 배치, 바이트코드 실행, 실행 최적화까지 책임지는 핵심 런타임 시스템입니다. 이 구조를 알아야 이후의 GC, 튜닝, 장애 분석 문서를 제대로 읽을 수 있습니다.
한 줄 정리¶
JVM 입문의 핵심은 자바 코드가 어떻게 실행되는가를 소스 코드가 아니라 런타임 구조의 관점에서 보기 시작하는 것입니다.
10.1 JVM 기초 가이드 2: 메모리 관리¶
🎯 목표: 힙·스택·메서드 영역 등 JVM 메모리 구조를 이해한다.
개요¶
이 문서는 JVM 기초 가이드 1에서 본 런타임 구조를 바탕으로, 실제 메모리 사용과 가비지 컬렉션을 어떻게 이해해야 하는지 정리한 두 번째 가이드입니다. JVM 메모리를 공부하는 목적은 단순히 힙, 스택 이름을 외우는 것이 아니라, 객체가 왜 남아 있는지, 어떤 종류의 메모리 문제가 발생하는지, 무엇을 먼저 관찰해야 하는지 판단하는 것에 있습니다.
Spring Boot 애플리케이션에서는 요청 처리, 직렬화, 캐시, 로그, 리플렉션 때문에 짧게 사는 객체와 오래 남는 객체가 계속 섞여 만들어집니다. 그래서 메모리 구조를 모르고서는 GC 로그도, 힙 덤프도, OutOfMemoryError도 제대로 읽기 어렵습니다.
1. JVM 메모리는 힙만이 아니다¶
초보 단계에서는 메모리 문제를 전부 힙 문제로 생각하기 쉽습니다. 하지만 실제 JVM 메모리는 더 넓게 봐야 합니다.
- Heap: 객체와 배열이 주로 생성되는 영역
- Java Thread Stack: 스레드별 메서드 프레임과 지역 변수
- Metaspace: 클래스 메타데이터를 담는 구현 영역
- Native Memory: JVM 자체, JNI, Direct Buffer 등 힙 바깥 메모리
즉, 메모리가 부족하다는 말은 항상 같은 뜻이 아닙니다.
Java heap space,Metaspace,Direct buffer memory,unable to create native thread는 서로 다른 종류의 문제일 수 있습니다.
2. 객체는 생성만 되는 것이 아니라 생애를 가진다¶
메모리 관점에서 객체를 볼 때는 생성 순간보다 얼마나 오래 살아남는가가 더 중요합니다.
대표적인 흐름은 아래처럼 이해하면 됩니다.
실무 애플리케이션에서는 대부분의 객체가 매우 짧게 살다가 사라집니다. 요청 하나를 처리하면서 만드는 DTO, 로그 메시지, 컬렉션 변환 결과가 여기에 가깝습니다. 반면 캐시, 싱글톤 빈이 오래 들고 있는 데이터, 정적 컬렉션은 훨씬 오래 남습니다.
이 차이를 알아야 "객체가 많다"와 "메모리 누수가 있다"를 구분할 수 있습니다.
3. 힙과 스택은 같이 봐야 한다¶
아래 코드를 보면 객체와 참조가 같은 곳에 있는 것이 아니라는 점이 분명해집니다. (참고 코드 — 힙·스택 배치를 보여주는 개념 예시입니다.)
public class OrderService {
public void placeOrder() {
Order order = new Order("order-1");
int quantity = 2;
process(order, quantity);
}
private void process(Order order, int quantity) {
String message = "processing";
System.out.println(order.getOrderNumber() + ": " + quantity + ", " + message);
}
}
이 코드를 볼 때 중요한 기준은 아래와 같습니다.
order,quantity,message같은 지역 변수와 참조값은 스택 프레임에서 다뤄집니다.- 실제
Order객체는 힙에 생성됩니다. - 메서드가 끝나면 스택 프레임은 사라지지만, 힙 객체는 다른 참조가 남아 있으면 계속 살아 있을 수 있습니다. 즉, 스택은 메서드 실행 흐름과 함께 정리되지만, 힙 객체는 참조 관계가 끊어져야만 회수 후보가 됩니다.
4. GC는 도달 가능성으로 살아 있는 객체를 판단한다¶
가비지 컬렉터는 단순히 "오래된 객체"를 지우는 것이 아닙니다. 핵심 기준은 도달 가능성(reachability) 입니다.
쉽게 말하면 GC Root에서 시작해서 따라갈 수 있는 객체는 살아 있는 것으로 보고, 더 이상 따라갈 수 없는 객체는 회수 대상이 됩니다.
대표적인 GC Root 예시는 아래처럼 이해하면 충분합니다.
- 현재 실행 중인 스레드의 로컬 변수
- static 필드가 가리키는 객체
- JNI 등 네이티브 코드가 붙잡고 있는 객체
도달 가능성의 차이를 파일로 만들어 직접 실행해 봅니다.
파일: ReachabilityExample.java
import java.util.ArrayList;
import java.util.List;
public class ReachabilityExample {
private static final List<String> CACHE = new ArrayList<>();
public static void main(String[] args) {
String temp = new String("short-lived");
CACHE.add(new String("long-lived"));
temp = null; // "short-lived"는 이제 GC Root에서 도달 불가 → 회수 후보
System.out.println("temp 참조 해제 후에도 CACHE가 붙잡은 객체: " + CACHE);
}
}
임의의 작업 디렉터리에 파일을 만들고 실행합니다.
위 예시에서 short-lived는 더 이상 참조가 없으면 회수 대상이 될 수 있지만, long-lived는 CACHE가 static 필드로 붙잡고 있어서 계속 남을 수 있습니다.
이 차이가 바로 "많이 생성되는 객체"와 "안 사라지는 객체"를 나누는 기준입니다.
5. Generational 관점은 여전히 중요하다¶
현대 HotSpot 환경에서는 세부 구현이 수집기마다 달라도, 많은 설명이 젊은 객체와 오래 사는 객체를 다르게 다룬다는 관점에서 출발합니다.
이때 힙에서 젊은 객체가 먼저 머무는 쪽을 Young 영역, 오래 살아남은 객체가 옮겨 가는 쪽을 Old 영역이라고 부릅니다. 그리고 힙 전체를 대상으로 하는 무거운 수집을 Full GC라고 부릅니다.
입문 단계에서는 아래 정도로 이해해도 충분합니다.
- 대부분의 객체는 금방 죽습니다.
- 오래 살아남는 객체는 더 오래 관리되는 영역으로 이동할 수 있습니다.
- 짧게 사는 객체가 폭증하면 Young 영역 중심의 GC가 자주 일어날 수 있습니다.
- 오래 사는 객체가 너무 많으면 Old 영역 압박과 Full GC 성격의 부담이 커질 수 있습니다. 중요한 점은 이 설명이 JVM 명세 자체라기보다, 실제 HotSpot 계열 수집기를 이해하기 위한 실무 관점이라는 점입니다.
6. 메모리 문제는 증상별로 봐야 한다¶
6.1 java.lang.OutOfMemoryError: Java heap space¶
힙에 더 이상 필요한 객체를 놓을 수 없을 때 발생합니다.
대표 원인:
- 큰 컬렉션이 계속 증가함
- 캐시 만료 정책이 없음
- 요청 결과를 과도하게 한 번에 메모리에 적재함
- 누수가 있어 더 이상 회수되지 않음
6.2 java.lang.OutOfMemoryError: Metaspace¶
클래스 메타데이터 영역이 부족할 때 발생합니다.
대표 원인:
- 동적 프록시나 클래스 생성이 과도함
- 클래스 로더 누수
- 애플리케이션 재배포 구조 문제
6.3 java.lang.StackOverflowError¶
재귀가 끝나지 않거나 스택 프레임이 너무 깊어질 때 발생합니다.
이 코드처럼 종료 조건이 없으면 스택 프레임이 계속 쌓입니다.
6.4 힙 밖 메모리 문제도 있다¶
NIO Direct Buffer, 네이티브 라이브러리, 스레드 생성 비용 때문에 힙 크기가 넉넉해도 프로세스 전체 메모리가 부족할 수 있습니다. 그래서 운영 환경에서는 OS 관점의 메모리 사용량과 JVM 내부 메모리 지표를 같이 봐야 합니다.
7. 메모리 누수는 "참조가 남아 있는 상태"다¶
Java는 GC가 있으니 메모리 누수가 없다고 오해하기 쉽습니다. 하지만 실제로는 안 써도 참조가 남아 있으면 GC가 못 치웁니다.
대표적인 누수 패턴은 아래와 같습니다.
- static 컬렉션이 계속 커짐
- 캐시에 만료나 제거 정책이 없음
- 이벤트 리스너를 등록만 하고 해제하지 않음
- 스레드 로컬 값을 정리하지 않음
- 큰 객체 그래프를 싱글톤 빈이 오래 붙잡고 있음
앞의 두 패턴이 겹친 형태가 아래입니다. (참고 코드 — 제거 정책 없는 static 캐시의 누수 패턴 개념 예시입니다.)
public class BadCache {
private static final Map<String, Object> CACHE = new HashMap<>();
public static void put(String key, Object value) {
CACHE.put(key, value);
}
}
이 코드는 문법상 문제는 없지만, 제거 정책이 없으면 장기적으로 메모리 사용량이 계속 증가할 수 있습니다.
참고 — 실무 저장소는 메모리 정책을 어디서 넘기는가¶
참고로 필자의 실무 저장소는 애플리케이션 코드 안에서 힙 크기를 고정하지 않고, 컨테이너 실행 레이어에서 JAVA_OPTS로 JVM 메모리 정책을 넘깁니다. 그래서 이 문서는 코드에 박힌 값 설명이라기보다 운영 환경에서 어떤 메모리 관찰 포인트를 먼저 볼지에 더 가깝습니다.
예상 결과
JVM은 컨테이너 메모리 한계를 기준으로 힙 상한을 공격적으로 쓰지 않도록 동작한다.
이런 구조에서 메모리 문제를 볼 때는 애플리케이션 힙만이 아니라 컨테이너 제한과 프로세스 전체 메모리를 함께 봐야 한다.
8. 관찰 없이 튜닝부터 하면 실패하기 쉽다¶
메모리 문제를 만나면 바로 최대 힙 크기 옵션인 -Xmx부터 키우고 싶어집니다. 하지만 순서는 대개 아래가 더 안전합니다.
- 어떤 에러인지 정확히 구분합니다.
- GC 로그와 힙 사용 패턴을 봅니다.
- 어떤 객체가 남는지 힙 덤프에서 확인합니다.
-
그다음에야 옵션 조정이나 구조 변경을 검토합니다. 입문 단계에서 먼저 알아둘 만한 관찰 포인트는 아래 정도입니다.
-
GC 로그: GC 빈도와 정지 시간을 봅니다.
- 힙 덤프: 어떤 객체가 많이 남는지 봅니다.
jcmd: JVM 상태를 질의합니다 (사용법은 10.2에서 자세히 다룹니다).- Native Memory Tracking: 힙 밖 메모리를 추적할 때 사용합니다.
이 가운데 관찰 자료를 처음부터 남겨 두는 대표 실행 옵션 조합은 아래와 같습니다. -Xlog:'gc*'의 따옴표는 zsh가 *를 파일 이름 패턴으로 해석하지 않게 막는 표기이고, 각 옵션의 의미는 10.2에서 자세히 다룹니다.
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heap.hprof \
-Xlog:'gc*' \
-jar app.jar
위 설정은 메모리 문제가 재현될 때 원인을 남겨 두는 기본적인 출발점으로 자주 사용됩니다.
9. Spring 애플리케이션에서 특히 주의할 지점¶
Spring Boot에서는 아래 상황이 메모리 사용량에 큰 영향을 줍니다.
- 대량 조회 결과를 한 번에 리스트로 적재하는 경우
- 캐시를 넣고 만료 정책을 충분히 설계하지 않은 경우
- 요청/응답 DTO를 과도하게 복사하는 경우
- 로그 메시지와 JSON 직렬화 중 임시 객체가 많이 생기는 경우
- 테스트에서 컨텍스트를 과도하게 반복 생성하는 경우 즉, Spring 메모리 문제는 종종 JVM 옵션보다 애플리케이션 구조와 데이터 흐름에서 먼저 원인을 찾는 편이 맞습니다.
자주 헷갈리는 지점¶
- 지역 변수가 사라진다고 해서 관련 힙 객체가 반드시 바로 사라지는 것은 아닙니다.
- GC가 있다고 해서 메모리 누수가 사라지는 것은 아닙니다.
- 힙이 넉넉하다고 해서 프로세스 전체 메모리가 충분한 것은 아닙니다.
Metaspace문제와Java heap space문제는 같은 대응으로 풀리지 않습니다.- 참조에
= null을 대입하는 것은 만능 해결책이 아니라, 실제 참조 구조를 이해하는 것이 먼저입니다.
공식 문서 기준으로 더 보면 좋은 자료¶
- JVM Spec Chapter 2: Run-Time Data Areas
- The java Command
- The jcmd Command
- Native Memory Tracking
- Garbage Collection Tuning Guide
✏️ JVM 기초 가이드 2 직접 해보기¶
객체와 지역변수가 각각 힙·스택 어디에 저장되는지 예제로 짚어 보라.
실습 순서
- 파일 열기 — 위에서 만든
ReachabilityExample.java를 엽니다. - 수정 — 각 줄의 객체·지역 변수가 힙과 스택 중 어디에 놓이는지 주석으로 표시합니다.
- 재실행 — 위 절의 실행 명령 재사용(
javac ReachabilityExample.java && java ReachabilityExample)으로 여전히 동작하는지 확인합니다.
정리¶
JVM 메모리 관점의 핵심은 "힙이 크냐 작냐"가 아닙니다. 어떤 객체가 왜 살아남는지, 어떤 메모리 영역에서 문제가 나는지, 무엇을 관찰해야 하는지 구분하는 것이 더 중요합니다. 이 기준이 있어야 다음 단계인 GC 튜닝과 장애 분석이 의미를 가집니다.
한 줄 정리¶
메모리 관리의 핵심은 객체를 많이 만들지 않는 것이 아니라, 객체가 왜 안 사라지는지 설명할 수 있는 상태에 도달하는 것입니다.
10.2 JVM 기초 가이드 3: 튜닝과 실전 활용¶
🎯 목표: GC 로그를 읽고 힙을 튜닝한다.
개요¶
이 문서는 JVM 옵션을 외우는 문서가 아니라, 운영 중인 Java 애플리케이션을 어떻게 관찰하고 조정할 것인가를 정리한 실전 가이드입니다. 튜닝은 몇 개의 유명한 옵션을 복사해 붙이는 작업이 아니라, 현재 증상을 측정하고 원인을 좁힌 뒤, 작은 변경을 적용하고 다시 확인하는 반복 과정입니다.
앞선 문서에서 JVM 구조와 메모리 관리를 다뤘다면, 이번 문서는 그 지식을 실제 운영 판단으로 연결합니다. 특히 Spring Boot 애플리케이션에서는 시작 시간, GC 정지 시간, 힙 사용량, 스레드 수, 네이티브 메모리 사용량이 함께 얽히기 때문에 단순히 -Xmx만 조정해서는 해결되지 않는 경우가 많습니다.
참고 — 실무 저장소 기준으로 보면¶
참고로 필자의 실무 저장소의 실행 이미지는 컨테이너 친화적인 JVM 옵션만 최소한으로 두고 있습니다.
그 저장소의 docs/DEPLOYMENT.md에는 여기에 -XX:InitialRAMPercentage=50.0를 더한 예시도 있습니다. 즉 이 프로젝트는 복잡한 GC 튜닝 값을 고정해 둔 상태가 아니라, 컨테이너 메모리 한도 안에서 안전하게 시작하고 실제 증상을 본 뒤 조정하는 방향에 가깝습니다.
예상 결과
이 프로젝트의 JVM 튜닝은 특정 GC 옵션을 미리 많이 주는 방식이 아니라,
기본 메모리 정책을 두고 운영 지표와 로그를 보면서 추가 조정하는 방식으로 이해하는 편이 맞다.
1. 튜닝의 출발점은 옵션이 아니라 관찰이다¶
메모리나 성능 문제가 보이면 초보 단계에서는 바로 힙 크기를 키우고 싶어집니다. 하지만 그 순서는 보통 좋지 않습니다.
더 안전한 순서는 아래와 같습니다.
- 증상을 구체적으로 정의합니다.
- 현재 상태를 관찰합니다.
- 원인 후보를 좁힙니다.
- 작은 조정을 적용합니다.
-
다시 측정합니다. 예를 들어 아래 증상들은 서로 다른 대응이 필요합니다.
-
응답이 간헐적으로 멈춥니다.
- 메모리 사용량이 계속 증가합니다.
- 애플리케이션 시작이 너무 느립니다.
- 컨테이너에서 OOMKilled(메모리 한도 초과로 컨테이너 런타임이 프로세스를 강제 종료하는 것)가 납니다.
- CPU는 낮은데 처리량이 안 나옵니다. 같은 "느리다"라는 말 안에도 원인이 전혀 다를 수 있습니다.
2. 가장 먼저 확인할 기본 옵션¶
운영에서 자주 먼저 보게 되는 옵션은 크게 네 묶음입니다.
2.1 힙 크기¶
초기 힙과 최대 힙 크기를 함께 지정하는 기본 형태입니다.
-Xms: 초기 힙 크기-Xmx: 최대 힙 크기 힙을 너무 작게 잡으면 GC가 지나치게 자주 일어날 수 있고, 너무 크게 잡으면 컨테이너나 서버 전체 메모리와 충돌할 수 있습니다. 크다고 항상 좋은 것이 아닙니다.
2.2 스레드 스택 크기¶
스레드당 스택 크기를 정하는 -Xss의 실행 형태입니다.
재귀가 깊거나 스레드가 매우 많은 환경에서는 영향을 줄 수 있습니다. 다만 이를 바꾸기 전에 먼저 왜 스택이 부족한지, 스레드 수가 왜 많은지부터 봐야 합니다.
2.3 GC 로그¶
GC 이벤트를 파일로 남기는 실행 형태입니다.
GC 로그는 튜닝의 출발점입니다. 정지 시간이 얼마나 긴지, 수집이 얼마나 자주 일어나는지, 메모리가 회수되는지 같은 기본 정보를 먼저 봐야 합니다.
2.4 장애 분석용 옵션¶
메모리 장애 순간에 힙 덤프 파일을 자동으로 남기는 실행 형태입니다.
이 옵션은 메모리 장애가 났을 때 사후 분석 자료를 남기기 위한 최소한의 안전장치입니다. 운영 환경에서는 이런 기록 장치가 없는 편이 더 위험합니다.
3. 무엇을 볼 것인가: 기본 관찰 포인트¶
튜닝은 관찰 항목이 정리되어 있어야 합니다. 아래 다섯 가지는 거의 항상 기본입니다.
- 힙 사용량의 추세
- GC 빈도와 정지 시간
- 스레드 수와 상태
- 클래스 수와 메타스페이스 사용량
- 프로세스 전체 메모리와 컨테이너 메모리 제한 애플리케이션 내부 지표도 중요하지만, JVM은 OS와 컨테이너 위에서 돌아가기 때문에 외부 메모리 제한과 함께 봐야 합니다.
4. jcmd를 기본 진단 도구로 익히기¶
현대 JDK에서는 jcmd가 가장 기본적인 현장 진단 도구 중 하나입니다. 도구를 여러 개 외우기보다, 먼저 jcmd로 무엇을 뽑아낼 수 있는지 익히는 편이 좋습니다.
4.1 프로세스 확인¶
인자 없이 실행해 현재 떠 있는 Java 프로세스 목록부터 확인합니다.
출력에 나오는 프로세스 ID를 아래 명령들의 <pid> 자리에 넣습니다.
4.2 힙 정보 확인¶
힙 구조와 사용 현황을 빠르게 보는 명령입니다.
힙이 실제로 부족한지, 특정 구간이 압박받는지 확인할 때 출발점이 됩니다.
4.3 클래스 히스토그램 확인¶
어떤 클래스 인스턴스가 많이 쌓여 있는지 세어 보는 명령입니다.
힙 덤프보다 가볍게 빠른 감을 잡는 용도로 좋습니다.
4.4 스레드 덤프 확인¶
전체 스레드의 상태를 덤프하는 명령입니다.
응답 지연, 데드락 의심, 스레드 과다 생성 문제를 볼 때 자주 사용합니다.
4.5 네이티브 메모리 확인¶
힙 밖 네이티브 메모리 사용 현황을 요약해 보여 주는 명령입니다.
힙만 보고는 설명되지 않는 메모리 증가를 추적할 때 매우 중요합니다. 특히 Direct Buffer, 클래스 메타데이터, 스레드 관련 메모리를 같이 봐야 할 때 도움이 됩니다.
5. GC를 바꾸기 전에 먼저 봐야 할 것¶
GC 알고리즘은 중요하지만, 대부분의 문제는 GC 종류를 바꾸기 전에 아래 항목에서 먼저 걸립니다.
- 힙 크기가 workload에 비해 너무 작습니다.
- 한 번에 너무 많은 데이터를 메모리에 적재합니다.
- 캐시 정책이 없습니다.
- 큰 객체 그래프가 오래 살아남습니다.
- 로그나 직렬화 때문에 짧게 사는 객체가 과도하게 생성됩니다. 즉, GC 튜닝은 구조 문제를 완전히 대신하지 못합니다. 애플리케이션이 비효율적으로 메모리를 쓰고 있다면 GC만 바꿔도 한계가 있습니다.
6. G1 GC를 볼 때의 기본 질문¶
현대 HotSpot 환경에서는 기본 수집기인 G1 GC를 자주 만나게 됩니다. 입문 단계에서는 세부 알고리즘보다 아래 질문이 더 중요합니다.
- pause(GC 정지 시간)가 사용자가 느낄 만큼 긴가
- Young GC가 지나치게 자주 발생하는가
- Old 영역 압박이 계속 증가하는가
- Full GC 성격의 무거운 수집이 보이는가
- GC 후에도 메모리가 충분히 회수되지 않는가
이 질문들에 답하려면 pause 목표를 제시하고 GC 로그를 남긴 채 돌려 봐야 합니다. 대표 실행 예시는 아래와 같습니다.
java -XX:MaxGCPauseMillis=200 \
-Xms1g -Xmx1g \
-Xlog:'gc*':file=/var/log/app-gc.log:time,level,tags \
-jar app.jar
MaxGCPauseMillis 같은 옵션은 목표치를 제시하는 것이지, 마법처럼 pause를 보장하는 스위치는 아닙니다. 결국 실제 workload에서 로그를 봐야 판단할 수 있습니다.
7. 튜닝 시나리오별로 접근이 달라야 한다¶
7.1 응답 시간이 중요한 웹 애플리케이션¶
웹 애플리케이션은 보통 짧은 정지 시간이 중요합니다.
이 경우 먼저 볼 질문:
- 특정 시간대에 pause가 몰리는가
- 대량 응답 생성이나 JSON 직렬화가 객체 폭증을 만드는가
- 캐시가 힙을 과도하게 잡아먹는가
- 요청 폭증 시 스레드 수가 과하게 늘어나는가
7.2 처리량이 중요한 배치 애플리케이션¶
배치는 응답 시간보다 전체 처리량이 더 중요할 수 있습니다.
이 경우 먼저 볼 질문:
- 한 번에 너무 큰 chunk를 메모리에 쌓고 있지 않은가
- 결과를 스트리밍하지 않고 전부 리스트에 올리고 있지 않은가
- GC pause보다 객체 생성량 자체가 비정상적으로 많지 않은가
7.3 컨테이너 환경¶
컨테이너에서는 JVM 내부 힙만이 아니라, 컨테이너 제한 메모리와 프로세스 전체 메모리를 함께 봐야 합니다.
이 경우 먼저 볼 질문:
-Xmx가 컨테이너 메모리 한계 대비 너무 공격적이지 않은가- 힙 밖 메모리까지 고려했는가
- OOM이 JVM 예외인지, 컨테이너에서 프로세스를 죽인 것인지 구분했는가
8. 흔한 문제와 기본 대응¶
8.1 메모리 사용량이 계속 오른다¶
먼저 해야 할 일:
- GC 후에도 사용량이 다시 낮아지는지 봅니다.
GC.class_histogram(4.3에서 본 명령)으로 어떤 객체가 많은지 봅니다.- 캐시, static 컬렉션, 장수 객체 참조를 의심합니다.
- 필요하면 힙 덤프를 남깁니다.
8.2 pause가 너무 길다¶
먼저 해야 할 일:
- GC 로그로 pause가 실제 원인인지 확인합니다.
- 힙이 너무 작아 수집이 과도하게 자주 일어나는지 봅니다.
- 너무 큰 객체 그래프나 대량 적재 구간이 있는지 봅니다.
- 그다음에야 GC 관련 옵션 조정을 검토합니다.
8.3 OutOfMemoryError: Metaspace¶
먼저 해야 할 일:
- 클래스 수가 비정상적으로 늘어나는지 봅니다.
- 프록시 생성, 리로딩, 클래스 로더 누수를 의심합니다.
- 단순히 힙을 늘리는 것으로 해결하려 하지 않습니다.
8.4 CPU는 낮은데 처리량이 안 나온다¶
먼저 해야 할 일:
- 스레드 덤프를 봅니다.
- 락 경합이나 외부 I/O 대기를 의심합니다.
- GC가 아니라 스레드 상태가 병목일 가능성을 확인합니다.
9. JFR은 "나중 고급 도구"가 아니라 실전 기본기다¶
성능 이슈를 한 번에 넓게 보려면 Java Flight Recorder(JFR)를 익히는 것이 좋습니다. 특히 GC, 스레드, 할당, 메서드 hotspot을 한 흐름으로 볼 수 있다는 점이 강합니다.
실행 중인 프로세스에서 60초 분량의 실행 기록을 파일로 남기는 명령입니다. <pid>는 4.1의 jcmd 출력에서 확인한 프로세스 ID를 넣습니다.
짧은 시간의 실제 트래픽 구간을 기록해 두면 "메모리 문제인지", "락 문제인지", "할당이 너무 많은지"를 한 번에 좁히기 쉬워집니다.
10. 실전에서 자주 하는 잘못된 접근¶
- 증상 확인 없이
-Xmx부터 크게 올리는 것 - 남이 쓰는 옵션을 맥락 없이 그대로 복사하는 것
- GC 알고리즘만 바꾸면 문제가 해결될 거라고 믿는 것
- 운영 장애가 났는데 GC 로그나 힙 덤프를 남기지 않은 것
- JVM 튜닝 문제를 애플리케이션 구조 문제와 분리해서 보는 것
튜닝은 대개 "JVM 옵션 문제"와 "코드 구조 문제"가 같이 얽혀 있습니다. 둘을 따로 떼어 생각하면 오판하기 쉽습니다.
11. 운영용 기본 템플릿 예시¶
아래 설정은 만능값이 아니라, 관찰 가능한 상태를 만드는 출발점 정도로 보는 편이 맞습니다. 2장에서 본 GC 로그·힙 덤프 옵션을 하나로 모은 형태이고, -Xlog:'gc*'의 따옴표는 zsh가 *를 파일 이름 패턴으로 해석하지 않게 막는 표기입니다.
java -Xms1g -Xmx1g \
-Xlog:'gc*':file=/var/log/app-gc.log:time,level,tags \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app.hprof \
-jar app.jar
필자의 실무 저장소에 바로 적용된 값은 아니고, 실제로는 앞의 "참고 — 실무 저장소 기준으로 보면"에서 본 것처럼 더 보수적인 컨테이너 기본값이 먼저 잡혀 있습니다.
예상 결과
운영용 기본 템플릿은 장애 분석 자료를 남기기 위한 확장 예시다.
이런 실무 프로젝트는 이보다 단순한 기본값으로 시작하고 있으며, GC 로그나 힙 덤프 설정은
필요 시 운영 환경에서 추가하는 방식으로 읽는 편이 정확하다.
여기서 시작한 뒤 실제 트래픽과 지표를 보고 조정해야 합니다.
참고로 -XX:+UseContainerSupport는 JDK 10부터 리눅스에서 기본 활성(true)이라, 위처럼 명시하는 것은 동작을 바꾸는 조정이 아니라 컨테이너 인지 동작을 쓰고 있다는 의도를 드러내는 표기에 가깝습니다 (실제로 동작을 바꾸는 쪽은 끌 때의 -XX:-UseContainerSupport입니다). 반면 -XX:MaxRAMPercentage는 기본값이 25.0이므로 75.0 지정은 실제 효력이 있는 조정입니다.
공식 문서 기준으로 더 보면 좋은 자료¶
✏️ JVM 기초 가이드 3 직접 해보기¶
-Xmx·-verbose:gc 옵션으로 실행해 GC 로그를 출력하고 읽어 보라.
-verbose:gc는 2.3에서 본 -Xlog GC 로그를 표준 출력으로 간단히 찍는 축약 옵션입니다.
실습 순서
- 파일 생성 — 10.1의
ReachabilityExample.java와 같은 디렉터리에GcLogDemo.java를 만듭니다. 단일 파일 실습이므로 package 선언 없이 씁니다. -
뼈대 입력 — 아래 뼈대를 그대로 입력합니다.
-
하나씩 구현 — 주석의 과제를 한 항목씩 구현합니다.
- 실행·확인 —
javac GcLogDemo.java && java -Xmx64m -verbose:gc GcLogDemo— 추가할 때마다 다시 실행해 GC 로그가 어떻게 달라지는지 확인합니다.
정리¶
JVM 튜닝의 핵심은 옵션을 많이 아는 것이 아니라, 어떤 지표를 보고 어떤 가설을 세울지 아는 것입니다. 좋은 튜닝은 크게 한 번 바꾸는 방식이 아니라, 관찰 가능한 상태를 만들고 작은 변경을 반복해서 검증하는 방식으로 진행됩니다.
한 줄 정리¶
JVM 실전 활용의 핵심은 옵션 조정이 아니라, 측정 가능한 운영 루프를 만드는 것입니다.