Better Kotlin — inline

Jaeho Choe
13 min readMay 28, 2021

--

인라인 스케이트는 못타도 인라인 키워드는 알아야하지 않겠습니까?

1. Inline Function

Kotlin stdlib의 코드를 살펴본 경험이 있다면 많은 유틸성 함수들이 인라인 함수로 구현되어있는걸 본적이 있으실겁니다. 예를 들어 repeat 함수가 있죠.

public inline fun repeat(times: Int, action: (Int) -> Unit) {
for (index in 0 until times) {
action(index)
}
}

inline 키워드가 하는 일은 인라인 함수가 호출되는 시점의 코드를 함수 본문의 코드로 대체 시키는 것입니다. 따라서 이 repeat 함수를 호출하면

println("repeat!")
repeat(5) {
println("$it")
}

컴파일 되면서 아래와 같은 코드로 대체됩니다.

System.out.println("repeat!");
for(byte i = 0; i < 5; ++i) {
System.out.println(String.valueOf(i));
}

일반적으로 함수가 호출되면 호출 시점에 함수 본문으로 점프하여 함수의 모든 명령문을 수행한 다음 다시 함수가 호출된 시점으로 점프하여 프로그램이 이어지는데 이에 비해 인라인 함수의 코드가 대체되는 방식은 몇가지 장점이 있습니다.

제네릭 유형을 사용할 수 있습니다.

Java가 처음 디자인 되었을때는 제네릭이 없었습니다. 때문에 2004년 JDK 1.5에 이르러서야 처음 제네릭이 추가될때 이전 버전과의 호환성을 유지해야 할 필요가 있었습니다. 이런 이유로 Java는 컴파일 하며 제네릭 유형을 소거하는 타입 소거(Type Erasure) 를 선택하게 되었고 아직도 JVM의 바이트 코드에는 제네릭 개념이 존재하지 않습니다. 때문에 우리는 런타임 상에서 제네릭 유형을 확인 할 수 없습니다. 어떤 CollectionList형인지는 확인 할 수 있지만 List<String> 인지는 확인할 수 없는 이유입니다.

list is List<String> // not allowed
list is List<*> // okay

같은 이유로 우리는 제네릭 유형의 타입을 얻어 올 수도 없습니다.

fun <T> isSameType(type: KClass<*>) = type == T::class // error

이런 제약을 inline 키워드를 사용하여 극복할 수 있습니다. 함수 호출시 함수 본문의 내용으로 대체되기 때문에 제네릭 타입에 접근할 수 있습니다. 이때 함께 사용하는 키워드가 reified 입니다. reified 는 인라인 함수에서만 사용할 수 있는데 reified 로 선언된 제네릭 유형은 함수 호출과 함께 선언된 타입을 본문이 대체될때 반영됩니다.

inline fun <reified T> isSameType(type: KClass<*>) = type == T::classisSameType<Int>(1::class) // true
isSameType<Int>("1"::class) // false

reified 키워드가 사용된 함수는 Java에서 호출 할 수 없는 Synthetic method 로 컴파일됩니다.

람다 파라메터를 사용할 경우 성능에 더 유리합니다.

사실 비단 함수형 인자를 받을때 뿐만 아니라 모든 함수는 인라인으로 사용할때 약간 더 빠릅니다. 앞서 살펴봤듯 실행과 함께 함수로 점프하고 백스택을 추적하여 복귀할 필요가 없기 때문입니다. Kotlin의 stdlib에서 자주 사용되는 함수를 인라인으로 구현한 이유이기도 합니다.

이 차이는 파라메터가 없는 함수에서는 미미하기 때문에 사용을 권장하지 않습니다.

그럼 람다 파라메터를 사용할때 특히 성능에 유리한 이유는 뭘까요? 이걸 이해하기 위해서는 먼저 람다(함수)가 어떻게 객체로 사용되는지에 대한 이해가 필요합니다. JS 환경에서는 함수가 일급 객체이기때문에 람다를 파라메터로 사용하는데에 제약이 없습니다. 하지만 Java에서는 파라메터를 어떤 형태로든 객체화 해야합니다. 그때 사용되는 인터페이스가 FunctionN 시리즈 입니다. JVM 환경에서 다음의 람다식은

val lambda: () -> Unit = { 
// code
}

아래와 같이 컴파일 됩니다.

Function0<Unit> lambda = new Fuction0<Unit>() { 
punlic Unit invoke() {
// code
}
}

따라서 람다를 전달받는 함수가 있을 경우 Function0 을 전달받도록 컴파일 됩니다. 하지만 이때 inline 키워드를 사용하게 되면 인자로 받는 함수가 그대로 복사됩니다. 이 두가지 옵션은 큰 차이가 있습니다. 전자의 경우 “객체 생성 비용 줄이기" 에서 알아봤던 불필요한 객체가 생성되어야 하는 점이죠. 이 차이는 일반적으로는 크지 않아 실제 사례에서는 중요하지 않게 생각될 수도 있습니다. 하지만 테스트를 통해서 명확하게 차이를 확인할 수 있습니다.

