자바와 JVM - 컴파일과 실행이 두 단계인 이유 (자바 초급 1단원)
이 단원에서 배우는 것
자바 초급 커리큘럼의 첫 단원이다. 앞 단원은 없다. 대신 이 커리큘럼 8단원 전체에서 하나의 예제 도메인을 쓴다. 사내 도서 대출 시스템이다. 책 제목이 있고, 연체일이 있고, 연체일에 따라 연체료가 붙는다. 이 소재를 1단원의 출력 한 줄에서 시작해 8단원의 패키지 구조까지 끌고 간다. 매 단원 새 소재를 꺼내지 않는다.
이번 단원은 코드를 쓰기 전에 javac 와 java 라는 두 명령이 각각 무엇을 하는지 정리한다. 이걸 건너뛰면 나중에 서버에 올렸을 때 나는 에러의 원인을 못 읽는다.
- 자바가 컴파일과 실행을 두 단계로 나눈 이유를 설명하고, 바이트코드가 무엇인지 말할 수 있다.
- JDK·JRE·JVM 이 각각 무엇을 포함하는지 구분하고, 어느 것을 설치해야 하는지 판단한다.
- 첫 프로그램을 컴파일·실행하고,
main메서드 시그니처의 각 조각이 왜 그 자리에 있는지 설명한다.
왜 필요한가
파이썬은 한 번인데 자바는 왜 두 번인가
파이썬이나 자바스크립트는 소스 파일을 인터프리터에 그대로 던진다. 자바는 그렇지 않다. 소스를 먼저 .class 파일로 바꾸고, 그 .class 를 다시 실행한다. 명령이 두 개인 이유는 두 단계가 하는 일이 다르기 때문이다.
LateFee.java --javac--> LateFee.class --java--> 실행 결과
(사람이 읽는 소스) (JVM이 읽는 바이트코드) (OS 위에서 도는 프로세스)
javac 가 만드는 .class 는 기계어가 아니다. x86 CPU 명령어도 아니고 ARM 명령어도 아니다. JVM 이라는 가상의 기계를 위한 명령어이고, 이걸 바이트코드라고 부른다. 그래서 같은 .class 파일 하나를 윈도우에서도, 리눅스 서버에서도, 애플 실리콘 맥에서도 고치지 않고 돌린다. OS 마다 다시 빌드하지 않아도 되는 것이 자바가 서버 쪽에서 오래 살아남은 이유 중 하나다.
두 단계가 실무에서 무엇을 바꾸는가
첫째, 문법 오류와 타입 오류를 실행 전에 잡는다. 파이썬이라면 그 줄까지 실행이 도달해야 터지는 오타를, 자바는 javac 단계에서 전부 걸러낸다. 배포한 서비스가 새벽 3시에 오타 때문에 죽는 일이 그만큼 줄어든다.
둘째, 실행 시점에 JIT 컴파일러가 개입한다. JVM 은 바이트코드를 한 줄씩 해석하다가, 자주 도는 부분을 감지하면 그 부분만 진짜 기계어로 바꿔치기한다. 그래서 자바 프로세스는 뜬 직후보다 몇 분 지난 뒤가 더 빠르다. 배포 직후 응답이 느리다가 안정되는 현상, 서비스 앞단에 워밍업 요청을 넣는 관행이 여기서 나온다.
셋째, 소스와 실행 파일이 분리되어 있으므로 둘의 버전이 어긋날 수 있다. 이게 신입이 가장 먼저 만나는 에러다. 뒤에서 다시 본다.
문법과 예제
JDK, JRE, JVM
셋은 포함 관계다. 큰 것부터 적으면 JDK ⊃ JRE ⊃ JVM 이다.
| 이름 | 안에 들어 있는 것 | 이걸로 할 수 있는 일 |
|---|---|---|
| JVM | 바이트코드 실행 엔진, 메모리 관리(GC), JIT | 실행만. 단독 설치 대상이 아니다 |
| JRE | JVM + 표준 라이브러리(java.lang, java.util 등) | 남이 만든 .class/.jar 실행 |
| JDK | JRE + javac, javap, jar, jshell 등 개발 도구 | 컴파일부터 실행까지 전부 |
개발자는 무조건 JDK 를 설치한다. "실행만 할 거니까 JRE 만 깔면 되지 않나"는 이제 의미가 없는 질문이다. Java 11 부터 오라클과 대부분의 배포판이 JRE 를 따로 배포하지 않는다. 서버에도 JDK 를 올린다.
JDK 는 여러 회사가 만든다. 내용물은 같은 표준(OpenJDK)에서 나오지만 라이선스와 지원 기간이 다르다. 실무에서 흔한 선택은 Eclipse Temurin, Amazon Corretto, Azul Zulu 다. 셋 다 무료이고 상용 서비스에 써도 된다. 오라클 JDK 는 버전마다 라이선스 조건이 달라 확인이 필요하므로, 팀에 정해진 것이 없다면 Temurin 이나 Corretto 를 고르면 논쟁이 없다.
이 커리큘럼은 Java 17 LTS 기준이다. 설치 후 두 명령이 모두 같은 버전을 가리키는지 먼저 확인한다.
$ java -version
openjdk version "17.0.19" 2026-04-21
OpenJDK Runtime Environment Homebrew (build 17.0.19+0)
OpenJDK 64-Bit Server VM Homebrew (build 17.0.19+0, mixed mode, sharing)
$ javac -version
javac 17.0.19
둘의 버전이 다르면 지금 바로 잡는다. javac 가 17, java 가 11 인 상태로 진도를 나가면 3단원쯤에서 원인 모를 에러를 만난다.
첫 프로그램
파일 이름은 LateFee.java 로 만든다. 파일명이 클래스명과 정확히 같아야 한다. 대소문자도 같아야 한다.
public class LateFee {
public static void main(String[] args) {
String title = "클린 코드";
int overdueDays = 12;
int fee = overdueDays * 100;
System.out.println(title + " 연체 " + overdueDays + "일");
System.out.println("연체료: " + fee + "원");
}
}
컴파일하고 실행한다. 컴파일 결과로 같은 폴더에 LateFee.class 가 생긴다.
$ javac -encoding UTF-8 LateFee.java
$ ls
LateFee.class LateFee.java
$ java LateFee
클린 코드 연체 12일
연체료: 1200원
실행할 때는 java LateFee 다. java LateFee.class 가 아니다. java 명령이 받는 것은 파일 이름이 아니라 클래스 이름이기 때문이다.
main 시그니처를 한 조각씩
외우기 전에 각 단어가 왜 필요한지 본다. 뒷 단원에서 하나씩 제대로 다룬다.
| 조각 | 이유 | 자세히 다루는 단원 |
|---|---|---|
public | JVM 이 클래스 밖에서 호출한다. 감춰져 있으면 못 부른다 | 8단원 |
static | 객체를 만들지 않은 상태에서 호출해야 한다. 프로그램 시작 시점엔 아무 객체도 없다 | 6단원 |
void | 돌려줄 값이 없다. 프로세스 종료 코드는 반환값이 아니라 System.exit 로 정한다 | 5단원 |
String[] args | 실행할 때 뒤에 붙인 문자열들이 배열로 들어온다 | 4단원 |
args 를 직접 확인해 본다. java LateFee 클린코드 12 처럼 뒤에 붙인 값이 배열로 들어온다.
public class LateFeeArgs {
public static void main(String[] args) {
System.out.println("전달된 인자 개수: " + args.length);
if (args.length >= 1) {
System.out.println("첫 번째 인자: " + args[0]);
}
}
}
$ javac -encoding UTF-8 LateFeeArgs.java
$ java LateFeeArgs 클린코드 12
전달된 인자 개수: 2
첫 번째 인자: 클린코드
연습할 때 컴파일을 건너뛰는 방법
Java 11 부터 단일 파일 소스는 컴파일 없이 바로 실행된다. .class 파일도 남지 않는다. 문법 하나 확인할 때 편하다.
$ java LateFee.java
클린 코드 연체 12일
연체료: 1200원
다만 이건 학습용이다. 파일이 둘 이상이거나 라이브러리를 쓰는 순간 못 쓴다. 실제 프로젝트는 Gradle 이나 Maven 이 javac 를 대신 호출한다.
참고로 Java 21부터는 클래스 선언과 String[] args 를 생략한 간결한 형태를 미리보기(preview) 기능으로 쓸 수 있고, 이후 버전에서 정식이 됐다. Java 17 에서는 쓸 수 없으므로 이 커리큘럼은 위의 표준 형태만 쓴다.
실무에서 자주 틀리는 것
1. UnsupportedClassVersionError — 개발 PC 와 서버의 자바 버전이 다르다
내 PC 에서는 잘 도는 .jar 를 서버에 올렸는데 뜨자마자 이렇게 죽는다.
Exception in thread "main" java.lang.UnsupportedClassVersionError:
LateFee has been compiled by a more recent version of the Java Runtime
(class file version 61.0), this version of the Java Runtime only recognizes
class file versions up to 55.0
메시지의 숫자가 답을 다 알려주고 있다. 클래스 파일 버전은 자바 버전에 1:1로 대응한다.
| 클래스 파일 버전 | 자바 버전 |
|---|---|
| 52.0 | Java 8 |
| 55.0 | Java 11 |
| 61.0 | Java 17 |
| 65.0 | Java 21 |
즉 위 에러는 "17로 컴파일한 걸 11 런타임에 올렸다"는 뜻이다. 지금 가진 .class 가 몇 버전인지는 javap 로 직접 볼 수 있다.
$ javap -v LateFee.class | grep "major version"
major version: 61
해결은 둘 중 하나다. 서버 JDK 를 올리거나, 컴파일할 때 낮은 버전을 목표로 지정한다. 후자는 --release 를 쓴다. -source/-target 만 쓰면 낮은 버전에 없는 API 를 참조해도 컴파일이 통과해 버려서 결국 실행 중에 NoSuchMethodError 로 터진다.
$ javac --release 11 -encoding UTF-8 LateFee.java
2. 한글이 물음표나 깨진 글자로 나온다
Java 17 까지 javac 는 -encoding 을 안 주면 OS 의 기본 인코딩으로 소스를 읽는다. 맥과 리눅스는 보통 UTF-8 이라 문제가 없지만, 윈도우 한국어판은 MS949 라서 UTF-8 로 저장한 소스의 한글이 깨진다. 팀원 중 한 명만 윈도우여도 이 문제가 생긴다.
$ javac -encoding UTF-8 LateFee.java
Java 18 부터는 기본 인코딩이 UTF-8 로 고정됐다(JEP 400). 하지만 이 커리큘럼의 기준인 17 은 해당하지 않으므로, 습관적으로 -encoding UTF-8 을 붙인다. 빌드 도구를 쓴다면 Gradle 은 tasks.withType(JavaCompile) { options.encoding = 'UTF-8' }, Maven 은 project.build.sourceEncoding 속성으로 같은 설정을 한다.
3. 파일 이름과 public 클래스 이름이 다르다
Wrong.java 안에 public class NotWrong 을 적으면 컴파일이 거부된다. 흔한 원인은 IDE 없이 파일을 복사해 이름만 바꿨을 때다.
Wrong.java:1: error: class NotWrong is public, should be declared in a file named NotWrong.java
public class NotWrong { }
^
1 error
4. 클래스 이름 자리에 파일 경로를 넣는다
java LateFee.class, java ./LateFee, java src/LateFee 는 전부 실패한다. java 에는 클래스 이름을 주고, 어디서 찾을지는 -cp 로 따로 준다. 이 구분이 8단원의 패키지 구조에서 그대로 이어진다.
$ javac -encoding UTF-8 -d out LateFee.java
$ java -cp out LateFee
스스로 확인하기
- 같은
.class파일 하나를 윈도우 노트북과 리눅스 서버에서 모두 실행할 수 있는 이유를 두 문장으로 설명하라. - 동료가
class file version 65.0을 인식하지 못한다는 에러를 보내왔다. 동료의 서버 자바 버전은 최대 몇인가? 그리고 코드를 수정하지 않고 해결하는 두 가지 방법을 적어라. - 아래 코드는 컴파일되지 않는다. 파일 이름은
Receipt.java다. 오류를 두 군데 찾아 고쳐라.public class receipt { public void main(String[] args) { System.out.println("영수증"); } }
정답
javac가 만드는 것은 특정 CPU 의 기계어가 아니라 JVM 이라는 가상 기계용 바이트코드다. 실제 OS 와 CPU 차이는 각 플랫폼에 맞게 따로 만들어진 JVM 이 흡수하므로, 바이트코드는 그대로 두고 JVM 만 바꿔 끼우면 된다.- 65.0 은 Java 21 이고 동료가 인식하지 못하므로 동료의 런타임은 21 미만이다. 해결책은 (가) 서버 JDK 를 21 이상으로 올린다, (나) 동료 서버 버전에 맞춰
javac --release 17처럼 낮은 버전을 목표로 다시 컴파일한다.-source/-target대신--release를 쓰는 이유는 API 존재 여부까지 검사해 주기 때문이다. - 두 곳이다. (가) 파일 이름이
Receipt.java이므로 클래스 이름은receipt가 아니라Receipt여야 한다. (나)main에static이 빠졌다. JVM 은 객체를 만들지 않고main을 호출하므로public static void main(String[] args)여야 한다.static이 없으면 컴파일은 되지만 실행 시Main method is not static으로 죽는다.
다음 단원에서는 이 프로그램에 등장한 int 와 String 의 차이, 즉 기본형과 참조형을 나누고 8가지 기본 자료형을 정리한다.