[Kotlin] 함수 반환 원리와 함께 이해해 보는 inline, noinline, crossinline

Kame·2025년 8월 17일

Kotlin

목록 보기
9/9
post-thumbnail

들어가며

이 글을 끝까지 읽는다면, Kotlin의 inline을 활용하면서 마주치는 다양한 키워드들의 의미를 알 수 있게 될 것입니다.

  • local return vs non-local return
  • noinline, crossinline
  • reified

선행 지식

함수 반환 방식

Local return

가장 일반적인 반환입니다. 현재 실행하고 있는 함수의 실행을 종료하고, 반환 타입이 있다면 반환값과 함께 반환을 하는 방식입니다.

fun localReturn() {
	println("Local function starts!")
	return // local return
		
	println("This statement is never executed") // not executed!
}

위 예시에서, return은 localReturn() 이라는 함수 내부에서 직접적으로 호출되었습니다. 이 때 현재 실행되고 있는 함수는 더 이상 실행되지 않음을 확인할 수 있습니다.

그렇다면 만약 특정 함수 안에서 또 다른 함수를 정의하는 경우라면 어떨지도 쉽게 추측해볼 수 있을 것입니다.

드물지만 먼저 함수 내부에 다른 지역 함수(local function)를 정의하고, 그 곳에서 반환문을 실행하는 상황을 생각해 보겠습니다.

fun outerFunction() {
    println("Outer function starts!")

    fun innerFunction() {
        println("Inner function starts!")
        return // local return

        println("This statement is never executed") // not executed!
    }
    
    innerFunction()
    println("Outer function finishes!")
}

다른 경우로, 함수 내부에서 다른 함수를 호출할 때 매개변수로 함수 형태를 넘겨주어야 하는 경우가 있습니다. 예시로 활용할 함수를 다음과 같이 정의해 보겠습니다.

fun innerFunction(
    doSomething: () -> Unit
) {
    println("Inner function starts!")
    doSomething()
    println("Inner function finishes!")
}

이 때 이 함수를 호출할 수 있는 방법은 두 가지가 있습니다.

첫 번째는, 람다 표현식을 활용하는 것입니다. 코틀린 사용자에게 대체로 익숙한 방식입니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
    }
}

두 번째로, 익명 함수를 넘겨줄 수도 있습니다.

fun outerFunction() {
    innerFunction(
        fun () {
            println("I'll do something in an anonymous function.")
        }
    )
}

두 방식은 컴파일러 관점에서 아무런 차이가 없습니다. 아래 코드를 디컴파일 해보면, 다음과 같이 동일한 방식으로 Function 형태의 객체를 넘겨 호출을 진행함을 확인할 수 있습니다. (Function 형태를 넘기는 모습에 대한 자세한 사항은 이전 편에서 설명하였습니다.)

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
    }
    
    innerFunction(
        fun () {
            println("I'll do something in an anonymous function.")
        }
    )
}
public static final void outerFunction() {
	  innerFunction(null.INSTANCE);
	  innerFunction(null.INSTANCE);
}

하지만 두 방식의 차이는 innerFunction에서 local return을 해야 하는 상황에서 드러납니다.

각 호출에 return문을 추가하여 innerFunction 내부에서 실행될 함수(doSomething)를 종료시켜 보겠습니다.(당연한 사실이지만, 이 return문은 innerFunction 함수 자체의 실행을 종료시키지 않음에 유의해야 합니다.)

놀라운 점은 같아보이는 두 호출 중 한 곳에서는 컴파일 에러가 발생한다는 것입니다.

잠시 스크롤을 멈추고 어디서 컴파일 에러가 발생하는지, 그리고 그 이유는 무엇인지 생각해 보시길 바랍니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        return // local return?
        println("This statement is never executed!")
    }

    innerFunction(
        fun () {
            println("I'll do something in an anonymous function.")
            return // local return!
            println("This statement is never executed!")
        }
    )
}

