본문 바로가기
Kotlin

[Kotlin in Action] 9.2 실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터

by Nhahan 2025. 4. 2.

JVM의 제네릭스는 보통 타입 소거(type erasure)를 사용해 구현된다. 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻이다. 이번 절에서는 코틀린 타입 소거가 실용적인 면에서 어떤 영향을 끼치는지 살펴보고 함수를 inline으로 선언함으로써 이런 제약을 어떻게 우회할 수 있는지 살펴본다. 함수를 inline으로 만들면 타입 인자가 지워지지 않게 할 수 있다(코틀린에서는 이를 실체화reify라고 부른다). 실체화한 타입 파라미터에 대해 자세히 다루고 실체화한 타입 파라미터가 유용한 이유를 예제를 통해 알아보자.

 

9.2.1 실행 시점의 제네릭: 타입 검사와 캐스트

자바와 마찬가지로 코틀린 제네릭 타입 인자 정보는 런타임에 지워진다. 이는 제네릭 클래스 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻이다. 예를 들어, List<String> 객체를 만들고 그 안에 문자열을 여럿 넣더라도 실행 시점에는 그 객체를 오직 List로만 볼 수 있다. 그 List 객체가 어떤 타입의 원소를 저장하는지 실행 시점에는 알 수 없다

 

코드를 실행할 때 다음 두 리스트에 어떤 일이 벌어지는지 생각해보자.

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

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

다음으로 타입 소거로 인해 인해 생기는 한계를 살펴보자. 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다. 예를 들어 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 리스트인지를 실행 시점에 검사할 수 없다. 일반적으로 말하자면 is 검사에서 타입 인자로 지정한 타입을 검사할 수는 없다. 다음 코드는 컴파일 시 오류를 발생시킨다.

>>> if (value is List<String>) { ... }
ERROR: Cannot check for instance of erased type

실행 시점에 어떤 값이 List인지 여부는 확실히 알아낼 수 있지만 그 리스트가 String의 리스트인지, Person의 리스트인지 혹은 다른 어떤 타입의 리스트인지는 알 수가 없다. 그런 정보는 지워진다. 다만 저장해야 하는 타입 정보의 크기가 줄어들어서 전반적인 메모리 사용량이 줄어든다는 제네릭 타입 소거 나름의 장점이 있다.

 

앞에서 말한 대로 코틀린에서는 타입 인자를 명시하지 않고 제네릭 타입을 사용할 수 없다. 그렇다면 어떤 값이 집합이나 맵이 아니라 리스트라는 사실을 어떻게 확인할 수 있을까? 바로 스타 프로젝션(star projection)을 사용하면 된다.

if (value is List<*>) { ... }

타입 파라미터가 2개 이상이라면 모든 타입 파라미터에 *를 포함시켜야 한다. 스타 프로젝션에 대해서는 뒤에서 더 자세히 다룬다. 지금은 인자를 알 수 없는 제네릭 타입을 표현할 때(자바의 List<?>와 비슷함) 스타 프로젝션을 쓴다고만 알아두면 된다. 앞의 예제에서 value가 List임을 알 수는 있지만 그 원소 타입은 알 수 없다.

as나 as? 캐스팅에도 여전히 제네릭 타입을 사용할 수 있다. 하지만 기저 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다는 점을 조심해야 한다. 실행 시점에는 제네릭 타입의 타입 인자를 알 수 없으므로 캐스팅은 항상 성공한다. 그런 타입 캐스팅을 사용하면 컴파일러가 "unchecked cast"라는 경고를 해준다. 하지만 컴파일러는 단순히 경고만 하고 컴파일을 진행하므로 다음 코드처럼 값을 원하는 제네릭 타입으로 캐스팅해 사용해도 된다.

// 제네릭 타입으로 타입 캐스팅하기
fun printSum(c: Collection<*>) {
    val intList = c as? List<Int> // <- Unchecked cast: List<*> to List<Int> 경고 발생
        ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}
>>> printSum(listOf(1, 2, 3))
6

컴파일러가 캐스팅 관련 경고를 한다는 점을 제외하면 모든 코드가 문제없이 컴파일된다. 하지만 잘못된 타입의 원소가 들어있는 리스트를 전달하면 실행 시점에 ClassCastException이 발생한다.

>>> printSum(setOf(1, 2, 3)) // 집합은 리스트가 아니므로 예외 발생
IllegalArgumentException: List is expected

>>>printSum(listOf("a", "b", "c")) // as? 캐스팅은 성공하지만 나중에 다른 예외가 발생한다.
ClassCastException: String cannot be cast to Number

 

코틀린 컴파일러는 컴파일 시점에 타입 정보가 주어진 경우네는 is 검사를 수행하게 허용할 수 있을 정도로 똑똑하다.

// 알려진 타입 인자를 사용해 타입 검사하기
fun printSum(c: Collection<Int>) {
    if (c is List<Int>) { // 이 검사는 올바르다.
        println(c.sum())
    }
}

