인라인 함수와 바이트코드 최적화
인라인 함수와 바이트코드 최적화
코틀린의 inline 키워드는 JVM 언어 설계에서 근본적인 딜레마를 해결합니다. 고차 함수(higher-order function)와 같은 고수준 추상화를 제공하면서도 익명 클래스 할당과 가상 디스패치(virtual dispatch)에 따르는 런타임 비용을 제거해야 한다는 문제입니다. 컴파일러는 함수 본문과 모든 람다 인자를 호출 지점(call site)에 직접 삽입하여, 개발자가 직접 명령형 코드를 작성한 것과 구조적으로 동일한 바이트코드를 생성합니다. 이 변환이 바이트코드 수준에서 어떻게 동작하는지, 그리고 noinline, crossinline, reified 같은 관련 수정자가 이 변환과 어떻게 상호작용하는지를 이해하는 것은 성능이 뛰어난 관용적 코틀린을 작성하는 데 필수적입니다. 인라인 함수는 실제 면접에서도 자주 출제되는 주제이며, 단순히 "성능이 좋아진다" 수준이 아니라 바이트코드 변환 원리까지 설명할 수 있어야 깊이 있는 답변이 됩니다.
이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.
- 일반 고차 함수 호출과 인라인 호출 사이의 바이트코드 차이를 추적하는 방법
- 인라인이
Function객체 할당과 가상 메서드 디스패치를 제거하는 이유 - 인라인을 통해 비지역 반환(non-local return)이 가능해지는 원리와, 인라인 없이는 금지되는 이유
noinline과crossinline이 개별 람다 매개변수의 인라인 동작을 제어하는 역할reified타입 매개변수를 활용하여 런타임에서 제네릭 타입 정보에 접근하는 방법
JVM에서의 일반 고차 함수
JVM에서는 함수가 일급 시민(first-class citizen)이 아닙니다. 코틀린이 일반(비인라인) 고차 함수에 전달되는 람다 표현식을 컴파일할 때, 해당 람다를 반드시 객체로 표현해야 합니다. 컴파일러는 kotlin.jvm.functions.FunctionN 인터페이스 중 하나를 구현하는 익명 내부 클래스를 생성하고, 호출 지점에서 해당 클래스의 인스턴스를 생성합니다. 즉, 람다를 사용할 때마다 객체 생성 비용이 발생하는 것입니다.
다음 코틀린 소스 코드를 살펴보겠습니다.
fun standardAction(block: () -> Unit) {
println("Before")
block()
println("After")
}
fun main() {
standardAction { println("Executing lambda") }
}
main() 함수를 디컴파일한 Java 코드를 보면 숨겨진 비용이 드러납니다.
// main() 디컴파일 결과:
Function0 lambdaInstance = new Function0() {
public void invoke() {
System.out.println("Executing lambda");
}
};
standardAction(lambdaInstance);
위 코드에서 두 가지 오버헤드를 확인할 수 있습니다. 첫째, 호출 지점이 실행될 때마다 Function0 객체가 힙(heap)에 할당됩니다. 타이트한 루프나 빈번하게 호출되는 유틸리티 함수에서는 가비지 컬렉터(garbage collector)에 지속적인 부담을 주게 됩니다. 둘째, 람다 호출 시 Function0.invoke()를 통한 가상 메서드 호출이 필요합니다. JVM은 런타임에 vtable 디스패치를 통해 구체적인 구현체를 찾아야 하므로, 특정 JIT 최적화를 방해하고 매 호출마다 간접 참조 계층이 추가됩니다.
인라인 함수와 복사-붙여넣기 변환
inline 키워드는 코틀린 컴파일러에게 함수 호출과 람다 객체를 모두 제거하도록 지시합니다. 호출 명령을 생성하는 대신, 컴파일러가 인라인 함수의 본문과 모든 람다 인자의 본문을 호출 지점에 직접 복사합니다. 쉽게 말해서, 컴파일러가 개발자 대신 코드를 "복사-붙여넣기"해 주는 것입니다.
inline fun inlineAction(block: () -> Unit) {
println("Before")
block()
println("After")
}
fun main() {
inlineAction { println("Executing lambda") }
}
main() 함수의 디컴파일 결과를 보면 평탄한(flat) 문장의 나열로 변환된 것을 확인할 수 있습니다.
// main() 디컴파일 결과:
System.out.println("Before");
System.out.println("Executing lambda");
System.out.println("After");