KB 02 자바에서 부르고 실행하기 — 파일 이름, const, kotlin-stdlib
고친 사람 github-actions[bot]
0. 자바에서 부른 세 줄 가운데 하나가 사라졌다
코틀린 파일 하나를 자바에서 불러 봤습니다. 코틀린 쪽은 함수 하나와 값 둘이 전부입니다.
const val MAX_NAME = 10
val greeting = "hello"
fun greet(name: String): String {
return greeting + ", " + name
}
val— 다시 대입할 수 없는 변수입니다.const val은 컴파일 시점에 값이 정해지는 상수입니다fun greet(name: String): String— 함수 선언입니다. 코틀린은 인자도 반환 타입도 이름 뒤에 콜론을 찍고 타입을 씁니다
이 Hello.kt 에는 클래스가 없습니다. 클래스 안이 아니라 파일 바로 아래에 쓴 선언을 톱레벨(top-level) 선언이라고 부릅니다. 코틀린 컴파일러는 톱레벨 함수와 프로퍼티를 파일 이름 + Kt 라는 클래스의 static 멤버로 넣습니다.
프로퍼티(property)는 필드와 게터를 한 이름으로 묶은 코틀린의 선언입니다. 위의 val greeting 이 그것입니다.
그러니 자바에서는 HelloKt 의 static 멤버를 부르면 됩니다. 함수는 이름 그대로 greet, val greeting 은 게터 getGreeting(), const val MAX_NAME 은 필드 이름 그대로입니다.
public class Main {
public static void main(String[] args) {
System.out.println(HelloKt.greet("x"));
System.out.println(HelloKt.getGreeting());
System.out.println(HelloKt.MAX_NAME);
}
}
$ kotlinc Hello.kt -d out
$ javac -cp out -d out Main.java
$ java -cp out:$KOTLIN_LIB/kotlin-stdlib.jar Main
hello, x
hello
10
세 줄 다 잘 나옵니다. 실행에 넣은 kotlin-stdlib.jar 는 코틀린 표준 라이브러리이고, 줄여서 stdlib 이라고 씁니다. kotlinc 설치 폴더의 lib/ 에 들어 있어서, 명령에는 그 폴더를 담은 환경 변수 KOTLIN_LIB 로 적었습니다.
그런데 자바가 컴파일한 Main.class 를 디컴파일러 CFR 0.152 로 열어 보면, 우리가 쓴 세 줄과 모양이 다릅니다. jar 하나로 된 도구라 java -jar cfr.jar 클래스파일 로 돌립니다.
$ java -jar cfr.jar out/Main.class
/*
* Decompiled with CFR 0.152.
*/
public class Main {
public static void main(String[] stringArray) {
System.out.println(HelloKt.greet("x"));
System.out.println(HelloKt.getGreeting());
System.out.println(10);
}
}
HelloKt.MAX_NAME 이 사라지고 10 이 들어앉았습니다. 이 편은 자바에서 코틀린을 부를 때 생기는 이런 일들을 봅니다. 상수가 호출부에 복사되는 것, 파일 이름을 바꾸면 깨지는 곳, 그리고 앞의 실행에서 kotlin-stdlib.jar 를 빼면 무엇이 멈추는지입니다.
출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. JVM 은 자바에서 왔는지 코틀린에서 왔는지 따지지 않는다
자바 코드가 코틀린 함수를 그냥 부를 수 있었던 까닭이 이 절에 있습니다. 0절에서 미룬 말도 여기서 한 번에 풉니다.
JVM 과 클래스 파일 — JVM(Java Virtual Machine)은 java 명령이 띄우는 자바 가상 머신입니다. 클래스 파일은 컴파일 결과인 .class 파일로, 클래스 이름과 필드·메서드 선언, 메서드마다 JVM 이 실행할 명령어 목록(바이트코드)이 들어 있습니다. JVM 은 그게 자바에서 왔는지 코틀린에서 왔는지 따지지 않습니다.
두 언어의 문법은 전혀 다르지만 javac 와 kotlinc 가 내놓는 것은 같은 규격의 클래스 파일입니다. 그래서 자바 쪽에서는 코틀린 선언이 그저 어떤 클래스의 static 멤버로 보입니다.
디컴파일(decompile) — 클래스 파일을 거꾸로 읽어 소스 코드 모양으로 다시 적는 일입니다.
실행기(launcher) — JVM 을 띄우고 첫 클래스를 찾아 main 을 불러 주는 java 명령의 앞단입니다.
디컴파일 결과를 읽는 법
디컴파일 결과는 원래 소스를 되살린 게 아니라 클래스 파일에 적힌 내용을 자바 문법으로 다시 적은 것입니다. 0절의 String[] stringArray 가 그 흔적입니다. 소스에는 args 라고 썼지만 클래스 파일에 인자 이름이 남지 않아 CFR 이 이름을 지어 붙였습니다.
코틀린 클래스를 디컴파일하면 맨 위에 머리 주석, import, 한 줄이 아주 긴 @Metadata(...)(코틀린 컴파일러가 클래스에 남기는 부가 정보)가 붙습니다. 이 편에서는 그 부분을 ... 로 줄여 싣습니다.
2. const val 은 호출부에 복사된다
0절에서 사라진 HelloKt.MAX_NAME 이 어디로 갔는지부터 풉니다.
greet 와 getGreeting 은 HelloKt 를 부르는 코드로 남았지만, HelloKt.MAX_NAME 은 필드를 읽는 코드가 없고 값 10 이 Main 에 복사됐습니다. 자바 컴파일러가 컴파일 시점 상수를 호출부에 접어 넣은 것입니다.
코틀린의 const val 은 자바의 public static final int MAX_NAME = 10; 과 같은 컴파일 시점 상수가 되므로, 자바 컴파일러가 똑같이 다룹니다.
그래서 const val MAX_NAME 의 값을 바꾸면 Hello.kt 만 다시 컴파일해서는 안 되고, 자바 호출부도 다시 컴파일해야 바뀐 값이 들어갑니다.
자바 컴파일러가 호출부에 복사하는 것은 이런 상수뿐입니다. 코틀린 컴파일러는 함수 몸통까지 복사하는 경우가 있는데, 4절에서 봅니다.
3. 파일 이름을 바꾸면 깨진다
자바는 정의하는 쪽에서, 코틀린은 쓰는 쪽에서
HelloKt 라는 클래스 이름은 파일 이름에서 옵니다. 그러면 파일 이름을 바꿀 때 무슨 일이 생기는지 자바와 나란히 봅니다.
자바부터 해 봅니다. 0절의 HelloKt 와 같은 멤버를 손으로 쓴 public final class HelloJ 가 든 HelloJ.java 의 파일 이름만 GreetingJ.java 로 바꿨습니다.
$ mv HelloJ.java GreetingJ.java
$ javac -d out GreetingJ.java
GreetingJ.java:1: error: class HelloJ is public, should be declared in a file named HelloJ.java
public final class HelloJ {
^
1 error
자바는 public 클래스 이름과 파일 이름이 어긋나면 정의하는 쪽을 컴파일할 때 멈춥니다.
코틀린 쪽도 Hello.kt 를 Greeting.kt 로 바꾸고, 예전 결과물을 지운 뒤 다시 컴파일했습니다.
$ mv Hello.kt Greeting.kt
$ rm -rf out && kotlinc Greeting.kt -d out
$ ls out
GreetingKt.class
META-INF
$ javac -cp out -d out Main.java 2>&1 | sed -n '1,5p;$p'
Main.java:3: error: cannot find symbol
System.out.println(HelloKt.greet("x"));
^
symbol: variable HelloKt
location: class Main
3 errors
sed -n '1,5p;$p' 로 앞 다섯 줄과 마지막 줄만 남겼습니다. 같은 에러가 HelloKt.getGreeting() 과 HelloKt.MAX_NAME 호출에도 나서 모두 세 개입니다.
META-INF 는 코틀린 컴파일러가 함께 남기는 모듈 정보 폴더입니다.
코틀린 컴파일은 조용히 끝나고 클래스 이름만 GreetingKt 로 바뀌었습니다. 멈추는 곳은 정의하는 쪽이 아니라 쓰는 쪽입니다.
코틀린끼리는 greet("x") 처럼 클래스 이름 없이 부르므로 소스에서는 안 보입니다. 하지만 클래스 이름으로 이 파일을 가리키는 곳이면 어디든 깨집니다.
- 위에서 본 자바 호출부
java -cp out HelloKt같은 실행 명령- 빌드 설정에 적어 둔 main 클래스 이름
HelloKt를 부르도록 이미 컴파일돼 있는 코틀린 클래스 파일
@file:JvmName 으로 이름 고정하기
클래스 이름을 파일 이름에서 떼어 놓는 애노테이션이 있습니다.
@file:JvmName("Greetings")
const val MAX_NAME = 10
// 이 아래는 0절의 Hello.kt 와 같다
@file:— 뒤의 애노테이션을 함수 하나가 아니라 이 파일 전체에 건다는 표시입니다@file:은package선언보다 위에 있어야 합니다. 아래에 두면kotlinc가 에러를 두 개 내는데, 그중 하나가'@file:' annotations can only be applied before package declaration.입니다JvmName("Greetings")— JVM 에서 쓸 클래스 이름을Greetings로 정합니다
자바 Main 은 Greetings.greet("x") 를 부르게 고치고, 파일 이름을 Greeting.kt 에서 Hi.kt 로 바꿔 가며 컴파일했습니다.
$ kotlinc Greeting.kt -d out
$ mv Greeting.kt Hi.kt
$ rm -rf out && kotlinc Hi.kt -d out
$ ls out
Greetings.class
META-INF
$ javac -cp out -d out Main.java
$ java -cp out:$KOTLIN_LIB/kotlin-stdlib.jar Main
hello, x
파일 이름이 바뀌어도 클래스는 Greetings 로 남고, 자바 쪽은 더 고치지 않아도 컴파일되고 돕니다. 자바에서 부르거나 실행 명령에 이름을 적을 파일이라면 이 한 줄을 달아 두는 편이 안전합니다.
4. 실행에는 무엇이 필요한가
stdlib 없이 도는 코드
지금까지는 stdlib 을 classpath 에 넣고 돌렸습니다. 이걸 빼면 무엇이 멈추는지 가르기 위해, stdlib 없이도 도는 코드부터 봅니다.
새 작업 폴더에 println("hello") 한 줄짜리 main 을 담은 Hello.kt 를 두고, stdlib 없이 돌린 뒤 자바 코드로 되돌렸습니다.
fun main() {
println("hello") // hello
}
$ kotlinc Hello.kt -d out
$ java -cp out HelloKt
$ java -jar cfr.jar out/HelloKt.class
...
public static final void main() {
System.out.println((Object)"hello");
}
...
classpath 에 stdlib 이 없는데도 돕니다. 그런데 main() 만 남기고 줄인 몸통에는 코틀린의 println 을 부르는 코드가 없고 자바의 System.out.println 이 곧바로 들어 있습니다. (Object) 캐스트는 클래스 파일이 println(Object) 버전을 부른다는 것을 CFR 이 드러낸 것입니다.
println 은 어디로 갔나
코틀린의 println 은 표준 라이브러리 kotlin.io 패키지에 있는 inline 함수이고, 몸통은 System.out.println 한 줄입니다(공식 문서).
kotlinc 는 인라인 함수를 부르는 곳에 호출 대신 함수 몸통을 복사해 넣습니다. 앞의 main() 에 System.out.println 이 들어 있던 것이 이 복사의 결과입니다.
자바 개발자에게 익숙한 인라인은 JIT(Just-In-Time) 컴파일러가 실행 중에 하는 최적화입니다. 이건 그와 달리 컴파일할 때 소스 수준에서, inline fun 으로 선언한 함수에만 일어납니다.
println 에는 @InlineOnly 애노테이션도 붙어 있는데, 복사를 일으키는 표시가 아니라 원본 메서드를 클래스 파일에서 private 으로 숨기는 표시입니다.
복사가 정말 몸통을 옮겨 붙이는지는 stdlib 바깥의 함수로도 볼 수 있습니다. inline 만 붙인 shout 를 직접 써 봤습니다.
inline fun shout(s: String) { System.out.println(s) }
fun main() { shout("hi") } // hi
컴파일하면 경고가 하나 납니다.
$ kotlinc Shout.kt -d out
Shout.kt:1:1: warning: expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types.
inline fun shout(s: String) { System.out.println(s) }
^^^^^^
경고는 함수 타입 인자가 없는 함수는 인라인해도 이득이 작다는 뜻입니다.
몸통이 어떻게 됐는지 CFR 로 되돌려 보고, stdlib 없이 돌렸습니다.
$ java -jar cfr.jar out/ShoutKt.class
...
public static final void main() {
String s$iv = "hi";
boolean $i$f$shout = false;
System.out.println(s$iv);
}
...
$ java -cp out ShoutKt
main() 에는 shout("hi") 호출이 없고, 복사된 System.out.println 이 들어 있습니다.
s$iv 와 $i$f$shout 는 몸통을 복사하면서 kotlinc 가 넣은 지역 변수입니다. stdlib 없이도 돕니다.
2절에서 자바 컴파일러가 호출부에 복사한 것은 상수뿐이었습니다. kotlinc 는 inline 함수의 몸통까지 복사합니다.
그렇다고 인라인이면 stdlib 이 필요 없는 것은 아닙니다. Todo.kt 는 fun main() { TODO() } 한 줄이고, TODO() 는 아직 구현하지 않았다는 예외를 던지는 stdlib 인라인 함수입니다.
$ kotlinc Todo.kt -d out
$ java -cp out TodoKt 2>&1 | grep -v '^[[:space:]]'
Error: Unable to initialize main class TodoKt
Caused by: java.lang.NoClassDefFoundError: kotlin/NotImplementedError
grep -v '^[[:space:]]' 는 공백으로 시작하는 스택 트레이스 줄을 뺍니다.
몸통은 복사됐어도 그 몸통이 만드는 kotlin.NotImplementedError 를 찾지 못해 멈췄습니다. println 이 stdlib 없이 돈 것은 복사된 몸통이 java. 로 시작하는 JDK 클래스만 부르기 때문입니다. kotlin. 으로 시작하는 클래스는 JDK 에 없고 stdlib 에 있습니다.
그 줄이 실행될 때 필요하다
그렇다면 kotlin. 클래스를 부르는 줄이 파일에 하나라도 있으면 멈출까요. 함수 다섯을 둔 Lazy.kt 를 자바에서 stdlib 없이 불러 봅니다.
fun maybe(flag: Boolean) {
if (flag) println(listOf(1, 2))
println(label("maybe ok"))
}
private fun label(s: String): String { return "[" + s + "]" }
fun twice(n: Int): Int { return n * 2 }
fun echo(s: String?): String? { return s }
fun greet(name: String): String { return "hello, " + name }
listOf(1, 2)— 읽기 전용 리스트를 만드는 stdlib 함수입니다private fun— 이 파일 안에서만 보이는 함수입니다.Int는 자바의int가 됩니다String?— 타입 뒤의?는null을 허용한다는 뜻입니다. 물음표가 없는String은null을 받지 않습니다
먼저 각 함수 안이 어떻게 됐는지 자바 코드로 되돌렸습니다.
$ kotlinc Lazy.kt -d out
$ javac -cp out -d out Call.java
$ java -jar cfr.jar out/LazyKt.class
...
public final class LazyKt {
public static final void maybe(boolean flag) {
if (flag) {
Object[] objectArray = new Integer[]{1, 2};
System.out.println(CollectionsKt.listOf((Object[])objectArray));
}
System.out.println((Object)LazyKt.label("maybe ok"));
}
private static final String label(String s) {
return '[' + s + ']';
}
public static final int twice(int n) {
return n * 2;
}
@Nullable
public static final String echo(@Nullable String s) {
return s;
}
@NotNull
public static final String greet(@NotNull String name) {
Intrinsics.checkNotNullParameter((Object)name, (String)"name");
return "hello, " + name;
}
}
두 가지를 봅니다.
maybe안의listOf는 stdlib 의CollectionsKt.listOf호출이 됐고,if (flag)안에 있습니다greet에만Intrinsics.checkNotNullParameter가 붙었습니다. 코틀린 컴파일러가null을 받지 않는 인자에 심는 널 검사로, 누가null을 넘기면 첫 줄에서 멈추게 합니다
이 검사를 담은 kotlin.jvm.internal.Intrinsics 도 stdlib 클래스입니다.
출력에 섞인 @NotNull·@Nullable 은 컴파일러가 붙인, 널 허용 여부 표시 애노테이션입니다.
public 함수가 null 을 받지 않는 참조 타입 인자를 받을 때 검사가 붙습니다. label 은 private, twice 는 int, echo 는 String? 라서 검사가 없습니다.
fun main(args: Array<String>) 처럼 인자를 받는 main 에도 같은 검사가 붙습니다. Array<String> 은 자바의 String[] 입니다.
자바 쪽 Call 은 LazyKt.maybe(false), LazyKt.twice(3), LazyKt.echo(null), LazyKt.greet("x") 를 차례로 부릅니다. stdlib 없이 돌렸습니다.
$ java -cp out Call 2>&1 | head -5
[maybe ok]
6
null
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics
at LazyKt.greet(Lazy.kt)
세 줄을 찍고 greet 의 널 검사에서 멈췄습니다. maybe 에는 CollectionsKt 를 부르는 줄이 있었지만 flag 가 false 라 실행되지 않았고, 그래서 그 클래스를 찾지도 않았습니다.
stdlib 은 kotlin. 클래스를 부르는 줄이 실행될 때 필요합니다.
예외가 하나 있습니다. 앞의 TodoKt 는 main 이 시작되기도 전에, 실행기가 main 클래스를 준비하는 단계(Unable to initialize main class)에서 먼저 멈췄습니다. 줄이 실행되기 전에 클래스를 찾는 경우도 있는 것입니다.
kotlin 명령은 stdlib 을 알아서 넣는다
kotlin 명령(코틀린 배포판의 실행기)으로 돌리면 jar 를 적지 않아도 됩니다. classpath 를 찍는 fun main() { println(System.getProperty("java.class.path")) } 한 줄을 Cp.kt 에 두고 돌렸습니다.
$ kotlinc Cp.kt -d out
$ kotlin -cp out CpKt | tr ':' '\n' | grep -o '[^/]*\.jar'
kotlin-stdlib.jar
kotlin-reflect.jar
tr ':' '\n' | grep -o '[^/]*\.jar'—:로 이어진 classpath 를 줄마다 가르고 jar 파일 이름만 남깁니다
kotlin 실행기는 stdlib 을 classpath 에 스스로 넣습니다. java 로 돌릴 때는 0절처럼 직접 넣어야 합니다.
println("hello") 한 줄은 실행되는 코드가 java. 클래스만 불러서 돈 쪽입니다. 실제 프로그램은 널 검사나 컬렉션 함수를 곧 만나므로, stdlib 을 함께 싣는다고 생각하는 편이 맞습니다. stdlib 에 무엇이 들었고 자바 라이브러리와 어떻게 이어지는지는 이 시리즈의 뒤쪽 편에서 다룹니다.
5. 한 장 요약
자바 컴파일러는
const val같은 컴파일 시점 상수를 호출부에 복사하고,kotlinc는inline함수의 몸통까지 복사한다. 톱레벨 선언이 사는 클래스 이름은 파일 이름에서 오므로 파일 이름을 바꾸면 쓰는 쪽이 조용히 깨진다. stdlib 은kotlin.클래스를 부르는 줄이 실행될 때 필요하다.
| 질문 | 답 | 확인한 방법 |
|---|---|---|
| 톱레벨 선언을 자바에서 어떻게 부르나 | HelloKt.greet("x") · HelloKt.getGreeting() · HelloKt.MAX_NAME |
javac + java |
const val 을 자바에서 읽으면 |
호출부에 값 10 이 복사된다. 값을 바꾸면 호출부도 다시 컴파일 |
Main.class 를 CFR 로 |
| 파일 이름을 바꾸면 | 자바는 정의하는 쪽 컴파일에서 멈추고, 코틀린은 클래스 이름이 조용히 바뀌어 쓰는 쪽이 깨진다 | mv 후 javac |
| 이름을 고정하려면 | 파일 맨 위, package 보다 위에 @file:JvmName("이름") |
파일 이름을 바꿔 두 번 컴파일 |
println 은 왜 stdlib 없이 도나 |
inline 함수라 몸통이 복사되고, 그 몸통이 java. 클래스만 부른다. @InlineOnly 는 원본을 private 으로 숨길 뿐 |
HelloKt · ShoutKt 를 CFR 로 |
| 인라인이면 늘 stdlib 이 필요 없나 | 아니다. TODO() 는 복사된 몸통이 kotlin.NotImplementedError 를 만들어 멈춘다 |
Todo.kt 를 stdlib 없이 실행 |
| 널 검사는 어디에 붙나 | public 함수의 null 을 받지 않는 참조 타입 인자. private·Int·String? 에는 없다 |
LazyKt 를 CFR 로 |
| stdlib 은 언제 필요한가 | kotlin. 클래스를 부르는 줄이 실행될 때. 실행되지 않은 listOf 줄은 통과한다 |
자바에서 LazyKt 호출 |
kotlin 명령은 |
stdlib 을 classpath 에 스스로 넣는다 | classpath 의 jar 이름 출력 |
관련 항목
자바에서 코틀린을 부를 때 거치는 처리 단계
javac · kotlinc · 클래스 파일 · 클래스패스 · 클래스 로더 · 자바 실행기 · JVM
클래스 파일을 열어 보는 도구
javap · 디컴파일 · 디컴파일러 · CFR · JAR
톱레벨 선언이 번역되는 클래스 파일 멤버
톱레벨 함수 · 톱레벨 프로퍼티 · 정적 메서드 · 정적 필드 · 게터 · 컴파일 타임 상수
자바에서 보이는 클래스 이름을 정하는 애노테이션
@JvmName · @JvmMultifileClass · 파일 애노테이션
호출부에 코드를 복사해 넣는 방식
인라인 함수 · 상수 접기 · JIT 컴파일러
코틀린 클래스 실행에 필요한 런타임과 빠지면 나는 오류
코틀린 표준 라이브러리 · 널 안전성 · NoClassDefFoundError · ClassNotFoundException