반응형

코틀린은 코드에 타입 안정성을 주기 위해 많은 노력들을 하고 있습니다. 제네릭 타입 역시 안정성을 높여 코드를 작성할 수 있게 해주는데요. 이 제네릭 타입은 자바에서도 제공되었기 때문에 아주 새로운 문법은 아니지만 약간의 특징과 사용법이 다르기 때문에 잘 알고 사용할 필요가 있습니다. 혹여나 제네릭을 처음 들어보시는 분들은 난이도가 좀 있으니 유의해서 꼼꼼하게 학습해보시길 권장합니다!

 

참고로 이 포스팅은 기본적인 제네릭의 사용 법에 대해 알고 있다는 가정하에 작성되었습니다.

자세한 내용은 코틀린 공식 문서를 참고하시면 되겠습니다.

 

제네릭(generic) : 파라미터 타입의 가변성과 제약사항

코드를 작성하다 보면 다양한 타입에 동일한 로직을 적용하기 위해 코드 재사용을 과도하게 하려는 경우가 있는데요. 이를 테면 파라미터를 전부 Any 로 받는다거나.. 등 이런 경우에는 타입 안정성을 저하시킬 수가 있습니다. 제네릭은 이런 이슈에 적절한 균형을 맞춰줍니다. 제네릭을 사용하면 다양한 타입에서 사용 가능한 코드를 작성할 수 있습니다!

 

코틀린 컴파일러는 제네릭 클래스 또는 함수가 의도하지 않은 타입에서 사용되는지를 검증할 수 있습니다.

 

자바에서는 기본적으로 제네릭은 타입 불변성을 강요했습니다. 제네릭 함수가 파라미터 타입 T 를 받는다면 T 의 부모 클래스나 자식 클래스를 사용하는 것이 불가능했습니다. 즉, 타입이 정확히 일치해야만 했습니다. 이것이 나쁜 것은 아닙니다. 개인적으로 코드에 제약은 크게 걸수록 안정성은 높아진다고 생각합니다.

 

우선 하나 예시를 통해 앞서 한 말이 어떤 내용이었는지 확인해보도록 하겠습니다.

open class Fruit
class Apple : Fruit()
class Banana : Fruit()

우선 클래스들을 선언하겠습니다. Fruit 이라는 부모 클래스를 상속하는 Apple, Banana 클래스를 준비합니다.

 

fun receiveFruits(fruits: Array<Fruit>) {
    println("Number of fruits: ${fruits.size}")
}

다음으로 위와 같이 Fruit 클래스를 제네릭 타입으로 선언된 배열(Array)을 파라미터로 받는 receiveFruits() 함수를 작성합니다.

 

위 함수의 파라미터로는 Array<Apple> or Array<Banana> 객체를 전달할 수 없습니다. (이는 자바에서도 마찬가지 입니다.)

사실 상속이란 대체 가능성을 의미하기 때문에 자식 클래스의 인스턴스는 부모 클래스의 인스턴스를 인자로 하는 모든 메소드에 전달할 수 있습니다.

그렇다면 왜 위 경우에는 Fruit 을 상속하는 Apple 이나 Banana 배열을 넘길 수 없었을까요?

이런 제약은 코틀린이 가진 제네릭에 대한 타입 불변성 때문에 발생합니다.

 

fun main() {
    val fruits: Array<Apple> = arrayOf(Apple())
    receiveFruits(fruits)
}

fun receiveFruits(fruits: Array<Fruit>) {
	fruits[0] = Banana() // 문제가 될 수 있음!
}

만약, Array<Apple> 객체를 Array<Fruit> 을 인자로 받는 함수에 인자로 전달될 수 잇다면 위와 같이 receiveFruits() 함수가 Banana 객체를 해당 파라미터에 담게될 때 문제가 발생합니다. Banana 는 Apple 처럼 취급될 수 없기 때문이죠.

타입 체크를 통해 fruits 의 요소 타입이 Banana 일 경우에만 변경 가능하도록 구현을 할 수도 있겠지만 이런 방식은 SOLID 원칙 중 리스코프 치환 원칙에 위배됩니다.

 

코틀린은 Apple 이 Fruit 을 상속 받았더라도 Array<Apple> 를 Array<Fruit> 으로 취급해서 전달하는 것을 막아서 제네릭을 타입 안정적으로 만들었습니다. 그럼 아래와 같은 경우에는 어떻게 될까요?

 

fun receiveFruits(fruits: List<Fruit>) {
    println("Number of fruits: ${fruits.size}")
}

