[Kotlin] inline class

2021. 4. 9. 02:12카테고리 없음

 

Inline class

왜 등장했나?

자바는 int, double 등 원시 타입이 존재하고 Integer, Double과 같은 원시 타입의 래퍼 클래스가 존재한다. 코틀린은 직접적인 래퍼 클래스를 제공하지는 않지만 Int, Double 등의 클래스는 필요에 의해 boxing 또는 unboxing 되며 때로는 원시타입처럼 때로는 래핑타입처럼 동작한다.

그런데 값 객체를 감싸서 사용하는 경우에 성능저하가 일어나는 문제가 발생하기 시작했다. 예를 들어, 갯수를 세는 객체를 class Count(val value: Int)이라는 클래스로 감싸서 사용하고 있었는데, 이 객체가 코드 상에서 너무 많이 생성되고 사라지는 빈도가 높다보니 JVM에 너무 많은 부하를 주는 것이다. 특히 Int 같이 원시타입으로 동작하는 경우 런타임에 최적화 되는 코드들이 많은데 Count 객체를 이용하면 이러한 혜택을 전혀받을 수 없어 객체화와 성능 사이에서 고민하는 경우가 생겨났다. 그러다보니 이러한 요구사항이 발생하기 시작했다.

Int, Double 등 기본으로 제공되는 래핑 타입 외에도 다른 타입들을 래핑할 수 있으면 좋겠다.

사용법

코틀린은 이를 해결하기 위해 inline 클래스를 도입했다. 사용법은 간단하다. 맨 앞에 inline 키워드만 붙여주면 된다.

inline class Count(val value: Int)

// Count의 인스턴스화가 실제로 일어나지 않는다
// 따라서 현재 코드에서 count는 런타임에 Int처럼 동작한다
val count = Count(1)

inline class의 몇 가지 주의할 점과 특징에 대해서 알아보자.

  • 프로퍼티는 하나만 허용된다. 객체의 래핑타입을 지원하기 위한 것이기 때문에 당연한 얘기다. (자바의 Integer 클래스의 필드가 2개라고 생각해보자)
  • 프로퍼티는 뒷받침 필드(backing field)를 갖지 않는다. 따라서 lateinit, delegate을 사용할 수 없다.
  • 프로퍼티는 불변이다. 즉, var를 사용할 수 없다.
  • 인터페이스를 구현할 수 있다.
  • 항상 final이다. 즉, 다른 클래스가 inline 클래스를 상속할 수 없다.

Boxing과 Unboxing

아래는 inline 클래스가 어느 때 boxing 되고 unboxing 되는지 확인할 수 있는 코드이다.

interface Countable

inline class Count(val value: Int) : Countable

fun asInline(c: Count) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: Countable) {}
fun asNullable(i: Count?) {}

fun <T> id(x: T): T = x

fun main() {
    val c = Count(42)

    asInline(c)    // unboxed: Count가 돌아다니기 때문에 객체가 생성되지 않는다.
    asGeneric(c)   // boxed: Generic으로 사용되기 때문에 Boxing되며 객체가 생성된다.
    asInterface(c) // boxed: 인터페이스로 사용되기 때문에 Boxing되며 객체가 생성된다.
    asNullable(c)  // boxed: Nullable한 타입이기 때문에 Boxing되며 객체가 생성된다.

    // Generic으로 먼저 변환되어 boxing 되어 넘어가지만, 타입 캐스팅 이후에 반환되기 때문에 다시 unboxing 된다.
    // 따라서 i는 unboxing 된 Count 타입이다
    val i = id(c)
}

Mangling (함수 이름바꾸기)

아래와 같은 코드를 보자.

inline class Count(val value: Int)

class CustomNumber {
    fun plus(value: Int) {}

    fun plus(value: Count) {}
}

위에서 Count는 런타임에 Int처럼 동작한다고 했다. 그렇다면 위 코드는 동일한 시그니처를 갖고 있기 때문에 동작하지 않을 것이다. 이를 해결하기 위해 코틀린은 inline class를 사용하는 메서드의 이름을 멋대로 바꿔버린다. 위 코드가 변환된 바이트 코드를 확인해보자.(Intelij에서는 Kotlin 바이트 코드를 손쉽게 확인할 수 있다. Tools - Kotlin - Show Kotlin Byte Codes)

무슨 말인지는 모르겠지만 아무튼 Count를 사용하는 메서드의 이름이 plus-해쉬코드로 변했다는 사실을 알 수 있다.

