Kotlin inline class와 inline functions을 알아보고, 적절하게 사용하는 방법을 살펴보자.



개인 광고 영역

이 글에서는 kotlin inline을 좀 더 살펴보려고 한다.

inline은 말 그대로 inline 키워드를 사용한 함수나 클래스의 내부 코드를 inline 해 컴파일한다는 것이다.

예를 들어 inline functions에 10줄의 코드를 담고 있고, inline을 붙인다면 이 10줄의 코드를 그대로 함수 호출한 부분에 함수 호출한 부분을 제외하고 inline 시켜줌을 뜻한다.

코틀린은 inline이라는 키워드를 직접 제공하며, 2가지 방식의 inline을 제공한다.

이 글에서는 inline class와 inline functions을 알아보고, 이번 글에서는 inline functions에 대해서 좀 더 살펴보겠다.


이 글에서 알아볼 내용

  • inline class를 간단하게 살펴본다.
  • inline functions을 간단하게 살펴본다.
  • inline functions 활용성을 높이기 위한 부분을 좀 더 살펴본다.


inline class

inline class는 Kotlin 1.3에 추가되었고, Primitive type에 대한 inline을 제공한다.

다음과 같이 inline class를 정의하고,

inline class Password(val value: String)

String으로 만든 password 대신 Password inline class를 활용하여 Password를 고유한 값으로 만들 수 있다.

val password: String = "Your password"

// 이 코드 대신

val password = Password("Your password")

이 글에서는 inline class보다는 inline functions을 살펴보는 글이다. inline class는 간단하게 어떤 곳에서 사용하면 좋을지 만 정리하고, 추후 글에서 inline class 부분을 살펴보겠다.


inline class는 언제 쓰면 좋을까?

inline class는 아래의 조건에 사용하면 좋은데, 동일한 type이 무수히 많은데, 이름만 가지곤 실수할 여지가 있다.

예를 들면 아래와 같은 Response data class가 존재한다.

data class UserInfoResponse(
  val index: Long,
  val indexId: Long,
  val userId: String,
  val userName: String,
  val age: Int,
  val registrationDate: Long,
  ...
)

하나의 클래스 안에 동일한 타입으로 여러 개의 값이 존재하는 건 매우 흔한 일이다.

유사한 이름으로 변수가 1개 이상 생길 수도 있다. 이 경우라면 충분히 중복이 일어날 수 있고, 실수도 할 수 있다.

유사하게 사용하는 index가 있고, indexId가 있다고 생각해보겠다. 유사한 이름으로 시작하고, 두 값 모두 Type이 Long이다.

처음 개발한 사람은 2개의 값을 구분하여 사용할 태지만 처음 온 분은 그렇지 않을 수 있다.(주석이 잘 되어있다면 예외 일수도)

이럴 때 아래와 같이 inline class를 정의해두고 사용하다면 약간의 실수라도 줄일 수 있을 텐데, 이럴 때 사용해 주면 좋다.

inline class Index(val index: Long)
inline class IndexId(val index: Long)

data class UserInfoResponse(
  val index: Index,
  val indexId: IndexId,
  ...
)

inline class를 위와 같이 사용하면 실제 컴파일 결과물에서는 아래와 같이 long으로 치환되어 inline 해줌을 확인할 수 있다.

public final class UserInfoResponse {
   private final long index;
   private final long indexId;
 }

단, inline class는 Primitive type을 기준으로 사용할 수 있고, inline class 코드 내에는 프로퍼티와 함수 정의하는 게 가능하다.


inline functions

본론으로 돌아와 inline functions을 알아보도록 하겠다. inline을 사용하면서 볼 수 있는 3가지 정의 방법이 있다.

  • inline : 함수 파라메터로 Higher-Order function을 사용한 함수 전달을 사용할 때 inline을 활용하고, 익명 클래스 생성 비용을 줄일 수 있다.
  • noinline : lambdas 식 표현에서 함께 사용을 하지만, 이 함수에서 호출하는 함수가 inline이 아닌 경우에 붙여준다.
  • crossinline : 다른 함수의 Higher-Order function 파라메터를 block {} 형태로 사용하는 경우 crossinline을 써야 한다.

