Skip to content

그래서 변성이 뭐야?

등록 날짜:2023년 12월 24일 at 오후 02:30

이번 글에서는 변성(Variance)이라는 것에 대해 조금 더 깊이 알아보려고 한다. 제네릭스를 공부하고 변성에 대한 얘기들을 할 때, 변성을 잘 이해하지 못하면 처음에는 정말 외계어처럼 들리기 때문이다. 이번 기회에 변성이란 무엇인지 정리해 보자.

시작하기전에

변성에 대해 이야기하기 전에 개발자들이 복잡성을 어떤 방법으로 극복하는지 짧게 보고 가려고 한다. 이것은 변성을 이해하는 데 있어 기반 지식이 될 것이다. 바로 다형성에 대한 이야기다.

서브타입에 의한 다형성

개발자들이 복잡성을 극복하는 주요 방법은 여러 객체를 보고 추상화를 통해 핵심적인 특징을 담은 상위 타입을 만들어내는 것이다.

abstract class Super {
  abstract fun behave()
}

class Sub: Super() {
  override fun behave() { ... }
}

class AnotherSub: Super() {
  override fun behave() { ... }
}

개발자들은 위와 같이 여러 객체의 세부적인 특징을 추상화한 상위 타입을 만들어 상위 타입을 가지고 생각한다.

fun behave(obj: Super) {
  obj.behave()
}

behave(Sub())
behave(AnotherSub())

위의 코드에서 behave 함수는 필요한 상황에 따라 다양한 서브타입을 인자로 받으며 서브타입의 특성에 맞게 각각의 동작을 할 것임을 알 수 있다.

여기서 개발자들에게 중요한 것은 Super 타입으로 치환할 수 있다면 어떤 서브타입이든지 behave라는 동작을 가진다는 것이다.

이렇게 상위 타입으로의 추상화에 성공한다면 하위 타입의 세부사항에 신경 쓰지 않고 상위 타입을 기반으로 생각할 수 있고 더 큰 구조를 비교적 간단히 세울 수 있게 된다.

큰 구조를 만들어내는데 성공한다면, 필요에 따라 서브타입을 더 만들어내거나 수정하는 과정을 통해 코드를 관리해나가게 된다.

즉, 개발자들은 하위 타입을 상위 타입으로 치환하여 하위 타입들 각각의 세부적인 특징을 신경 쓰지 않고 추상화를 기반으로 한 핵심적인 특징만을 가진 상위 타입을 활용하여 복잡성을 극복한다.

매개변수에 의한 다형성

어떤 함수나 클래스를 만들 때, 다양한 타입에 사용될 수 있는 일반적인 로직을 작성할 때가 많다.

class Box<T>(var value: T)

fun <T> swapValues(a: T, b: T): Pair<T, T> = Pair(b, a)

위의 코드를 보면 Box 클래스는 value로 임의의 타입의 값을 가지고 있는 컨테이너일 뿐이고 swapValues라는 함수는 주어진 값들의 위치를 바꿔줄 뿐이다.

두 가지 모두 전달되는 타입에 관계없이 쓰일 수 있는 일반적인 로직을 가지고 있다. 이러한 타입에 대한 추상화를 제네릭스(Generics)라고 한다.

제네릭스가 없다면, 각각의 다른 타입 조합에 대해 필요한 동작을 수행하기 위해 중복된 코드를 작성해야만 할 것이다.

즉, 제네릭스는 타입을 매개변수화하여 다양한 타입에 적용될 수 있는 더 일반화된 코드를 작성할 수 있게 한다.

변성이 필요한 이유

개발자들은 서브타입에 의한 다형성과 제네릭스를 이용해서 복잡성을 극복하려고 한다는 것은 이해 했을 것이다. 하지만, 제네릭스를 도입하면서 한 가지 문제가 생겼는데, 제네릭 타입은 서브타입 관계를 판단하기 어렵다는 것이다. 다시 말해서, 개발자들은 타입 A와 타입 B 사이의 서브타입 관계를 파악하는 건 비교적 쉽게 할 수 있지만 타입 Generic<A>와 타입 Generic<B> 사이의 서브타입 관계를 파악하는 건 어렵게 느낀다.

