[Kotlin] generic 타입 & reified 키워드

allocProc
9 min readJul 25, 2022

--

이번 글에서는 다음과 같은 내용을 설명합니다.

  • 제네릭 타입 파라미터란?
  • JVM 에서 제네릭 타입은 어떻게 구현되어 있는가?
  • 기존 제네릭 타입의 한계점
  • 이를 극복하기 위한 코틀린의 reified 키워드
  • 제가 reified 키워드를 유용하게 활용한 사례

제네릭 타입 파라미터

제네릭 타입이란 타입을 파라미터로 받는 클래스나 인터페이스 혹은 메소드를 말한다. 자바 1.5 에서 처음으로 도입된 기능인데 기본적인 모양은 다음과 같다.

위 처럼 자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 <> 기호를 클래스 이름 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다.

코틀린의 제네릭 타입 파라미터는 자바의 그것과 비슷하다. 하지만 완전히 같지는 않다. 지금부터 그 차이를 살펴보자.

제네릭은 어떻게 동작하는가?

JVM 의 제네릭스는 타입 소거(type erasure)를 사용해 구현된다. 이는 런타임 시점에 제네릭 클래스의 인스턴스에는 타입 파라미터의 정보가 사라진다는 뜻이다. 정확한 설명을 위해 오라클 공식 문서의 설명을 가져왔다.

Generics were introduced to the Java language to provide tighter type checks at compile time and to support generic programming. To implement generics, the Java compiler applies type erasure to:
- Replace all type parameters in generic types with their bounds or Object if the type parameters are unbounded. The produced bytecode, therefore, contains only ordinary classes, interfaces, and methods.

제네릭 타입은 제네릭은 컴파일 타입에만 제약을 가하고 런타임에 바운드 타입이 있는 경우 해당 바운드 타입으로, 없는 경우 Object 로 대체된다.

  • bounded type → bound type
  • unbounded type → Object

코틀린도 마찬가지로 제네릭 타입 파라미터 정보는 런타임에 지워진다. 예를 들어 List<String> 객체를 만들고 그 안에 문자열을 여럿 넣더라도 런타임에는 그 객체를 오직 List 로만 볼 수 있다.

val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)

컴파일러는 위의 두 리스트를 서로 다른 타입으로 인식하지만 런타임 시점에 그 둘은 완전히 같은 타입의 객체다. 그럼에도 우리가 보통 List<String> 에는 문자열만 들어있고 List<Int> 에는 정수만 들어있다고 가정할 수 있는 이유는 컴파일러가 올바른 타입만 각 리스트에 넣도록 보장해주기 때문이다.

타입 소거로 인한 제네릭의 한계

타입 소거로 인해 타입 정보가 사라지기 때문에 런타임에 타입 파라미터를 검사할 수 없다. 예를 들어 어떤 리스트가 문자열로 이루어졌는지 혹은 다른 객체로 이루어 졌는지 검사할 수가 없다는 뜻이다.

위처럼 어떤 객체가 List 인지의 여부는 is List<*> 와 같은 문법으로 알아낼 수 있어도(이를 star projection 이라고 부른다.) 그 리스트가 구체적으로 문자열로 이루어진 리스트인지, 혹은 Person 클래스의 리스트인지는 알 수가 없다. 그런 구체적인 정보는 지워진다.

하지만 코틀린에서는 제네릭 타입의 is 검사가 수행될 수 있는 몇가지 경우가 있는데, 대표적인 사례가 아래처럼 컴파일 시점에 타입 정보가 주어진 경우다.

val value = listOf<String>("abcd", "efg")
if (value is List<String>) { // 이 검사는 허용된다.
}

위 코드 처럼 코틀린 컴파일러는 컴파일 시점에 명시적으로 타입을 알 수 있다면 타입 검사의 수행을 허용한다. 물론 이런 경우가 많지는 않을 것이다.

여기까지만 보면 코틀린도 자바와 마찬가지로 제네릭 함수의 본문에서 해당 타입 파라미터를 가리킬 수 없다. 하지만 코틀린의 inline 함수 안에서는 어떨까? 함수의 본문을 그대로 옮기는 inline 함수라면 이 제약을 극복할 수 있어보인다.

reified 키워드

앞에서 이야기했듯이 코틀린 제네릭 타입의 타입 파라미터 정보는 런타임에 지워진다. 따라서 제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 생성할 때 사용한 타입 파라미터 정보를 알아낼 수 없다. 제네릭 함수의 타입 파라미터 또한 마찬가지다.

하지만 코틀린에는 이런 제약을 피할 수 있는 경우가 하나 있다. inline 함수의 타입 파라미터는 런타임 시점에도 알 수 있다.

