|

코틀린 inline은 왜 쓸까: reified, noinline, crossinline 실전 기준

코틀린 inline 함수와 reified noinline crossinline 실전 기준을 설명하는 대표 이미지
성능보다 더 자주 체감되는 것은 타입과 제어 흐름을 다루는 힘이다

코틀린 inline 함수를 처음 배우면 보통 “람다 객체를 덜 만들어서 빠르다”는 설명부터 듣습니다. 맞는 말이지만, 실무에서 더 자주 체감되는 가치는 따로 있습니다. 바로 reified로 타입을 직접 다루는 감각, 그리고 noinline·crossinline으로 람다의 쓰임새와 제어 흐름을 정교하게 나누는 힘입니다.

이 글은 inline을 무조건 성능 최적화 도구처럼 다루지 않습니다. 대신 언제 API가 더 읽기 쉬워지는지, 언제 non-local return이 편리해지는지, 언제 반대로 inline을 섣불리 붙이면 과해지는지를 실제 코드 기준으로 정리해보겠습니다.

먼저 한 줄 기준만 잡아도 좋습니다. 코틀린 inline 함수는 빠르게 만들기 위한 장치이기도 하지만, 더 자주 중요한 이유는 제네릭 타입 정보와 람다 제어 흐름을 호출 지점으로 끌어오기 쉽기 때문입니다.


코틀린 inline, 먼저 감 잡기

inline은 고차 함수 호출을 컴파일러가 호출 지점에 펼쳐 넣게 만드는 장치입니다. Kotlin 공식 문서도 함수 객체 생성과 가상 호출 오버헤드를 줄일 수 있다고 설명하지만, 동시에 큰 함수를 남발하면 코드 크기가 커질 수 있다고 분명히 경고합니다. 즉 inline은 “붙이면 무조건 이득”인 키워드가 아닙니다.

inline fun <T> withLock(lock: Lock, action: () -> T): T {
    lock.lock()
    return try {
        action()
    } finally {
        lock.unlock()
    }
}

이런 형태는 inline이 잘 어울립니다. 호출 비용을 줄이는 면도 있지만, 더 중요한 것은 “작은 제어 구조를 라이브러리처럼 제공한다”는 점입니다. `withLock { … }`처럼 쓰면 try-finally 패턴을 호출부에 자연스럽게 녹일 수 있습니다.

비슷한 감각은 코틀린 scope function 정리를 볼 때도 느낄 수 있습니다. 코틀린은 문법을 늘리기보다 함수를 통해 제어 흐름을 표현하는 쪽을 자주 택합니다. inline은 그 기반을 받쳐 주는 도구입니다.


reified가 중요한 이유

실무에서 inline이 가장 반갑게 느껴지는 순간은 대개 reified를 붙일 때입니다. 코틀린의 일반 제네릭은 런타임에 타입 정보가 지워지기 때문에, 보통은 `Class`나 `KClass`를 따로 넘겨야 합니다. 그런데 inline 함수 안에서는 `reified` 타입 파라미터를 써서 그 타입을 거의 보통 클래스처럼 다룰 수 있습니다.

class Container {
    private val services = mutableMapOf<Class<*>, Any>()

    fun <T : Any> register(type: Class<T>, value: T) {
        services[type] = value
    }

    fun <T : Any> resolve(type: Class<T>): T {
        val value = services[type] ?: error("No service: ${'$'}{type.simpleName}")
        @Suppress("UNCHECKED_CAST")
        return value as T
    }
}

inline fun <reified T : Any> Container.resolve(): T {
    return resolve(T::class.java)
}

val userService = container.resolve<UserService>()

호출부가 훨씬 짧아졌죠. `resolve(UserService::class.java)`보다 `resolve<UserService>()`가 읽기 쉽고, 타입 인자를 인수 목록 밖으로 분리해두지 않아도 됩니다. reified의 실전 가치는 성능보다 API 가독성에 더 크게 느껴질 때가 많습니다.

