"Write Once, Run Anywhere" - 플랫폼 독립적인 자바의 철학

서문

2025년 현재 한국 취업 시장에서 대학생이 압도적으로 많다. 이는 대학생활만으로 취업하기 어렵다는 것을 반증한다. 그러므로 취업을 위하여 정보처리기사, SQLD 등의 기사 자격증은 필수적이다. 국무지원 부트캠프는 일반적으로 6개월간의 과정이므로 자바에 관한 심도있는 이해를 바라기는 어렵다.

따라서 우리는 자바의 JVM에 대한 심층적인 이해를 높이고자 이 문서를 작성한다. 이 가이드는 자바 개발자가 반드시 알아야 할 JVM의 핵심 개념부터 최신 기술 동향까지를 다룬다.


들어가기 전 - JDK/JRE/JVM

JDK(Java Development Kit): 자바 개발 환경으로 자바 어플리케이션을 개발하기 위해 필요한 도구를 제공한다(javac, javap 등). JDK는 JRE + 개발 도구라고 볼 수 있다.

JRE(Java Runtime Environment): 자바를 실행하기 위한 환경이며, JVM, 자바 클래스 라이브러리, 기타 실행용 파일을 포함한다.

JVM(Java Virtual Machine): 자바 가상 머신으로, 자바 프로그램을 실행하기 위한 가상 머신이다. 운영체제에 종속되지 않게 자바 프로그램을 실행할 수 있게 해준다. 물론 JVM 자체는 운영체제 종속적이며(운영체제에 맞춰 설치해야 함), 이것이 바로 "Write Once, Run Anywhere"의 핵심이다.

용어정리

  • javac: Java compiler, 자바 소스 파일(.java)을 자바 바이트 코드 파일(.class)로 변환하는 컴파일러
  • javap: 자바 바이트 코드 파일(.class)을 사람이 읽기 쉬운 형태로 역어셈블링 해주는 프로그램

0. 자바 프로그램의 동작 과정

자바 프로그램이 실행되는 전체 과정을 시각적으로 이해하면, 이후의 세부 내용을 더 쉽게 파악할 수 있다.

이 과정의 각 단계를 상세히 살펴보자.


1. 컴파일 (javac)

런타임 이전(컴파일 타임)에 자바 소스 코드(.java)를 컴파일러(javac)를 통해 바이트코드(.class)로 변환하는 과정이다.

바이트코드란 무엇인가?

바이트코드는 자체는 바이너리 파일이다. 즉 인간이 파악하기 어려운 이진수로 구성되어 있다. 명령어 1바이트(8비트)를 사용하여 "바이트코드"라고 불리며, 그 외의 데이터는 명령어로 조작할 대상의 포인터나 데이터를 담는다. 바이트코드는 Symbolic Reference를 사용한다.

Symbolic Reference vs Direct Reference

  • Symbolic Reference (심볼릭 참조): 참조값을 우리가 작성하면서 사용한 class, field, method의 이름을 참조로 사용하는 것
  • Direct Reference (직접 참조): 참조값을 실제 물리 메모리주소로 사용하는 것 (예: C의 포인터)

기계어 vs 어셈블리어 vs 바이트코드

기계어: 기계가 직접 이해할 수 있는 이진수를 의미한다. 하드웨어에 종속적인 것이 특징이다(CPU 명령어 체계에 따라 달라진다).

어셈블리어: 기계어는 이진수로 되어있기에 인간이 보기에 어렵다. 이를 극복하고자 인간이 읽고 쓰기 쉽도록 1대1 대응하는 언어로 작성한 것이 어셈블리어다. 기계어와 1대1 대응하기에 마찬가지로 하드웨어 종속적이다.

자바 바이트코드: CPU가 직접 알아들을 수 없기에 기계어는 아니다. 어셈블리어처럼 사람이 읽을 수 있는 형태로 변환할 수 있다. 그러나 자바 바이트코드는 자바의 철학에 따라 하드웨어 종속적이지 않다는 것이 가장 큰 특징이다.

바이트코드를 역어셈블러(javap)로 해석해서 보면 다음과 같은 형태로 볼 수 있다:

plain
 text
public void example();
  Code:
     0: getstatic     #2
     3: ldc           #3
     5: invokevirtual #4
     8: return

주의: IDE에서 .class 파일을 열면 역컴파일된 자바 코드를 보여주는데, 실제 .class 파일은 바이너리 형식이므로 완전히 다르게 생겼다. 바이트코드가 저렇게 생겼다고 착각하면 안 된다!


2. 클래스 로딩(Class Loading)

