KB 01 첫 파일 — fun main 한 줄이 JVM 에서 도는 법
고친 사람 github-actions[bot]
0. 클래스가 하나도 없는 파일이 돈다
JDK 21 까지의 자바로 hello 한 줄을 찍으려면 public class 하나, public static void main(String[] args) 하나, 중괄호 두 쌍이 필요했습니다. 코틀린은 이걸로 끝입니다.
fun main() {
println("hello") // hello
}
fun— 함수를 선언하는 키워드입니다. 돌려줄 값이 없으면 반환 타입을 적지 않습니다.main()의 괄호에는String[] args가 없습니다println("hello")— 한 줄 출력입니다. 앞에System.out.이 없고, 줄 끝에 세미콜론도 없습니다
클래스 선언도 static 도 없습니다. 이 세 줄을 Hello.kt 로 저장하고 코틀린 컴파일러 kotlinc 로 컴파일해 돌렸습니다.
$ kotlinc Hello.kt -d out
$ java -cp out HelloKt
돌긴 돕니다. 그런데 실행 명령을 다시 보면 이상합니다. HelloKt 라는 이름은 쓴 적이 없습니다. 결과물 폴더를 열어 봤습니다.
$ ls out
HelloKt.class
META-INF
HelloKt.class 라는 클래스 파일이 생겼습니다. META-INF 는 코틀린 컴파일러가 함께 남기는 모듈 정보 폴더인데, 이 편에서는 다루지 않습니다.
이 클래스 파일을 javap 로 열면 이상한 게 하나 더 나옵니다. 클래스 파일과 javap 가 무엇인지는 1절에서 풉니다.
$ javap -p out/HelloKt.class
Compiled from "Hello.kt"
public final class HelloKt {
public static final void main();
public static void main(java.lang.String[]);
}
쓴 적 없는 클래스 HelloKt 가 있고, 그 안에 main 이 두 개 있습니다. 하나는 우리가 쓴 인자 없는 main() 이고, 다른 하나는 쓴 적 없는 main(java.lang.String[]) 입니다.
HelloKt 는 누가 지었고, main 은 왜 둘일까요. 같은 일을 하는 자바 코드를 옆에 놓고, 두 언어의 문법이 어디서 갈리는지, 그 차이가 결과물에서 무엇이 되는지 따라가며 답을 찾겠습니다.
출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과이고, JDK 25 와 대조하는 대목만 JDK 25 로 돌렸습니다.
1. 용어와 도구 — 클래스 파일, javap, 디컴파일
코틀린 문법이 결과물에서 무엇이 되는지 보려면 .class 를 열어 볼 일이 생깁니다. 그때 쓰는 말과 도구만 먼저 짧게 정리합니다.
JVM(Java Virtual Machine) — java 명령이 띄우는 자바 가상 머신입니다. 자바든 코틀린이든 결과물은 이 JVM 을 겨냥합니다.
클래스 파일(class file) — 컴파일 결과인 .class 파일입니다. 클래스 이름, 필드와 메서드의 선언, 그리고 메서드마다 JVM 이 실행할 명령어 목록인 바이트코드(bytecode)가 들어 있습니다. JVM 은 이 파일이 자바에서 왔는지 코틀린에서 왔는지 따지지 않습니다.
javap -p — JDK(Java Development Kit)에 딸려 오는 도구로, 클래스 파일의 모양을 봅니다. 클래스 이름, final·static 같은 수식어, 필드와 메서드 선언이 자바 선언문처럼 나옵니다. -p 는 private 멤버까지 보여 달라는 옵션입니다.
CFR 0.152 — 디컴파일러입니다. 디컴파일(decompile)은 클래스 파일을 거꾸로 읽어 소스 코드 모양으로 다시 적는 일인데, 선언 말고 메서드 안에서 벌어지는 일은 이걸로 봅니다. jar 하나로 된 도구라 java -jar cfr.jar 로 돌립니다.
코틀린 컴파일러 kotlinc 는 SDKMAN 이나 공식 배포판으로 설치합니다.
2. 자바는 왜 클래스를 강제했나, 코틀린은 그걸 어떻게 피했나
JDK 21 실행기가 요구하는 것
코틀린이 왜 클래스를 지어야 했는지 알려면, 자바 프로그램을 띄우는 쪽이 무엇을 요구하는지부터 봐야 합니다.
자바 쪽은 익숙한 Hello.java 입니다. 클래스 한 겹, static, 그리고 쓰지도 않는 String[] args 까지 코틀린 세 줄에는 없던 것이 그대로 적혀 있습니다.
public class Hello {
public static void main(String[] args) {
System.out.println("hello");
}
}
이 파일을 javac 로 컴파일해 java -cp out Hello 를 넘기면 실행기(launcher)가 일을 시작합니다. 실행기는 JVM 을 띄우고, 첫 클래스를 찾아 그 안의 main 을 불러 주는 java 명령의 앞단입니다.
어떤 main 을 받아 주는지는 실행기 판마다 다릅니다. static void main() 만 있는 클래스 S 를 JDK 21 로 컴파일해, JDK 21 과 JDK 25 실행기에 차례로 넣었습니다. 명령의 $JDK25 는 JDK 25 설치 폴더를 담은 환경 변수입니다.
public class S {
static void main() {
System.out.println("no args");
}
}
$ javac -d out S.java
$ java -cp out S
Error: Main method not found in class S, please define the main method as:
public static void main(String[] args)
or a JavaFX application class must extend javafx.application.Application
$ $JDK25/bin/java -cp out S
no args
같은 클래스 파일인데 JDK 21 실행기는 거절하고 JDK 25 실행기는 돌립니다. 그러니 이건 JVM 의 규칙이 아니라 실행기의 규칙입니다.
JDK 21 까지의 실행기는 클래스 안의 public static void main(String[] args) 만 받습니다. 자바가 hello 한 줄에도 클래스와 그 모양의 main 을 쓰게 했던 이유가 이것입니다.
코틀린 컴파일러가 지은 클래스
코틀린도 이 규칙을 피할 수 없습니다. 대신 컴파일러가 규칙에 맞는 클래스를 지어 줍니다. 방금 본 자바 선언과, 0절에서 javap -p 로 연 HelloKt.class 를 맞대면 이렇습니다.
Hello.java → Hello.class |
Hello.kt → HelloKt.class |
|
|---|---|---|
| 클래스 이름 | Hello (쓴 이름) |
HelloKt (파일 이름 + Kt) |
| 클래스 수식어 | public |
public final |
| 생성자 | public Hello() — 안 써도 기본 생성자가 생긴다 |
없음 |
main |
main(String[]) 하나 |
main() 과 main(String[]) 둘 |
모양은 알았으니, main 두 개가 안에서 무엇을 하는지 CFR 로 자바 코드로 되돌렸습니다.
$ java -jar cfr.jar out/HelloKt.class
...
public final class HelloKt {
public static final void main() {
System.out.println((Object)"hello");
}
public static /* synthetic */ void main(String[] args) {
HelloKt.main();
}
}
맨 위의 머리 주석, import, 그리고 한 줄이 아주 긴 @Metadata(...)(코틀린 컴파일러가 클래스에 남기는 부가 정보)는 ... 로 줄였습니다.
디컴파일 결과는 원래 소스를 되살린 게 아니라, 클래스 파일에 적힌 내용을 자바 문법으로 다시 적은 것입니다. (Object)"hello" 의 캐스트가 그 흔적입니다. 클래스 파일이 println(String) 이 아니라 println(Object) 를 부르고 있어서, CFR 이 그 사실을 캐스트로 드러냈습니다.
두 가지가 보입니다.
- 위쪽
main()에는 코틀린의println대신 자바의System.out.println이 곧바로 들어 있습니다. 왜 그런지는 이 시리즈의 다음 편에서 풉니다 - 아래쪽
main(String[] args)는 받은args를 쓰지도 않고HelloKt.main()을 부르기만 합니다. 앞의/* synthetic */은 소스에 없고 컴파일러가 만들어 넣은 메서드, 곧 합성(synthetic) 메서드라는 표시입니다
합성 main(String[])
인자 없는 main() 이 있는데 왜 합성 main(String[]) 이 또 필요했을까요. kotlinc 가 기본으로 Java 8 에서도 돌아갈 결과물을 만들기 때문입니다. javac 가 돌고 있는 JDK 를 겨냥하는 것과 다른 점입니다.
Java 8 의 실행기가 받는 것은 public static void main(String[]) 하나뿐이라, 인자 없는 main() 만으로는 그 실행기에서 못 돕니다. 그래서 코틀린 컴파일러가 그 모양의 합성 main(String[]) 을 하나 더 만들어, 우리가 쓴 main() 으로 넘겨줍니다.
JDK 25 에서는 자바도 컴파일러가 클래스를 짓는다
자바도 같은 방향으로 왔습니다. JDK 25 의 자바는 클래스 선언 없이 이렇게 쓸 수 있습니다. jdk25/Hello.java 로 저장하고, JDK 21 결과물과 섞이지 않게 out25/ 로 컴파일했습니다.
void main() {
IO.println("hello"); // hello
}
IO 는 JDK 25 에서 정식으로 쓸 수 있게 된 콘솔 입출력 클래스입니다.
$ $JDK25/bin/javac -d out25 jdk25/Hello.java
$ $JDK25/bin/java -cp out25 Hello
소스에는 클래스 이름이 없는데 java -cp out25 Hello 가 돕니다. 실행기가 찾아 들어갈 Hello 클래스를 javac 가 파일 이름으로 지어 놓았다는 뜻입니다. 자바도 이제 컴파일러가 클래스를 지어 줍니다.
컴파일러가 대신 써 준다는 것은 같은데, 지어 주는 모양은 코틀린과 셋이 다릅니다.
| 대조 축 | 코틀린 Hello.kt → HelloKt.class |
JDK 25 자바 Hello.java → Hello.class |
|---|---|---|
| 클래스 이름 | 파일 이름 + Kt |
파일 이름 그대로, Kt 가 안 붙는다 |
main 과 생성자 |
static 메서드. 생성자가 없다 |
인스턴스 메서드. 생성자 Hello() 가 있다 |
합성 main(String[]) |
있다 | 없다 |
이 클래스는 인자 없는 인스턴스 main 을 받아 주는 JDK 25 실행기를 전제로 합니다.
인자를 받는 main
명령줄 인자가 필요하면 배열을 받는 main 을 씁니다. 이때는 합성 메서드가 필요 없는 대신, 자바에 없던 줄이 하나 생깁니다.
fun main(args: Array<String>) {
println(args.size)
}
$ kotlinc args/Hello.kt -d aout
$ javap -p aout/HelloKt.class
Compiled from "Hello.kt"
public final class HelloKt {
public static final void main(java.lang.String[]);
}
$ java -jar cfr.jar aout/HelloKt.class
...
public final class HelloKt {
public static final void main(@NotNull String[] args) {
Intrinsics.checkNotNullParameter((Object)args, (String)"args");
int n = args.length;
System.out.println(n);
}
}
args: Array<String>— 코틀린은 이름 뒤에 콜론, 그 뒤에 타입을 씁니다.Array<String>은String[]이 되고,args.size는args.length입니다
이번엔 main 이 하나입니다. 이미 실행기가 찾는 모양이라 합성 main(String[]) 이 필요 없었습니다. @NotNull 은 이 인자가 null 이 아니라는 표시로 컴파일러가 붙인 애노테이션입니다.
대신 첫 줄에 Intrinsics.checkNotNullParameter 가 붙었습니다. 코틀린의 Array<String> 은 null 을 받지 않는 타입이라, 누가 null 을 넘기면 첫 줄에서 멈추도록 컴파일러가 검사를 심은 것입니다.
타입 뒤에 ? 를 붙이면(String?) null 을 허용하고, 그때는 이 검사가 붙지 않습니다.
이 검사를 담은 클래스는 kotlin.jvm.internal.Intrinsics, kotlin. 으로 시작하는 클래스입니다. java. 로 시작하는 클래스는 JDK 에 들어 있지만, kotlin. 으로 시작하는 클래스는 JDK 에 없습니다.
그래서 Intrinsics.checkNotNullParameter 가 든 이 클래스 파일은 코틀린 표준 라이브러리 jar 없이 실행하면 멈춥니다.
널 허용 타입에서 이 검사가 어떻게 달라지는지, 그리고 무엇이 표준 라이브러리를 필요하게 만드는지는 다음 편에서 실측으로 가릅니다.
3. 톱레벨 함수와 톱레벨 프로퍼티는 static 멤버가 된다
main 만 특별 대접을 받은 걸까요. 이 절에서는 함수와 값을 파일에 바로 두면 무엇이 되는지 봅니다.
코틀린의 프로퍼티(property)는 필드와 게터를 한 이름으로 묶은 선언입니다. 다시 대입할 수 있는 var 로 선언하면 세터까지 같은 이름에 묶입니다.
클래스 안이 아니라 파일 바로 아래에 쓴 선언은 톱레벨(top-level) 선언이라고 부릅니다. 0절의 main 도 톱레벨 함수였습니다.
톱레벨에 함수 하나와 프로퍼티 둘을 두고, 같은 모양으로 손수 쓴 자바 클래스와 맞대 봅니다. 보려는 것은 하나입니다. 클래스 밖에 적은 코틀린 선언 셋이, 자바라면 클래스 안에 static 을 붙여 써야 했던 멤버와 똑같이 컴파일되는지입니다. 그래서 코틀린 코드, 그것을 자바로 손수 옮긴 HelloJ, 코틀린 결과물을 javap -p 로 연 목록을 차례로 놓습니다.
const val MAX_NAME = 10
val greeting = "hello"
fun greet(name: String): String {
return greeting + ", " + name
}
val— 다시 대입할 수 없는 변수입니다. 클래스 밖에 두면 톱레벨 프로퍼티가 됩니다const val— 컴파일 시점에 값이 정해지는 상수입니다.val과const val의 차이와 쓰임은 이 시리즈의 뒤쪽 편에서 자세히 다룹니다- 타입을 안 적으면 값에서
Int·String을 알아냅니다.name: String은 인자, 괄호 뒤의: String은 반환 타입입니다
같은 일을 자바로 손수 쓰면 이렇습니다.
public final class HelloJ {
public static final int MAX_NAME = 10;
private static final String greeting = "hello";
public static String getGreeting() { return greeting; }
public static String greet(String name) {
return greeting + ", " + name;
}
}
$ kotlinc Hello.kt -d out
$ javap -p out/HelloKt.class
Compiled from "Hello.kt"
public final class HelloKt {
public static final int MAX_NAME;
private static final java.lang.String greeting;
public static final java.lang.String getGreeting();
public static final java.lang.String greet(java.lang.String);
static {};
}
코틀린 쪽 결과물입니다. 손으로 쓴 HelloJ 의 선언과 줄을 맞추면 멤버 목록이 거의 그대로 겹칩니다. 코틀린 선언이 각각 무엇이 됐는지는 이렇습니다.
| 코틀린 선언 | HelloKt 안에서 된 것 |
자바에서 부르는 법 |
|---|---|---|
const val MAX_NAME = 10 |
public static final int MAX_NAME 필드 |
HelloKt.MAX_NAME |
val greeting = "hello" |
private static final 필드 + public static getGreeting() 게터 |
HelloKt.getGreeting() |
fun greet(name: String) |
public static final String greet(String) 메서드 |
HelloKt.greet("x") |
톱레벨 함수와 프로퍼티는 static 멤버가 됩니다. 클래스 밖에 있으니 속할 객체가 없고, 객체 없이 클래스 이름으로 부르는 멤버가 JVM 에서는 static 이기 때문입니다.
톱레벨에 쓴 class 는 다릅니다. HelloKt 에 들어가지 않고 제 이름의 클래스 파일이 따로 생깁니다.
코틀린에는 static 이라는 키워드 자체가 없습니다. 자바에서 static 유틸 메서드를 모아 두던 클래스는 코틀린에서 톱레벨 함수를 모아 둔 파일로 대신합니다.
클래스 하나에 딸린 static 멤버가 필요할 때는 companion object 를 씁니다. 클래스 안에 그런 멤버를 모아 두는 문법인데, 이 편에서는 이름만 적어 둡니다.
val 은 자바의 static final 과 같지 않다
멤버 목록이 이렇게 겹치는데, HelloKt 에만 있고 손으로 쓴 HelloJ 에는 없는 줄이 하나 있습니다. 맨 아래의 static {} 입니다.
static {} 은 클래스가 처음 초기화될 때 한 번 도는 정적 초기화 블록입니다. 코틀린은 val greeting = "hello" 의 "hello" 를 여기서 필드에 넣습니다.
소스에 = "hello" 라고 붙여 적었어도 값이 들어가는 때는 실행 시점입니다. 그래서 코틀린의 val 은 private static final 필드로 번역되어도 자바가 말하는 컴파일 시점 상수가 아닙니다.
게터가 갈리는 것도 같은 까닭입니다. val greeting 은 감춰진 필드와 getGreeting() 한 쌍이 됐지만, const val MAX_NAME 은 게터 없이 public static final int 필드가 그대로 열려 있습니다. 값이 컴파일할 때 이미 정해지는 쪽은 읽어 올 게터가 필요 없습니다.
greet 에서 갈리는 것은 하나 더 있습니다. 코틀린의 String 은 null 을 받지 않는 타입이라 2절에서 본 Intrinsics.checkNotNullParameter 가 첫 줄에 붙지만, 자바의 String 에는 그 구분이 없어 손으로 쓴 HelloJ.greet 에는 검사가 없습니다.
코틀린에서 값을 컴파일 시점 상수로 만들려면 const val 을 씁니다. 이 상수를 자바에서 읽으면 어떻게 되는지, 그리고 파일 이름을 바꾸면 무엇이 깨지는지는 다음 편에서 봅니다.
4. 한 장 요약
JDK 21 까지의 실행기는 클래스 안의
public static void main(String[])만 받는다. 그래서 코틀린 컴파일러가 파일마다 클래스를 하나 지어 톱레벨 선언을 static 멤버로 넣고, 합성main(String[])까지 붙인다. 코틀린에서 클래스를 안 써도 되는 건 JVM 이 바뀌어서가 아니라 컴파일러가 대신 써 주기 때문이고, JDK 25 의 자바도 같은 길로 왔다.
| 질문 | 답 | 확인한 방법 |
|---|---|---|
클래스 없는 Hello.kt 는 어디로 가나 |
HelloKt 클래스가 생긴다. 이름은 파일 이름 + Kt |
ls out · javap -p |
main 은 왜 둘인가 |
kotlinc 가 Java 8 대상 결과물을 만들어서, 옛 실행기가 받는 합성 main(String[]) 을 붙이고 그게 main() 을 부른다 |
javap -p · CFR |
| 자바의 main 규칙은 | JDK 21 실행기는 static void main() 을 거절, JDK 25 는 클래스 선언 없는 void main() 도 받는다. 감싸는 클래스는 컴파일러가 파일 이름으로 지어 준다 |
JDK 21·25 실행기 대조 |
인자를 받는 main 은 |
합성 메서드 없이 하나. 대신 첫 줄에 Intrinsics.checkNotNullParameter 널 검사가 붙는다 |
javap -p · CFR |
| 톱레벨 함수·프로퍼티는 무엇이 되나 | 함수 → static 메서드, val → private static 필드 + 게터, const val → public static final 필드 |
손으로 쓴 자바 클래스와 javap -p 대조 |
val 은 자바의 static final 과 같나 |
다르다. static {} 에서 실행 시점에 채워지는 필드 + 게터다. 컴파일 시점 상수는 const val 쪽이다 |
javap -p 의 static {} |
관련 항목
코틀린 소스가 JVM 에서 실행되기까지 거치는 처리 단계
kotlinc · javac · 컴파일러 · 바이트코드 · 클래스 파일 · 자바 실행기 · JVM
클래스 파일을 열어 보는 도구와 표기
javap · 디컴파일 · 디컴파일러 · CFR · 클래스 파일 버전
톱레벨 선언이 번역되는 클래스 파일 멤버
정적 메서드 · 정적 필드 · 게터 · 정적 초기화 블록 · 합성 메서드 · 컴파일 타임 상수
코틀린에서 static 을 대신하는 수단
톱레벨 함수 · 톱레벨 프로퍼티 · companion object
첫 파일을 이루는 코틀린 문법 요소
main 함수 · 프로퍼티 · val · const val · 타입 추론 · 널 안전성