java Hello.java가 숨기고 있는 것 — JVM, 바이트코드, 그리고 "한 번 쓰면 어디서나"의 진짜 의미

Java 11부터 java Hello.java 한 줄로 프로그램을 바로 실행할 수 있게 됐다. (JEP-330) 파일을 저장하고 java 명령 하나면 끝난다. 이 명령이 내부적으로 무엇을 하는지 모른 채 써도 프로그램은 잘 돌아간다.

문제는 여기서 시작된다. "Java는 컴파일 언어인가 인터프리터 언어인가?"라는 질문에 답하지 못하는 것은, 단계를 건너뛰었기 때문이다. javac.java 파일을 무엇으로 바꾸는지, JVM이 그 결과물을 어떻게 실행하는지, "Write Once Run Anywhere"가 마케팅 구호가 아니라 정밀한 기술적 설계임을 이해하지 못하면 — GC 튜닝, 클래스 로딩 에러, 버전 호환성 문제가 발생했을 때 원인을 추적할 수 없다.

이 글은 java Hello.java가 숨기는 두 단계 — 소스 코드에서 바이트코드, 바이트코드에서 기계어 — 를 직접 들여다본다. JDK와 JVM이 정확히 무엇이고, Java 11에서 "JRE"를 다운로드할 수 없는 이유는 무엇이며, .class 파일에 담긴 바이트코드가 어떻게 Linux, macOS, Windows에서 같은 의미로 실행되는지를 다룬다.

JDK, JRE, JVM — 세 개를 구분해야 하는 이유

Java를 처음 설치할 때 마주치는 혼란: JDK, JRE, JVM 중 무엇을 설치해야 하는가? Java 11 이전 튜토리얼은 "JRE만 필요하면 JRE를 설치하고, 개발하려면 JDK를 설치하라"고 안내한다. 이 안내는 Java 11부터 틀렸다.

flowchart TD
    JDK["JDK (Java Development Kit)<br/>javac, java, jshell, jlink 등<br/>+ JRE 포함 (Java 10까지)"]
    JDK --> JRE["JRE (Java Runtime Environment)<br/>JVM + core library<br/>별도 다운로드: Java 10까지만"]
    JRE --> JVM["JVM (Java Virtual Machine)<br/>바이트코드 → 기계어 실행<br/>spec, 구현은 다양"]

Oracle은 Java 11부터 JRE를 별도로 제공하지 않는다. (Oracle Java 11 릴리스 노트) JDK가 유일한 배포판이다. "실행만 하려면 JRE"라는 구분이 사라진 것 — 더 이상 "개발 도구 없이 실행 환경만" 제공하는 패키지를 다운로드할 수 없다.

대신 Java 9에서 도입된 jlink필요한 모듈만 포함한 사용자 정의 런타임 이미지를 생성할 수 있다. (JEP-282) JRE 대신 더 정밀한 방식 — 애플리케이션이 사용하는 모듈만 추려서 경량 런타임을 만든다.

각각이 무엇인가

구성 포함하는 것 역할
JVM 실행 엔진(인터프리터 + JIT 컴파일러), GC, 클래스 로더 바이트코드를 읽고 기계어로 실행. 사양(spec)은 있고 구현은 다양(HotSpot, OpenJ9, GraalVM)
JRE JVM + core library(java.base, java.util 등) Java 프로그램 실행 환경. Java 10까지 별도 배포, Java 11부터 JDK에 통합
JDK JRE + 개발 도구(javac, jshell, jdb, jlink, jar 등) 개발·컴파일·디버깅. Java 11부터 유일한 배포판

JVM은 소프트웨어가 아니라 명세다. (JVM Specification) 이 명세를 구현한 것이 HotSpot(OpenJDK/Oracle JDK 기본), Eclipse OpenJ9(IBM 계열), GraalVM(Oracle, AOT 컴파일 지원) 등이다. "JVM"이라는 말이 HotSpot을 지칭하는 경우가 많지만, 엄밀히는 구현이 아니라 명세다.

컴파일 — .java에서 .class

Java 소스 코드가 실행되려면 두 단계를 거친다. 첫 번째는 javac.java 파일을 .class 파일(바이트코드)로 컴파일하는 것이다.