이렇게 3가지가 있는데 inline부터 차근차근 알아보자.

inline functions은 이미 매우 잘 활용하고 있는데 바로 Scope functions이다.

그중 하나를 also를 그대로 가져와보았다.

@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}

위 also를 사용하면 아래와 같이 사용하는 게 가능한데,

"inline test".also { println(it) }

이 코드의 디컴파일 결과는 아래와 같다. also에 정의되어 있는 Highter-Order functions의 block을 호출하는 코드는 제거되고, println()을 하는 부분이 inline 되었음을 알 수 있다.

String var0 = "inline test";
String var5 = "it " + var0;
System.out.print(var5);


inline 활용성을 올려보자.

inline을 적용하면 함수 호출을 줄일 수 있다. 그럼 언제 inline을 사용하는 게 좋을까?

아래 코드도 inline을 걸어주면 좋을까?

inline fun Context.showToast(message: String) {
    Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}

정답은 그럴 수도, 아닐 수도 있다.

위 코드에 대한 Android Studio의 경고 알림 형태가 노출되는데, 이 문구를 읽어보고 Kotlin in action에서 소개하는 내용을 함께 살펴보자.

Expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of functional types

inline으로 인한 예상 성능의 영향은 미미하다고 한다. inline 함수 유형의 매개 변수가 있을 때 가장 유용하니, 여기서는 사용하지 않는 것을 추천한다고 한다.

함수 유형의 매개 변수란 바로 Higher-Order functions - 제 블로그 글을 말한다.

Higher-Order functions을 사용하는 Kotlin scope functions을 살펴보면 바로 이해는 할 수 있다.


showToast의 디컴파일을 해보자

적용한 코드에 대한 디컴파일을 결과를 확인해보고 넘어가자.

showToast 함수가 inline을 붙인 함수이고, test()에서 사용하는 부분이 실제 inline 되어 코드에 붙은 부분임을 알 수 있다.

kotlin에서 작성한 코드에서는 test 함수에서 showToast를 호출했지만, 실제 디컴파일 결과물에서는 showToast를 호출하는 것 대신 inline 되어있는 Toast.makeText를 호출함을 알 수 있다.

public final void showToast(@NotNull Context $this$showToast, @NotNull String message) {
   int $i$f$showToast = 0;
   Intrinsics.checkParameterIsNotNull($this$showToast, "$this$showToast");
   Intrinsics.checkParameterIsNotNull(message, "message");
   Toast.makeText($this$showToast, (CharSequence)message, 0).show();
}

public final void test(@NotNull Context context) {
   Intrinsics.checkParameterIsNotNull(context, "context");
   String message$iv = "Show Toast";
   int $i$f$showToast = false;
   Toast.makeText(context, (CharSequence)message$iv, 0).show();
}


미미한 성능?

미미한 성능으로 노출하는 이유는 Kotlin in action 책의 내용을 참고하면 이해할 수 있다.

이미 JVM은 강력하게 inline을 지원하고 있고, JVM은 코드 실행 분석을 통해 가장 이익이 되는 방법으로 inline을 하고 있다.

결과적으로 이미 JVM에서 inline을 하고 있으므로 파라메터에 Higher-Order functions을 넘겨주는 형태가 아니라면 굳이 inline을 적용할 필요가 크지 않음을 알 수 있다.


그럼 좋은 접근 방법은?

inline이 필요 없는 케이스를 먼저봤다. 그럼 언제가 좋을까?

Scope functions에는 이미 많은 inline 함수가 존재한다. 이들을 참고하여 inline을 붙여주는 게 가장 좋은 접근 방법이다.


왜 Higher-Order functions을 사용할 때 inline을 적용하는 게 좋을까?

함수에 Higher-order functions을 지정하고, inline을 사용하지 않은 경우를 먼저 디컴파일 해보면 이해를 할 수 있다.

fun String?.notNull(body: String.() -> Unit) {
    this?.body()
}

