코틀린 컴파일러와 바이트코드 — 코틀린 코드가 JVM에서 실행되기까지
"코틀린 코드를 작성할 때 편리한 기능들이 많은데, 이 기능들이 실제로 어떤 바이트코드로 변환되는지 알고 있으면 성능 이슈를 미리 잡을 수 있지 않을까요?"
코틀린은 JVM 위에서 동작하므로, 결국 자바 바이트코드로 변환됩니다. 코틀린의 편리한 문법 뒤에 어떤 바이트코드가 숨어 있는지 알면, 불필요한 객체 생성이나 성능 비용을 이해할 수 있습니다. IntelliJ의 디컴파일 기능을 활용해서 직접 확인해보겠습니다.
코틀린 컴파일 파이프라인
코틀린 코드가 실행되기까지의 과정입니다.
소스 코드 (.kt)
↓
[프론트엔드] 파싱 → AST → 의미 분석 → FIR (K2)
↓
[백엔드] IR(Intermediate Representation) 생성
↓
[코드 생성] JVM 바이트코드 (.class)
↓
[JVM] 클래스 로딩 → JIT 컴파일 → 네이티브 코드 실행
K1 vs K2 컴파일러
| 구분 | K1 (기존) | K2 (새로운) |
|---|---|---|
| 프론트엔드 | PSI 기반 | FIR(Front-end IR) 기반 |
| 컴파일 속도 | 기준 | 약 2배 빠름 |
| 타입 추론 | 제한적 | 개선된 알고리즘 |
| 상태 | 유지보수 | Kotlin 2.0부터 기본 |
K2 컴파일러는 새로운 프론트엔드 아키텍처(FIR)를 도입하여, 타입 추론과 의미 분석 성능을 크게 개선했습니다.
// build.gradle.kts — K2 활성화 (Kotlin 2.0 이전)
kotlin {
compilerOptions {
languageVersion.set(KotlinVersion.KOTLIN_2_0)
}
}
디컴파일로 바이트코드 확인하기
IntelliJ에서 코틀린 바이트코드를 확인하는 방법입니다.
.kt파일을 엽니다Tools → Kotlin → Show Kotlin Bytecode를 선택합니다Decompile버튼을 클릭하면 자바 코드로 변환된 결과를 볼 수 있습니다
주요 코틀린 기능의 바이트코드 변환
Null Safety
fun greet(name: String) { // non-null 파라미터
println("Hello, $name")
}
디컴파일 결과:
public static final void greet(@NotNull String name) {
Intrinsics.checkNotNullParameter(name, "name");
System.out.println("Hello, " + name);
}
Intrinsics.checkNotNullParameter()가 자동으로 삽입되어, 자바에서 null을 전달하면 즉시 IllegalArgumentException이 발생합니다. 이 체크는 매우 가볍지만, 핫 패스에서 수백만 번 호출되는 함수라면 @Suppress("NOTHING_TO_INLINE")이나 내부 API에서 고려할 수 있습니다.
data class
data class User(val name: String, val age: Int)
디컴파일하면 다음이 자동 생성됩니다.
public final class User {
@NotNull private final String name;
private final int age;
// 생성자
public User(@NotNull String name, int age) { ... }
// getter
@NotNull public final String getName() { return this.name; }
public final int getAge() { return this.age; }
// equals — 모든 프로퍼티 비교
public boolean equals(@Nullable Object other) { ... }
// hashCode — 모든 프로퍼티 기반
public int hashCode() { ... }
// toString
@NotNull public String toString() {
return "User(name=" + this.name + ", age=" + this.age + ")";
}
// copy
@NotNull public final User copy(@NotNull String name, int age) { ... }
// componentN
@NotNull public final String component1() { return this.name; }
public final int component2() { return this.age; }
}
프로퍼티가 많은 data class는 그만큼 많은 메서드가 생성됩니다. 메서드 수 제한이 있는 Android에서는 주의가 필요합니다.
companion object
class UserRepository {
companion object {
const val TABLE_NAME = "users"
@JvmStatic
fun create(): UserRepository = UserRepository()
}
}
디컴파일 결과:
public final class UserRepository {
@NotNull public static final String TABLE_NAME = "users";
@NotNull public static final Companion Companion = new Companion(null);
// @JvmStatic이 있으므로 static 위임 메서드 생성
@NotNull
public static final UserRepository create() {
return Companion.create();
}
// 내부 클래스
public static final class Companion {
@NotNull
public final UserRepository create() {
return new UserRepository();
}
}
}
const val은 진짜 static final 필드로 변환되지만, companion object의 일반 프로퍼티는 Companion 클래스를 통해 접근합니다. @JvmStatic이 있으면 외부 클래스에 static 위임 메서드가 추가됩니다.
람다와 고차 함수
fun processList(list: List<Int>, transform: (Int) -> Int): List<Int> {
return list.map(transform)
}
// 호출
val result = processList(listOf(1, 2, 3)) { it * 2 }
디컴파일하면 람다가 Function1 인터페이스를 구현하는 익명 클래스로 변환됩니다.
// 람다 → Function1 구현체
Function1 transform = new Function1() {
public Object invoke(Object p1) {
return ((Number)p1).intValue() * 2;
}
};
inline 함수를 사용하면 이 Function 객체 생성을 피할 수 있습니다.
inline fun processList(list: List<Int>, transform: (Int) -> Int): List<Int> {
return list.map(transform)
}
inline 함수는 호출 지점에 함수 본문이 복사되므로, Function 객체가 생성되지 않습니다.
when 표현식
fun describe(value: Any): String = when (value) {
is Int -> "정수: $value"
is String -> "문자열: $value"
is List<*> -> "리스트 크기: ${value.size}"
else -> "알 수 없음"
}
타입 체크가 포함된 when은 instanceof 체인으로 변환됩니다.
public static final String describe(Object value) {
if (value instanceof Integer) {
return "정수: " + value;
} else if (value instanceof String) {
return "문자열: " + value;
} else if (value instanceof List) {
return "리스트 크기: " + ((List)value).size();
} else {
return "알 수 없음";
}
}
반면 Int나 enum을 대상으로 하는 when은 JVM의 tableswitch나 lookupswitch로 변환되어 더 효율적입니다.
문자열 템플릿
val greeting = "Hello, $name! You are $age years old."
디컴파일 결과:
String greeting = "Hello, " + name + "! You are " + age + " years old.";
단순한 경우 문자열 연결로 변환되고, 복잡한 경우 StringBuilder를 사용합니다. Kotlin 2.0부터는 StringConcatFactory(JDK 9+)를 활용한 최적화가 적용됩니다.
확장 함수
fun String.addExclamation(): String = "$this!"
디컴파일 결과:
@NotNull
public static final String addExclamation(@NotNull String $this$addExclamation) {
Intrinsics.checkNotNullParameter($this$addExclamation, "<this>");
return $this$addExclamation + "!";
}
확장 함수는 첫 번째 파라미터로 수신 객체를 받는 static 메서드로 변환됩니다. 런타임 오버헤드가 없습니다.
코루틴
suspend fun fetchData(): String {
delay(1000)
return "data"
}
코루틴의 바이트코드 변환은 복잡합니다. 별도의 글(코루틴 내부 동작 편)에서 상세히 다루고 있지만, 핵심을 요약하면 다음과 같습니다.
Continuation파라미터가 추가됩니다- 중단점마다 상태 머신의
label이 할당됩니다 - 하나의 상태 머신 객체가 중간 상태를 필드로 저장합니다
성능에 영향을 주는 패턴
주의: 범위 연산자와 객체 생성
for (i in 1..1000000) { /* ... */ } // IntRange 객체 생성 없이 최적화됨
for (i in list.indices) { /* ... */ } // 동일하게 최적화됨
// 하지만
val range = 1..1000000
for (i in range) { /* ... */ } // IntRange 객체가 힙에 생성될 수 있음
주의: 박싱 오버헤드
val list: List<Int> = listOf(1, 2, 3) // Integer 박싱 발생
val array: IntArray = intArrayOf(1, 2, 3) // 원시 타입, 박싱 없음
제네릭은 타입 파라미터가 객체여야 하므로, List<Int>의 원소는 Integer로 박싱됩니다. 성능이 중요한 곳에서는 IntArray, LongArray 등을 사용합니다.
주의: 프로퍼티 접근
class User(val name: String) // getter 메서드를 통해 접근
class OptimizedUser(@JvmField val name: String) // 필드 직접 접근
@JvmField를 사용하면 getter 없이 필드에 직접 접근하므로 미세한 성능 이점이 있습니다. 다만 캡슐화가 깨지므로, 내부 API에서만 사용하는 것이 좋습니다.
바이트코드를 확인해야 하는 상황
모든 코드의 바이트코드를 확인할 필요는 없습니다. 다음 상황에서 확인하면 유용합니다.
- 성능 핫스팟: 프로파일러에서 특정 함수의 CPU 사용량이 높을 때
- 자바 호환성: 코틀린 라이브러리를 자바에서 사용해야 할 때
- 인라인 효과 확인: inline 함수가 제대로 인라이닝되는지 확인할 때
- 코루틴 디버깅: suspend 함수의 상태 머신을 이해해야 할 때
정리
- 코틀린 코드는 프론트엔드(파싱, 분석) → IR 생성 → JVM 바이트코드 생성 순서로 컴파일됩니다
- K2 컴파일러는 FIR 기반의 새로운 프론트엔드로 컴파일 속도가 약 2배 향상되었습니다
- null safety는
Intrinsics.checkNotNullParameter()삽입으로 구현됩니다 - data class는 equals, hashCode, toString, copy, componentN 메서드를 자동 생성합니다
- companion object는 내부 static 클래스로 변환되며,
@JvmStatic이 있으면 static 위임 메서드가 추가됩니다 - 확장 함수는 첫 번째 파라미터로 수신 객체를 받는 static 메서드로, 오버헤드가 없습니다
- inline 함수는 Function 객체 생성을 피할 수 있어, 고차 함수의 성능 비용을 제거합니다
- IntelliJ의
Tools → Kotlin → Show Kotlin Bytecode → Decompile로 언제든 확인할 수 있습니다