이 어려움을 해결하기 위해, 사람들은 타입 A와 타입 B의 서브타입 관계에 따른 타입 Generic<A> 와 타입 Generic<B>의 서브타입 관계를 연구하였고, 그렇게 해서 발견한 성질이 변성이다.

이 변성이라는 성질로 인해 개발자들은 특정 조건에서 타입 A와 타입 B의 서브타입 관계를 보고 Generic<A>Generic<B> 사이의 서브타입 관계를 손쉽게 알아낼 수 있게 되었다.

변성

변성이란 결국 타입 A와 타입 B의 “서브타입 관계”와 타입 Generic<A>와 타입 Generic<B>의 “서브타입 관계” 사이의 관계성을 의미한다.

변성의 종류

이것이 변성의 종류다. 하지만, 이것만 가지고는 변성을 활용하기 힘들다. 그냥 결과적인 내용일 뿐이기 때문이다. 변성을 효과적으로 활용하기 위해서는 각각의 변성을 만들어내는 특정 조건에 대해서 알아야 한다.

변성을 만들어내는 조건

우리가 주목해야 하는 부분은 Generic<T> 제네릭 타입에서 타입 T가 어떤 식으로 활용이 되는지를 봐야 한다.

T로 들어오는 임의의 타입들이 서로 서브타입 관계가 확실하다면 아래의 세 가지 조건에 따라 변성이 결정된다.

(주의: 코틀린에서는 생성자와 private 속성은 변성 조건 검사에서 제외된다.)

  1. T가 출력으로만 쓰일 경우: 공변
    class ReadOnlyList<T>(vararg elements: T) {
      private val list: List<T> = elements.toList()
      val size = list.size
      fun get(index: Int): T = list[index]
    }
    T가 출력으로만 쓰였다는 것의 의미는 위의 코드를 봤을 때 T가 반환값으로만 사용되었다는 것을 의미한다. 다른 말로 하면 T를 꺼낼 수만 있지 넣을 수는 없다.
  2. T가 입력으로만 쓰일 경우: 반공변
    class WriteOnlyList<T>(vararg elements: T) {
      private val list: MutableList<T> = elements.toMutableList()
      val size = list.size
      fun add(element: T) = list.add(element)
    }
    T가 입력으로만 쓰였다는 것의 의미는 위의 코드를 봤을 때 T가 메서드의 매개변수로만 사용되었다는 것을 의미한다. 다른 말로 하면 T를 넣을 수만 있지 꺼낼 수는 없다.
  3. 그 외의 경우: 무변
    그 외의 경우에는 T가 입력과 출력으로 섞여 쓰였다는 것을 의미하는데, 이 경우는 제네릭 타입 간의 서브타입 관계를 확실히 알 수 없다.

선언 지점 변성

변성은 타입 검사기가 전지전능하게 알려주는 것이 아니다. 개발자가 변성이 성립하는 구조를 만들고 타입 검사기에게 변성을 알려주는 것이다. 타입 검사는 개발자가 알려준 변성을 토대로 구조를 보고 타입 치환이 일어났을 때, 그 이후의 연산의 결과로 타입에 논리적인 문제가 없으면 오류를 내지 않는다.

아래의 코드는 Int 타입은 Number 타입의 서브타입이며 ReadOnlyList<T>는 공변 조건을 가지고 있기 때문에 문제가 없어야 한다.

val intList = ReadOnlyList<Int>(1, 2, 3)
val numList: ReadOnlyList<Number> = intList

하지만 타입 검사기는 ReadOnlyList<T>T에 대해 공변이 성립하는 것을 모르기 때문에 기본적으로 무변이라고 보고 타입이 일치하지 않는다는 오류를 낸다. 이 문제를 해결하기 위해서는 ReadOnlyList<T>를 선언하는 지점에서 T가 공변 조건을 만족한다는 것. 즉, 출력으로만 쓰인다는 것을 표시해 주어야 한다.

// out T로 표시하여 T가 출력으로만 쓰인다는 것을 알린다
class ReadOnlyList<out T>(vararg elements: T) { ... }

반공변이 성립할 경우의 코드 예시는 아래와 같다.

// in T로 표시하여 T가 입력으로만 쓰인다는 것을 알린다
class WriteOnlyList<in T>(vararg elements: T) { ... }

val numList = WriteOnlyList<Number>(1.1, 1.2, 1.3)
val intList: WriteOnlyList<Int> = numList

타입 검사기 입장에서의 변성

타입 검사기는 개발자가 알려준 변성대로 코드를 보고 정적으로 타입상에 문제가 없는지를 판단한다.

val intList = ReadOnlyList<Int>(1, 2, 3)
val numList: ReadOnlyList<Number> = intList

위의 공변의 예시 코드를 타입 검사기가 보았을 때, numList는 원소를 꺼낼 수만 있고 넣을 수는 없기 때문에, numListNumber타입의 원소를 넣어 intList의 타입을 망가뜨릴수가 없다. 그래서 타입 검사기는 문제없다고 생각한다.

val numList = WriteOnlyList<Number>(1.1, 1.2, 1.3)
val intList: WriteOnlyList<Int> = numList

반공변의 경우를 보았을 때는, numListNumber타입의 원소를 추가시키면 intList의 타입이 망가지는 것처럼 보일 수 있다. 하지만 WriteOnlyList<T>는 원소를 넣을 수만 있지 꺼낼 수가 없기 때문에 정말 잘못된 타입의 값이 들어있다고 하더라도 그것이 밖으로 꺼내져 쓰일 수가 없다. 그래서 이 경우 또한 타입 검사기는 문제가 없다고 생각한다.

한 가지 주의해야 할 점은, 변성을 이용한 코드가 타입 검사를 통과했다고 해서 그것이 논리적으로 문제가 없다는 것을 보장하지는 않는다.

위의 반공변에서의 코드에서 Number사람이라고 생각하고 Int의사라고 생각하면 사람 리스트의사 리스트로 치환하는 일이 일어났고, 그 뒤로 의사 리스트의사만 추가된다고 하더라도 이미 사람 리스트에 적혀있던 의사가 아닌 사람들도 의사 리스트에 포함된것이 사실이다.

타입 검사기는 개발자의 바람대로 코드를 최대한 봐준 것이기 때문에, 동작하는 기능이 논리상 문제가 없게 하는 것은 결국 개발자의 몫이다.

사용 지점 변성

유용한 구조는 공변적으로만, 또는 반공변적으로만 생기지 않았다. 사실 그동안 변성을 설명하기 위해 예시로 사용한 ReadOnlyListWriteOnlyList는 읽기만 가능하거나 쓰기만 가능해서 애초에 유용하지 않다.

class MyList<T>(vararg elements: T) {
    private val list: MutableList<T> = elements.toMutableList()
    val size = list.size
    fun get(index: Int): T = list[index]
    fun add(element: T) = list.add(element)
    ...
}

유용한 구조는 보통 읽기와 쓰기 능력을 함께 가지고 있다.

변성을 활용할 수 있도록 구조를 만드는 것이 구조 자체의 유용성을 떨어뜨린다면 변성은 유용한 것이 맞다고 할 수 있을까? 개발자들은 이 모순을 사용 지점 변성을 사용하여 해결한다

fun <T> readList(index: Int, list: MyList<out T>) = list.get(index)
fun <T> addListElement(element: T, list: MyList<in T>) = list.add(element)

위의 코드에서 readList 함수는 MyList<T>를 사용할 때 T가 공변적인 부분만 사용한다는 것을 사용 지점에서 알려준다. 반면에 addListElement 함수는 MyList<T>를 사용할 때 T가 반공변적인 부분만 사용한다는 것을 알려준다.

사용 지점 변성을 영어로 타입 프로젝션이라고 한다. 즉, 특정 구조에서 특정 관점으로 필요한 부분만 비춰보는 것이다.

끝으로

지금까지 변성을 알아보기 위하여

  1. 복잡성을 해결하기 위한 방법인 다형성
  2. 변성이 필요한 이유
  3. 변성을 만들어내는 조건
  4. 변성을 타입 검사기에게 알리는 방법

에 대하여 알아보았다.

쉬운 설명을 위해서 간단한 코드 예시를 들었는데, 예시로 든 코드들이 억지스러운 부분이 있는 점이 보이지만 전달하고자 하는 내용은 잘 전달이 되었으면 좋겠다. 또한, 이 글이 변성에 대한 얘기가 나왔을 때 대화 흐름을 잃지 않고 이해하는 데 도움이 되었으면 좋겠다.