| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- Flyway
- 마이크로서비스아키텍처
- 도커
- 백엔드
- Java 8
- 인프라
- 마이크로서비스
- 컨테이너
- Database
- GitHub Packages
- ci/cd
- springboot
- 멀티모듈
- SpringCloud
- 트러블슈팅
- GCP
- MSA
- docker
- 자바
- 분산시스템
- dockercompose
- PostgreSQL
- 공통모듈
- 마이그레이션
- 백엔드면접준비
- java
- github actions
- CS
- 아키텍처
- gradle
- Today
- Total
NYO_O
JVM 메모리 구조 — Heap, Stack 본문
지난 시간, 우리는 자바라는 언어가 어떤 철학을 바탕으로 설계되었는지, 그리고 왜 수십 년이 지난 지금까지도 현업에서 널리 쓰이는지를 살펴보았습니다. 자바가 가진 가장 강력한 특징 중 하나가 바로 플랫폼 독립성인데, 그 독립성을 실제로 구현하는 것이 JVM(Java Virtual Machine)입니다.
자바로 개발을 하다 보면 new 키워드로 객체를 만들고, 메서드를 호출하고, 변수에 값을 담는 일을 반복합니다. 그런데 이 데이터들이 실행 중에 실제로 어디에 저장되는지 생각해 본 적이 있으신가요? 그 답이 바로 JVM 메모리 구조에 있습니다.
JVM 메모리 구조를 이해하면 "왜 NullPointerException이 발생하는가", "왜 메모리가 부족하다는 오류가 나는가", "GC는 어떤 기준으로 객체를 지우는가" 같은 질문들에 답할 수 있게 됩니다. 오늘은 JVM이 어떻게 메모리를 나누어 관리하는지, Heap과 Stack은 어떤 역할을 하는지, 그리고 그 사이에 어떤 영역들이 존재하는지를 정리해 보겠습니다.
JVM은 왜 필요한가
사전 지식 — JVM이란? JVM은 자바 바이트코드를 읽어 운영체제 위에서 실행시켜 주는 가상 머신입니다. 개발자가 작성한 .java 파일은 컴파일러를 거쳐 .class 파일(바이트코드)이 되고, 이 바이트코드를 JVM이 해석하여 각 운영체제에 맞는 기계어로 변환합니다.
자바는 "Write Once, Run Anywhere"라는 슬로건으로 알려져 있습니다. 윈도우에서 작성한 자바 코드가 리눅스, macOS에서도 그대로 실행될 수 있는 이유가 여기에 있습니다. 운영체제마다 JVM이 따로 존재하고, 그 JVM이 플랫폼 차이를 흡수해 주기 때문입니다.
그렇다면 JVM은 실행 중에 어떤 데이터를 어디에 보관할까요? 이를 정의하는 것이 바로 Runtime Data Area, 즉 JVM 메모리 구조입니다.
JVM 메모리 구조 전체 그림
JVM이 프로그램을 실행하면서 사용하는 메모리 영역은 크게 두 가지 기준으로 나뉩니다. 바로 모든 스레드가 함께 쓰는 영역과 스레드마다 따로 갖는 영역입니다.
| 구분 | 영역 | 특징 |
| 스레드 공유 | Heap | 객체 인스턴스, 배열 저장 |
| 스레드 공유 | Metaspace | 클래스 메타데이터 저장 (Java 8+) |
| 스레드 공유 | Code Cache | JIT 컴파일된 네이티브 코드 저장 |
| 스레드 독립 | Stack | 메서드 호출 프레임 저장 |
| 스레드 독립 | PC Register | 현재 실행 중인 명령어 주소 저장 |
| 스레드 독립 | Native Method Stack | 네이티브 메서드 실행 정보 저장 |
스레드가 공유하는 영역은 여러 스레드가 동시에 접근할 수 있기 때문에 동시성 문제가 발생할 수 있습니다. 반면 스레드 독립 영역은 각 스레드가 자신만의 공간을 갖기 때문에 다른 스레드와 간섭이 없습니다.
이 중에서 실제 개발과 가장 밀접하게 연결되는 영역이 Heap과 Stack입니다. 이 두 영역을 먼저 깊이 살펴보겠습니다.
Heap
Heap은 JVM에서 가장 크고 중요한 메모리 영역입니다. 한 마디로 정의하면, new 키워드로 생성한 모든 객체 인스턴스와 배열이 저장되는 공간입니다.
String name = new String("Hello");
위 코드를 실행하면 두 가지 일이 동시에 일어납니다. "Hello"라는 내용을 가진 String 객체는 Heap에 생성되고, name이라는 참조 변수는 Stack에 생성됩니다. name은 Heap에 있는 객체의 주소를 가리키는 일종의 화살표 역할을 합니다.