flowchart LR
    SRC["Hello.java<br/>(소스 코드)"] -->|javac| CLS["Hello.class<br/>(바이트코드)"]
    CLS -->|java| JVM["JVM<br/>(인터프리터 + JIT)"]
    JVM -->|실행| OUT["콘솔 출력"]

전통적인 방식으로 컴파일과 실행을 분리해 보자:

// Java 25 — Hello.java
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, Java 25");
    }
}
# 1단계: 컴파일 (.java -> .class)
javac Hello.java

# 2단계: 실행 (.class를 JVM에 로드)
java Hello
Hello, Java 25

javac Hello.java를 실행하면 Hello.class 파일이 생성된다. 이 파일은 소스 코드가 아니다 — 바이트코드(bytecode)라는 중간 표현이다. .class 파일을 텍스트 편집기로 열면 읽을 수 없지만, javap로 역어셈블하면 내용을 볼 수 있다:

# Hello.class의 바이트코드 역어셈블
javap -c Hello
Compiled from "Hello.java"
public class Hello {
  public Hello();
    Code:
       0: aload_0
       1: invokespecial #1  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #13 // String Hello, Java 25
       5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

getstatic, ldc, invokevirtual — 이것들이 JVM이 이해하는 명령어(바이트코드 opcode)다. (JVM Spec §6.5 - Opcode 정의) 각 opcode는 1바이트로 표현되며, JVM이 실행할 수 있는 기본 단위다.

javap는 JDK에 포함된 도구다. 별도 설치 불필요. javap -c는 바이트코드, javap -v는 상수 풀(constant pool)까지 포함한 전체 덤프, javap -p는 private 멤버까지 표시.

--release 플래그 — 컴파일 타임과 런타임 버전 불일치 함정

Java 9부터 javac --release N 플래그가 도입됐다. 구버전용 -source/-target 조합을 대체하며, 더 안전한 크로스 컴파일을 보장한다.

# Java 25 컴파일러로 Java 17 호환 바이트코드 생성
javac --release 17 Hello.java

# 구 방식 (Java 8 시절) — 위험: 컴파일은 되지만 런타임에 NoSuchMethodError 가능
javac -source 17 -target 17 Hello.java

-source/-target은 바이트코드 버전만 지정할 뿐, 컴파일러가 참조하는 API는 현재 JDK(Java 25)의 API다. Java 25에서 추가된 메서드를 쓰고 -target 17로 컴파일하면, 바이트코드는 Java 17 호환이지만 Java 17 런타임에서 NoSuchMethodError가 발생한다. --release 17은 컴파일러가 Java 17의 API만 참조하므로 이 문제를 원천 차단한다.

바이트코드가 존재하는 이유 — 플랫폼 독립성

C/C++는 소스 코드를 각 OS의 기계어로 직접 컴파일한다. Linux에서 컴파일한 바이너리는 Windows에서 실행되지 않는다. Java는 다른 길을 택했다 — 모든 플랫폼에서 동일한 바이트코드를 생성하고, 각 플랫폼의 JVM이 바이트코드를 해당 기계어로 번역한다.

단계 C/C++ Java
컴파일 대상 OS별 기계어(x86-64, ARM 등) 플랫폼 독립 바이트코드
실행 OS가 기계어 직접 실행 JVM이 바이트코드를 기계어로 번역(인터프리터 + JIT) 후 실행
결과물 이식성 컴파일 환경에 종속 .class 파일은 어느 OS의 JVM에서든 실행

"Write Once Run Anywhere(WORA)"는 마케팅 구호가 아니다 — 바이트코드라는 중간 단계를 두어 컴파일 결과물(.class)이 OS에 종속되지 않는다는 정확한 기술적 설계를 가리키는 말이다. 물론 JVM 자체는 OS별로 다르다(Linux용 JVM, Windows용 JVM). 하지만 같은 Hello.class를 어느 JVM에서든 실행할 수 있다.

이 설계의 대가: 직접 기계어로 컴파일하는 C/C++에 비해 실행 시 추가 번역 단계(JVM)가 필요하다. 이 대가를 줄이기 위해 JIT 컴파일러가 존재한다(아래).

JVM이 바이트코드를 실행하는 방식 — 인터프리터와 JIT의 이중 구조

JVM이 .class 파일의 바이트코드를 읽어 실행하는 과정은 단순한 인터프리터가 아니다. 두 가지 실행 방식을 상황에 따라 혼합한다.

flowchart TD
    LOAD["1. 클래스 로딩<br/>ClassLoader가 .class 로드"] --> VERIFY["2. 바이트코드 검증<br/>Verifier가 타입 안전성 확인"]
    VERIFY --> INTERP["3. 인터프리터 실행<br/>(초기: 모든 코드를 한 줄씩 해석)"]
    INTERP -->|"자주 실행되는 코드 감지<br/>(hot spot)"| JIT["4. JIT 컴파일<br/>HotSpot → 네이티브 기계어"]
    JIT --> NATIVE["5. 네이티브 코드 실행<br/>(이후 해당 코드는 기계어로 직행)"]

인터프리터는 바이트코드를 한 줄씩 읽어 기계어로 번역하며 실행한다. 장점은 즉시 실행(컴파일 대기 없음), 단점은 같은 코드를 반복 번역한다는 것 — 루프가 10,000번 돌면 같은 바이트코드를 10,000번 해석한다.

JIT(Just-In-Time) 컴파일러는 이 비효율을 해결한다. JVM이 실행 중에 "hot spot" — 자주 실행되는 코드 — 를 감지하면, 그 코드를 네이티브 기계어로 한 번에 컴파일한다. 이후 같은 코드를 만나면 인터프리터를 건너뛰고 컴파일된 기계어를 직접 실행한다.

HotSpot JVM(OpenJDK/Oracle JDK의 기본 구현)이라는 이름 자체가 이 메커니즘에서 왔다 — "hot spot"을 찾아 JIT 컴파일하는 JVM. (HotSpot VM 문서)

C1과 C2 — 두 단계 JIT 컴파일러

HotSpot JVM은 두 개의 JIT 컴파일러를 가진다:

JIT 컴파일러 목표 특징
C1 (Client) 빠른 컴파일 적은 최적화, 빠른 시작. 짧은 실행 애플리케이션에 적합
C2 (Server) 극한 최적화 aggressive 인라이닝, 루프 언롤링, escape analysis. 긴 실행 서버 애플리케이션에 적합

Java 8부터 Tiered Compilation이 기본으로 활성화돼 — 처음엔 C1으로 빠르게 컴파일하고, 코드가 충분히 hot 해지면 C2로 다시 컴파일한다. 두 컴파일러의 장점을 모두 취하는 방식이다.

java -version 출력에서 "mixed mode"는 인터프리터 + JIT 혼합을 의미한다. java -Xint -version은 인터프리터 전용(JIT 끔), java -Xcomp -version은 JIT 우선 모드. 성능 차이를 직접 체감할 수 있다.

GC — JVM이 메모리를 회수하는 방식

Java의 핵심 설계 결정 중 하나: 개발자가 메모리를 직접 해제하지 않는다. C/C++의 malloc/free, new/delete에 대응하는 명시적 해제가 Java에는 없다. 대신 Garbage Collector(GC) 가 더 이상 참조되지 않는 객체를 자동으로 회수한다.

이것이 가능한 이유는 JVM이 모든 객체 참조를 추적하고 있기 때문이다 — GC root(스택 변수, static 필드 등)에서 도달할 수 없는 객체는 쓰레기로 판단한다. Java 25에서 기본 GC는 G1 GC이며, ZGC(JEP-439, 세대별 ZGC)와 Shenandoah도 선택 가능하다. GC에 대한 자세한 내용은 24-jvm-gc.md에서 다룬다.

OpenJDK vs Oracle JDK — 어떤 것을 쓸 것인가

"JDK를 설치하라"고 했는데, 어떤 JDK인가? Java 생태계에는 여러 JDK 배포판(distribution)이 있다:

배포판 제공 라이선스 특징
OpenJDK 커뮤니티(Oracle 주도) GPL v2 Java의 참조 구현. 소스 코드의 단일 출처
Oracle JDK Oracle Oracle No-Fee Terms (교육/개발 무료, 프로덕션은 유료 조건) OpenJDK 기반. 상업 지원 포함
Eclipse Temurin(Adoptium) Eclipse 재단 GPL v2 OpenJDK 빌드. 커뮤니티 표준 배포판
Amazon Corretto AWS GPL v2 OpenJDK 빌드. AWS 환경 최적화
Azul Zulu Azul GPL v2 / 상업 OpenJDK 빌드. Zing(상업 GC) 옵션

Java 11부터 Oracle JDK와 OpenJDK는 본질적으로 같은 코드베이스다. (Oracle 발표) Oracle JDK는 상업 지원과 일부 WebStart 등 부가 기능을 더할 뿐, 코어는 동일하다. 프로덕션에서는 OpenJDK 기반 배포판(Temurin, Corretto 등)을 쓰는 것이 일반적이다.

Java 25 LTS — 왜 LTS인가, 무엇이 달라졌나

Java는 6개월마다 새 버전을 릴리스한다. 그중 LTS(Long-Term Support) 버전은 장기 지원(최소 8년 보안 패치)이 보장된다. 비-LTS 버전은 다음 버전 나올 때까지만 지원된다(6개월).

버전 연도 LTS 핵심 변화
Java 8 2014 LTS(종료 연장됨) Lambda, Stream, Optional
Java 11 2018 LTS var, HttpClient, JEP-330(단일 파일 실행)
Java 17 2021 LTS Sealed classes, Pattern Matching(switch), record 정식
Java 21 2023 LTS Virtual Threads(JEP-444), Sequenced Collections, Pattern Matching for switch 정식
Java 25 2025-09 LTS Compact Object Headers(JEP-519), Scoped Values(JEP-506 finalized), AOT Method Profiling(JEP-515), Module Import Declarations(JEP-511), Flexible Constructor Bodies(JEP-513), Generational Shenandoah(JEP-521)

이 학습서의 기준은 Java 25 LTS다. Spring Framework 7.0 / Spring Boot 4.0의 최소 요구 버전이 Java 17이며, 권장 LTS는 Java 25다. Java 21은 "이전 LTS"로, Java 8/11은 "역사적 환경"으로 다룬다.

실습 — Java 25에서 프로그램 실행하기

사전: Java 25 설치 확인(java -version). 설치되지 않았다면 Adoptium(Temurin)에서 다운로드.

방식 1 — 단일 파일 직접 실행 (Java 11+, JEP-330)

// Java 25 — Hello.java
public class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, Java 25");
        System.out.println("JVM: " + System.getProperty("java.vm.name"));
        System.out.println("Java version: " + System.getProperty("java.version"));
    }
}
# 컴파일 없이 직접 실행 (JEP-330, Java 11+)
java Hello.java
Hello, Java 25
JVM: OpenJDK 64-Bit Server VM
Java version: 25