fun main() {
    val fruits: List<Apple> = listOf(Apple(), Apple())
    receiveFruits(fruits)   // Number of fruits: 2
}

위 경우에는 정상적으로 Number of fruits: 2 라는 문자열이 출력됩니다!

단지 Array 를 List 로 바꿨을 뿐인데 왜 List 는 동작하는걸까요?

Array<T> 는 가변(mutable) 이지만 List<T> 는 불변(immutable) 입니다. 개발자는 Array 의 아이템은 변경할 수 있지만 List 의 아이템은 변경할 수 없습니다. 그렇다면 컴파일러는 어떻게 저 차이점을 알고 알려주는 걸까요?

그 것은 두 타입이 정의되는 방식에 달려 있습니다.

 

Array<T> 는 class Array<T> 로 정의되어 있고, List<T> 는 interface List<out E> 로 정의되어 있습니다. 가장 큰 차이는 List 제네릭 타입에 사용된 out 입니다. 이에 대해 자세히 살펴보겠습니다.

 

<out T> 으로 공변성(covariance) 사용하기

가끔은 우리가 코틀린에게 타입 안정성을 희생하지 않고 약간의 제약을 풀어달라고 요청해야할 때도 있습니다. 다시 말하면 코틀린 컴파일러가 공변성을 허용해서 제네릭 베이스 타입이 요구되는 곳에 제네릭 파생 타입이 허용되도록 하길 원하는 것인데요. 이럴 때 타입 프로젝션(type projections)이 필요합니다.

 

예제를 살펴보겠습니다.

fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val fruitsBasket1 = Array<Fruit>(3) { _ -> Fruit() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2)
}

copyFromTo() 함수는 from 배열의 객체를 순회하면서 to 배열로 값을 넣어주는 함수입니다.

이때 전달 받은 두 배열의 크기는 동일하다고 가정하겠습니다.

위와 같이 구현 할 경우에는 별 문제 없이 동작합니다. 왜냐하면 copyFromTo() 함수의 파라미터로 Fruit 타입의 배열로 정의했고, 그에 맞춰 넘겨줬기 때문입니다.

 

하지만 아래와 같이 Fruit 이 아닌 Apple 객체의 배열을 넘겨준다면 컴파일 에러가 발생하게 됩니다.

fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2) // type mismatch
}

코틀린은 Array<Fruit> 자리에 Array<Apple> 을 전달하지 못하도록 막습니다.

하지만 위 케이스에서는 from 파라미터는 파라미터의 값을 읽기만 하기 때문에 Array<T> 의 T 에 Fruit 클래스나 Fruit 클래스의 하위 클래스가 전달되더라도 아무런 위험이 없습니다. 이런 것을 타입이나 파생 타입에 접근하기 위한 파라미터 타입의 공변성이라고 이야기합니다.

 

Fruit 의 자식 클래스들을 전달 가능하게 해보겠습니다. copyFromTo() 함수의 from 파라미터를 Array<out Fruit> 타입으로 수정해주면 됩니다.

fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2)
}

이때 코틀린은 from 레퍼런스에 data 가 새로 들어가게 하는 메소드 호출이 없다는 사실을 확인하고 메소드 시그니처가 호출되는 것을 확인하여 이를 검증합니다.

 

이때 Array<out Fruit> 으로 선언된 from 파라미터는 읽기만 가능할 뿐 내용을 변경하거나 추가하려 할 경우에는 컴파일 에러가 발생하게 됩니다.

fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        from[i] = Fruit() // compile error !
    }
}

 

Array<T> 클래스는 T 타입의 객체를 읽고, 쓰는 메소드 모두를 가지고 있습니다. 하지만 out 키워드를 통해 공변성을 사용하기 위해서는 우리가 코틀린 컴파일러에게 주어진 Array<T> 파라미터에서 어떤 값도 추가하거나 변경하지 않겠다는 약속을 해야 합니다.

이런 제네릭 클래스를 사용하는 관점에서 공변성을 이용하는 걸 use-site variance 또는 type projection 이라고 부릅니다. 이와 달리 제네릭 타입을 사용할 때가 아닌 선언할 때 공변성을 사용한다고 지정하는 것을 declation-site variance 라고 부릅니다. 이에 대한 예제로는 처음에 살펴 봤던 List<out T> 로 되어있는 List 인터페이스에서 찾아볼 수 있습니다.

 

List<out T> 로 declation-site variance 가 정의 되어 있기 때문에 receiveFruits() 함수를 정의할 때 파라미터에 List<out Fruit> 형태로 선언하지 않고도 List<Apple> 을 receiveFruits() 함수에 전달할 수 있었던 것입니다.

 