Heap 특징과 가비지 컬렉터(GC)
Heap에 한 번 올라간 객체는 개발자가 직접 지우지 않아도 됩니다. 더 이상 아무도 참조하지 않는 객체를 JVM이 자동으로 탐지해서 제거합니다. 이 역할을 담당하는 것이 GC(Garbage Collector) 입니다. C나 C++ 같은 언어에서는 개발자가 직접 메모리를 해제해야 하지만, 자바에서는 GC가 이 작업을 대신해 줍니다.
여기서 한 가지 기억해야할 점은 Heap이 꽉 차면 GC가 실행된다는 것입니다. GC가 실행되는 동안에는 애플리케이션이 잠시 멈추는 현상(Stop-The-World)이 발생할 수 있고, 이것이 성능 문제로 이어지기도 합니다.
Heap 내부 구조 (Young / Old Generation)
Heap은 GC 효율을 높이기 위해 내부적으로 두 영역으로 나뉩니다.
| 세부 영역 | 설명 |
| Young Generation | 새로 생성된 객체가 처음 배치되는 영역입니다. 내부적으로 Eden과 Survivor 0, Survivor 1 세 공간으로 나뉩니다. |
| Old Generation (Tenured) |
Young Generation에서 오랫동안 살아남은 객체가 이동하는 영역입니다. 상대적으로 수명이 긴 객체들이 모입니다. |
대부분의 객체는 생성된 직후 금방 사용이 끝나고 버려집니다. 예를 들어, 반복문 안에서 생성되는 임시 객체나 메서드 호출 중에 만들어지는 중간 결과값 같은 것들입니다. 반면 애플리케이션 전체에서 오래 쓰이는 객체, 예를 들어 스프링의 Bean 객체들은 오랫동안 살아남습니다.
이 특성을 활용해서 Young Generation에서는 자주, 빠르게 GC를 수행하고, Old Generation에서는 드물게 GC를 수행합니다. 이렇게 하면 전체적인 GC 비용을 크게 줄일 수 있습니다.