확인할 것: java Hello.java는 내부적으로 (1) 소스를 임시 바이트코드로 컴파일하고 (2) 메모리에 로드하여 실행한다. .class 파일을 디스크에 생성하지 않는다.

[Java 11+] JEP-330으로 도입된 single-file source-code 실행은 학습·프로토타이핑 용도다. 다중 파일 프로젝트는 여전히 javac로 컴파일하거나 빌드 도구(Maven, Gradle)를 써야 한다.

방식 2 — 전통적 컴파일 + 실행

# 1단계: 컴파일 (.class 파일 생성)
javac Hello.java

# .class 파일 확인
ls Hello.class
Hello.class

확인할 것: javac.class 파일을 생성한다. 이 파일은 OS에 독립적인 바이트코드다.

# 2단계: 바이트코드 역어셈블로 내부 확인
javap -c Hello
Compiled from "Hello.java"
public class Hello {
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7  // Field java/lang/System.out
       3: ldc           #13 // String Hello, Java 25
       5: invokevirtual #15 // Method println
       8: return
}

확인할 것: javap -c가 보여주는 것이 바이트코드다. getstatic, ldc, invokevirtual은 JVM이 실행하는 명령어다.

방식 3 — JShell (Java 9+ REPL)

# Java 25 — 대화형 실험
jshell
|  Welcome to JShell -- Version 25
|  For an introduction type: /help intro