정답을 알기 위해서는, 람다식의 원리를 생각해 봐야 합니다.

일반적으로 람다식에서는 return 키워드를 직접 사용할 수 없습니다. 람다식과 익명 함수 사이에 적용되는 반환 방식이 다르기 때문입니다.

코틀린에서 람다식은 일반적인 함수가 아니라 중괄호로 이뤄진 표현식으로, 식에서 단순히 return을 호출한다고 해도 어느 함수를 종료시켜야 하는지 모호해집니다.

fun ambiguousReturn() {
    val lambda = {
        return // lambda를 종료? ambiguousReturn()을 종료?
    }
}

반면 익명함수는 독립적인 함수 스코프를 가집니다. 익명함수는 fun 키워드로 시작하는 완전한 함수이므로, 자체적인 스코프를 가지고 있어 return의 대상이 명확하므로 local return이 가능했던 것입니다.

fun clearReturn() {
    val anonymousFunc = fun() {
        return *// finishes anonynous function only!*
    }
}

따라서 람다에서 return을 사용하려면 명시적으로 어떤 함수를 반환할 것인지 레이블링을 해주는 것이 필요합니다. 어떤 스코프로 반환할지를 명확히 지정해야 하기 때문입니다.

이 문제를 해결하기 위해, 다음과 같이 레이블을 활용해 볼 수 있습니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        return@innerFunction // local return with labeling! ✅
        println("This statement is never executed!")
    }
    
    println("---")

    innerFunction(
        fun () {
            println("I'll do something in an anonymous function.")
            return // local return! ✅
            println("This statement is never executed!")
        }
    )
}

Inner function starts!
I'll do something in a lambda expression.
Inner function finishes!
---
Inner function starts!
I'll do something in an anonymous function.
Inner function finishes!

결국 람다식을 활용할 때, local return을 구현하기 위해서는 레이블을 활용하여 명확히 어떤 함수로부터 벗어날 것인지 명시해야 합니다.

Non-local return

여기서 non-local은 ‘local만 반환하는 것이 아님’ 이라고 생각하면 이해가 쉽습니다.

즉, 람다 내부의 return이 그 람다를 감싸고 있는 바깥 함수까지 종료시킴을 의미합니다.

직전에 살펴보았던 예시를 변형해보겠습니다.

같은 방법으로 레이블을 명시하며, outerFunction이 반환되도록 return문을 활용해 보았습니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        return@outerFunction // non-local return??
        println("This statement is never executed!")
    }
    
    println("---")

    innerFunction(
        fun () {
            println("I'll do something in an anonymous function.")
            return@outerFunction // non-local return??
            println("This statement is never executed!")
        }
    )
}

fun innerFunction(
    doSomething: () -> Unit
) {
    println("Inner function starts!")
    doSomething()
    println("This statement should never be executed!") // non-local return
}

하지만 불행하게도, 이러한 접근은 불가능합니다.

그 이유들로, Kotlin 측의 설계 철학에 기반한 것으로 추측해 볼 수 있습니다.

  • 성능상의 이유
    • 일반 함수에서 non-local return을 지원하려면 런타임에 추가적인 예외 처리 메커니즘이 필요
      • 함수 호출마다 오버헤드를 발생
  • 명확성과 예측 가능성
    • 일반 함수에서 non-local return을 허용하면 코드의 실행 흐름을 예측하기 어려워짐

inline 키워드 활용하여 non-local return하기

같은 목적을 달성할 수 있는 방식으로는 여러가지가 있으나(스코프 함수+레이블 활용하기, boolean 반환값 활용하기 등), 이 상황에서는 inline 키워드를 활용하면 간편하게 이를 달성할 수 있습니다.

inline 함수의 경우 컴파일 시점에 람다의 내용이 호출하는 곳에 직접 붙여넣어집니다. 따라서 람다 내부의 return이 마치 바깥 함수에서 직접 호출된 것처럼 동작하기 때문에 이것이 가능한 것입니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        return@outerFunction // non-local return ✅
        println("This statement is never executed!")
    }
}