이것이 어떻게 가능한지 잠시 생각해보자. 어떤 함수에 inline 키워드를 붙이면 컴파일러는 그 함수를 호출한 식을 모두 함수 본문으로 바꾼다. 해당 함수의 바이트 코드를 직접 갖다 붙인다는 뜻이다. 먼저 코드로 살펴보자.

바로 위에서 살펴본 isA 함수를 (1) inline 함수로 만들고 (2) 타입 파라미터에 reified 키워드를 붙이자. 이러면 런타임 시점에 value 의 타입이 T 의 인스턴스인지를 검사할 수 있다.

inline fun <reified T> isA(value: Any) = value is T

위에서 설명한 inline 함수의 특성을 이용하여 컴파일러는 타입 파라미터로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있다. 이로 인해 inline 함수에서 reified 를 사용한 타입 파라미터를 실체화할 수 있는 것이다.

대부분의 자바 라이브러리 코드를 보면 Class<T> 타입을 메서드 파라미터로 받는 걸 봤을 것이다. 이는 타입 파라미터를 구체화할 수 없었기 때문이다. 대표적인 예시가 Jackson 이나 Gson 같은 json 라이브러리이다.

위 처럼 타입 파라미터를 메소드 인자로 받는다. 그래서 아래와 같이 사용해야했다.

val person = objectMapper.readValue(json, Person::class.java)

하지만 코틀린의 reified 를 활용한다면 다음 처럼 타입 파라미터로 받을 수 있다.

val person = objectMapper.readValue<Person>(json)

훨씬 짧고 간결하다! 클래스를 메소드 파라미터가 아니라 타입 파라미터로 전달할 수 있다. ::class.java 처럼 쓸 때 보다 훨씬 쓰기 쉽고 읽기도 쉽다.

물론 함수가 자바로 구현되었거나 코틀린으로 구현되었더라도 inlinereified 로 구현되지않았다면 자바를 사용할때처럼 타입을 메소드 파라미터로 직접 전달해야한다.

reified 파라미터의 제약

reified 타입 파라미터는 아주 유용한 도구지만 몇 가지 제약이 있다. 일부는 코틀린이 실체화를 구현하는 방식에 의해 생기는 제약으로 향후 완화될 가능성이 있다.

코틀린은 다음과 같은 경우에만 reified 타입 파라미터를 사용할 수 있다.

  • 타입 검사(is, !is) 와 캐스팅(as, as?)
  • 코틀린 리플렉션 API(::class)
  • 코틀린 타입에 대응하는 java.lang.Class 를 얻기(::class.java)
  • 다른 함수를 호출할 때 타입 인자로 사용

하지만 다음과 같은 경우에는 사용할 수 없다.

  • 타입 파라미터 클래스의 인스턴스 생성
  • 타입 파라미터 클래스의 companion method 호출
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified 로 지정하기

마지막 제약은 달리 말하면 reified 타입 파라미터를 사용하는 모든 함수는 반드시 인라인 되어야 한다는 뜻이다.

reified 키워드를 내가 활용한 곳

보통 Java, Kotlin 를 사용하는 애플리케이션(android 혹은 spring)에서 외부 api 를 HTTP 로 호출할 때는 거의 대부분 Retrofit 을 사용한다. 그리고 retrofit 을 사용하려면 호출하려는 url 마다 retrofit 인스턴스를 builder 형태로 생성해야하는데, 이는 똑같은 6–7 줄 정도의 코드를 매번 반복 작성하게한다.

개발자가 Retrofit 인터페이스를 정의하고 매번 그 구현체를 위처럼 Retrofit 이 제공하는 Builder 패턴으로 생성하는 코드를 작성한다. 위 코드는 마지막의 create 메소드 때문에 따로 제네릭 메소드로 분리하기 어려웠다. 하지만 코틀린의 reified 타입 파라미터를 활용하면 다음처럼 작성할 수 있다.

위 제네릭 메소드를 사용하면 retrofit 구현체를 생성하는 과정이 다음 코드처럼 단순화된다.

inline 과 reified 덕분에 타입 파라미터를 런타임에 실체화할 수 있었고 이를 이용해 Retrofit 구현체를 생성하는 부분을 제네릭 메소드로 분리할 수 있었다.

etc

마지막으로 reified 라는 영어 단어 본래의 의미를 알고 넘어가자.

영어 단어의 뜻 그대로 기존 런타임에서는 알 수 없었던 모호한 타입 파라미터 정보를 구체화 시킨다는 뜻으로 사용했다.

--

--