inline 키워드의 역할이 무엇이냐는 질문을 하면, 대부분 "함수의 본문을 컴파일 타임에 호출 위치로 복사하여 함수 호출 오버헤드를 제거하는 것"이라고 답할 것입니다. 하지만 이러한 설명은 inline 키워드의 진정한 가치를 완전히 설명하지 못합니다.
우리가 주목해야 할 점은 "inline의 최적화 대상이 무엇인가?"입니다. inline 키워드가 진정으로 빛을 발하는 상황과 그 이유를 자세히 살펴보겠습니다.
inline 함수는 컴파일 타임에 호출 지점으로 함수의 본문이 복사됩니다. 이는 런타임에 함수 호출 스택을 생성하지 않고도 해당 기능을 수행할 수 있게 해줍니다.
하지만 단순한 함수에서는 inline의 효과가 미미합니다. 다행히도 IDE는 매우 스마트하여, 아래와 같이 성능 향상에 무의미한 코드를 작성하면 경고를 표출해줍니다.
fun main() {
greet(target = "kmkim")
}
private inline fun greet(target: String) {
println("Hello $target")
}
이 코드를 Java로 디컴파일하면 다음과 같습니다.
public static final void main() {
String target$iv = "kmkim";
int $i$f$greet = 0;
System.out.println("Hello " + target$iv);
}
private static final void greet(String target) {
int $i$f$greet = 0;
System.out.println("Hello " + target);
}
함수 호출은 인라인되었지만, 성능 향상은 미미합니다. IDE가 "Expected performance impact from inlining is insignificant"라고 경고하는 이유입니다.
Kotlin에서 람다 표현식은 내부적으로 Function 인터페이스를 구현한 객체로 변환됩니다. 해당 인터페이스의 invoke operator 함수에 동작이 정의되며, 사용하는 곳에서는 invoke를 호출하여 함수를 실행시킬 수 있습니다.
fun main() {
var target = "kmkim"
greet(target = target, onFinish = { target = "kame" })
}
private fun greet(
target: String,
onFinish: () -> Unit,
) {
println("Hello $target")
onFinish()
}
public static final void main() {
final Ref.ObjectRef target = new Ref.ObjectRef();
target.element = "kmkim";
greet((String)target.element, new Function0() {
public final void invoke() {
target.element = "kame";
}
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
});
}
private static final void greet(String target, Function0 onFinish) {
System.out.println("Hello " + target);
onFinish.invoke();
}
이 때, 다음과 같은 비용들이 발생합니다.
이전 greet 함수에 inline 키워드를 추가해보겠습니다.
fun main() {
var target = "kmkim"
greet(target = target, onFinish = { target = "kame" })
}
private inline fun greet(
target: String,
onFinish: () -> Unit,
) {
println("Hello $target")
onFinish()
}
inline이 적용된 후 디컴파일된 Java 코드는 다음과 같습니다.
public static final void main() {
Object target = null;
Object target$iv = "kmkim";
int $i$f$greet = 0;
System.out.println("Hello " + target$iv);
int var3 = 0;
target$iv = "kame"; // 람다 본문이 직접 인라인됨
}
여기서 세 가지 변화를 확인할 수 있습니다.
inline 키워드를 활용했을 때와 활용하지 않았을 때의 함수 소요 시간을 비교해보겠습니다.
fun main() {
val start = System.currentTimeMillis()
var target = "kmkim"
greet(target = target, onFinish = { target = "kame" })
val end = System.currentTimeMillis()
println(end - start)
}
실제 성능 차이를 측정해보니, 다음과 같이 소요 시간이 단축됨을 확인할 수 있었습니다.
greet 함수에 inline 키워드가 없을 때 : 32 출력
greet 함수에 inline 키워드가 있을 때 : 4 출력
함수 타입이 아닌 일반 객체를 매개변수로 사용하는 경우는 어떨지 살펴보겠습니다. Function 타입과 마찬가지로 힙에 할당되는 객체라면, 마찬가지로 성능 향상에 유의미한 변화가 있어야 하지 않을까 하는 의문을 가질 수 있을 것입니다.
class Task(private val action: () -> Unit) {
fun execute() {
action()
}
}
private inline fun greet(
target: String,
task: Task
) {
println("Hello $target")
onFinish.execute()
}
fun main() {
var target = "kmkim"
greet(target = target, task = Task { target = "kame" })
}
이 경우 디컴파일된 코드는 다음과 같습니다.
public static final void main() {
// ...
Task task$iv = new Task(new Function0() { // 여전히 Function 객체 생성!
public final void invoke() {
target.element = "kame";
}
});
// ...
}
이 코드를 실행해 보았을 때, 소요 시간이 대략 16~20ms 사이에서 형성됩니다. 그렇다면 inline 코드를 제거하면 소요 시간이 더 늘어날 것이라고 생각할 수도 있습니다.
하지만 그렇지 않습니다. 여러 번 실행해보면 inline 키워드를 붙였을 때와 비슷한 시간이 소요되고, 어떤 때는 inline 키워드가 있을 때보다 더 적은 시간이 소요되기도 합니다.
실제로 해당 함수에 inline 키워드를 추가했을 때, IDE가 아래와 같이 경고를 표시해 줍니다.
Task 객체 자체는 인라인되지만, 그 안의 람다는 여전히 Function 객체로 생성됩니다. 따라서 성능 향상 효과가 제한적입니다.
결국 inline 키워드는 단순히 함수 본문을 복사해 붙여 넣는다는 의미를 넘는 역할을 수행해줍니다. 일반적으로 람다를 함수에 전달하면 람다 객체가 생성되지만, inline 함수를 사용하면 이 객체 생성을 생략할 수 있도록 컴파일러가 최적화해 줍니다.
즉, inline은 컴파일 타임에 람다 매개변수를 포함한 함수 호출 코드를 실제 호출 위치에 인라인으로 확장하면서 변형합니다. 이 덕분에 불필요한 객체 생성을 줄이고, 실행 성능을 높일 수 있습니다. 따라서 inline의 효과는 매개변수로 람다 함수를 활용할 때 더욱 두드러지게 나타납니다.
inline 함수는 자신보다 더 제한적인 가시성을 가진 함수나 클래스에 접근할 수 없습니다.
private fun privateFunction() = "private"
inline fun publicInlineFunction() {
privateFunction() // 컴파일 에러!
// public inline 함수가 private 함수를 호출할 수 없음
}
이는 inline 함수가 호출 지점으로 복사되면서, 원래 접근할 수 없던 private 멤버에 접근하게 될 수 있기 때문입니다.
inline 키워드에 재귀 호출을 허용하면 컴파일 시점에 무한히 코드가 확장될 수 있습니다. 따라서 컴파일러 측에서 해당 방식을 활용했을 때 에러를 발생시킵니다.
inline fun factorial(n: Int): Int {
return if (n <= 1) 1
else n * factorial(n - 1) // 컴파일 에러! 무한 인라인 확장
}
inline fun process(action: () -> Unit) {
action()
}
fun someFunction() {
println("Function reference")
}
fun main() {
process(::someFunction) // 함수 참조는 여전히 객체 생성
process { println("Lambda") } // 이건 최적화됨
}
fun anotherFunction(callback: () -> Unit) {
callback()
}
inline fun wrapper(action: () -> Unit) {
anotherFunction(action) // action이 다른 함수로 전달되면 객체 생성 필요
}
inline + 함수 타입 매개변수조합은 항상 필승 전략일까?
우리가 정의하는 inline 함수에는 여러 가지 경우의 수가 존재합니다.
inline 함수는 디버깅을 어렵게 만들 수 있습니다.
fun main() {
try {
callInlineFunction()
} catch (e: Exception) {
e.printStackTrace() // 스택 트레이스가 부정확할 수 있음
}
}
private inline fun callInlineFunction() {
throw IllegalStateException("Something went wrong!")
}
실행 결과는 다음과 같습니다.
java.lang.IllegalStateException: Something went wrong!
at study.MainKt.main(main.kt:14) // 실제로는 존재하지 않는 라인!
14번째 줄은 없는데, 14번째 줄에서 예외가 발생했다고 알려주고 있습니다. 물론 IDE는 매우 똑똑하여 아래 사진과 같은 옵션을 제공해주지만, 함수가 복잡해지면 이 방식에도 한계가 있을 것입니다.
반면 inline을 제거하면, 예외 발생의 정확한 위치가 표시됩니다.
java.lang.IllegalStateException: Something went wrong!
at study.MainKt.callInlineFunction(main.kt:12)
at study.MainKt.main(main.kt:5)
Kotlin의 inline 키워드는 단순한 함수 복사 도구가 아닙니다. 함수 타입 매개변수를 받는 고차 함수에서 람다 객체 생성을 최적화하는 강력한 메커니즘입니다.
inline 키워드를 활용할 때 고려해야 할 핵심 포인트들을 정리하면 다음과 같습니다.
inline을 남용하지 말고, 적절한 상황에서 측정을 통해 검증한 후 사용한다면, 성능과 코드 품질 모두를 향상시킬 수 있습니다. 특히 라이브러리, SDK 등을 개발하고 있다면 inline 키워드를 제대로 이해하고 활용해야 사용자에게 더 나은 성능을 제공할 수 있을 것입니다.