여기서 한 가지 문제가 생긴다. 바로 자바 리플렉션이다. 자바 리플렉션은 이 바이트 코드를 사용하기 때문에 리플렉션을 통해 위 메서드를 부를 수가 없다. 아래 코드를 보면 확실히 알 수 있다.

val kotlinCN = CustomNumber::class
val javaCN = CustomNumber::class.java

assertThat(kotlinCN.declaredFunctions.filter { it.name == "plus" }).hasSize(2) // success
assertThat(javaCN.declaredMethods.filter { it.name == "plus" }).hasSize(2)     // fail

위 코드에서 코틀린 리플렉션은 plus란 이름을 가진 메서드가 2개라는 확인하지만, 자바 리플렉션은 mangled method를 인식하지 못하는 모습을 보인다. 만약 내가 inline 클래스를 파라미터로 사용하고 있고, java 라이브러리를 사용하는데 NoSuchMethodException이 발생한다면 이를 의심해볼 수 있을 것이다.

Synthetic Constructor

사실 이 글을 쓰게 된 이유이다. 위의 내용들은 모두 kotlin 공식 docs 를 번역하고 살짝 살을 붙인 내용들이다. 하지만 공식 문서에 synthetic constructor 관련 내용이 없었고, Spring-Data를 사용할 때 관련한 버그의 원인을 찾다보니 여기까지 오게 되었다.

 

아래 Button 클래스를 보자.

class Button(val count: Count)

위 Mangling의 내용을 상기해본다면 생성자도 mangling 될까? 아쉽게도 생성자에는 메서드명이 없다. 따라서 mangling이 불가능하다. 코틀린에서는 이를 해결하기 위해 특이한 전략을 사용한다.

class Button private constructor(val count: Int) {
    constructor(count: Int, marker: DefaultConstructorMarker) : this(count)
}

컴파일된 바이트코드는 위와 같이 작동한다(이해를 쉽게 하기 위한 예시기 때문에 정확히 위와 같이 바뀌진 않는다). Int를 파라미터로 받는 생성자를 private으로 감싸고 다른 collision을 피하기 위해 DefaultConstructorMarker를 추가한 다른 생성자를 열어준다. 그렇기 때문에 synthetic(소스 코드 상으로 명시한 생성자와 컴파일러가 생성한 생성자의 내용이 다르다는 것을 명시하는 기능) constructor가 생긴다고 하는 것이다.

 

코틀린을 만든 사람들은 이런 로우레벨의 내용을 개발자들이 알 필요 없게 만들고 싶었기 때문에 문서화를 하지 않은 것 같다. 하지만 mangling과 마찬가지로 java에서 리플렉션을 사용할 때 에러를 마주하게 된다.

val javaButton = Button::class.java
javaButton.constructors[0].newInstance(Count(1)) // java.lang.IllegalArgumentException: wrong number of arguments

분명 Button의 생성자 파라미터는 Count 하나인데, 파라미터의 갯수가 잘못되었다는 예외가 발생한다. 바로 DefaultConstructorMarker의 존재 때문이다. 

 

Spring Data 라이브러리는 DB의 데이터를 객체로 변환할 때 리플렉션을 통해 생성자를 얻고 이를 invoke하는 방식을 사용한다. 문제는 Spring Data는 자바 라이브러리이기 때문에 inline 클래스를 제대로 가져오지 못한다. 따라서 Spring Data를 사용하는 객체에 inline class가 포함되어 있다면 1. 빈을 생성할 수 없다는 UnsatisfiedDependencyException 그리고 그 내부 에러인 2. 실제 파라미터의 갯수와 생성자의 파라미터 갯수가 달라서 생기는 IndexOutOfBoundException이 같이 뜨게 된다. 

 

여담

스프링 데이터 진영에서 이 문제가 제기된지 2년이 지났지만 따로 움직임은 없다. 다만 스프링 쪽에서 코틀린 개발진들에게 private 생성자를 public으로 열어달라고 요청한 내용은 있다. 이후 진행사항은 모르겠다.

현재(2021.04.09) 기준으로 코틀린 버전은 1.4.30이다. 현 버전에서 inline class는 experimental 기능이다. 하지만 다음 버전인 1.5버전부터 공식적으로 inline 클래스를 공식적으로 지원할 예정이다. 정식 출시 전에는 이 문제가 고쳐졌으면 하는 바람이 있다.