위는 컴파일 시점에 c 컬렉션이 Int값을 저장한다는 사실이 알려져 있으므로 c가 List<Int>인지 검사할 수 있다.

 

9.2.2 실체화한 타입 파라미터를 사용한 함수 선언

inline 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.

어떤 함수에 inline 키워드를 붙이면 컴파일러는 그 함수를 호출한 식을 모두 함수 본문으로 바꾼다. 함수가 람다를 인자로 사용하는 경우 그 함수를 인라인 함수로 만들면 람다 코드도 함께 인라이닝되고, 그에 따라 무명 클래스와 객체가 생성되지 않아서 성능이 더 좋아질 수 있다.

// 실체화한 타입 파라미터를 사용하는 함수 정의하기
inline fun <reified T> isA(value: Any) = value is T // 이 코드는 컴파일 가능하다.
>>> println(isA<String>("abc"))
true
>>> pritnln(isA<String>(123))
false

실체화한 타입 파라미터를 사용하는 예를 살펴보자. 실체화한 타입 파라미터를 활용하는 가장 간단한 예제 중 하나는 표준 라이브러리 함수인 filterIsInstance다. 이 함수는 인자로 받은 컬렉션의 원소 중에서 타입 인자로 지정한 클래스의 인스턴스만을 모아서 만든 리스트를 반환한다. 다음 예제는 filterIsInstance 사용법을 보여준다.

// filterIsInstance 표준 라이브러리 함수 사용하기
>>> val items = listOf("one", 2, "three")
>>> println(items.filterIsInstance<String>())
[one, three]

filterIsInstance의 타입 인자로 String을 지정함으로써 문자열만 필요하다는 사실을 기술한다. 이 함수의 반환 타입은 따라서 List<String>이다. 여기서는 타입 인자를 실행 시점에 알 수 있고, filterIsInstance는 그 타입 인자를 사용해 리스트의 원소 중에 타입 인자와 타입이 일치하는 원소만을 추려낼 수 있다.

 

9.2.3 실체화한 타입 파라미터로 클래스 참조 대신

java.lang.Class 타입 인자를 파라미터로 받는 API에 대한 코틀린 어댑터(adapter)를 구축하는 경우 실체화한 타입 파라미터를 자주 사용한다. java.lang.Class를 사용하는 API의 예로는 JDK의 ServiceLoader가 있다. ServiceLoader는 어떤 추상 클래스나 인터페이스를 표현하는 java.lang.Class를 받아서 그 클래스나 인스턴스를 구현한 인스턴스를 반환한다. 실체화한 타입 파라미터를 활용해 이런 API를 쉽게 호출할 수 있게 만드는 방법을 살펴보자.

표준 자바 API인 ServiceLoader를 사용해 서비스를 읽어 들이려면 다음 코드처럼 호출해야 한다.

val serviceImpl = ServiceLoader.load(Service::class.java)

::class.java 구문은 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법을 보여준다. Service::class.java라는 코드는 Service.class라는 자바 코드와 완전히 같다.

이제 이 예제를 구체화한 타입 파라미터를 사용해 다시 작성해보자.

val serviceImpl = loadService<Service>()

이제는 읽어 들일 서비스 클래스를 loadService 함수의 타입 인자로 지정한다. 클래스를 타입 인자로 지정하면 ::class.java라고 쓰는 경우보다 훨씬 더 읽고 이해가 쉽다.

loadService 함수를 어떻게 정의할 수 있는지 살펴보자.

inline fun <reified T> loadService() { // 타입 파라미터를 "reified"로 표시한다.
    return ServiceLoader.load(T::class.java) // T::class로 타입 파라미터의 클래스를 가져온다.
}

일반 클래스에 사용할 수 있는 ::class.java 구문을 이 경우에도 사용할 수 있다. 이를 통해 타입 파라미터로 지정된 클래스에 따른 java.lang.Class를 얻을 수 있고, 그렇게 얻은 클래스 참조를 보통 때와 마찬가지로 사용할 수 있다.

 

9.2.4 실체화한 타입 파라미터의 제약

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

 

다음과 같은 경우에 실체화한 타입 파라미터를 사용할 수 있다.

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

다음과 같은 일은 할 수 없다.

  • 타입 파라미터 클래스의 인스턴스 생성하기
  • 타입 파라미터 클래스의 동반 객체 메소드 호출하기
  • 실체화한 타입 파라미터를 요구하는 함수로 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

"클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기" 이 제약으로 인해 한가지 흥미로운 파급 효과가 생긴다. 실체화한 타입 파라미터를 인라인 함수에만 사용할 수 있으므로 실체화된 타입 파라미터를 사용하는 함수는 자신에게 전달되는 모든 람다를 인라이닝한다. 람다 내부에서 타입 파라미터를 사용하는 방식에 따라서는 람다를 인라이닝할 수 없는 경우가 생기기도 하고, 성능 문제로 람다를 인라이닝하고 싶지 않을 수도 있다. 이런 경우 noinline 변경자를 함수 타입 파라미터에 붙여서 인라이닝을 금지할 수 있다.

 

반응형