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가 정식으로 들어왔다. 이 학습서 전체의 기준 버전이다.
생각해 볼 문제
java -Xint Hello(인터프리터 전용)와java Hello(혼합 모드)의 실행 시간을 비교해 보자. 어느 정도 차이가 나는가? JIT의 효과를 체감할 수 있는가?javap -v Hello.class를 실행해 상수 풀(constant pool)을 확인해 보자.#7,#13,#15가 각각 무엇을 가리키는가?- C/C++ 프로그램과 Java 프로그램을 각각 Linux에서 컴파일한 뒤, 그 결과물을 macOS에서 실행하려고 하면 어떤 일이 벌어지는가? 둘의 차이는 어디서 오는가?
jlink --module-path $JAVA_HOME/jmods --add-modules java.base --output myjre로 생성한 런타임 이미지의 크기를 확인해 보자. 전체 JDK와 얼마나 차이가 나는가?- Java 25에서
java Hello.java를 실행할 때,.class파일이 생성되지 않는다면, JVM은 바이트코드를 어디에 보관하는가? (힌트: JEP-330의 구현 방식) SecurityManager가 Java 17에서 deprecated 되고 Java 25에서 여전히 존재한다. 이것이 의미하는 바는 무엇이며, Java의 보안 모델이 어떻게 변화하고 있는가?
참고
- JVM Specification (Java 25) - 접근 2026-07-10
- Java Language Specification (Java 25) - 접근 2026-07-10
- Java 25 API Documentation - 접근 2026-07-10
- JEP-330: Launch Single-File Source-Code Programs - 접근 2026-07-10
- JEP-282: jlink: The Java Linker - 접근 2026-07-10
- JEP-444: Virtual Threads - 접근 2026-07-10
- JEP-439: Generational ZGC - 접근 2026-07-10
- OpenJDK - 접근 2026-07-10
- Oracle JDK and OpenJDK announcement - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 02. types (0) | 2026.07.10 |
|---|