inline fun repeat1(times: Int, action: (Int) -> Unit) {
for(i in 0..times) {
action(i)
}
}
fun repeat2(times: Int, action: (Int) -> Unit) {
for(i in 0..times) {
action(i)
}
}
fun nothing() {}val one = measureTimeMillis {
repeat1(1000000000) {
nothing()
}
}
val two = measureTimeMillis {
repeat2(1000000000) {
nothing()
}
}
println("$one $two") // 1 482

repeat1()은 단순히 반복되는 수만큼 nothing() 을 호출하지만 repeat2()의 경우 반복되는 수만큼 Function1 객체를 생성하여 호출한다는 점에서 성능의 차이가 발생합니다. 객체 생성에 들어가는 비용이 성능에 어떤 영향을 미치는지는 이 페이지 에서 더 자세히 다루고 있습니다.

범위 외 리턴을 사용할 수 있습니다.

앞서 예시의 repeat2 함수는 iffor문과 제어 구조가 매우 흡사합니다. 하지만 다른점은 if나 for문과 다르게 코드 블럭 안에서 return이 불가능 합니다.

if (flag) {
nothing()
return
}
for (i in 1..100) {
nothing()
return
}
repeat2(100) {
nothing()
return // not allowed
}

전달 받은 Function 객체의 invoke() 함수가 호출되며 이미 제어권이 다른 함수로 넘어갔기 때문에 현재 함수를 return 시킬 수가 없기때문입니다. 당연하게도 인라인된 함수는 내용이 복사되는 형태로 컴파일 되기 때문에 이런 제약에서 자유로울 수 있습니다.

repeat1(100) {
nothing()
return // okay!
}

인라인 함수의 함정

이제껏 살펴본 바와같이 인라인 함수는 여러가지로 유용하지만 몇가지 제약도 존재합니다.

  • 인라인 함수를 재귀적으로 사용할 수 없습니다. 인라인 함수는 코드를 함수 본문으로 대체하기 때문에 재귀 호출을 한다면 코드는 무한히 늘어납니다.
  • 인라인 함수 내에서는 접근 제어자 사용이 제한됩니다. 함수의 접근 제어자 보다 제한적인 접근 제어자는 사용할 수 없습니다. 예를 들어 함수가 public 이라면 함수내에서는 internal 이나 private 클래스를 사용할 수 없습니다.
  • 인라인 함수는 코드의 양을 증가시킵니다. 함수의 내용이 그대로 복사되기 때문에 단순하게 많이 사용하는 것만으로도 코드의 양이 많아집니다. 특히 인라인 함수에서 인라인 함수를 호출하는 것은 기하급수적으로 코드의 양을 늘릴 수 있기 때문에 매우 주의해야 합니다.

2. Inline Class (value class)

inline은 함수 뿐 아니라 클래스에도 적용할 수 있습니다. 인라인 클래스는 Kotlin 1.3 버전에서 도입되었으며 기본 생성자에 하나의 파라메터만 갖고 있는 경우에 사용할 수 있습니다. 다음 코드를 참고해 주세요.

inline class 는 Kotlin 1.5 에서 deprecated 되었습니다. 대신 value class 로 사용합니다.

value class Language(val value: String) {
fun log() {
println("I am $value")
}
}

이 클래스를 아래와 같이 사용할때

val language: Language = Language("Kotlin")
language.log()

컴파일러는 위 코드를 다음과 같이 컴파일합니다.

val language: String = "Kotlin"
Language.`log-impl`(language)

예시 코드에서 확인 할 수 있듯 인라인 클래스를 사용하면 불필요한 객체를 생성하지 않아 성능에 영향을 주지 않으면서 특정 유형에 대한 wrapper 클래스를 만들 수 있습니다. 이런 인라인 클래스의 특성을 유용하게 활용할 수 있는 두가지 방법이 있습니다.

단위 표시

fun doAfter(time: Int, action: () -> Unit) 

이런 함수가 있다고 가정해봅시다. 함수의 파라메터로 전달되는 time의 값은 어떤 단위일까요? time은 시, 분, 초, ms, ns 어떤 단위일 수도 있습니다. 이렇게 단위의 값을 명확하게 표시하지 않아 잘못된 단위의 값을 넣는 것은 심각한 실수가 될 수 있습니다. 예를 들어 록히드 마틴에서 제작하고 NASA에서 발사한 화성 기후 궤도선 사건은 SI단위와 야드파운드법을 혼동하여 목표로 했던 화성에 도착하지 못했습니다. 그 결과 98년 당시 3800억에 달하는 비용을 쏟아 부은 프로젝트는 실패하고 말죠. 김리 글라이더 사건도 단위를 혼동하여 발생한 수많은 인명피해를 발생 시킬 수 있는 사건이었습니다. 이렇듯 단위를 명확하게 표시하지 않아 생겨나는 비용은 매우매우 비쌀 수 있습니다. 이런 혼동을 피하기 위해서 개발자가 할 수 있는 가장 쉽고 일반적인 방법은 파라메터의 이름에 단위를 포함하는 것입니다.