inline fun innerFunction(
    doSomething: () -> Unit
) {
    println("Inner function starts!")
    doSomething()
    println("This statement should NEVER be executed!")
}

Inner function starts!
I'll do something in a lambda expression.

자바로 디컴파일 한 코드를 확인해 보면, inline 함수의 원리에 따라 outerFunction 부분에서 return문 이후의 코드 자체가 존재하지 않음을 확인할 수 있습니다.

public static final void outerFunction() {
    int $i$f$innerFunction = 0;
    System.out.println("Inner function starts!");
    int var1 = 0;
    System.out.println("I'll do something in a lambda expression.");
}

public static final void innerFunction(@NotNull Function0 doSomething) {
    Intrinsics.checkNotNullParameter(doSomething, "doSomething");
    int $i$f$innerFunction = 0;
    System.out.println("Inner function starts!");
    doSomething.invoke();
    System.out.println("This statement should NEVER be executed!");
}

참고로 inline 키워드가 붙은 함수를 호출할 때는 람다식에서 non-local return을 할 때 return문에 레이블을 명시하지 않아도 됩니다. inline 함수의 원리에 따라, innerFunction 내부의 코드들이 return문을 포함하여 그대로 outerFunction으로 인라인 된 것으로 간주할 수 있기 때문입니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        return // non-local return ✅
        println("This statement is never executed!")
    }
}

inline fun innerFunction(
    doSomething: () -> Unit
) {
    println("Inner function starts!")
    doSomething()
    println("This statement should NEVER be executed!")
}

Inner function starts!
I'll do something in a lambda expression.

하지만 만약 innerFunction 내부에서 return문을 활용한다고 해도, non-local return으로 동작하지 않음에 유의해야 합니다. 추측하건대 이것이 가능해진다면, 특히나 여러 람다가 중첩되는 경우 모든 람다를 반환해버려, 개발자가 코드 흐름을 제어하기 힘들어지기 때문일 것입니다.

fun outerFunction() {
    innerFunction {
        println("I'll do something in a lambda expression.")
        println("Lambda expression finishes!")
    }
    println("This statement SHOULD be executed in outer function!")
}

inline fun innerFunction(
    doSomething: () -> Unit
) {
    println("Inner function starts!")
    doSomething()
    return // local return - innerFunction only
    println("This statement should NEVER be executed in inner function!")
}

Inner function starts!
I'll do something in a lambda expression.
Lambda expression finishes!
This statement SHOULD be executed in outer function!

public static final void outerFunction() {
	  int $i$f$innerFunction = 0;
	  System.out.println("Inner function starts!");
	  int var1 = 0;
	  System.out.println("I'll do something in a lambda expression.");
	  System.out.println("Lambda expression finishes!");
	  System.out.println("This statement SHOULD be executed in outer function!");
}

public static final void innerFunction(@NotNull Function0 doSomething) {
	  Intrinsics.checkNotNullParameter(doSomething, "doSomething");
	  int $i$f$innerFunction = 0;
	  System.out.println("Inner function starts!");
	  doSomething.invoke();
}

Kotlin에서의 예시 - 컬렉션 함수

흔히 사용하는 컬렉션 함수 중 forEach에 람다식을 넘겨준다고 가정해 보겠습니다.

fun collection() {
    listOf(1, 2, 3).forEach {
        println("Processing $num...")
        if (num == 2) return // non-local return

        println("This statement is NEVER executed!")
    }
}

Processing 1...
This statement is executed except for 2
Processing 2...

non-local이 가능한 이유는, forEach를 비롯한 코틀린의 컬렉션 함수들은 대개 inline으로 구현되어 있기 때문입니다.

@kotlin.internal.HidesMembers
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}

예시 코드를 디컴파일 해보면 다음과 같이 forEach를 활용한 코드가 collection()에서 직접 인라인되고, return을 직접 호출하는 형태로 바뀜을 알 수 있습니다.