이를 다르게 말하자면, List<out T> 는 코틀린에게 receiveFruits() 를 비롯해 이와 유사한 모든 함수들에게서 List<T> 에 변경이나 추가가 없다는 것을 보장해줍니다. 즉, out 키워드를 사용하여 공변성을 사용할 경우에는 읽기만(read-only) 사용 가능하다고 볼 수 있는 것이죠.

 

read-only 가 있다면 반대로 쓰기 전용(write-only) 도 있겠죠? 반대의 경우인 반공변성(contravariance) 에 대해 알아보겠습니다.

 

<in T> 으로 반공변성(contravariance) 사용하기

fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

copyFromTo() 메소드를 다시 보도록 하겠습니다. T 가 Fruit 타입이거나 Fruit 의 하위 클래스라면 아무 Array<T> 로부터 객체를 복사하는 것이 적절합니다. 공변성은 코틀린이 from 파라미터를 유연하도록 하는 게 안전하다는 사실을 알려줍니다.

 

그렇다면 이번에는 to 파라미터를 살펴보겠습니다. to 파라미터의 타입은 변경 불가능한 Array<Fruit> 입니다.

to 파라미터에 Array<Fruit> 을 전달한다면 아무런 문제도 없습니다. 근데 Fruit collection이나 Fruit 기반의 하위 클래스 collection 에 Fruit 이나 Fruit 의 부모 클래스를 전달하고 싶을 때는 어떨까요?

 

to 파라미터의 타입을 Array<Any> 로 선언하면 해결될 것 같지만, 우리는 앞서 이것이 불가능하다는 것을 학습했습니다. 우리는 반드시 컴파일러에게 파라미터 타입 인스턴스가 필요한 곳에 파라미터 타입의 베이스 타입이 접근할 수 있도록 명시적으로 반공변성 권한을 요청해야 합니다.

 

반공변성을 사용하지 않고 Array<Any> 를 to 자리에 넣으면 어떻게 되는지 확인해보겠습니다.

fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Any>(3) { _ -> Any() }
    copyFromTo(fruitsBasket1, fruitsBasket2) // Error! type mismatch
}

컴파일 에러가 발생합니다. 다시 한 번 코틀린의 기본 타입 불변성이 우리를 보호해준 것입니다.

우리는 코틀린에게 다시 진정하라고 요청할 수 있습니다. 이번에는 to 파라미터에 원래 요청된 타입이나 그 타입의 조상 타입이 가능하게 하는 권한(반공변성)을 요청할 것입니다.

 

fun copyFromTo(from: Array<out Fruit>, to: Array<in Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Any>(3) { _ -> Any() }
    copyFromTo(fruitsBasket1, fruitsBasket2)
}

Array<Fruit> 이었던 두 번째 파라미터를 Array<in Fruit> 으로 변경했습니다. in 키워드는 함수가 파라미터에 값을 설정할 수 있게 만들고, 값을 읽을 수 없게 만듭니다.

 

쉽게 기억해봅시다!

읽기 전용은 안에 들어있는 값을 빼서 읽어야 하니까 out, 쓰기 전용은 새로운 값을 집어 넣어야 하니까 in 으로 외웁시다!

 

제네릭 함수와 클래스를 디자인하는 것은 쉬운 작업이 아닙니다. 타입, 변수, 결과에 대해서 충분한 시간을 들여서 생각을 해야 합니다. 그리고 파라미터 타입을 이용해서 작업을 할 때는 전달될 수 있는 파라미터 타입을 제한하는 것에 대한 고려도 해야합니다. 그러니 조급해말고 천천히 확실하게 학습하여 내 것으로 만들어 나갑시다!

 

where 를 사용한 파라미터 타입 제한

제네릭은 파라미터에 타입을 쓸 수 있도록 유연함을 제공해줍니다. 하지만 때때로 너무 많은 유연성은 올바른 선택이 아닐 때가 있습니다. 여러 타입을 사용할 수 있지만 제약조건이 필요한 경우도 분명 있습니다.

 

아래 예제를 살펴보겠습니다.

fun <T> useAndClose(input: T) {
    input.close()  // ERROR: unresolved reference: close
}

위 코드는 컴파일 에러를 발생시킵니다. 왜냐하면 T 타입에 close() 라는 함수가 있다는 것이 보장되어 있지 않기 때문입니다. 하지만 우리는 코틀린에게 인터페이스를 통해서 close() 함수가 있는 타입만 들어올 수 있도록 제약을 걸 수 있습니다. 예를 들어보자면 AutoCloseable 인터페이스가 있습니다.

 

