IT/java|Spring

[JVM] Heap 구조 뜯어보기: new 한 줄이 메모리에서 벌어지는 일

snapcoder 2026. 3. 17. 20:17
728x90
반응형
SMALL

 

이전 글에서 부하테스트를 주제로 포스팅하면서 JVM Heap 구조를 간략히 언급했는데,

이번에는 JVM 자체를 한 번 깊게 다뤄보고자 합니다.

2026.03.16 - [IT/java|Spring] - [부하테스트] JMeter Ramp-up부터 JVM Heap 최적화까지: 서버 성능 개선기

 

[부하테스트] JMeter Ramp-up부터 JVM Heap 최적화까지: 서버 성능 개선기

배경여러 서비스를 운영하면서 공통 질문이 생겼습니다. "POD 한 대가 실제로 얼마나 버틸 수 있는가?"부하테스트를 준비하던 중, 검증계·운영계 모두 JVM Heap이 Pod 메모리와 맞지 않게 설정되어

snapcode.tistory.com

 

알고 있다고 생각하면서도, 막상 설명하려면 막히는 내용 이기도 합니다.

이미 알고 계신 분께는 복습이, 처음 접하시는 분께는 새로운 발견이 되길 바랍니다.

 

 

 

배경

이전 글에서 JVM Heap을 Pod 메모리의 75%로 조정하는 최적화를 다뤘습니다. 그 과정에서 "Heap 안에 Young Generation, Eden, Survivor, Old Generation이 있다"는 내용을 언급했는데, 이번 글에서는 그 구조를 객체의 생애주기 흐름으로 풀어보겠습니다.

new Object()를 호출하는 순간, 그 객체는 JVM 메모리 안에서 어떻게 되는지 살펴보겠습니다.

 


 

 

JVM 메모리 전체 구성

먼저 전체 그림부터 짚고 넘어갑니다.

# JVM 프로세스 메모리 구성 (4,096 MiB Pod 기준)
│
├─ Heap Memory                    2,048 MiB  ← -Xms(초기 크기) / -Xmx(최대 크기) 로 제어
│   ├─ Young Generation                      ← -XX:NewSize / -XX:MaxNewSize 로 제어
│   │   │                                      (새로 생성된 객체가 최초로 할당되는 영역)
│   │   ├─ Eden Space                          (새 객체 최초 할당 공간, 꽉 차면 Minor GC 트리거)
│   │   └─ Survivor Space                      (Minor GC 후 살아남은 객체 임시 보관)
│   │       ├─ From (S0)
│   │       └─ To   (S1)
│   └─ Old Generation                          (오래 살아남은 객체 저장, Full GC 대상)
│
├─ Non-Heap Memory
│   ├─ Metaspace              ~256 MiB         (클래스 메타데이터 저장 / Java 8 이전의 Permanent Generation에 해당)
│   └─ Code Cache              ~64 MiB         (JIT 컴파일된 코드 저장으로, 반복 실행 성능 향상)
│
└─ 기타 JVM 프로세스 메모리
    ├─ Thread Stack            ~200 MiB         (스레드 200개 × 1 MiB, 각 스레드의 호출 스택)
    ├─ Direct Memory           ~128 MiB         (NIO/Netty 버퍼 처리로, GC 범위 밖에서 I/O 성능 확보)
    ├─ GC 내부 구조             ~50 MiB
    ├─ OpenTelemetry Agent     ~256 MiB         (APM 계측 오버헤드 처리로, 트레이싱 비용 상시 발생)
    ├─ OS 버퍼                 ~100 MiB
    └─ 여유                    ~994 MiB
                               ─────────
                               4,096 MiB