is 검사도 자연스러워진다

inline fun <reified T> Iterable<*>.firstAsOrNull(): T? {
    for (item in this) {
        if (item is T) return item
    }
    return null
}

val firstNumber = listOf("a", 1, 2L).firstAsOrNull<Int>()

일반 제네릭 함수였다면 `item is T` 같은 검사를 할 수 없습니다. 타입이 지워져 있기 때문입니다. reified를 쓰면 이 벽이 낮아집니다. 그래서 컬렉션 필터링, 서비스 조회, 간단한 파서 래퍼, Android argument 헬퍼 같은 코드에서 특히 체감이 큽니다.

reified가 만능은 아니다

다만 reified가 타입 소거를 완전히 없애 주는 것은 아닙니다. 예를 들어 `List`의 원소 타입까지 모두 런타임에 정밀하게 보존해 주는 만능 스위치처럼 이해하면 곤란합니다. reified는 호출부의 타입 인자를 더 자연스럽게 끌어와 검사하고 전달하는 데 강한 도구라고 보는 쪽이 실무 감각에 가깝습니다.

상태와 타입을 읽기 좋게 모델링하는 감각은 코틀린 sealed class vs enum 글과도 이어집니다. 둘 다 호출부를 단순하게 만들고, 타입이 코드 의미를 더 많이 설명하게 만드는 방향이기 때문입니다.


noinline은 왜 필요할까

inline 함수를 쓰면 람다도 함께 호출부에 펼쳐 넣는 것이 기본입니다. 그런데 모든 람다가 항상 그렇게 다뤄지면 곤란한 장면이 있습니다. 어떤 람다는 나중에 호출하려고 저장해야 하고, 다른 함수로 전달해야 할 수도 있습니다. 그럴 때 쓰는 표시가 `noinline`입니다.

inline fun <T> measure(
    label: String,
    block: () -> T,
    noinline onDone: (Long) -> Unit
): T {
    val start = System.nanoTime()
    val result = block()
    val elapsed = System.nanoTime() - start
    onDone(elapsed)
    return result
}

여기서 `block`은 바로 실행해도 되니 인라인하기 좋습니다. 반면 `onDone`은 로깅 시스템에 넘기거나 컬렉션에 담아 둘 수도 있는 성격입니다. 이런 람다까지 무조건 인라인 대상으로 취급하면 함수 설계가 오히려 뻣뻣해집니다.

정리하면 이렇습니다. inline은 제어 구조처럼 즉시 실행할 람다에 잘 맞고, noinline은 값처럼 다뤄야 할 람다에 필요합니다. 하나의 함수 안에서도 람다의 역할은 다를 수 있다는 점을 보여 주는 키워드가 noinline입니다.


crossinline은 언제 쓸까

inline 함수의 또 다른 특징은 람다 안에서 바깥 함수를 바로 `return`할 수 있다는 점입니다. 이것을 non-local return이라고 부릅니다. 이 기능은 `forEach`처럼 작은 제어 구조를 만들 때 꽤 편합니다.

fun hasError(messages: List<String>): Boolean {
    messages.forEach {
        if (it.startsWith("ERROR")) return true
    }
    return false
}

하지만 람다가 지금 당장 현재 함수 본문에서 실행되지 않고, 다른 실행 문맥으로 넘어가면 이야기가 달라집니다. 예를 들어 Runnable 안에서 나중에 실행되거나, 내부 객체 메서드에서 호출될 수 있다면 바깥 함수로 점프하는 `return`을 허용하면 흐름이 헷갈려집니다. 이때 `crossinline`으로 non-local return을 막습니다.

inline fun runOnBackground(crossinline block: () -> Unit) {
    val runnable = Runnable {
        block()
    }
    Thread(runnable).start()
}

fun example() {
    runOnBackground {
        // return // 허용되지 않음
        println("work")
    }
}