함수를 제약조건을 사용해서 다시 정의해보겠습니다.

fun <T: AutoCloseable> useAndClose(input: T) {
    input.close()  // OK
}

이제 useAndClose() 함수는 모든 타입이 아닌, AutoCloseable 인터페이스를 구현한 클래스만이 파라미터로 전달 가능하게 됐습니다.

 

이처럼 하나의 제약조건을 넣기 위해서 파라미터 타입 뒤에 콜론(:)을 넣은 후 제약조건을 정의하면 됩니다. 하지만 여러 개의 제약 조건을 넣을 땐 이런 방식으론 불가능합니다. 이럴 때는 where 를 사용해야 합니다.

 

파라미터 타입이 AutoCloseable 을 만족하는 것에 추가로 Appendable 을 제약조건으로 더해보도록 하겠습니다.

fun <T> useAndClose(input: T) where T: AutoCloseable, T: Appendable {
    input.append("there")
    input.close()
}

위와 같이 메소드 정의 끝부분에 where 절을 쓰고 콤마(,)로 구분해서 제약 조건을 나열합니다.

그러면 이제 우리는 전달받은 파라미터의 close() 함수와 append() 함수를 사용할 수 있게 됩니다.

 

스타 프로젝션(star projection)

코틀린의 제네릭과 Java 의 제네릭의 차이점은 선언처 가변성(declaration-site variance) 뿐만이 아닙니다.

Java 는 개발자가 raw 타입을 직접 만들 수 있습니다. 예를 들어 ArrayList 를 제네릭 사용하지 않고 raw type(Object) 를 요소로 하는 객체를 생성할 수 있습니다.

 

하지만 raw type 을 직접 만드는 것은 일반적으로 타입 안정성이 없고 가급적 하지 말아야 할 일입니다. 그리고 Java 에서는 함수가 모든 타입의 제네릭 객체를 받아서 읽기 전용으로 사용할 수 있도록 만들기 위해 와일드 카드 타입(?)을 사용합니다.

 

파라미터 타입을 정의하는 스타 프로젝션(star projection) <*> 은 제네릭 읽기전용 타입과 raw 타입을 위한 코틀린의 기능입니다.

 

스타 프로젝션은 타입에 대해 정확히는 알 수 없지만 타입 안정성을 유지하면서 파라미터를 전달할 때 사용됩니다. 스타 프로젝션은 읽는 것만 허용하고 쓰는 것은 허용하지 않습니다. 아래는 스타 프로젝션을 사용한 코드입니다.

fun printValues(values: Array<*>) {
    for (value in values) {
        println(value)
    }
    values[0] = values[1] // ERROR
}

printValues() 함수는 Array<*> 을 파라미터로 받습니다. 그리고 함수 내에서 어떠한 변경도 허용되지 않습니다.

 

만약 파라미터를 Array<T> 로 작성했다면 위에서 Error 가 발생한 values[0] = values[1] 코드도 컴파일이 됐을 것입니다. 이 경우 콜렉션을 반복하는 도중 콜렉션을 변경할 가능성에 노출되기 때문에 예기치 못한 오류가 발생할 가능성이 생겨버럽니다.

 

스타 프로젝션은 이런 부주의한 오류로부터 우리를 보호해줍니다. 여기서 사용된 스타 프로젝션<*> 은 out T 와 동일하지만 더 간결하게 작성할 수 있다는 장점이 있습니다.

스타 프로젝션을 <in T> 방식으로 대체한다면 <in Nothing> 을 사용한 것과 의미가 같아집니다. 스타 프로젝트는 모든 쓰기를 방지하고 안정성까지 제공해줍니다.

 

구체화된 타입 파라미터 (Reified Type Parameters)

Java 에서 제네릭을 사용할 때 Class<T> 를 함수 파라미터로 전달해야 하는 코드를 작성하신 적 있으신가요? 주로 리플렉션을 사용할 때 Class<T> 형태의 타입을 사용해보신 적 있으실 겁니다. 하지만 함수의 파라미터로 Class<T> 를 넘기는 코드는 code smell 로 볼 수 있는데요. 보통 제네릭 함수에서 특정 타입이 필요하지만 자바의 타입 이레이저 때문에 타입 정보를 잃어버릴 경우 필수적으로 따라옵니다.

 

코틀린은 reified 키워드를 통한 구체화된 타입 파라미터(Reified Type Parameter)를 이용해서 code smell 을 제거했습니다.

 