public static final void collection() {
	  Integer[] var0 = new Integer[]{1, 2, 3};
	  Iterable $this$forEach$iv = (Iterable)CollectionsKt.listOf(var0);
	  int $i$f$forEach = 0;

	  for(Object element$iv : $this$forEach$iv) {
	     int it = ((Number)element$iv).intValue();
	     int var5 = 0;
	     System.out.println("Processing " + it + "...");
	     if (it == 2) {
	        return; // return when processing 2
	     }
	
	     System.out.println("This statement is NEVER executed!");
	  }
}

앞서 살펴 보았듯, local return은 레이블링을 통해 달성할 수 있습니다.

fun collection() {
    listOf(1, 2, 3).forEach {
        println("Processing $num...")
        if (num == 2) return@forEach // local return

        println("This statement is executed except for 2")
    }
}
public static final void collection() {
	  Integer[] var0 = new Integer[]{1, 2, 3};
	  Iterable $this$forEach$iv = (Iterable)CollectionsKt.listOf(var0);
	  int $i$f$forEach = 0;

	  for(Object element$iv : $this$forEach$iv) {
	     int it = ((Number)element$iv).intValue();
	     int var5 = 0;
	     System.out.println("Processing " + it + "...");
	     if (it != 2) {
	        System.out.println("This statement is executed except for 2");
	     }
	  }
}

다양한 return 상황 대응하기

noinline

inline 함수를 정의함에도, 매개 변수로 넘겨 받을 함수의 바디를 그대로 붙여넣지 않고 Function 형태로 넘겨주고 싶다면?

inline을 하지 않으니, non-local 반환도 당연히 하지 않는다

inline 함수의 매개변수 중 일부만 인라인하지 않고 싶을 때 사용합니다. inline 함수에서 모든 람다 매개변수가 인라인되는 것이 기본 동작이지만, 특정 람다는 인라인하지 않고 Function 객체로 유지하고 싶은 경우가 있을 때 활용합니다.

inline fun mixedInline(
    action1: () -> Unit,
    noinline action2: () -> Unit
) {
    println("Before actions")
    action1() // 인라인됨
    action2() // Function 객체로 호출됨
    println("After actions")
}

앞선 설명을 토대로 생각해 보면, noinline으로 표시된 람다에서는 inline을 활용하지 않은 함수처럼 non-local return이 불가능할 것입니다. 함수 바디가 직접 인라인 되지 않고 Function 객체로 처리되어, 일반적인 함수 호출과 동일하게 동작하기 때문입니다.

fun noinlineReturn() {
    mixedInline(
        action1 = {
            println("Action 1")
            return *// non-local return 가능 ✅*
        },
        action2 = {
            println("Action 2")
            return *// 컴파일 에러! non-local return 불가능 ❌*
        }
    )
}

사용 사례

1. Function 인터페이스의 기능을 활용해야 할 필요가 있을 때

Function 객체를 변수에 저장하거나, 다른 함수에 매개변수로 전달해야 하는 경우입니다.

inline fun processWithStorage(
    inlineAction: () -> Unit,
    noinline storedAction: () -> Unit
) {
    inlineAction() *// 바로 인라인됨*
    
    *// storedAction을 변수에 저장*
    val savedFunction = storedAction
    
    *// 나중에 다른 곳에서 사용*
    executeStoredFunction(savedFunction)
}

fun executeStoredFunction(func: () -> Unit) {
    println("Executing stored function...")
    func()
}

2. 고차 함수에 람다를 전달해야 하는 경우

inline fun complexProcessor(
    inlineProcessor: (String) -> Unit,
    noinline asyncProcessor: (String) -> Unit
) {
    val data = "Hello World"
    
    // 즉시 처리 (인라인)
    inlineProcessor(data)
    
    // 비동기 처리를 위해 다른 함수에 전달 (Function 객체)
    processAsync(data, asyncProcessor)
}