즉 `crossinline`은 람다를 인라인하긴 하되, “이 람다 안에서 바깥 함수까지 탈출하는 return은 허용하지 않겠다”고 경계를 그어 주는 표시입니다. 호출 위치와 실행 위치가 달라질 수 있는 API에서는 이 선이 아주 중요합니다.


실무에서 inline이 빛나는 장면

  • reified로 `Class<T>` 전달을 감추고 호출부를 짧게 만들 때
  • `withLock`, `use`, `transaction`처럼 작은 제어 구조를 함수로 감쌀 때
  • 컬렉션 순회나 DSL처럼 짧은 람다가 아주 자주 호출될 때
  • 람다 안의 return, break, continue 같은 제어 흐름이 코드 가독성에 실제로 도움 될 때

여기서 공통점은 “자주 호출된다”보다도 “호출부 표현력이 좋아진다”에 있습니다. 성능 이득은 보너스일 수 있지만, inline을 붙이는 주된 이유를 언제나 미세 최적화로만 설명하면 코드 설계의 절반을 놓치게 됩니다.

반대로 과한 순간도 있다

  • 본문이 길고 복잡한 함수
  • 람다 파라미터가 없거나, 인라인 가치가 거의 없는 단순 래퍼
  • public API라서 binary compatibility를 더 신중히 봐야 하는 라이브러리 코드
  • 성능 근거 없이 습관처럼 붙이는 경우

Kotlin 공식 문서도 public inline API는 다른 모듈에 코드가 펼쳐져 들어가므로 변경 시 binary compatibility 주의를 요구합니다. 애플리케이션 내부 코드에서는 덜 아플 수 있지만, 라이브러리 API라면 이야기가 더 무거워집니다.


빠르게 고르는 기준

  1. 타입 인자를 함수 안에서 직접 검사하거나 `T::class`처럼 쓰고 싶은가? 그렇다면 reified + inline 후보입니다.
  2. 람다를 지금 바로 실행하는 작은 제어 구조인가? 그렇다면 inline이 잘 맞습니다.
  3. 어떤 람다는 저장하거나 다른 함수로 넘겨야 하는가? 그 파라미터에는 noinline을 검토합니다.
  4. 람다가 다른 실행 문맥에서 호출될 수 있는가? 바깥 함수로의 return을 막기 위해 crossinline을 검토합니다.
  5. 붙였을 때 얻는 가독성 이득이 분명한가? 아니라면 성능 기대만으로 inline을 늘리지 않는 편이 안전합니다.

한 문장으로 줄이면 이렇습니다. inline은 빠른 코드의 비밀이라기보다, 타입과 제어 흐름을 더 좋은 문장으로 쓰게 해 주는 도구입니다.


함께 보면 좋은 자료

코틀린 함수 호출 문맥을 더 부드럽게 읽고 싶다면 코틀린 let, run, apply, also, with 차이 글을 함께 보세요. 타입이 코드를 설명하게 만드는 쪽은 코틀린 sealed class vs enum 글과도 자연스럽게 이어집니다.

공식 문서는 Kotlin Inline functionsKotlin Generics를 먼저 보면 충분합니다. 이 글의 핵심 설명도 그 범위를 벗어나지 않도록 맞췄습니다.


정리

코틀린 inline 함수를 이해할 때 “람다 객체를 덜 만든다”에서 멈추면 반만 본 셈입니다. 실전에서는 reified로 타입 전달을 단순하게 만들고, noinline으로 람다를 값처럼 다루고, crossinline으로 제어 흐름의 경계를 분명히 하는 장면이 훨씬 자주 나옵니다.

그래서 질문은 “이 함수가 더 빨라질까” 하나만이 아닙니다. “이 함수가 호출부에서 더 읽기 쉬워질까”, “타입과 흐름이 더 안전해질까”까지 함께 봐야 합니다. 이 기준으로 보면 inline은 성능 팁이 아니라 꽤 강한 API 설계 도구입니다.

함께보면 좋은 글