inline 함수와 reified — 람다의 성능 비용을 없애는 방법
코틀린에서 람다를 사용하면 매번 Function 객체가 생성된다. 대부분의 상황에서는 문제가 안 되지만, 성능에 민감한 코드에서는 이 비용이 무시할 수 없다. inline 함수는 이 문제를 컴파일 타임에 해결한다. 그리고 inline만의 특권인 reified 타입 파라미터까지 — 확실히 이해해 두겠습니다.
람다의 성능 비용 — Function 객체 생성
먼저 왜 inline이 필요한지 이해하자.
// 이 고차 함수를 호출할 때마다...
fun performOperation(x: Int, operation: (Int) -> Int): Int {
return operation(x)
}
// ... 람다는 Function1 객체로 변환된다
performOperation(5) { it * 2 }
컴파일러가 내부적으로 생성하는 코드는 대략 이런 형태다.
// 바이트코드 수준에서 일어나는 일 (개념적 설명)
performOperation(5, new Function1<Integer, Integer>() {
@Override
public Integer invoke(Integer it) {
return it * 2;
}
});
이 과정에서 발생하는 비용은 다음과 같다.
- 객체 생성 — Function 인스턴스가 매번 생성됨
- ** 메모리 할당** — 힙에 할당되고 GC 대상이 됨
- ** 클로저 캡처** — 바깥 변수를 캡처하면 추가 메모리 사용
inline 함수 — 바이트코드 인라이닝
inline 키워드를 붙이면 컴파일러가 함수 본문을 호출 지점에 직접 삽입한다.
inline fun performOperation(x: Int, operation: (Int) -> Int): Int {
return operation(x)
}
// 호출
val result = performOperation(5) { it * 2 }
컴파일 후에는 이렇게 변환된다.
// 인라이닝 결과 — 함수 호출도 없고, Function 객체도 없다
val result = 5 * 2 // 직접 삽입됨
표준 라이브러리의 inline 함수들
코틀린 표준 라이브러리의 많은 고차 함수가 inline으로 선언되어 있다.
// 이 함수들은 모두 inline
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>
public inline fun <T> T.let(block: (T) -> R): R
public inline fun <T> T.apply(block: T.() -> Unit): T
그래서 filter, map, let, apply 같은 함수를 사용해도 성능 걱정을 하지 않아도 되는 것이다.
비지역 반환(Non-local Return)
inline 함수의 람다에서는 바깥 함수를 종료하는 return을 쓸 수 있다. 이것이 비지역 반환이다.
inline fun forEach(items: List<Int>, action: (Int) -> Unit) {
for (item in items) {
action(item)
}
}
fun findFirstNegative(numbers: List<Int>): Int? {
forEach(numbers) { num ->
if (num < 0) return num // findFirstNegative를 반환! (비지역 반환)
}
return null
}
이것이 가능한 이유는 인라이닝 후에 return이 findFirstNegative 함수 본문 안에 직접 위치하기 때문이다.
비지역 반환이 불가능한 경우
// inline이 아닌 함수의 람다에서는 비지역 반환 불가
fun nonInlineForEach(items: List<Int>, action: (Int) -> Unit) {
for (item in items) {
action(item)
}
}
fun test(numbers: List<Int>) {
nonInlineForEach(numbers) { num ->
// if (num < 0) return // 컴파일 에러!
if (num < 0) return@nonInlineForEach // 레이블 반환만 가능
}
}
noinline — 특정 람다만 인라이닝 제외
inline 함수의 모든 람다 파라미터는 기본적으로 인라이닝된다. 특정 람다를 인라이닝에서 제외하려면 noinline을 붙인다.
inline fun execute(
inlinedAction: () -> Unit,
noinline storedAction: () -> Unit // 이 람다는 인라이닝하지 않음
) {
inlinedAction()
// 인라이닝된 람다는 변수에 저장하거나 다른 함수에 전달할 수 없다
// val saved = inlinedAction // 컴파일 에러!
// noinline 람다는 가능
val saved = storedAction // OK — Function 객체로 존재
someOtherFunction(saved) // OK — 다른 함수에 전달 가능
}
noinline이 필요한 이유
인라이닝된 람다는 바이트코드에 직접 삽입되기 때문에 "객체"가 아니다. 따라서 변수에 저장하거나 다른 함수에 전달할 수 없다. 이런 동작이 필요한 경우 noinline을 사용한다.
crossinline — 비지역 반환 금지
crossinline은 람다가 다른 실행 컨텍스트(예: 다른 람다 내부, 다른 스레드)로 전달될 때 사용한다. 비지역 반환을 금지하면서 인라이닝은 유지한다.
inline fun runInThread(crossinline action: () -> Unit) {
// action이 다른 람다(Runnable) 내부에서 실행됨
Thread(Runnable {
action() // crossinline 덕분에 인라이닝 가능
}).start()
}
fun test() {
runInThread {
println("다른 스레드에서 실행")
// return // 컴파일 에러! crossinline은 비지역 반환 금지
}
}
왜 필요할까?
action이 Runnable 람다 내부에서 실행되므로, 비지역 반환을 하면 의미가 없다(다른 스레드에서 실행 중이므로 바깥 함수를 종료할 수 없다). crossinline은 이런 상황에서 컴파일 타임에 안전성을 보장한다.
noinline vs crossinline 비교
| 특성 | noinline | crossinline |
|---|---|---|
| 인라이닝 | 하지 않음 | 함 |
| 비지역 반환 | 불가 (람다가 객체) | 금지 (명시적) |
| 변수 저장 | 가능 | 불가 |
| 사용 시점 | 람다를 저장/전달해야 할 때 | 다른 컨텍스트에서 실행할 때 |
reified 타입 파라미터 — inline의 특권
JVM의 타입 소거(type erasure) 때문에 일반적으로 제네릭의 타입 정보는 런타임에 사라진다. 하지만 reified를 사용하면 inline 함수에서 타입 정보를 유지할 수 있다.
// 일반 제네릭 — 런타임에 타입 정보 없음
fun <T> isType(value: Any): Boolean {
// return value is T // 컴파일 에러! T의 타입을 알 수 없음
return false
}
// reified — 런타임에 타입 정보 유지
inline fun <reified T> isType(value: Any): Boolean {
return value is T // OK!
}
println(isType<String>("hello")) // true
println(isType<Int>("hello")) // false
reified가 가능한 이유
inline 함수는 호출 지점에 코드가 삽입되므로, 컴파일 타임에 실제 타입을 알 수 있다.
// 소스 코드
inline fun <reified T> check(value: Any) = value is T
check<String>("hello")
// 인라이닝 후 (개념적)
"hello" is String // T가 String으로 대체됨
reified의 실무 활용
// 1. 타입 검사
inline fun <reified T> filterByType(items: List<Any>): List<T> {
return items.filterIsInstance<T>()
}
val mixed = listOf(1, "hello", 2, "world", 3.0)
val strings: List<String> = filterByType(mixed) // ["hello", "world"]
// 2. 클래스 참조 얻기
inline fun <reified T> getClassName(): String {
return T::class.simpleName ?: "Unknown"
}
println(getClassName<String>()) // "String"
// 3. Jackson/Gson 같은 라이브러리에서 활용
inline fun <reified T> String.fromJson(): T {
return ObjectMapper().readValue(this, T::class.java)
}
val user = """{"name":"코틀린"}""".fromJson<User>()
표준 라이브러리의 reified 예시
// filterIsInstance — 특정 타입만 필터링
val items: List<Any> = listOf(1, "hello", 2.0, "world")
val strings = items.filterIsInstance<String>() // ["hello", "world"]
// 내부 구현
public inline fun <reified R> Iterable<*>.filterIsInstance(): List<R> {
return filterIsInstanceTo(ArrayList(), R::class.java)
}
inline 함수 남용 주의
인라이닝하면 안 되는 경우
// 나쁜 예 — 함수 본문이 크면 바이트코드 크기가 급증
inline fun bigFunction(action: () -> Unit) {
// ... 수십 줄의 코드 ...
action()
// ... 수십 줄의 코드 ...
}
// bigFunction을 10곳에서 호출하면 → 본문이 10번 복사됨
inline을 사용해야 하는 경우
- 람다를 파라미터로 받을 때 — 주된 사용 목적
- reified가 필요할 때 — inline 함수에서만 가능
- ** 비지역 반환이 필요할 때** — inline 함수의 람다에서만 가능
inline을 사용하지 말아야 하는 경우
- ** 함수 본문이 클 때** — 바이트코드 크기 증가
- ** 람다를 받지 않을 때** — 이점이 거의 없음
- ** 재귀 함수** — 인라이닝 불가
inline 프로퍼티
함수뿐만 아니라 프로퍼티의 접근자에도 inline을 사용할 수 있다.
class StringWrapper(private val value: String) {
// getter를 인라이닝 — 함수 호출 오버헤드 제거
inline val length: Int
get() = value.length
// backing field가 없는 프로퍼티에만 사용 가능
// inline var name: String = "" // 컴파일 에러! backing field가 있으면 불가
}
정리
- inline의 핵심 — 함수 본문과 람다를 호출 지점에 삽입. Function 객체 생성 제거
- ** 비지역 반환** — inline 함수의 람다에서만 가능. 바깥 함수를 종료
- noinline — 특정 람다를 인라이닝에서 제외. 변수 저장/전달이 필요할 때
- crossinline — 인라이닝은 유지, 비지역 반환만 금지. 다른 실행 컨텍스트용
- reified — inline 전용. 타입 소거 우회, 런타임 타입 정보 사용 가능
- ** 남용 금지** — 본문이 큰 함수에 inline을 붙이면 바이트코드 크기 급증