< 이해를 한층 두껍게 해주는, 용어 풀이 >
* MiB : Mebibyte. 1 MiB = 1,024 KiB = 약 1MB. 메모리 단위
* Pod : Kubernetes에서 컨테이너를 실행하는 최소 단위. 서버 인스턴스 1개라고 이해하면 됨
* Heap Memory : JVM이 객체를 저장하는 주 메모리 공간. GC가 관리
* Xms : X memory start. JVM 시작 시 초기 Heap 크기
* Xmx : X memory maximum. JVM이 사용할 수 있는 최대 Heap 크기
* Young Generation : 새로 생성된 객체가 할당되는 Heap 영역. Minor GC 대상
* XX:NewSize / XX:MaxNewSize : Young Generation 크기를 제어하는 JVM 옵션
* Eden Space : 객체가 처음 생성되어 할당되는 공간. 에덴동산에서 유래
* Minor GC : Young Generation이 꽉 찼을 때 발생하는 빠른 가비지 컬렉션
* GC : Garbage Collection. 더 이상 참조되지 않는 객체를 자동으로 메모리에서 제거하는 과정
* Survivor Space : Minor GC에서 살아남은 객체를 임시 보관하는 공간
* From (S0) / To (S1) : Survivor의 두 칸. 항상 한 칸은 비워두고 살아남은 객체를 번갈아 복사
* Old Generation : Survivor를 여러 번 살아남은 객체가 승격되는 영역. Full GC 대상
* Full GC : Heap 전체를 대상으로 하는 무거운 GC. 실행 중 애플리케이션이 잠깐 멈춤(STW)
* STW : Stop-The-World. Full GC 실행 중 모든 애플리케이션 스레드가 일시 정지되는 현상
* Non-Heap Memory : Heap 외 JVM이 사용하는 메모리. GC 대상이 아님
* Metaspace : 클래스 구조·메서드 정보 등 메타데이터를 저장하는 영역. Java 8부터 PermGen 대체
* Permanent Generation (PermGen) : Java 7 이하에서 클래스 메타데이터를 저장하던 고정 크기 영역. Java 8부터 Metaspace로 교체
* Code Cache : JIT 컴파일러가 변환한 기계어 코드를 저장해두는 영역
* JIT : Just-In-Time 컴파일러. 자주 실행되는 코드를 런타임에 기계어로 번역해 성능 향상
* Thread Stack : 각 스레드마다 독립적으로 갖는 호출 스택. 메서드 호출 순서·지역변수 저장
* Direct Memory : GC 범위 밖에서 OS와 직접 통신하는 메모리 버퍼
* NIO : Non-blocking I/O. 블로킹(응답 올때까지 기다리는 것) 없이 I/O를 처리하는 Java 표준 API
* Netty : NIO 기반의 고성능 네트워크 프레임워크. Direct Memory를 활용해 대용량 I/O 처리
* OpenTelemetry Agent : 분산 트레이싱·메트릭·로그를 자동 계측하는 Java Agent. -javaagent 옵션으로 JVM에 부착
* APM : Application Performance Monitoring. 응답시간·처리량·에러율 등을 실시간 수집·시각화하는 모니터링 체계
* OS 버퍼 : 운영체제가 소켓·파일 I/O 처리를 위해 사용하는 커널 레벨 메모리

 

우선 크게 세가지 영역으로 나뉩니다.

  • Heap : 객체가 살고 죽는 공간. GC의 주 무대
  • Non-Heap : 클래스 정보·JIT 코드 저장. GC 대상이 아님
  • 기타 : 스레드·I/O·에이전트 등 JVM 프로세스 운영에 필요한 나머지 메모리


 

 

 

 

 

 

Heap — 객체의 생애주기

태어난걸 축하해

1단계 : 탄생 — Eden Space

new Object()를 호출하면 객체는 Eden Space에 할당됩니다.

"Eden" 이라는 이름은 성경의 에덴동산(Garden of Eden)에서 유래했습니다. 새로 태어난 생명(객체)이 처음 머무는 곳이라는 의미입니다. 대부분의 객체는 이곳에서 짧게 살다가 사라집니다. 요청 하나를 처리하면서 만들어진 임시 객체들, 루프 안에서 생성된 문자열 등이 대표적입니다.

Eden이 꽉 차면 Minor GC가 트리거됩니다. 이 시점에 JVM은 Eden에 있는 객체를 전수 검사합니다.

  • 더 이상 참조되지 않는 객체 → 즉시 제거
  • 아직 참조 중인 객체 → Survivor Space로 이동

 

 

 

 

 

 

 

 

에덴동산이 꽉찼어. 정리하자.

2단계 : 생존 — Survivor Space (From / To)

Survivor Space는 From(S0)To(S1) 두 칸으로 나뉩니다. 언뜻 보면 왜 두 칸인지 의아할 수 있는데, Minor GC 동작 방식 때문입니다.

# Minor GC 발생 시 Survivor 동작 흐름

[Minor GC 전]
  Eden   : 객체 가득 참
  From   : 이전 GC에서 살아남은 객체들 존재
  To     : 비어있음

[Minor GC 실행]
  1. Eden + From에서 살아있는 객체를 To로 복사
  2. 복사 시 각 객체의 age(생존 횟수) += 1
  3. Eden + From 전체를 비움

[Minor GC 후]
  Eden   : 비어있음
  From   : 비어있음  ← 역할이 뒤바뀜 (기존 To가 새로운 From)
  To     : 비어있음  ← 다음 GC를 위해 항상 한 칸은 비워둠

 

핵심은 항상 한 칸(To)은 비어있어야 한다는 것입니다. 살아있는 객체를 한쪽에서 다른 쪽으로 통째로 복사하는 방식(Copying GC)을 쓰기 때문입니다. 덕분에 메모리 단편화 없이 빠르게 정리할 수 있습니다. GC가 끝나면 From과 To의 역할은 뒤바뀝니다.

* 단편화란? 메모리 곳곳에 작은 빈 공간이 흩어져서, 전체 여유는 있는데 막상 큰 객체를 넣을 자리가 없는 상태

 

 

 

 

 

 

실버타운으로 모셔드릴께요.

3단계 : 승격 — Old Generation

Minor GC를 거칠 때마다 살아남은 객체의 age(나이) 가 1씩 증가합니다. age가 임계값(기본 15)에 도달하면 Old Generation으로 승격(Promotion) 됩니다.