JVM이 컴파일된 바이트코드를 읽고 메모리에 로드를 수행하는 과정이다. 자바 프로그램을 실행하기 위해 필요한 클래스와 객체들을 메모리(Run Time Data Area)에 옮기는 것이다.

클래스 로더의 3가지 역할

  1. Loading (로딩): 클래스 파일을 탑재하는 과정
  2. Linking (링킹): 클래스 파일을 사용하기 위해 검증(Verification) + 기본값으로 초기화(Preparation)
  3. Initialization (초기화): 정적(static) 필드의 값들을 코드 상에서 정의한 값으로 초기화

클래스 로더의 특징

1. 계층적 (Hierarchical)

코드를 구현하다 보면 중복을 줄이기 위해 상속을 사용하게 된다. ClassLoader도 계층적으로 생성이 가능하다. 클래스 로더는 다음과 같은 계층 구조를 가지고 있다.

2. 가시성 (Visibility)

  • 부모 클래스 로더는 자식 클래스 로더의 데이터에 접근할 수 없다
  • 같은 부모를 가진 자식 클래스 로더들은 서로의 데이터에 접근할 수 없다
  • 자식 클래스 로더는 부모 클래스 로더의 데이터에 접근할 수 있다 (단방향)

3. 위임형 로드 요청 (Delegation)

부모 클래스 로더가 우선권을 가지고 있으므로, 자식 클래스 로더는 부모 클래스 로더에 먼저 요청을 위임한다. 따라서 ClassLoader3 → ClassLoader2 → ClassLoader1 순서로 요청이 이루어진다.

4. 언로드 불가 (Unload Impossibility)

ClassLoader에는 클래스 언로딩(Unloading) 기능이 없다. 언로딩을 하기 위해서는 ClassLoader 자체를 삭제하고, ClassLoader을 다시 생성하는 방법이 있다.

ClassLoader의 역할

  • 부트스트랩 클래스 로더: JVM을 기동할 때 생성되며, Object 클래스들을 비롯하여 자바 API들을 로드한다. 다른 클래스 로더와 달리 자바가 아니라 네이티브 코드로 구현되어 있다.
  • 익스텐션 클래스 로더(Extension Class Loader): 기본 자바 API를 제외한 확장 클래스들을 로드한다. 다양한 보안 확장 기능 등을 여기에서 로드하게 된다.
  • 시스템 클래스 로더(System Class Loader): 부트스트랩 클래스 로더와 익스텐션 클래스 로더가 JVM 자체의 구성 요소들을 로드한다면, 시스템 클래스 로더는 애플리케이션의 클래스들을 로드한다. 사용자가 지정한 $CLASSPATH 내의 클래스들을 로드한다.
  • 사용자 정의 클래스 로더(User-Defined Class Loader): 애플리케이션 사용자가 직접 코드 상에서 생성해서 사용하는 클래스 로더이다.

3. Run Time Data Area(런타임 데이터 영역)

런타임 데이터 영역은 JVM이 프로그램을 실행하는 동안 사용하는 메모리 영역이다. 이 영역은 여러 개의 섹션으로 나뉜다.

공유 메모리 영역 (JVM당 하나)

Heap Area: JVM당 하나만 존재하며 모든 스레드가 공유한다. Java로 구성된 객체와 JRE 클래스들이 올라간다. 문자열에 대한 정보를 가진 String Pool, 실제 데이터를 가진 인스턴스, 배열 등이 여기에 저장된다. GC의 주요 대상이다.

Method Area(Metaspace): Java 8부터는 Metaspace라고 불린다. JVM당 하나만 존재하며 스레드가 모두 공유한다. 인스턴스 생성을 위한 객체 구조, 생성자, 필드 등이 저장되고, Runtime 상수풀과 정적(static) 변수, 메서드 데이터와 같은 클래스 데이터가 여기에 저장된다.

Constant Pool:

  • Class Constant Pool: 클래스 파일의 상수(static) 값이 저장되는 곳. 컴파일 타임에 생성되며 심볼릭 참조를 사용한다.
  • Runtime Constant Pool: Class Constant Pool에 저장되어 있던 값이 런타임 시 이 영역으로 옮겨진다. 각 클래스, 인터페이스의 상수 외에도 메서드와 필드에 대한 모든 래퍼런스 정보를 가지고 있다. 클래스 로드 과정에서 심볼릭 참조가 직접 참조(물리 메모리 주소)로 변환된다.

스레드별 메모리 영역

PC Register (Program Counter Register): 프로그램 카운터 레지스터로서 현재 실행 중인 명령어의 주소가 기록된다(실제 물리주소).

Native Method Stack: 자바로 작성된 프로그램 실행 시 순수하게 자바로만 구성된 코드를 사용할 수 없는 시스템의 자원이나 API가 존재한다. 이런 다른 언어로 작성된 메서드들을 Native Method라고 한다. Native Method가 사용하는 스택이다.

JVM Stack: 메서드가 호출될 때마다 Frame들을 저장하는 일종의 function call stack이다. 메서드가 완료되면 프레임이 pop된다.

  • Frame (메서드와 주기를 함께함): 실행 중인 메서드가 속한 클래스의 로컬 변수 배열, 피연산자 스택, 런타임 상수풀에 대한 참조를 가지고 있다.

4. 바이트코드 해석 및 실행

JVM의 바이트코드를 해석해서 기계어로 변환해 실행하는 과정이다. 기본적으로 Execution Engine(실행엔진)의 인터프리터와 JIT 컴파일러가 수행하며, Native 코드의 경우 Native Method Interface, Native Method Library가 수행하게 된다.

인터프리터 vs 컴파일러

인터프리터의 특징

  • 명령어를 하나씩 읽고 해석하기에 하나하나의 명령어에 대한 해석은 빠르다
  • 반복해서 명령어가 나오면 계속 번역하기에 컴파일러보다 상대적으로 느리다
  • JVM의 기본 해석 방법이다

컴파일러의 특징

  • 코드 파일 전체를 읽고 한번에 해석된 파일을 내놓는 방법이다
  • 모든 코드 내용을 알고 번역하기에 여러 가지 최적화가 가능하다
  • 컴파일 타임이 따로 필요하지만 빠르다

JIT(Just-In-Time) 컴파일러

JIT 컴파일러는 인터프리터의 단점을 보완하기 위해 자주 실행하는 명령어(HotSpot이라는 표현을 씀)인 경우 실시간으로 컴파일을 추가 진행해 미리 기계어로 바꿔준다. JVM의 속도향상을 위한 보조 해석 방법이다.

JIT 컴파일이 성능을 높이는 이유:

  • 자주 실행되는 코드 경로를 미리 컴파일해 캐싱한다
  • 이후 동일한 코드는 컴파일된 버전을 직접 실행한다
  • 반복 실행으로 인한 오버헤드를 크게 줄인다

5. 가비지 컬렉터(GC, Garbage Collector)

자바의 JVM에서는 가비지 컬렉터가 불필요한 메모리를 알아서 정리해주어 메모리 누수를 방지해주고 메모리를 청소해준다. 이것이 자바의 가장 큰 장점 중 하나다.

GC의 동작 원리

1. Stop The World

가비지 컬렉션을 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다. GC를 실행하는 쓰레드를 제외한 모든 쓰레드들의 작업이 중단되고, GC가 완료되면 작업이 재개된다.

2. Mark, Sweep and Compact

Mark (마크): 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업이다. 모든 객체를 순회하면서 어떤 객체가 여전히 사용 중인지를 판단한다.

Sweep (스윕): Mark 단계에서 사용되지 않음으로 식별된 메모리를 해제하는 작업이다. 사용되지 않는 객체들이 점유하고 있던 메모리를 회수한다.

Compact (컴팩트): 메모리 단편화를 해결하는 작업이다. Sweep 이후 흩어져 있는 메모리를 연속으로 모아서 새로운 객체 할당 공간을 확보한다.

메모리 단편화란?

메모리를 사용 후 해제하게 되면 메모리 사이에 빈 공간이 생기게 된다. 이렇게 되면 총 12K의 여유 메모리가 있음에도 5K가 넘는 작업은 실행할 수 없다. 이런 현상을 메모리 단편화라고 한다. 이를 해결하기 위해서는 사용 중인 메모리를 연속으로 모으고 나머지 공간을 온전히 사용할 수 있도록 해야 한다.

세대별 GC의 이론

현대의 모든 JVM GC는 세대별 컬렉션 이론에 기반한다:

약한 세대 가설: 대다수 객체는 일찍 죽는다. 실제 통계에 따르면 신세대 객체의 98%가 첫 번째 GC에서 회수된다.

강한 세대 가설: GC에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.

세대 간 참조 가설: 세대 간 참조의 개수는 같은 세대 안에서의 참조보다 훨씬 적다.

힙 메모리 구조

Young Generation (신세대):

  • Eden: 새 객체가 할당되는 영역
  • Survivor 0, 1: 한 번 이상 GC를 살아남은 객체들이 임시로 머무는 공간

Old Generation (구세대):

  • Tenured: 오래 살아남은 객체들의 거주지