jshell> var name = "Java 25";
name ==> "Java 25"

jshell> System.out.println("Hello, " + name);
Hello, Java 25

jshell> /exit
|  Goodbye

확인할 것: JShell로 컴파일 없이 Java 코드를 즉시 실험할 수 있다. API 동작 확인, 문법 테스트에 유용.

왜 이 구조인가 — Java 설계의 트레이드오프

지금까지의 모든 것이 세 가지 설계 결정으로 요약된다.

  • 왜 바이트코드 중간 단계를 두었나? 직접 기계어로 컴파일하면 이식성을 포기해야 한다. 바이트코드 + JVM 구조는 "컴파일 한 번, 어디서든 실행"을 가능하게 한다. 대가: 실행 시 번역 오버헤드(JIT로 완화).
  • 왜 인터프리터와 JIT를 혼합했나? 인터프리터만 쓰면 느리고, JIT만 쓰면 시작 지연이 길다(모든 코드를 미리 컴파일). 실행 초반은 인터프리터로 빠르게 시작하고, hot spot이 감지되면 JIT로 최적화 — 두 방식의 장점을 취한다.
  • 왜 GC를 언어에 내장했나? 개발자가 메모리 해제를 직접 관리(C/C++ 방식)하면 use-after-free, double-free, memory leak이 빈번한다. GC는 이 클래스의 버그를 원천 제거한다. 대가: GC 실행 시 일시 정지(stop-the-world)와 메모리 오버헤드.