fun processAsync(data: String, processor: (String) -> Unit) {
    // 실제로는 코루틴이나 다른 스레드에서 실행될 수 있음
    processor(data)
}

3. 컴파일 시점에 크기가 너무 커지는 것을 방지

inline 함수가 여러 람다를 받고, 그 중 일부만 자주 사용되는 경우 나머지를 noinline으로 처리하여 코드 크기를 최적화할 수 있습니다.

inline fun heavyProcessor(
    criticalAction: () -> Unit, *// 자주 사용됨 - 인라인*
    noinline rareCaseAction: () -> Unit, *// 드물게 사용됨 - Function 객체*
    noinline debugAction: () -> Unit *// 디버그용 - Function 객체*
) {
    criticalAction() *// 성능이 중요한 부분*
    
    if (DEBUG_MODE) {
        debugAction()
    }
    
    if (someRareCondition) {
        rareCaseAction()
    }
}

crossinline

inline은 하지만, non-local 반환은 하지 않는다

crossinline 키워드는 inline 함수의 람다 매개변수가 다른 실행 컨텍스트(예: 다른 람다, 지역 객체, 중첩 함수)에서 호출되지만, non-local return은 허용하지 않고 싶을 때 사용합니다. 즉 inline은 하고 싶지만 non-local은 허용하고 싶지 않을 때 활용합니다.

일반적으로 inline 함수의 람다는 non-local return이 가능하지만, 람다가 다른 실행 컨텍스트에서 호출되는 경우 이는 문제가 될 수 있습니다.

inline fun problematicInline(action: () -> Unit) {
    val wrapper = {
        action() // 이 경우 action의 non-local return이 문제가 될 수 있음
    }
    wrapper()
}

사용 사례

1. 람다가 다른 람다 내부에서 호출되는 경우

inline fun processWithWrapper(crossinline action: () -> Unit) {
    val wrapper = {
        println("Before wrapped action")
        action() *// crossinline으로 non-local return 방지*
        println("After wrapped action")
    }
    wrapper()
}

fun testCrossinline() {
    processWithWrapper {
        println("Inside action")
        *// return // 컴파일 에러! non-local return 불가능*
    }
    println("This will be executed") *// 실행됨*
}

2. 지역 객체나 익명 클래스에서 람다를 사용하는 경우

inline fun createHandler(crossinline onClick: () -> Unit) {
    val handler = object : ClickHandler {
        override fun handleClick() {
            onClick() *// 객체 내부에서 호출*
        }
    }
    handler.handleClick()
}

interface ClickHandler {
    fun handleClick()
}

3. 비동기 실행이나 콜백에서 람다를 사용하는 경우

inline fun asyncProcess(crossinline callback: (String) -> Unit) {
    // 실제 상황에서는 다른 스레드나 콜백에서 실행될 수 있음
    val asyncTask = {
        val result = "Async Result"
        callback(result) // 다른 실행 컨텍스트에서 호출
    }
    asyncTask()
}

fun testAsync() {
    asyncProcess { result ->
        println("Received: $result")
        // return // 컴파일 에러! non-local return 불가능
    }
    println("This will be executed")
}

4. 실제 Android 개발 예시

inline fun View.onClick(crossinline action: () -> Unit) {
    this.setOnClickListener { 
        action() // 리스너 내부에서 호출되므로 crossinline 필요
    }
}

fun setupButton() {
    button.onClick {
        println("Button clicked")
        // return // 만약 허용된다면 setupButton()을 종료시켜 버리므로, crossinline으로 이를 방지
    }
    println("This should always execute")

마치며

inline 관련 키워드들을 이해하고 잘 활용하면, 더욱 효율적이고 안전한 코드를 작성할 수 있을 것입니다. 각각의 키워드는 특정 상황에서의 필요에 의해 만들어진 것이므로, 상황에 맞게 적절히 활용하는 것이 중요합니다.

profile
Software Engineer

0개의 댓글