이를 사용하는 코드는 아래와 같이 추가했다.

fun test() {
    "aaa".notNull {
        println("this message $this")
    }
}

notNull이라는 함수는 보다시피 inline을 추가하지 않았으니 결과물은 기본적인 게 노출될 것이다.

디컴파일 결과에서 볼 수 있든 notNull을 직접 호출하는 test 함수가 있고, notNull 함수에서 body를 invoke 하는 것을 확인할 수 있다.

public static final void test() {
   notNull("aaa", (Function1)null.INSTANCE);
}

public static final void notNull(@Nullable String $this$notNull, @NotNull Function1 body) {
   Intrinsics.checkParameterIsNotNull(body, "body");
   if ($this$notNull != null) {
      Unit var10000 = (Unit)body.invoke($this$notNull);
   }
}

결과적으로 익명 클래스를 호출하여 println를 처리함을 알 수 있다. 현재 코드에서는 click 이벤트와 같이 여러 번 호출하는 코드는 없으나, 이 이벤트에서 사용한다면 매번 anonymous class(익명 클래스, 무명 클래스)를 생성하는 비용이 발생할 수 있다.


inline을 붙여 anonymous class(익명 클래스, 무명 클래스) 생성 비용을 줄이자.

anonymous class(익명 클래스, 무명 클래스)를 생성하고, 사용하는데 들어가는 비용이 발생하는데, 이를 줄이는 방법은 매우 간단하다.

위에서 작성한 코드에서 inline만 붙여주면 끝이다. inline을 붙임으로 anonymous class(익명 클래스, 무명 클래스) 생성 코드는 제거되고 inline 되어있는 코드를 볼 수 있다.

public static final void test() {
   String $this$notNull$iv = "aaa";
   String var4 = "this message " + $this$notNull$iv;
   System.out.println(var4);
}

이런 샘플 코드에서는 큰 의미 없을 수 있으나, 이 코드가 collection에서 사용된다면 엄청난 차이가 발생한다.

kotlin collection에서 흔하게 사용하는 forEach, map 등은 모두 inline을 활용하고 있다.

다음 글에서 collection을 다룰 때 sequence에 관해 알아볼 건데, 미리 알려준다면 큰 데이터가 아니라면 inline을 활용한 collection이 성능상 더 좋기도 하다.


inline이 불가능한 경우를 알아보자. - noinline

inline 문서를 보면 noinline이 소개되어 있다.

옛날에 작성했던 코드를 기준으로 noinline을 살펴보려고 한다. injectViewModel과 createViewModel 2개를 만들었고, 하나에는 inline을 하나에는 아무것도 명시하지 않았다.

// 5번
inline fun <reified VIEW_MODEL : ViewModel> AppCompatActivity.injectViewModel(
    noinline onCreateViewModel: () -> VIEW_MODEL // 4번
): VIEW_MODEL =
    ViewModelProvider(
        this.viewModelStore,
        createViewModel(onCreateViewModel) // 3번
    ).get(VIEW_MODEL::class.java)

fun <VIEW_MODEL : ViewModel> createViewModel(onCreateViewModel: () -> VIEW_MODEL) = // 2번
    object : ViewModelProvider.Factory {

        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return onCreateViewModel() as T // 1번
        }
    }

위 코드의 번호를 거꾸로 매겨두었으며, 1번부터 순서대로 설명해보면 아래와 같다.

  1. 여기에서 lambda로 전달된 onCreateViewModel의 return 값을 사용한다. 이 위치는 익명 클래스인 ViewModelProvider.Factory에서 사용한다.
  2. 익명 클래스를 리턴하는 함수이며, inline의 명시가 없다. 그렇기에 Higher-Order function 정의에서도 별도의 명시는 없다.
  3. createViewModel 메소드로 lambdas 식인 onCreateViewModel을 넘긴다.
  4. ViewModelProvider에서 호출하는 createViewModel이 inline이 아니기 때문에 여기에서 noinline을 명시했다.
  5. 호출하는 injectViewModel 함수는 inline으로 정의했다.