이 세 가지는 독립이 아니다. 바이트코드이기 때문에 JIT가 같은 코드를 반복 최적화할 수 있고, GC가 있기 때문에 객체 참조 추적이 JVM 수준에서 가능하며, 이식성이 확보되기 때문에 같은 GC 알고리즘이 다양한 OS에서 동일하게 동작한다. 하나를 빼면 나머지의 근거가 흔들린다.

요약 — 이 글의 결론

  • JRE는 Java 11부터 별도 배포되지 않는다. JDK가 유일한 배포판이며, 경량 런타임이 필요하면 jlink로 맞춤형 이미지를 생성한다.
  • javac가 만드는 .class 파일은 기계어가 아니라 바이트코드다. 이 바이트코드는 OS에 독립적이며, 각 OS의 JVM이 실행 시 기계어로 번역한다. WORA는 이 중간 단계에서 나온다.
  • JVM은 인터프리터와 JIT 컴파일러를 상황에 따라 혼합한다. 실행 초반은 인터프리터로 빠르게 시작하고, hot spot은 JIT(C1 → C2)로 최적화한다. "Java가 느리다"는 평가는 JIT가 웜업되기 전의 상태를 가리키는 경우가 많다.
  • OpenJDK와 Oracle JDK는 본질적으로 같은 코드베이스다. 프로덕션에서는 OpenJDK 기반 배포판(Temurin, Corretto)이 일반적이다.
  • Java 25 LTS는 현재 엔터프라이즈 표준이다. Compact Object Headers, Scoped Values(finalized), AOT Method Profiling, Flexible Constructor Bodies가 정식으로 들어왔다. 이 학습서 전체의 기준 버전이다.

생각해 볼 문제

  1. java -Xint Hello(인터프리터 전용)와 java Hello(혼합 모드)의 실행 시간을 비교해 보자. 어느 정도 차이가 나는가? JIT의 효과를 체감할 수 있는가?
  2. javap -v Hello.class를 실행해 상수 풀(constant pool)을 확인해 보자. #7, #13, #15가 각각 무엇을 가리키는가?
  3. C/C++ 프로그램과 Java 프로그램을 각각 Linux에서 컴파일한 뒤, 그 결과물을 macOS에서 실행하려고 하면 어떤 일이 벌어지는가? 둘의 차이는 어디서 오는가?
  4. jlink --module-path $JAVA_HOME/jmods --add-modules java.base --output myjre로 생성한 런타임 이미지의 크기를 확인해 보자. 전체 JDK와 얼마나 차이가 나는가?
  5. Java 25에서 java Hello.java를 실행할 때, .class 파일이 생성되지 않는다면, JVM은 바이트코드를 어디에 보관하는가? (힌트: JEP-330의 구현 방식)
  6. SecurityManager가 Java 17에서 deprecated 되고 Java 25에서 여전히 존재한다. 이것이 의미하는 바는 무엇이며, Java의 보안 모델이 어떻게 변화하고 있는가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 02. types  (0) 2026.07.10