[Kotlin] Generic - 제네릭과 타입안정성
프로젝트를 진행하면서 코드의 재사용을 위해 제네릭 함수를 사용하거나, Collection 과 같은 제네릭 클래스들을 빌트인 자료구조로 편하게 사용하곤 합니다. 이때까지 제네릭은 객체지향 프로그래밍에서 고급 기법으로 학부시절에도 그 이론을 이해하기가 너무 어려웠습니다. 그래서 관련 아티클들을 읽고, Kotlin-In-Action 책으로 코틀린 개발자들이 자바의 제네릭을 좀 더 사용하기에, 또 이해하기 쉽도록 구현하고 설명한 내용들을 학습해보았고, 이를 토대로 정리해 보고자 합니다.
제네릭
제네릭의 어원인 Generic 은 “일반적인” 이라는 뜻입니다. 그 단어에서 유추 가능하듯, 어떤 클래스나 인터페이스 또는 함수를 타입에 관계없이 일반화 하는 형태로 사용됩니다. 가령, 일반화한다는 것은 공통적인 것들을 추출한다 는 의미입니다. 가장 흔하게 추상클래스가 그 역할을 위해 사용되곤 합니다. 그와 달리 핵심적인 것만 드러내고 구체적인 것들은 구현체에 의존하게 하는 추상화와 같다고 생각해서는 안됩니다. 인터페이스와 추상클래스의 차이가 그 예시입니다. 이 둘은 같지 않지만 결국 코드의 재 사용성과 리펙토링의 용이를 높여준다는 점에서의 공통적인 목표를 위해 사용되고 있습니다.
자바에서 제네릭은 여러 타입들에 공통적인 기능들을 수행하기 위해 정의하는 기법 이라고 볼 수 있습니다. 타입에 대해 일반화 하기 위해 런타임에 제네릭 클래스의 인스턴스가 생성되거나, 제네릭 함수가 호출되는 부분에서 타입이 정해지기 때문에 이때 결정하는 타입에 의존하게 됩니다. 하지만 런타임에 타입이 결정된다고 해서 컴파일 타임에 타입에 관해 검증할 수 없다면, 런타임에 타입과 관련된 예외가 발생하여 사용자에게 나쁜 사용자 경험을 제공할 수도 있습니다.
따라서 자바에서는 제네릭을 사용하는 경우 컴파일 타임에 변성이나 실체화(Reified)와 같은 방법을 통해 최대한 검증하여 컴파일 예외로 개발자에게 알려주기 위해 최대한 노력합니다. 이 방법들을 하나하나 살펴보는 것이 해당 포스팅의 주 목적입니다.
먼저, 제네릭이 뭔지 이해하기 위해 가장 흔하게 사용되는 Collection 의 List 를 예시로 살펴보겠습니다.
public interface List<out E> : Collection<E> {
override val size: Int
override fun isEmpty(): Boolean
override fun contains(element: @UnsafeVariance E): Boolean
override fun iterator(): Iterator<E>
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
public operator fun get(index: Int): E
public fun indexOf(element: @UnsafeVariance E): Int
public fun lastIndexOf(element: @UnsafeVariance E): Int
public fun listIterator(): ListIterator<E>
public fun listIterator(index: Int): ListIterator<E>
public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
자바의 List 는 같은 타입을 가지는 여러 데이터들을 집합 형태로 관리하기 위해 데이터가 중복될 수 있고, 순서가 있는 특성을 가지는 Collection 으로 데이터의 접근을 위한 기능과 연산자들을 제공하는 인터페이스 입니다.
List의 선언부에서는 어떤 타입으로 내부 데이터를 다룰지 관심을 두지 않습니다. 이처럼 내부에서 사용할 데이터 타입은 모르게 하고, List라는 자료구조를 제공하기 위한 책임만 갖고, 그에 대한 기능들을 제공하기 위해 공통적인 것들만 추상적으로 모아 놓았습니다. 나중에 해당 구현체의 인스턴스가 생성될 때, 어떤 타입을 다룰지 결정하게 됩니다.
변성
List<out T> 의 형태에서 T를 타입 파라미터 혹은 타입 인자 라고 부릅니다. 제네릭의 타입 파라미터 앞에 붙은 out 은 변성의 개념중 공변성을 나타내기 위한 키워드 입니다. 또, List 와 같은 제네릭 인터페이스나 클래스를 기저타입 이라고 부릅니다.
List<Number> 을 예시로 들면, List 는 기저타입, 변성은 선언부에서 out 이 붙은 공변성, 그리고 타입 파라미터는 Number 가 됩니다. 제네릭의 타입은 기저타입과 타입파라미터를 합쳐서 결정되기 때문에 kotlin val a = List<Number>
에서 a라는 변수의 타입은 List<Number> 라고 말할 수 있습니다. 그렇다면, List<Number> 타입의 함수 인자로 정수 리스트를 전달할 수 있을까요? 정답은 가능하다 입니다. 다른 예시로, Array<Number> 타입의 함수 인자로 Array<Int> 를 전달할 수 있을까요? 정답은 불가능하다 입니다.
이를 이해하려면, 변성의 개념을 먼저 이해해야 합니다. 변성이란, 제네릭 타입이 타입 파라미터의 간의 관계로 부터 어떤 영향을 받는가 를 말합니다. 자바에서는 변성을 통해 제네릭의 타입 안정성을 컴파일 타임에 검증하기 위한 방법으로 이용하고 있습니다. 변성은 무공변성, 공변성, 반공변성 과 같이 3가지로 존재합니다.
무공변성
기본적으로 코틀린에서 변성에 대해 아무 표시도 하지 않으면 무공변성 을 따르게 됩니다. 무공변성은 제네릭 타입이 타입 파라미터 간의 관계로 부터 아무런 영향도 받지 않습니다. 따라서, 실제로 타입 파라미터간에 부모-자식 관계를 갖더라도, 제네릭은 그렇지 않게 됩니다.
앞서 본, Array<Number> 는 Array<T> 로, 무공변성을 따릅니다. 따라서, Int 는 Number 의 하위타입 이지만, Array<Number> 와 Array<Int> 는 아무런 관계가 없고 함수 인자로 전달할 경우 컴파일 에러가 발생하게 됩니다.
fun test(b: Array<Number>) {
b.set(4, 1)
}
fun abcd() {
val array: Array<Int> = arrayOf(1,2)
test(array) // --> Compile Error 발생: Type mismatch. Required: Array<Number> Found: Array<Int>
}
이렇게 되는 이유는 타입 안정성과 관련이 있습니다. 공변성과 반공변성을 살펴보면 이를 이해할 수 있습니다.
공변성
앞서 먼저 예시를 들었었던 List는 공변성을 나타내는 out 키워드로 선언되어 있습니다. 이를 “선언 지점 변성” 이라고도 합니다. 실제로 자바에서는 선언 지점 변성을 제공하지 않으며, 사용 지점 변성만을 이용할 수 있지만 코틀린은 두가지 방법을 모두 제공합니다.
공변성은 제네릭 타입간의 관계가 타입 파라미터 간의 관계를 유지하는 것 을 의미합니다. 따라서, 타입 파라미터간에 부모-자식의 관계를 가졌다면 제네릭에서도 부모-자식 관계를 유지하기 때문에, Number 의 하위타입인 Int 타입 파라미터를 가지는 List<Int> 를 함수의 List<Number> 인자로 전달할 수 있습니다.
fun test(a: List<Number>) {
a.get(0)
}
fun abcd() {
val list: List<Int> = listOf(1,2)
test(list) // --> Compile success
}
왜 공변성이라는 개념이 존재하고, 제네릭 타입 관계가 타입 파라미터의 관계를 유지하도록 만든 걸까요? 여기서 잠깐 멈춰 고민을 해보는 시간을 가져보시는 것도 좋을 것 같습니다.
중요한 이유는 제네릭의 목적에 의해 결국은 제네릭 클래스의 인스턴스를 생성하거나 제네릭 함수를 호출하는 부분에서 타입이 결정되기 때문에 타입 안정성 문제가 생기고 이를 해결하기 위해 변성을 통해 컴파일 타임에 검증한다는 것 입니다. 이 점을 염두해 두면서 계속해서 글을 읽으시면 이해하는데 도움이 될 것이라 생각됩니다.
반공변성
마지막으로 제네릭 타입 파라미터 앞에 in 키워드를 명시하면 반공변성을 나타냅니다. 이는 제네릭 타입간의 관계가 타입 파라미터간의 관계에서 역전되는 것 을 말합니다. 즉, 부모-자식 관계가 역전되어 MutableList<in Number> 를 함수 인자로 요구하는 경우, MutableList<Int>를 전달할 수 없습니다.
fun test(a: MutableList<in Number>) {
// TODO
}
fun abcd() {
val list: MutableList<Int> = mutableListOf(1,2)
test(list) // --> Compile Error 발생: Type mismatch. Required: MutableList<Number> Found: MutableList<Int>
}
여기서 예시가 조금 달라졌습니다. List 가 아닌, MutableList 로 반공변성의 예시를 들었습니다. 만약 MutableList 가 아닌, List를 함수 인자로 받을 때, in 키워드로 프로젝션(제약을 가함)할 경우(이를 사용 지점 변성 이라고 합니다.), 다음과 같은 컴파일 에러가 발생합니다.
Projection is conflicting with variance of the corresponding type parameter of List. Remove the projection or replace it with ‘*‘
위의 오류는 선언부(클래스)에서 읽기 전용(out)이라 명시한 상태에서, 사용부(함수)에서 반대의 변성(in)을 지정하여 충돌이 발생했다는 컴파일 에러 입니다. 이러한 오류가 발생한 이유는 List의 경우 변경 가능한 연산자와 기능이 제공되지 않는 읽기 전용 이고, 따라서 선언 지점에서 out 키워드를 명시하였는데, 사용 지점에서 in-프로젝션하여 충돌이 발생됬기 때문입니다.(사용 지점 변성은 무공변성으로 선언된 제네릭 타입에 사용하는 지점(함수)에서 필요에 따라 프로젝션 하기 위해 사용합니다. 이 부분은 뒤에서 더 이어서 설명하겠습니다.)
읽기(out) 전용
List 인터페이스는 읽기와 관련된 기능만 제공한다는 점을 선언부에서 확인하실 수 있습니다. 코틀린의 Collection 은 변경이 불가능한 인터페이스(List, Set, Map 등)와 변경이 가능한 인터페이스(MutableList, MutableSet, MutableMap 등)로 책임을 분리하도록 설계되어 있습니다. 이렇듯 읽기와 관련된 기능만 제공하고 있는 인터페이스들을 보면 out 이라는 키워드를 통해 공변성으로 선언되고 있습니다.
앞서 out 이 붙으면 공변성을 따르고, 제네릭 타입간의 관계가 타입 파라미터의 관계를 유지한다 고 했었습니다. 그렇다면 이제 왜 out 이 공변성 개념을 나타내며, 이것이 제네릭 타입간 관계가 유지하게 하고, 읽기 전용일 때 사용하는지에 대해 이해해볼 시점 입니다.
fun test(a: List<Number>): Number {
return a.get(0)
}
fun abcd() {
val intList: List<Int> = listOf(1,2)
test(list) // --> Compile success
}
해당 예시를 다시 살펴보면, List<Number> 를 파라미터로 요구하는 test 함수로 List<Int> 를 전달하고 있습니다. test 함수 내부에서는 리스트의 첫번째 요소를 읽어 해당 값을 다시 Number 타입으로 반환 하고 있습니다. test 함수 인자로 전달한 변수 intList 는 정수형 List 이고, Int 는 Number 의 하위타입 이기 때문에 다형성 원리를 근거로 Int 를 Number 로 캐스팅하여 반환할 수 있습니다.
즉, List 에서 값을 꺼내어 반환하는(읽는) 기능을 수행할 때 반드시 반환하고자 하는 함수 인자의 제네릭 타입에 전달하고자 하는 제네릭 타입이 같거나, 자식이어야만 합니다. 이러한 관계는 읽기 와 관련된 기능을 수행할 때 필연적입니다.
이해가 어려우 신가요? 그렇다면 가장 훌륭한 예시는 반대되는 상황을 살펴보는 것 입니다.
만약, 반대로 List<Int> 를 함수 인자로 요구하는 상황에서 List<Number> 를 전달한다면 어떨까요? Number 에는 숫자로써 Int 뿐만 아니라 Double, Long, Float 과 같은 타입들도 존재할 수 있습니다. 따라서 Int 의 부모-자식 관계가 아닌 형제 타입에 대해서 전달 받아 첫번째 요소를 꺼내어 Int 로 캐스팅하려 한다면 ClassCastException 런타임 예외가 발생할 것입니다.
fun test(a: List<Int>): Int {
return a.get(0) // --> 형제 타입들에 대해 Int 로 반환할 수 없습니다.
}
fun abcd() {
val intList: List<Number> = listOf(1, 2.0, 3f, 4L)
test(list)
}
결국 중요한 점은, 해당 타입을 꺼내어(읽어서) 반환한다는 점입니다. 반환하는 자리 즉, 리턴타입의 위치를 out 위치(생산하는 위치) 라고 얘기하며, out 키워드가 타입 파라미터 앞에 표시된 경우, 타입 파라미터는 항상 out 위치에만 존재할 수 있습니다. 반대로 in 키워드가 달린 반공변성은 함수의 매개변수 위치(소비하는 위치)에만 존재할 수 있습니다.
이와 별개로, 여기서 test 함수를 호출하는 부분에서는 왜 예외가 발생하지 않느냐는 생각이 들 수 있습니다. 그 이유는 런타임에서는 제네릭의 타입 파라미터를 소거하여 기억하지 않기 때문에 기저타입에 대해서는 알지만, 타입 파라미터는 알지 못하기 때문입니다. 따라서 List<Int> 를 요구함에도 불구하고 List 라는 정보만 알고 있기 때문에 List<Number> 를 함수 인자로 전달하여도 문제가 발생하지는 않습니다. 다만, 결국 값을 소비하는 과정에서 문제가 발생할 여지가 생기기 때문에 변성 규칙을 통해 컴파일 타임에 검증하는 것입니다.
쓰기(in) 전용
out 키워드를 명시하여 공변성을 따르는 제네릭이 왜 타입 파라미터간의 관계를 유지하게 되는지 이해했습니다. 그렇다면 반대로 반공변성도 한번 살펴보겠습니다.
open class GrandParent
open class Parent : GrandParent()
class Child : Parent()
class Uncle : GrandParent()
fun inputData(input: Parent, dest: MutableList<in Parent>) {
dest.add(input)
}
fun test() {
inputData(input = Parent(), dest = mutableListOf<GrandParent>())
}
해당 코드는 증조, 증조를 상속하는 부모, 증조를 상속하는 삼촌, 마지막으로 부모를 상속하는 자식 클래스가 있을 때, 제네릭 리스트에 삽입(소비)하는 예시입니다. 쓰기(소비)전용으로 만들기 위해 inputData 함수의 dest 타입인 MutableList 에 in-프로젝션을 적용했습니다.(MutableList 는 읽기와 쓰기 연산 모두를 제공하는 무공변성으로 선언되어 있고, 개발자가 필요에 따라 프로젝션하는 사용 지점 변성을 이용할 수 있습니다.)
위 상황에서, Parent 타입 파라미터를 요구하는 MutableList 에 GrandParent 타입 파라미터의 MutableList 를 전달하는 경우, 삽입(소비)하는 MutableList<GrandParent> 의 GrandParent 가 삽입하는 Parent 인스턴스의 부모 이기 때문에 삽입 과정에서 문제가 발생하지 않습니다.
즉, 데이터를 삽입하는(쓰는) 기능을 수행하는데 있어서 함수 인자인 dest 로 MutableList<Parent> 에 대한 타입 파라미터와 같거나 부모가 전달되어야만 합니다. 이는 쓰기(소비)연산에서 필연적입니다.
하지만, MutableList<Parent> 를 요구하는 함수 인자에 MutableList<Child> 를 전달하면, Parent 인스턴스를 삽입(소비)하려는 경우 Parent 는 자식인 Child 로 바로 캐스팅 될 수 없기 때문에 예외가 발생합니다. 자바에서 다형성 원리에 의해 자식타입은 부모타입으로 치환될 수 있지만, 부모 타입으로 생성된 인스턴스는 자식타입으로 치환될 수 없습니다. 다만, 자식타입으로 생성된 인스턴스를 부모 타입으로 캐스팅 한 후에 다시 원래의 자식 타입으로 캐스팅 하려는 경우에는 가능합니다.
fun inputData(input: Parent, dest: MutableList<in Parent>) {
dest.add(input)
}
fun test() {
inputData(input = Parent(), dest = mutableListOf<Child>()) // --> 캐스팅 예외 발생: Child cannot be casting to Parent
}
또한, 제네릭은 런타임에 타입 파라미터에 대한 정보를 소거하기 때문에 mutableListOf
결국 런타임에 예외를 발생시키지 않기 위해 반공변성 규칙을 통한 컴파일 타임에 “Type mismatch - Required: Parent, Found: Child” 라는 예외를 발생시켜 줍니다.
따라서 함수의 매개변수(in 위치)로 타입 파라미터의 타입을 입력받아 소비하는 경우, 제네릭에서 동등하거나, 그보다 더 상위 타입으로 강제하기 위한 반공변성 규칙이 컴파일 타임에 사용되는 것입니다.
현재 예시로 든 MutableList 는 무공변성 입니다. 이제 이 인터페이스의 내부에 존재하는 기능들을 한번 살펴보겠습니다.
public interface MutableList<E> : List<E>, MutableCollection<E> {
override fun add(element: E): Boolean // <<- in 위치
override fun remove(element: E): Boolean // <<- in 위치
override fun addAll(elements: Collection<E>): Boolean // <<- in 위치
public fun addAll(index: Int, elements: Collection<E>): Boolean // <<- in 위치
override fun removeAll(elements: Collection<E>): Boolean // <<- in 위치
override fun retainAll(elements: Collection<E>): Boolean // <<- in 위치
override fun clear(): Unit
public fun add(index: Int, element: E): Unit // <<- in 위치
public fun removeAt(index: Int): E // <<- Out 위치
override fun listIterator(): MutableListIterator<E> // <<- Out 위치
override fun listIterator(index: Int): MutableListIterator<E> // <<- Out 위치
override fun subList(fromIndex: Int, toIndex: Int): MutableList<E> // <<- Out 위치
}
해당 인터페이스는 함수의 입력 매개변수 위치(in 위치)와 리턴 위치(out 위치) 에 타입 파라미터 E 를 위치시킴으로써 읽기(out) 와 쓰기(in) 와 관련된 양쪽의 기능을 제공하기 때문에 타입 안정성을 보장하기 위해 자기 자신의 타입만 받을 수 있는 무공변성으로 선언된 것을 확인할 수 있습니다.
변성을 통한 타입 안정성
결론적으로, “제네릭은 타입에 관계없이 순수한 기능과 속성에 대한 일반화 개념이고, 객체지향 프로그래밍 내에서 제네릭 인스턴스 생성 및 함수 호출 시점에 타입을 결정 시킴으로써 발생하는 타입 안정성 문제를 해결하기 위해 변성이라는 성질을 이용한다” 고 정리할 수 있습니다.
한가지 더 살펴봐야 하는 부분은 제네릭은 런타임에 타입 파라미터의 정보를 소거한다는 점 입니다. List<Any> 타입으로 선언하고 전달받은 값의 타입에 따라 다르게 동작시켜 주려는 경우, 그렇게 할 수 없습니다.
fun getAndPrint(a: List<Any>) {
when(a) {
is List<String> -> //TODO
is List<Int> -> //TODO
/*
위에서 Cannot check for an instance or erased type 예외가 발생합니다.
*/
}
}
하지만 기저타입의 정보는 알 수 있기 때문에 Collection 으로 전달 받아 그것이 List인지, Set인지, Map 인지에 대한 정보를 판단하는 것은 가능합니다. 다만, 해당 제네릭의 타입 파라미터를 정확하게 알 수 없기 때문에 이때는 스타 프로젝션 을 사용해야만 합니다.
fun test(a: Collection<Any>) {
when(a) {
is List<*> -> // TODO
is Set<*> -> // TODO
is Map<*, *> -> // TODO
}
}
스타 프로젝션
스타 프로젝션은 제네릭의 타입 파라미터를 정확히 알 수 없는 경우 사용합니다. “정확히 알 수 없다” 는 것은 타입 파라미터가 존재하긴 하지만, 무엇인지 알 수 없음 을 의미합니다. 무엇인지 모르기 때문에 MutableList<*> 에는 값을 넣을 수 없는 읽기 전용 으로 사용됩니다. 타입 파라미터가 무엇인지 모르는 상태에서 아무 값이나 넣으면 문제가 생길 수 있기 때문입니다. 이런 경우 코틀린에서 모든 타입이 존재할 수 있다는 의미로 최상위 타입인 Any? 의 하위타입으로 동작하며, 이는 정확히 스타 프로젝션이 <out Any?> 로 동작함 을 의미합니다.
스타 프로젝션을 이용하면, List에 is 나 as 를 사용해도 컴파일러는 unChecked Cast 라는 경고를 띄워주지만 컴파일 에러는 발생하지 않게 됩니다. 하지만, List<*>의 의미는 결국 타입 파라미터에 대한 캐스팅에서는 런타임에 잠재적 예외가 발생할 수 있습니다. 개발자는 이런 코드를 지양해야만 합니다. 위험이 발생할 수 있는 부분은 캡슐화를 통해 감춰주고, 안정적으로 사용할 수 있는 기능을 보여지게 하는 형태로 코드를 작성하는 것이 여러 개발자와 함께 코드를 작성하는데 용이할 수 있습니다.
fun test(a: Collection<Any>) {
if(a is List<*>)
a.first() as String // --> T 가 String 이 아닌 경우, 런타임에 ClassCastException 예외 발생
}
자바와는 달리 코틀린에서는 제네릭의 타입 파라미터가 소거되지 않고 런타임에 그 정보를 알 수 있도록 실체화(reified)하는 방법을 통해 이러한 문제들을 해결할 수 있습니다.
실체화
실체화(reified)는 inline 함수, 프로퍼티 또는 클래스에 제네릭 타입 파라미터로 reified 키워드를 함께 명시하는 형태로 선언할 수 있습니다.
inline fun <reified T> test(a: Any): Boolean {
return a is T
}
fun main() {
val isString = test<String>("")
println(isString)
}
inline 키워드는 컴파일 과정에서 컴파일러가 해당 함수의 구현부를 바이트코드로 변환하여 함수가 호출된 부분에 그대로 삽입(inlining) 해줍니다. 그 과정에서 reified 키워드가 작성된 경우 함수 호출부에 명시된 타입 파라미터 정보를 런타임이 알 수 있도록 함께 삽입해주게 됩니다. 따라서 타입 파라미터 정보가 소거되지 않고 런타임에 의해 이용될 수 있기 때문에 위와 같은 예시에서 unchecked cast 와 같은 경고를 띄워주지 않습니다.(그렇기 때문에 자바에서는 사용될 수 없습니다.)
이런 타입 검사 또는 캐스팅(is 또는 as) 예시 뿐만 아니라, reified 키워드는 자바 클래스에 접근하거나, 코틀린 리플렉션을 이용할 때도 자주 사용됩니다.
inline fun <reified T> T.getClassName(): String = T::class.java.name
public fun <R> Iterable<*>.filterIsInstance(klass: Class<R>): List<R> {
return filterIsInstanceTo(ArrayList<R>(), klass)
}
다만 타입 파라미터의 인스턴스를 직접 생성하거나, 타입 파라미터 클래스의 companion object 를 호출하거나, inline 키워드가 없는 경우에는 실체화(reified) 를 사용할 수 없습니다.
댓글남기기