Young Generation 내부의 객체 이동 흐름
1. Eden 영역과 Minor GC: new 키워드를 통해 새롭게 생성된 대부분의 객체는 가장 먼저 Eden 영역에 할당됩니다. 애플리케이션이 실행되면서 객체가 계속 만들어져 Eden 영역이 가득 차면, 메모리 공간을 확보하기 위해 가비지 컬렉션이 발생합니다. 이것을 Minor GC라고 부릅니다. Minor GC는 짧은 시간 안에 매우 빠르게 수행되며, 더 이상 참조되지 않는 쓰레기 객체는 즉시 지우고 살아남은 객체들만 다음 단계인 Survivor 영역으로 대피시킵니다.
2. Survivor 0과 1 영역의 핑퐁 게임: Eden 영역에서 살아남은 객체는 Survivor 0 또는 Survivor 1 중 한 곳으로 이동합니다. 이때 중요한 규칙은 두 Survivor 영역 중 하나는 항상 비어 있어야(Empty) 한다는 점입니다. Minor GC가 발생할 때마다 살아남은 객체들은 현재 비어 있는 다른 Survivor 영역으로 일괄 복사됩니다. 이렇게 객체를 번갈아 가며 이동시키는 이유는 메모리 파편화(Fragmentation)를 막아 메모리 공간을 차곡차곡 효율적으로 사용하기 위함입니다.
3. Promotion (승격) - Old Generation으로의 안착: 객체가 여러 번의 Minor GC를 거치면서도 소멸되지 않고 살아남는다면, JVM은 이 객체를 '오래 사용될 객체'로 간주합니다. 객체는 Survivor 영역을 오갈 때마다 '나이(Age)'가 1씩 증가하는데, 이 나이가 JVM이 설정한 특정 임계치(일반적으로 15)에 도달하면 비로소 더 넓은 Old Generation 영역으로 이동하게 됩니다. 이처럼 수명이 긴 객체가 Young Generation에서 Old Generation으로 넘어가는 과정을 Promotion(승격)이라고 합니다.
결과적으로 JVM은 이 구조를 통해 짧게 쓰고 버려지는 객체는 Young Generation에서 빠르게 치우고, 오래 쓰이는 객체만 Old Generation으로 승격시켜 전체적인 애플리케이션의 메모리 성능을 최적화합니다.
Eden 영역이 가득 차야만 GC가 발생할까?
Minor GC는 Eden 영역이 100% 꽉 찼을 때만 발생하는 것은 아닙니다. 가장 주요한 원인이 공간 부족일 뿐, JVM의 상태와 사용 중인 GC 알고리즘에 따라 다양한 시점에 발생할 수 있습니다.
- 정확한 기준은 '할당 실패(Allocation Failure)': 물리적인 공간이 꽉 찼는지가 기준이 아닙니다. 애플리케이션이 새로운 객체를 생성하려 할 때, '해당 객체의 크기만큼 연속된 빈 공간이 Eden에 없을 때' Minor GC가 트리거됩니다. 즉, 여유 공간이 남아있더라도 새로 들어올 객체보다 작다면 GC가 발생합니다.
- Full GC 발생 시 연쇄 작용: Old Generation 영역이 가득 차거나 메타스페이스(Metaspace)가 부족하여 Full GC(Major GC)가 발생하게 되면, 힙 메모리 전체를 정리하면서 Young Generation도 함께 청소됩니다. 이때는 Eden 영역이 텅 비어 있더라도 무조건 GC 대상이 됩니다.
- 개발자의 명시적 호출: 코드상에서 System.gc() 메서드를 직접 호출하면 Eden 영역의 포화 상태와 무관하게 즉시 강제 GC가 발생합니다. (다만, 애플리케이션을 멈추게 하는 치명적인 성능 저하를 유발하므로 실무에서는 절대 사용하지 않는 안티 패턴입니다.)
- G1 GC의 휴리스틱 (Java 9 이상 기본): 과거의 GC 방식과 달리, 모던 Java에서 기본으로 채택하는 G1 GC(Garbage-First GC)는 고정된 크기의 Eden 공간을 두지 않고 '리전(Region)' 단위로 동적 관리를 합니다. 사용자가 설정한 '목표 정지 시간(Pause Time Goal)'을 지키기 위해, JVM이 내부적인 판단 하에 Eden 영역이 완전히 가득 차기 전에도 유연하게 GC를 수행합니다.
결과적으로 주로 Eden 영역에 객체를 할당할 공간이 부족할 때 발생하지만, 전체적인 메모리 상황이나 GC 알고리즘의 최적화 전략에 따라 그 이전에도 유동적으로 발생할 수 있습니다.
Stack
Stack은 메서드가 호출될 때마다 생겨나고, 메서드가 끝나면 사라지는 메모리 영역입니다. 스레드마다 독립적으로 하나씩 존재하며, 메서드 호출이 쌓이는 구조가 접시를 쌓는 것과 비슷해서 Stack(스택)이라는 이름이 붙었습니다.
메서드가 호출될 때마다 스택 프레임(Stack Frame) 이라는 단위가 하나씩 쌓입니다.
| 정보 | 설명 |
| 지역 변수 | 메서드 내부에서 선언된 변수 (기본형 타입 데이터 및 참조 변수) |
| 피연산자 스택 | 연산 중인 중간 값 |
| 프레임 데이터 | 메서드 반환 주소, 예외 처리 정보 등 |
public class Example {
public static void main(String[] args) {
int result = add(3, 5); // (2) add() 호출 → 새 스택 프레임 생성
System.out.println(result);
} // (4) main() 종료 → main 스택 프레임 제거
public static int add(int a, int b) {
// (3) a=3, b=5, sum=8 모두 이 스택 프레임 안에 저장됩니다
int sum = a + b;
return sum;
} // (3) add() 종료 → add 스택 프레임 제거, 반환값은 main 프레임으로 전달
}
// (1) main() 호출 → main 스택 프레임 생성
Stack에 저장된 지역 변수들은 메서드가 끝나는 순간 자동으로 사라집니다. GC가 개입할 필요 없이 프레임 자체가 제거되기 때문입니다.
StackOverflowError는 왜 발생할까
Stack의 크기는 무한하지 않습니다. 메서드가 호출될 때마다 스택 프레임이 쌓이는데, 종료 조건 없는 재귀 호출처럼 프레임이 끝없이 쌓이다 보면 Stack 공간이 가득 차게 됩니다. 이때 발생하는 것이 StackOverflowError입니다.
public void infiniteRecursion() {
// 종료 조건 없이 자기 자신을 계속 호출합니다
// 스택 프레임이 계속 쌓이다가 결국 StackOverflowError 발생
infiniteRecursion();
}
처음 이 오류를 만나면 당황스러울 수 있는데, 스택 트레이스를 보면 같은 메서드가 수백 줄 반복되는 패턴을 확인할 수 있습니다. 대부분 재귀 메서드의 탈출 조건을 빠뜨렸거나, 예상치 못한 순환 참조가 원인입니다.
Heap과 Stack의 관계
두 영역이 실제로 어떻게 연결되는지 구체적인 예를 보겠습니다.
public void run() {
// 1. user라는 참조 변수 자체는 Stack에 저장됩니다
// 2. new User("Alice")로 생성된 User 객체는 Heap에 저장됩니다
// 3. Stack의 user는 Heap에 있는 객체의 주소(참조값)를 가집니다
User user = new User("Alice");
System.out.println(user.name); // Heap에 있는 객체의 필드에 접근
} // run() 종료 → user 참조 변수가 Stack에서 제거됨
// → Heap의 User 객체를 가리키는 참조가 없어짐 → GC 대상이 됩니다
정리하면 Stack에는 참조값(화살표)이 저장되고, 실제 객체의 내용은 Heap에 저장됩니다.
run() 메서드가 종료되어 Stack의 user 참조 변수가 사라진다고 해서 Heap의 객체가 즉시 소멸하는 것은 아닙니다. 만약 프로그램 내의 다른 어떤 변수도 이 User 객체를 가리키지 않는다면, 그 객체는 도달 불가능(Unreachable) 상태가 됩니다. GC는 주기적으로 힙 영역을 돌며 이 Unreachable 상태의 객체들을 찾아내 수거합니다.
NullPointerException이 발생하는 이유도 여기서 이해할 수 있습니다. Stack에 있는 참조 변수가 아무 객체도 가리키고 있지 않을 때(null), 그 참조를 통해 Heap의 객체에 접근하려 하면 예외가 발생합니다.
Metaspace
자바 코드를 실행하려면 객체뿐 아니라 클래스 자체에 대한 정보도 메모리에 저장되어 있어야 합니다. 이 구조 정보를 클래스 메타데이터라고 하며, 이것을 저장하는 곳이 Metaspace입니다.
| 저장 정보 | 설명 |
| 클래스 메타데이터 | 클래스 이름, 필드 정보, 메서드 시그니처 등 클래스의 구조 정보 |
| 메서드 바이트코드 | 각 메서드의 실행 코드 |
| 상수 풀 | 문자열 리터럴이나 클래스/메서드 참조 정보 |
과거 Java 7까지는 클래스 정보가 Heap 내부의 고정된 공간인 PermGen에 저장되어 OutOfMemoryError: PermGen space가 잦았습니다. Java 8부터는 이를 해체하고, 클래스 메타데이터는 힙 바깥인 운영체제(OS) 관리 영역(Native Memory)인 Metaspace로 옮겼습니다.
크기 제한이 없다는 것의 양면성 Metaspace는 기본적으로 크기 상한이 없어서 클래스가 늘어나면 필요한 만큼 메모리를 자동으로 확장합니다. 하지만 스프링(Spring)이나 하이버네이트(Hibernate)처럼 동적 프록시 클래스를 무수히 만들어내는 프레임워크 환경에서 메모리 누수가 발생한다면, Metaspace가 서버 전체의 물리 메모리(RAM)를 모두 잠식할 위험이 있습니다.
따라서 실제 운영 환경에서는 시스템 전체가 다운되는 것을 막기 위해 -XX:MaxMetaspaceSize 옵션으로 반드시 상한을 지정해 두는 것을 권장합니다.
그 외 영역 — Code Cache, PC Register, Native Method Stack
Heap, Stack, Metaspace가 핵심 영역이라면, 나머지 세 영역은 JVM이 내부적으로 코드를 실행하기 위해 사용하는 보조 영역입니다. 일상적인 개발에서 직접 다룰 일은 많지 않지만, JVM 동작 원리를 이해하는 데 도움이 됩니다.
Code Cache
사전 지식 — JIT 컴파일이란? JVM은 처음에는 바이트코드를 한 줄씩 해석(인터프리트)하는 방식으로 실행합니다. 그런데 자주 호출되는 코드는 매번 해석하는 대신, 네이티브 기계어로 미리 컴파일해두면 훨씬 빠릅니다. 이 방식을 JIT(Just-In-Time) 컴파일이라 합니다.
JIT 컴파일러가 변환한 네이티브 코드를 저장해 두는 공간입니다. 한 번 컴파일된 코드는 Code Cache에 보관되고, 같은 코드가 다시 호출될 때는 컴파일 없이 바로 실행됩니다. 덕분에 자주 호출되는 메서드일수록 점점 빠르게 실행됩니다. 자바 애플리케이션이 처음 구동 직후보다 잠시 후에 더 빠르게 느껴지는 이유 중 하나가 여기에 있습니다.
Code Cache의 크기는 -XX:ReservedCodeCacheSize 옵션으로 설정할 수 있습니다. 이 공간이 가득 차면 JIT 컴파일이 중단되고 다시 인터프리트 방식으로 돌아가기 때문에 성능이 눈에 띄게 저하될 수 있습니다.
PC Register (Program Counter Register)
현재 스레드가 실행 중인 JVM 명령어의 주소를 저장하는 매우 작은 영역입니다. 스레드마다 독립적으로 하나씩 존재합니다.
컨텍스트 스위칭이 일어날 때, 즉 CPU가 다른 스레드로 전환될 때 "이 스레드는 어디까지 실행했었는지"를 PC Register에 기록해 둡니다. 나중에 다시 이 스레드로 돌아오면 PC Register를 참조해 중단된 지점부터 이어서 실행합니다. 책갈피와 비슷한 역할이라고 볼 수 있습니다.
Native Method Stack
자바 코드가 아닌 C나 C++로 작성된 네이티브 메서드가 실행될 때 사용하는 별도의 스택입니다. 예를 들어 Thread.start()를 호출하면 내부적으로 start0()이라는 네이티브 메서드가 실행되는데, 이때 Native Method Stack이 사용됩니다. JNI(Java Native Interface)를 통해 OS 기능이나 하드웨어를 직접 제어할 때 이 영역이 관여합니다.
정리
Heap과 Stack의 차이점
Heap은 모든 스레드가 공유하는 영역으로, new 키워드로 생성된 객체 인스턴스가 저장됩니다. GC의 관리를 받으며, 객체를 가리키는 참조가 모두 사라질 때 수거됩니다. Stack은 스레드마다 독립적으로 존재하며 메서드 호출 시 생성된 스택 프레임 안에 지역 변수와 참조값이 저장됩니다. 메서드가 종료되면 해당 스택 프레임이 자동으로 제거되므로 GC가 필요하지 않습니다.
Java 8에서 PermGen이 Metaspace로 바뀐 이유
PermGen은 Heap 내부에 고정 크기로 존재했기 때문에, 클래스가 많이 로딩되거나 동적으로 클래스가 생성되는 환경에서 OutOfMemoryError: PermGen space가 빈번하게 발생했습니다. Metaspace는 이를 개선하여 Heap 바깥의 Native Memory 영역에 위치하고 크기가 동적으로 조절됩니다. 다만 상한이 없다는 특성 때문에 운영 환경에서는 -XX:MaxMetaspaceSize로 한도를 지정하는 것이 좋습니다.
StackOverflowError와 OutOfMemoryError의 차이
StackOverflowError는 스레드의 Stack 공간이 가득 찼을 때 발생합니다. 주로 종료 조건 없는 재귀 호출이 원인입니다. OutOfMemoryError는 Heap이나 Metaspace 등 메모리 영역에 더 이상 할당할 공간이 없을 때 발생합니다. 두 오류 모두 메모리 관련 문제이지만 발생 영역과 원인이 다릅니다. 오류 메시지에 함께 출력되는 영역 이름을 확인하면 어디서 문제가 생겼는지 빠르게 파악할 수 있습니다.
마무리
오늘은 JVM이 메모리를 어떻게 구분하여 관리하는지를 살펴보았습니다. new로 생성한 객체는 Heap에, 메서드의 지역 변수와 참조값은 Stack에, 클래스 구조 정보는 Metaspace에 저장됩니다. 이 세 영역의 역할과 차이를 이해하면 NullPointerException, StackOverflowError, OutOfMemoryError 같은 오류가 왜 발생하는지 스스로 설명할 수 있게 됩니다.
'BackEnd > Java' 카테고리의 다른 글
| GC 튜닝 — GC 로그를 읽고 성능 문제를 잡는 방법 (0) | 2026.05.29 |
|---|---|
| GC, 자바는 메모리를 어떻게 스스로 정리할까 (0) | 2026.05.29 |
| Java 25 (0) | 2026.05.29 |
| Java 21 (1) | 2026.05.29 |
| Java 17 (0) | 2026.05.29 |