이 코드의 결과물은 injectViewModel의 내부는 inline을 하지만, createViewModel 함수는 inline을 명시하지 않았기에 inline 한 injectViewModel에서 익명 함수를 생성하여 createViewModel을 호출하게 된다.

위 코드는 필자가 과거에 작성했던 코드 중 하나이다.


AAC-ViewModel의 코드를 보자

이런 부분은 구글이 만든 viewModels 초기화 코드를 보면 유사하게 처리되어 있음을 알 수 있는데, 최종적으로 호출되어야 할 ownerProducer, factoryProducer 파라메터는 noinline을 지정했음을 알 수 있다.

@MainThread
inline fun <reified VM : ViewModel> Fragment.viewModels(
    noinline ownerProducer: () -> ViewModelStoreOwner = { this },
    noinline factoryProducer: (() -> Factory)? = null
) = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)

createViewModelLazy 역시 위에서 봤던 createViewModel 함수와 유사하게 inline 명시를 하지 않았기에 여기서도 noinline 정의를 할 수밖에 없다.

@MainThread
fun <VM : ViewModel> Fragment.createViewModelLazy(
    viewModelClass: KClass<VM>,
    storeProducer: () -> ViewModelStore,
    factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        defaultViewModelProviderFactory
    }
    return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
}


noinline과 crossinline을 함께 알아보자.

noinline을 위에서 알아보았는데, crossinline을 알아보기 위한 샘플 코드를 하나 추가로 알아보자.

아래 inline runNetwork 함수가 있다. onLoaded는 noinline을 하고 있고, onCache는 crossinline을 하고 있다.

이 코드에서 보면 runNetwork 함수는 inline을 하지 않고, url과 callback을 사용하고 있다. 이때 2개의 방법을 풀 loadData에서 사용하고 있다.

loadCache가 필요치 않은 경우에는 runNetwork에 파라메터로 이름과 onLoaded를 함께 전달한다. 이때는 파라메터로 즉시 전달하기 때문에 noinline 되어야 한다.

위에서 보았던 ViewModel 샘플은 이에 해당하기에 noinline을 처리해야 한다.

이번엔 crossinline이다. crossinline이 써진 이유는 loadCache의 값이 true이고, runNetwork를 함수 호출하지만 block {} 형태로 람다를 표현하고 있다.

이 경우에는 crossinline을 명시해 주어야 한다.

inline fun loadData(
    loadCache: Boolean,
    noinline onLoaded: () -> Unit, // noinline
    crossinline onCache: () -> Unit // crossinline
) {
    if (loadCache.not()) {
        runNetwork("online", onLoaded) // runNetwork에 람다 표현식을 직접 전달한다.
    } else {
        runNetwork("local?") { // runNetwork을 람다 표현으로 작성하고, onLoaded와 onCache를 호출한다.
            onLoaded()
            // cache...
            onCache()
        }
    }
}

fun runNetwork(url: String, callback: () -> Unit) {
    // ...
}

조금 어려운데 결국 inline 함수에서 inline 적용하지 않은 함수의 lambdas 식을 호출하게 되면 noinline 또는 crossinline을 적용해야 한다.


정리하면

  • inline class 활용은 이름을 명확화하는 데 도움을 준다.
  • 무조건적인 inline은 성능 향상에 도움을 주지는 않는다.
    • Higher-Order function과 함께 적절하게 inline 하는 게 중요하다.
    • 함수 전달이 아닌 경우라면 JVM이 잘 해주길 기다리자.
  • 함수 전달 시에는 꼭 inline을 명시해 주는 게 좋다.
  • noinline은 파라메터로 제3의 함수에 전달할 때 붙인다.
  • crossinline은 파라메터로 제3의 함수에 전달할 때 붙이나, 호출하는 함수에서 block {}을 표현할 경우 붙여줘야 한다.


마무리

강력한 inline을 적당히 잘 활용한다면 코드 성능에 좋은 영향을 미칠 수 있음을 확인해보았다.

다음 글에서는 collection을 살펴보도록 하겠다.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments