본문 바로가기

Language

제네릭, 그리고 변성(Variance)에 대한 고찰 (2) - Kotlin

 

 지난 포스트에서 이야기한 'PECS' 공식은 어찌보면 난해하다. 한정적 와일드 카드 타입 파라메터에 사용하는 `extends` 와 `super` 키워드에 의해서 탄생한 용어이기 때문이다. 게다가 자바에는 상속, 상위 클래스를 지칭하는 같거나 비슷한 키워드가 있어서 더 혼란스럽다. 'PECS' 에서 말하는 `extends` 키워드와 상속을 나타내는 그것은 의미가 다르다!

 

 코틀린은 많은 부분에서 자바보다 나은점을 보여준다. 특히나 지금 이야기하고 있는 변성에 관련한 키워드도 그 중 하나인데, 무척 직관적인 `out` 과 `in` 이라는 키워드로 'PECS' 공식을 표현하기 때문이다.

 

 공변, 반공변의 원리와 이해는 지난 포스트에서 충분히 설명했으므로, 지금부터는 코틀린 변성의 키워드 중심으로 간단히 짚고 넘어가고자 한다.

 

공변

 우리가 이해하고 있는 것처럼, 공변이란 타입 인자의 하위 타입 관계가 제네릭의 타입 파라메터에도 유효한 것이다. 또한 공변하는 제네릭 클래스는 생산만 가능하다. 아래를 보자

 

class Producer<out T>(
    private val value: T
) {
    fun get(): T {
        return value
    }
}

 

 어떠한 값을 가지고 있으며, 생산만 할 수 있는 말그대로 `Producer` 클래스를 정의했다. 만약 이 클래스에 아래와 같은 `set` 메서드를 작성한다면 어떻게 될까?

 

fun set(value: T) {
    this.value = value
}

 

 

메서드 시그니쳐 형식 파라메터 선언 부분에 위 처럼 컴파일 에러가 발생하는데, 자세히 보면 `out` 변성을 지정한 클래스 타입 파라메터가 `in` position에 나타난다는 것을 말한다. 한마디로 `out` 변성의 타입 파라메터를 가진 클래스는 소비할 수 없다.

 

제 공변을 테스트해 보자. 아래 메서드는 `Number` 타입 파라메터를 가진 `Producer` 객체를 파라메터로 받는다.

 

fun useProducer(producer: Producer<Number>) {
    println("Produce value: ${producer.get()}")
}

 

이때, `Number` 클래스의 하위타입인 `Int` 를 타입 파라메터를 가진 `Producer` 객체를 해당 메서드에 전달 해보자.

 

fun main() {
    val producer = Producer<Int>(999)
    useProducer(producer)
}
// Output >> Produce value: 999

 

컴파일 에러 없이 `producer` 객체의 값을 잘 출력했다. 이로써 공변이 제대로 동작한다는 것을 확인 할 수 있다.

 

반공변

반공변에서는 하위타입 관계가 뒤집히며, 소비만 가능해지는 소비자가 된다. 이제 아래 처럼 `Consumer` 클래스를 정의해보자

 

class Consumer<in T>{
    fun consume(value: T) {
        println("Consume value: $value")
    }
}

 

위에서 살펴본 반례와 비슷하게, 아래처럼 값을 생산하는 메서드를 작성한다면 어떻게 될까?

 

fun produce(): T {
    return this.value
}

 

 

반환 타입으로 지정한 `T` 에서 위와 같은 컴파일에러가 발생한다. 해당 타입 파라메터가 `out` position에 나타난다는 내용이다. 다시 정리하면, `in` position 변성을 지정한 클래스는 생산할 수 없다.

 

이제 아래처럼  `Number` 타입 파라메터를 가진 `Consumer` 객체를 파라메터로 받아 처리하는 메서드를 정의한다

 

fun useConsumer(consumer: Consumer<Number>) {
    consumer.consume(999)
}

 

이때, 위 메서드에 `Any` 타입을 타입 파라메터로 가지는 `Consumer` 객체를 전달하면 무슨일이 일어날까? `Any` 클래스는 자바의 `Object` 와 같은 클래스이다.

 

fun main() {
    val consumer = Consumer<Any>()
    useConsumer(consumer)
}
// Output >> Consume value: 999

 

 `Consumer<Any>` 타입의 객체를 `Consumer<Number>` 타입이 기대되는 자리에 전달해도 컴파일 에러 없이 코드가 동작했다. 위에서 언급한 것처럼 하위타입 관계가 뒤집힌 상황인 것이다. 이것이 반공변이다.

 

사용 지점 변성

 이제까지 이야기 한 것은 선언지점 변성으로, 클래스 선언부에 지정한 변성을 말한다. 참고로 자바는 선언지점 변성을 사용할 수 없다. 다시말해  `class SomeClazz<? extends T> { .. }` 와 같은 표현이 불가능하다는 이야기이고, 해당 변성이 사용되는 지점에만 한정적 와일드 카드를 사용해 변성을 지정할 수 있다. 이것이 사용지점 변성인데, 코틀린도 당연히 가능하다.

 

fun <E> union(first: MutableSet<out E>, second: MutableSet<out E>): Set<E> {
    ...
}

 

위 처럼 두개의 `Set` 을 합치는 `union` 메서드를 작성해봤다. 메서드의 내부 로직이  `Set` 의 요소들을 꺼내서 합친 후 새로운 `Set` 을 반환하는 코드라고 가정한다면, 파라메터로 전달받는 `first`와 `second`의 변성은 `out` 으로 정의해야 한다. 

 

참고로 `MutableSet` 컨테이너는 무공변이기 때문에 변성을 지정했다. 하지만 코틀린의 `Set` 클래스는 태생이 공변적이므로, 메서드 시그니처의 파라메터 타입을 `Set`으로 지정하면 따로 변성을 지정할 필요가 없다.

 

마치며

 제네릭과 관련하여 가장 난해하다고 할 수 있는 변성에 대해 두번에 걸쳐 포스팅을 했다. 사실, 실무를 하면서 직접 변성을 지정하며 클래스를 설계하고 그것을 사용하는 비즈니스 로직을 작성하는 것은 거의 드물 것이라 단언할 수 있다. (자주 쓰는 코틀린의 'mutable' 하지 않은 컨테이너들은 모두 `out` 변성이 지정되어 있지만 이것은 플랫폼 라이브러리이고 우리 개발자들이 직접 지정한 것은 아니다.)

 

 임백준님의 말처럼 수많은 개발자가 협업하는 코드 베이스에 변성을 사용한 코드가 존재한다면, 유연하다고는 할 수 있어도 이해하기 쉽고 심플한 코드라고 말하기 어려울 것이다. 이 때문에 극단의 범용성과 유연성을 요구하는 라이브러리를 개발하지 않고서야, 비즈니스 로직에 직접 변성을 사용할 일은 앞으로도 없어 보인다.

 

 하지만 내가 경험한 것, 아는 것이 당연히 전부는 아니다. 언제 어느 상황에서 괴팍한(?) 비즈니스 요구사항을 마주하여 변성을 사용하게 될지 모르는 일. 정확한 이해를 바탕으로 내것으로 만들면 언젠가 든든한 무기가 되지 않을까.