Old Generation에는 오랫동안 살아남은 객체들이 모입니다. 캐시, 싱글톤, DB 커넥션 풀 등이 대표적입니다. Old Generation이 꽉 차면 Full GC가 발생합니다.

Full GC는 Minor GC보다 훨씬 무겁습니다. Heap 전체를 스캔하고, 그 동안 Stop-the-World(STW) — 즉 애플리케이션이 잠깐 멈춥니다. 응답 지연 스파이크의 주원인 중 하나 입니다.

# 객체 생애주기 요약

new Object()
    ↓
 Eden Space  →  (Minor GC 탈락)  →  소멸
    ↓ 살아남음
 Survivor (From ↔ To 반복)
    ↓ age >= 15
 Old Generation  →  (Full GC 탈락)  →  소멸

 

 

 

 


 

 

 

Non-Heap — 코드와 클래스의 공간

Metaspace

클래스 정의(메서드 목록, 필드 정보, 어노테이션 등)가 저장됩니다. Java 8 이전에는 Permanent Generation(PermGen) 이라는 고정 크기 영역에 저장했는데, 클래스가 많아지면 java.lang.OutOfMemoryError: PermGen space 에러가 잦았습니다. Java 8부터 Metaspace로 교체되면서, Native 메모리 영역으로 이동, 필요에 따라 자동 확장됩니다.

 

 

 

 

 

 

 

 

매번 한줄씩 읽기 힘들어. 기계어로 번역해서 저장해두자.

Code Cache

JVM은 처음에 소스코드(.java) → 바이트코드(.class) → JVM이 한 줄씩 해석(인터프리터) → 실행하다가,

자주 호출되는 코드를 감지하면 JIT(Just-In-Time) 컴파일러가 기계어로 변환해 Code Cache에 저장합니다. 이후 해당 코드는 변환 없이 바로 실행되어 성능이 향상됩니다. => 이게 부하테스트에서 Ramp-up이 중요한 이유와도 연결됨!

  * 실무에서 자주 JIT에 올라가는 것들:
     - HTTP 요청마다 호출되는 핵심 비즈니스 로직
     - 루프 안에서 반복 실행되는 코드
     - 자주 쓰는 유틸 메서드 (날짜 변환, 문자열 처리 등)
     - 프레임워크 내부 코드 (Spring의 DispatcherServlet 등)

 


 

 

 

 

 

기타 JVM 프로세스 메모리

Heap·Non-Heap 외에도 JVM 프로세스는 추가 메모리를 사용합니다.

영역 설명
Thread Stack 각 스레드마다 독립적으로 가지는 호출 스택. 스레드 200개 × 1 MiB ≈ 200 MiB
Direct Memory NIO/Netty가 GC 범위 밖에서 직접 관리하는 버퍼 메모리. I/O 성능 확보에 사용
GC 내부 구조 GC 알고리즘이 참조 추적·카드 테이블 관리 등에 사용하는 내부 메모리
OpenTelemetry Agent -javaagent로 부착된 APM 계측 오버헤드. 트레이싱 비용 상시 발생 (~256 MiB)
OS 버퍼 OS 레벨 소켓·파일 I/O 버퍼

 


 

 

 

 

 

Reserved vs Committed

JVM 메모리를 이야기할 때 자주 등장하는 두 개념입니다.

  • Reserved : OS에 주소 공간만 예약해둔 상태. 실제 RAM은 미사용
  • Committed : 실제 RAM에 매핑된 상태. 실제로 사용 중

예) -Xmx3072m 설정 시 JVM은 3,072 MiB를 Reserved로 잡아두고 => 객체가 쌓이면서 점진적으로 Committed로 전환합니다. Heap뿐 아니라 Metaspace 등 Non-Heap 영역도 동일한 방식으로 동작합니다.

 

 


 

정리

  1. new Object()Eden Space 할당 (에덴동산: 새 생명이 태어나는 곳)
  2. Eden이 꽉 차면 Minor GC 트리거 → 살아남은 객체는 Survivor Space 이동(From ↔ To)
  3. From/To는 항상 한 칸이 비어있어야 Copying GC 방식으로 단편화 없이 정리 가능
  4. age >= 15 → Old Generation 승격Full GC 대상
  5. Non-Heap(Metaspace·Code Cache)은 GC 대상이 아닌 클래스 정보·JIT 코드 저장 공간
  6. Reserved(예약) → Committed(실제 사용) 전환 방식으로 메모리 효율 확보

 

마치며

JVM 메모리 구조를 알고 나면, -Xmx 하나 건드릴 때도 "왜 이 값인가" 를 설명 할 수 있게 됩니다.

Pod 메모리 안에서 Heap과 Non-Heap이 어떻게 나뉘는지 감이 오셨으면 좋겠습니다.

실무 업무 하시면서, 메모리 설정값을 자신있게 건드려 보시길 바랍니다.

 

 

728x90
반응형
LIST