티스토리 뷰
Stop-the-World와 메모리 관리 - Java GC의 이해와 실전 튜닝 2편: Java는 왜 GC를 선택했는가 - JVM 아키텍처와 자동 메모리 관리의 필연성
ebson 2026. 4. 9. 23:09들어가며
이전 글에서는 GC가 등장하게 된 배경을 수동 메모리 관리의 구조적 결함에서 출발하여 정리했습니다. 프로그래밍 언어들이 메모리 관리에 대해 서로 다른 접근을 취한 이유를 살펴보았는데, 그중 Java는 GC를 단순히 편의 기능으로 채택한 것이 아니라 언어의 핵심 가치를 실현하기 위한 구조적 전제로 받아들인 경우에 해당합니다.
Java가 처음 세상에 나온 것은 1995년입니다. 당시 C와 C++이 시스템 프로그래밍부터 애플리케이션 개발까지 광범위하게 사용되고 있었고, 수동 메모리 관리는 개발자가 당연히 감당해야 할 영역으로 여겨지던 시기였습니다. 이런 상황에서 Java가 GC를 런타임에 내장하겠다는 결정은, 성능보다 안전성과 이식성을 우선하겠다는 명확한 입장 표명이었습니다.
이번 글에서는 Java가 왜 GC를 필수적으로 채택해야 했는지를 JVM 아키텍처의 관점에서 살펴보겠습니다. Java의 설계 목표와 JVM의 구조가 어떻게 자동 메모리 관리를 불가피하게 만들었는지, GC를 선택함으로써 무엇을 얻고 무엇을 감수해야 했는지, 그리고 JVM 메모리 구조에서 GC가 실제로 관리하는 영역은 어디인지를 정리해 보겠습니다.
Green Project에서 Java로 - 플랫폼 독립성이라는 설계 목표
Java의 기원은 1991년으로 거슬러 올라갑니다. James Gosling이 이끈 Sun Microsystems의 Green Project는 원래 가전제품용 임베디드 소프트웨어를 개발하기 위한 프로젝트였습니다. TV 셋톱박스, PDA 같은 다양한 하드웨어에서 동일한 소프트웨어가 동작해야 한다는 요구사항이 있었고, 이 요구사항이 결국 Java라는 언어의 핵심 설계 방향을 결정짓게 됩니다.
당시 C++로 개발된 소프트웨어는 대상 하드웨어의 CPU 아키텍처, 메모리 레이아웃, 운영체제에 강하게 결합되어 있었습니다. 새로운 플랫폼을 지원하려면 소스 코드를 수정하고 다시 컴파일해야 했으며, 포인터 크기나 바이트 순서(Endianness)의 차이로 인한 이식성 문제가 빈번하게 발생했습니다. Green Project 팀은 이 문제를 해결하기 위해 특정 하드웨어에 종속되지 않는 중간 표현(Intermediate Representation)과 이를 실행하는 가상 머신이라는 구조를 설계했습니다. 이것이 바이트코드(Bytecode)와 JVM(Java Virtual Machine)의 출발점입니다.
1995년 Java 1.0이 출시되면서 "Write Once, Run Anywhere"라는 표현이 Java의 대표적 가치 제안이 되었습니다. 소스 코드를 한 번 작성하고 바이트코드로 컴파일하면, JVM이 설치된 어떤 플랫폼에서든 동일하게 실행된다는 것입니다. 이 가치를 실현하려면 JVM이 프로그램의 실행 환경을 완전히 제어해야 합니다. 메모리 할당, 스레드 관리, 바이트코드 실행이 모두 JVM의 통제 아래 있어야 플랫폼 간 동작의 일관성이 보장되기 때문입니다.
1996년에 출판된 Java 설계 백서인 "The Java Language Environment: A White Paper"(James Gosling & Henry McGilton)는 Java의 설계 특성을 11가지로 명시하고 있습니다. Simple, Object-Oriented, Distributed, Robust, Secure, Architecture-Neutral, Portable, Interpreted, High-Performance, Multithreaded, Dynamic입니다. 이 중 GC의 필요성과 직접적으로 연결되는 것은 Simple(수동 메모리 관리를 포함한 C++의 복잡성 제거), Robust(컴파일 타임 및 런타임 검사 강화), Secure(포인터 제거가 보안 모델의 전제), 그리고 Architecture-Neutral과 Portable(플랫폼 독립성을 위해 JVM이 메모리를 완전히 관리해야 함)입니다.
포인터의 제거와 GC의 필연적 관계
Java가 C/C++과 결별한 가장 상징적인 지점은 포인터 산술(Pointer Arithmetic)의 제거입니다. C에서 포인터는 메모리 주소를 직접 다루는 수단이며, 포인터에 정수를 더하거나 빼는 연산을 통해 메모리의 임의 위치에 접근할 수 있습니다. 이 능력은 하드웨어를 직접 제어해야 하는 시스템 프로그래밍에서는 필수적이지만, 동시에 앞서 살펴본 댕글링 포인터, 버퍼 오버플로 같은 메모리 오류의 근본 원인이기도 합니다.
Java는 포인터 대신 참조(Reference)를 도입했습니다. 참조는 객체의 메모리 주소를 가리키지만, 개발자가 이 주소를 직접 조작할 수는 없습니다. 주소를 정수로 변환하거나 임의의 값을 주소로 해석하는 연산이 언어 차원에서 허용되지 않습니다. 이 설계가 GC와 어떻게 연결되는지는 객체 이동(Relocation)의 맥락에서 이해할 수 있습니다.
GC가 힙 메모리를 효율적으로 관리하려면 살아있는 객체를 이동시켜 단편화를 해소하는 작업, 즉 압축(Compaction)이 필요합니다. 객체가 이동하면 메모리 주소가 변경되는데, 만약 개발자가 이전 주소를 직접 들고 있다면(C의 포인터처럼) 이동 후 해당 주소는 유효하지 않게 됩니다. Java의 참조는 JVM이 관리하는 간접 참조이므로, 객체가 이동하면 JVM이 참조를 갱신하여 프로그램의 논리에는 영향을 주지 않습니다. 포인터 산술의 제거는 단순히 안전성을 위한 선택이었을 뿐 아니라, GC가 정상적으로 동작하기 위한 구조적 전제 조건이었던 셈입니다.
보안 측면에서도 포인터의 제거는 중요합니다. Java의 보안 모델은 프로그램이 허용되지 않은 메모리 영역에 접근할 수 없다는 것을 전제로 합니다. 만약 포인터 산술이 가능하다면 이 전제가 무너지고, 샌드박스(Sandbox) 기반의 보안 아키텍처 전체가 성립하지 않게 됩니다. 설계 백서가 보안을 5대 목표 중 하나로 명시한 것과 포인터의 제거는 같은 맥락에 있습니다.
결국 Java에서 GC는 "있으면 편리한 기능"이 아닙니다. JVM이 메모리를 완전히 관리하지 않으면 플랫폼 독립성이 깨지고, 포인터가 없으면 개발자가 직접 메모리를 해제할 수 없으며, 보안 모델이 메모리 접근 제어를 전제합니다. 이 세 가지가 맞물리면서 GC는 Java의 존재 이유를 떠받치는 구조적 기둥이 되었습니다.
GC가 가져다준 것과 그 대가
Java가 GC를 선택함으로써 얻은 가장 실질적인 이점은 메모리 안전성입니다. C/C에서 빈번하게 발생하는 메모리 관련 버그 — 댕글링 포인터, 이중 해제, 버퍼 오버플로 — 가 Java에서는 구조적으로 발생할 수 없습니다. Java에서도 NullPointerException은 발생하지만, 이것은 프로그램이 정의된 예외를 던지는 것이지 C/C의 정의되지 않은 동작(Undefined Behavior)과는 본질적으로 다릅니다. 정의되지 않은 동작은 프로그램의 행동을 예측할 수 없게 만들지만, NullPointerException은 발생 지점과 원인이 명확하게 보고됩니다.
개발 생산성 측면에서도 효과가 컸습니다. 개발자가 객체의 할당 시점은 결정하되, 해제 시점은 신경 쓰지 않아도 된다는 것은 코드의 복잡도를 상당히 줄여 줍니다. C++에서 복잡한 객체 그래프의 소유권을 관리하기 위해 들여야 하는 노력 — 누가 이 객체를 마지막으로 사용하는가, 언제 해제해야 하는가 — 을 GC가 대신 처리합니다. Java 7에서 도입된 try-with-resources 구문은 파일 핸들이나 데이터베이스 연결 같은 비메모리 자원의 해제를 체계화하여, 메모리는 GC가, 외부 자원은 개발자가 명시적으로 관리하는 역할 분담을 더욱 명확히 했습니다.
힙 단편화 관리도 GC의 중요한 기여입니다. C에서 malloc과 free를 반복하면 시간이 지나면서 힙에 작은 빈 공간들이 흩어져 단편화가 발생합니다. 전체 여유 메모리는 충분하지만 연속된 공간이 없어 할당에 실패하는 상황이 올 수 있으며, 장시간 실행되는 서버 프로세스에서는 이것이 실질적인 문제가 됩니다. Java의 GC는 살아있는 객체를 한쪽으로 모아 압축(Compaction)하는 과정을 통해 단편화를 자동으로 해소합니다. 개발자가 메모리 할당자(Allocator)의 특성을 고려하지 않아도 되는 것입니다.
그러나 이 이점들에는 분명한 대가가 따릅니다.
가장 널리 알려진 것은 Stop-the-World(STW) 지연입니다. GC가 힙을 정리하기 위해 실행되는 동안 모든 애플리케이션 스레드가 일시 정지합니다. 초기 Java, 특히 JDK 1.0에서 1.3까지는 단일 스레드로 동작하는 Serial GC만 존재했기 때문에, 힙이 조금만 커져도 수백 밀리초에서 수 초에 이르는 정지가 발생하는 것이 일상적이었습니다. 이 경험이 "Java는 느리다"는 초기 인식의 주요 원인 중 하나였으며, 이후 Java GC의 발전 방향을 결정짓는 핵심 과제가 되었습니다.
메모리 오버헤드도 무시할 수 없습니다. JVM에서 모든 객체는 객체 헤더(Object Header)를 갖습니다. HotSpot VM 기준으로 64비트 환경에서 객체 헤더는 Mark Word(8바이트)와 Class Pointer(압축 시 4바이트, 비압축 시 8바이트)로 구성되며, 배열의 경우 길이 정보가 추가됩니다. 여기에 GC 메타데이터(Mark Bit, Card Table 등)와 참조(Reference) 크기까지 고려하면, 동일한 데이터를 표현하는 데 C 대비 2배에서 5배까지 많은 메모리를 사용할 수 있습니다. int 값 하나를 Integer 객체로 박싱(Boxing)하면 4바이트가 16바이트 이상이 되는 것이 대표적인 예입니다.
GC의 비결정적 지연(Non-deterministic Latency)도 특정 영역에서는 치명적인 한계입니다. GC가 언제 실행될지, 얼마나 오래 실행될지를 정확히 예측할 수 없으므로, 엄격한 응답 시간 보장이 필요한 실시간(Real-time) 시스템에서는 Java가 적합하지 않은 선택이 될 수 있습니다. 항공 제어 시스템이나 의료 장비 소프트웨어에서 Java가 거의 사용되지 않는 이유입니다.
메모리 해제 시점의 비결정성도 초기 Java에서 혼란을 일으킨 부분입니다. 객체가 더 이상 참조되지 않는 순간과 실제로 GC가 해당 객체의 메모리를 회수하는 순간 사이에는 간극이 있습니다. finalize() 메서드는 객체가 회수되기 직전에 호출되도록 설계되었지만, 호출 시점이 보장되지 않고 호출 자체가 이루어지지 않을 수도 있어서 자원 해제 용도로는 신뢰할 수 없었습니다. 이 문제의 심각성을 인식한 OpenJDK 팀은 finalize()를 Java 9에서 deprecated로 지정했고, JEP 421을 통해 향후 제거(removal for removal)를 예고한 상태입니다.
JVM 메모리 구조 — GC가 관리하는 영역과 그렇지 않은 영역
GC의 동작을 이해하려면 먼저 JVM이 메모리를 어떤 구조로 나누어 사용하는지를 알아야 합니다. JVM Specification(Chapter 2.5, Runtime Data Areas)은 JVM이 프로그램 실행 시 사용하는 메모리 영역을 다섯 가지로 정의하고 있으며, 이 중 GC가 관여하는 영역과 그렇지 않은 영역이 명확히 구분됩니다.
Heap - GC의 핵심 무대
Heap은 JVM이 관리하는 메모리 영역 중 가장 크고, GC의 주요 관리 대상입니다. new 키워드로 생성되는 모든 객체 인스턴스와 배열이 이 영역에 할당됩니다. JVM이 시작될 때 생성되며, -Xms 옵션으로 초기 크기를, -Xmx 옵션으로 최대 크기를 지정할 수 있습니다.
Heap은 GC 전략에 따라 내부적으로 다시 나뉩니다. 대부분의 현대 GC에서 Heap은 Young Generation과 Old Generation으로 구분됩니다. 새로 생성된 객체는 Young Generation의 Eden 영역에 할당되고, 몇 차례의 GC에서 살아남은 객체는 Old Generation으로 승격(Promotion)됩니다. 이 구조의 근거는 이전 글에서 다룬 세대별 가설(Generational Hypothesis) — 대부분의 객체는 할당 직후 곧 죽는다는 관찰 — 입니다. 세대별 GC의 구체적인 동작 방식은 다음 편에서 각 GC 알고리즘별로 상세히 살펴볼 예정입니다.
Heap의 크기 설정은 GC 성능에 직접적인 영향을 미칩니다. Heap이 너무 작으면 GC가 자주 발생하고, 너무 크면 한 번의 GC에서 처리해야 할 대상이 많아져 STW 시간이 길어질 수 있습니다. 적절한 크기를 결정하는 것은 이후 튜닝 편에서 다룰 핵심 주제 중 하나입니다.
JVM Stack - 스레드와 함께 태어나고 사라지는 영역
JVM Stack은 스레드가 생성될 때 함께 만들어지고, 스레드가 종료되면 함께 사라지는 영역입니다. 각 메서드 호출마다 프레임(Frame)이 하나씩 쌓이며, 프레임 안에는 로컬 변수(Local Variables), 연산 스택(Operand Stack), 메서드 반환 주소가 담깁니다.
Stack에 저장되는 데이터는 기본형(Primitive Type) 값과 객체에 대한 참조(Reference)입니다. 여기서 중요한 구분이 있습니다. 참조 자체는 Stack에 있지만, 참조가 가리키는 객체는 Heap에 있습니다. 메서드가 종료되면 해당 프레임은 Stack에서 제거되고, 프레임에 담겨 있던 로컬 변수와 참조도 함께 사라집니다. 이 과정에서 GC는 관여하지 않습니다. Stack의 생명주기는 메서드 호출과 반환이라는 명확한 규칙을 따르기 때문에, GC와 같은 자동 관리 메커니즘이 필요하지 않은 것입니다.
다만 Stack에서 참조가 제거된다는 것이 곧 Heap의 객체가 제거된다는 의미는 아닙니다. Stack의 참조가 사라져도 다른 곳에서 해당 객체를 참조하고 있다면 GC는 그 객체를 회수하지 않습니다. GC가 판단하는 것은 "이 객체에 도달할 수 있는 참조가 하나라도 남아 있는가"이며, Stack의 로컬 변수는 이 도달 가능성 판단의 출발점(GC Root) 중 하나입니다.
Metaspace - PermGen의 후계자
Metaspace는 클래스 메타데이터, 상수 풀, 메서드와 필드 정보를 저장하는 영역입니다. Java 8 이전에는 이 역할을 PermGen(Permanent Generation)이 담당했습니다. PermGen은 Heap의 일부로 간주되어 고정 크기가 할당되었는데, 동적으로 클래스를 로딩하는 애플리케이션(특히 애플리케이션 서버에서 다수의 WAR를 배포하는 환경)에서 java.lang.OutOfMemoryError: PermGen space 오류가 빈번하게 발생했습니다.
JEP 122를 통해 Java 8에서 PermGen이 제거되고 Metaspace가 도입되었습니다. Metaspace는 Heap이 아닌 네이티브 메모리에 할당되며, 기본적으로 운영체제가 허용하는 범위 내에서 자동으로 확장됩니다. -XX:MetaspaceSize로 초기 크기를, -XX:MaxMetaspaceSize로 상한을 지정할 수 있습니다.
GC와 Metaspace의 관계는 제한적입니다. Metaspace에 대한 GC는 클래스 언로딩(Class Unloading)이 발생할 때만 수행됩니다. 클래스 로더가 더 이상 도달 불가능해지면, 해당 클래스 로더가 로딩한 클래스들의 메타데이터가 Metaspace에서 회수됩니다. 일반적인 애플리케이션에서 클래스 언로딩이 빈번하게 발생하지는 않으므로, Metaspace의 GC가 성능에 영향을 미치는 경우는 상대적으로 드뭅니다.
PC Register와 Native Method Stack
PC Register(Program Counter Register)는 각 스레드가 현재 실행 중인 바이트코드 명령어의 주소를 저장하는 작은 영역입니다. Native Method Stack은 JNI(Java Native Interface)를 통해 네이티브 메서드(C/C++로 작성된 코드)를 실행할 때 사용되는 스택입니다. 이 두 영역 모두 스레드별로 생성되고 스레드 종료 시 해제되며, GC의 관리 대상이 아닙니다.
GC 관심 영역의 정리
정리하면, GC가 관여하는 영역과 그렇지 않은 영역은 다음과 같이 구분됩니다.
GC의 핵심 대상은 Heap입니다. 모든 객체 인스턴스와 배열이 여기에 할당되며, Young Generation과 Old Generation에 대한 수집이 GC 활동의 대부분을 차지합니다. GC의 부수적 대상은 Metaspace입니다. 클래스 언로딩이 발생하는 경우에 한해 메타데이터가 회수됩니다. JVM Stack, PC Register, Native Method Stack은 GC의 관리 밖에 있으며, 스레드의 생명주기에 따라 자동으로 관리됩니다.
이 구분이 실무적으로 의미하는 바는 명확합니다. GC의 성능 영향은 Heap에 할당되는 객체의 수와 생존 기간에 의해 결정됩니다. Stack에 할당되는 기본형 값이나 참조 변수는 GC와 무관합니다. 따라서 GC 튜닝이나 메모리 문제 진단에서 주목해야 할 대상은 Heap이며, Heap에서 어떤 객체가 얼마나 오래 살아있는가가 핵심 관찰 포인트가 됩니다.
마치며
이번 글에서는 Java가 왜 GC를 필수적으로 채택해야 했는지를 JVM 아키텍처의 관점에서 정리해 보았습니다. "Write Once, Run Anywhere"라는 설계 목표를 실현하기 위해 JVM이 메모리를 완전히 관리해야 했고, 포인터 산술의 제거가 GC의 구조적 전제 조건이었으며, 보안 모델 또한 메모리 접근 제어를 요구했습니다.
GC를 통해 Java는 메모리 안전성, 개발 생산성, 단편화 관리라는 이점을 확보했지만, Stop-the-World 지연, 메모리 오버헤드, 비결정적 지연이라는 트레이드오프를 감수해야 했습니다. 특히 초기 Java에서 STW는 심각한 성능 문제였으며, 이 문제를 해결하기 위한 노력이 이후 25년간의 GC 알고리즘 발전을 이끈 원동력이 되었습니다.
글을 정리하면서 다시 생각하게 된 점이 있습니다. JVM 메모리 구조에서 GC는 Heap이라는 특정 영역을 관리하기 위한 전담 메커니즘이고, Stack이나 Metaspace는 각각 다른 생명주기 규칙을 따릅니다. GC의 성능 영향을 이해하고 진단하려면 이 영역 구분을 명확히 인지하는 것이 출발점이라는 것을 다시 확인할 수 있었습니다.
다음 글에서는 Java GC가 이 Heap 영역을 어떻게 관리해 왔는지, Serial GC에서 시작하여 Parallel GC, CMS를 거쳐 G1GC에 이르기까지 GC 알고리즘이 어떤 문제를 해결하며 진화해 왔는지를 정리해 보겠습니다.
참고 출처
- Gosling, J. & McGilton, H. (1996). "The Java Language Environment: A White Paper." Sun Microsystems.
- Lindholm, T. et al. "The Java Virtual Machine Specification, Java SE 21 Edition." Oracle. Chapter 2.5: Runtime Data Areas.
- Oracle. "Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide, Release 21."
- JEP 122: Remove the Permanent Generation (OpenJDK)
- JEP 421: Deprecate Finalization for Removal (OpenJDK)
'STUDY' 카테고리의 다른 글
- Total
- Today
- Yesterday
- Spring Batch
- InterruptedException
- 스레드 생명주기
- Java Performance
- DB 트랜잭션
- 백엔드 성능 튜닝
- 트래픽 처리
- 캐시 성능 비교
- Cache Avalanche
- 백엔드 아키텍처
- Double-Checked Locking
- 백엔드 성능 설계
- Redis vs DB
- Cache Aside
- Eager Initialization
- Enum 기반 싱글톤
- 캐시와 인덱스
- 트랜잭션 관리
- 캐시 장애
- mybatis
- Cache Penetration
- Hot Key 문제
- Initialization-on-Demand Holder Idiom
- 동시성처리
- Redis 캐시 전략
- Redis 성능 개선
- TTL 설계
- 백엔드 성능
- spring batch 5
- DB 인덱스 성능
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