구체화를 확실히 이해하기 위해서는 일단 좀 장황하고 지저분한 코드를 사용해봐야 할 것 같습니다. 앞서 사용했던 Fruit, Apple, Banana 클래스를 활용하여 아래와 같이 함수를 작성해보겠습니다.

fun <T> findFirst(fruits: List<Fruit>, ofClass: Class<T>): T {
    val selected = fruits.filter { fruit -> ofClass.isInstance(fruit) }
    if (selected.isEmpty()) {
        throw RuntimeException("Not found")
    }
    return ofClass.cast(selected[0])
}

바이트 코드로 컴파일 되면서 파라미터 타입 T 가 지워지기 때문에 함수 안에서 T 를 fruit is T 나 selected[0] as T 처럼 연산자와 함께 사용할 수 없습니다. 해결 방법으로는 Java 와 코틀린 모두 위 예제와 같이 우리가 원하는 객체의 타입을 파라미터로 던져야합니다.

 

위 예제에서는 ofClass: Class<T> 의 방법을 사용했습니다. 그리고 코드에서 우리는 ofClass 를 타입 체크와 타입 캐스팅을 위해서 사용했습니다. 그 결과 코드가 장황하고 지저분한 느낌이 있습니다. 이런 접근은 함수가 호출될 때마다 런타임 타입 정보를 추가적인 인자로 전달해야만 하기 때문에 함수를 함수를 호출하는 쪽과 받아주는 쪽 모두에게 나쁜 코드를 만들게 됩니다.

 

다행히도 코틀린에는 reified 타입 파라미터라는 훨씬 좋은 기능이 있습니다.

코틀린도 자바와 마찬가지로 타입 이레이저의 한계를 다뤄야 하기 때문에 실행 시간에 파라미터 타입은 사용할 수 없습니다. 하지만 코틀린은 파라미터 타입이 reified 라고 마크되어 있고 함수가 inline 으로 선언되었다면 우리가 파라미터 타입을 사용할 수 있도록 권한을 줍니다. inline 의 장점에 대해서는 추후 별도로 다루도록 하고, 지금은 간단하게 인라인 함수란 컴파일 시점에서 확정되므로 함수 호출 시 오버헤드는 없는 함수 정도로만 알면 되겠습니다.

 

이제 위 findFirst 함수를 리팩토링 해보겠습니다!

inline fun <reified T> findFirst(fruits: List<Fruit>): T {
    val selected = fruits.filter { fruit -> fruit is T }
    if (selected.isEmpty()) {
        throw RuntimeException("Not found")
    }
    return selected[0] as T
}

파라미터 타입 T를 reified 로 선언하고 Class<T> 파라미터를 제거했습니다. 그리고 이제는 함수 안에서 T 를 타입 체크와 캐스팅용으로 사용 가능합니다. 함수가 inline 으로 선언되어 있기 때문에 함수의 바디가 함수 호출하는 부분에서 확장됩니다. 그래서 코드가 확장될 때 타입 T 는 컴파일 시간에 확인되는 실제 타입으로 대체됩니다. 참고로 reified 키워드는 inline 함수에서만 사용 가능합니다.

 

이 덕에 함수의 가독성을 높이는 것 이외에도 reified 타입 파라미터를 사용하는 함수를 호출하는 사용자 입장에서도 장점이 있습니다. reified 타입 파라미터는 함수에 추가적인 클래스 정보를 전달하지 않도록 만들어주고, 코드에서 캐스팅을 안전하게 하는 데 도움을 주고 컴파일 시간 안정성을 확보한 채로 리턴 타입을 커스터마이징 할 수 있게 해줍니다.

 

대표적으로 예시로는 코틀린 스탠다드 라이브러리에 있는 listOf<T>() 와 mutableListOf<T>() 함수 등이 있습니다.

 

정리

코틀린은 완전 새로운 수준의 타입 안정성을 추구합니다.

제네릭 함수와 클래스를 사용할 때 개발자의 니즈를 충족시켜 주기 위해서 파라미터 타입을 조정하여 타입 안정성과 유연성을 제공해줍니다. 게다가 reified 타입 파라미터는 컴파일 타임에 타입 안정성을 강화해서 코드의 오류를 제거해줍니다.

 

제네릭이 어려울 수 있지만 보다 좋은 디자인의 코드를 작성하기에 유용한 기능이기 때문에 반복해서 숙지하여 내 것으로 만들도록 합시다!

반응형
  • yong 2021.08.25 04:07

    정말 피와 살이되는 글입니다 ㅠㅠ

반응형