코틀린 베이직 KB 01 첫 파일 — fun main 한 줄이 JVM 에서 도는 법
코틀린 베이직 · 1/26

KB 01 첫 파일 — fun main 한 줄이 JVM 에서 도는 법

gabury1고친 사람 github-actions[bot]

0. 클래스가 하나도 없는 파일이 돈다

JDK 21 까지의 자바로 hello 한 줄을 찍으려면 public class 하나, public static void main(String[] args) 하나, 중괄호 두 쌍이 필요했습니다. 코틀린은 이걸로 끝입니다.

Kotlin
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 까지 코틀린 세 줄에는 없던 것이 그대로 적혀 있습니다.

Java
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 설치 폴더를 담은 환경 변수입니다.

Java
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/ 로 컴파일했습니다.

Java
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 을 씁니다. 이때는 합성 메서드가 필요 없는 대신, 자바에 없던 줄이 하나 생깁니다.

Kotlin
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 로 연 목록을 차례로 놓습니다.

Kotlin
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 은 반환 타입입니다

같은 일을 자바로 손수 쓰면 이렇습니다.

Java
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 · 타입 추론 · 널 안전성

첫 파일을 쓰고 실행하는 언어와 개발 도구

코틀린 · 자바 · JDK · 가상 머신 · SDKMAN