fun doAfter(timeMills: Int, action: () -> Unit)

훨씬 나아졌죠? 이제 함수를 사용하기전 확인한다면 time의 값으로 millisecond 가 사용되는걸 쉽게 유추할 수 있습니다. 하지만 파라메터가 아니라 반환값일 경우에는 어떨까요? 함수의 이름에도 반환값을 표현할 수 있겠지만 이런 방법은 함수 이름의 길이를 더 길게 만들고 때로는 함수의 디자인 방향과 맞지 않을 수 있습니다. 파라메터나 함수의 이름을 변경하여 단위를 나타내는 것보다 더 나은 대안으로 인라인 클래스를 활용할 수 있습니다.

value class Millis(val value: Int)
fun doAfter(time: Millis, action: () -> Unit)

위와 같이 사용하면 불필요한 데이터 클래스나 wrapper 클래스를 정의하지 않고도 오용의 위험성 없이 단위를 나타낼 수 있습니다. 이것만으로도 이미 훌륭하지만 아마도 리펙토링에 욕심이 있는 사람이라면 더 나아간 형태를 생각할 수도 있습니다. 일반적으로 이런 코드는 아래과 같이 개선해볼 여지가 있겠죠.

interface TimeUnit {
val millis: Long
}
value class Second(val value: Int): TimeUnit {
override val millis: Long get() = value * 1000L
}
value class Millis(val value: Int): TimeUnit {
override val millis: Long get() = value
}
fun doAfter(time: TimeUnit, action: () -> Unit)

이렇게 인터페이스를 사용하여 구현하면 다양한 시간단위를 입력 받도록 좀 더 유연하게 구현할 수 있습니다. 하지만 인라인 클래스가 인터페이스로 사용될 경우에는 인라인 될 수 없습니다. 다음 코드를 컴파일한 결과를 각각 비교해보세요.

fun whatTime(time: Second) {
println("Time is ${time.millis}")
}
fun whatTime(time: TimeUnit) {
println("Time is ${time.millis}")
}

위 함수는 각각 다음과 같이 컴파일 됩니다.

public final void whatTime(int time) {
System.out.println("Time is " + Second.getMillis-impl(time));
}
public final void whatTime(@NotNull TimeUnit time) {
System.out.println("Time is " + time.getMillis());
}

파라메터를 인라인 클래스 Second로 전달 받는 함수는 컴파일 이후 기대했던대로 int 값을 전달 받지만 인터페이스인 TimeUnit 형태로 사용된 함수는 TimeUnit 객체를 그대로 전달 받습니다. 이처럼 인터페이스를 통해 인라인 클래스를 사용하는것은 성능상 유리함을 얻을 순 없습니다.

타입 miss 방지

데이터를 다룰때 여러 비슷한 유형의 값을 가진 데이터를 구조화해야 하는 경우가 있습니다. 예를 들어 게시글을 데이터로 만들땐 Int 형으로 이뤄진 게시글 Id, 게시판 Id, 작성자 Id 등이 있을 수 있겠죠. 이런 게시글을 Sqlite 데이터 베이스에 저장하기 위해서는 다음과 같은 형태가 필요합니다.

class Article { 
@ColumnInfo(name = "article_id") val articleId: Int,
@ColumnInfo(name = "writer_id") val writerId: Int,
@ColumnInfo(name = "board_id") val boardId: Int
}

위와 같이 Id 값이 모두 Int형일 경우 이런 값들을 서로 잘 못 할 가능성이 있고 또한 잘못된 값이 입력되는 것으로 부터 컴파일러가 보호해줄 수 없습니다. 이런 경우에도 인라인 클래스가 좋은 해결책이 될 수 있습니다.

value class ArticleId(val value: Int)
value class WriterId(val value: Int)
value class BoardId(val value: Int)
class Article {
@ColumnInfo(name = "article_id") val articleId: ArticleId,
@ColumnInfo(name = "writer_id") val writerId: WriterId,
@ColumnInfo(name = "board_id") val boardId: BoardId
}

각각의 Id를 인라인 클래스로 선언하여 사용하면 오용의 위험성이 없이 안전하게 사용할 수 있으며 컴파일시 모든 값이 Int로 대체되기 때문에 데이터베이스에도 올바른 값이 전달 됩니다. 이와같이 인라인 클래스를 활용하면 이전엔 존재하지 않던 Int이면서 동시에 ArticleId 이기도한 새로운 유형을 사용할 수 있으며 성능에는 영향을 주지 않으며 더 안전한 코드를 얻을 수 있습니다.

참고 자료:
https://blogs.oracle.com/javamagazine/behind-the-scenes-how-do-lambda-expressions-really-work-in-java
https://kotlinlang.org/docs/inline-functions.html
https://kotlinlang.org/docs/inline-classes.html
https://leanpub.com/effectivekotlin

--

--

Jaeho Choe
Jaeho Choe

No responses yet