0. 들어가며 🏃🏻‍♂️

이번 글은 코틀린에서의 제네릭과 관련된 내용을 정리해보려합니다. 제네릭과 공변에 대한 내용은 한번 제대로 정리해보고 싶었는데 마침 코틀린 인 액션을 읽고 코틀린의 제네릭에 대해 배우게 되었습니다. 자바와 비슷하면서도 다른 기능들을 제공해주는데 제네릭 개념과 함께 이를 정리해보겠습니다.

 

본 글은 자바 혹은 코틀린 제네릭에 대한 문법정도는 알고 읽어야 도움이 되실 것 같습니다.


1. 들어가기 전 코드 베이스 설명

제네릭과 공변 관련 개념을 설명하기위해 본 글에서는 아래와 같은 코드를 예시로 사용하려 합니다. 간단한 코드이니 한번 살펴봐주세요!

// 동물
open class Animal(val name: String)

// 포유류
open class Mammal(name: String): Animal(name)

// 호랑이
class Tiger(name: String): Mammal(name)

// 사자
class Lion(name: String): Mammal(name)

// 동물원
class Zoo {
    private val animals = mutableListOf<Animal>()

    fun getAnimal(index: Int): Animal {
        return animals[index]
    }

    fun addAnimal(animal: Animal) {
        animals.add(animal)
    }

    fun moveFrom(zoo: Zoo) {
        animals.addAll(zoo.animals)
    }
}

이 때 Zoo 클래스를 한번 살펴보도록 하겠습니다. 만약 호랑이만 혹은 사자만 있는 동물원을 구성하고 싶다면 어떻게 해야할까요? 이 때 사용할 수 있는 개념이 바로 제네릭인데 아래처럼 코드를 변경하고 main 함수를 아래처럼 구성한다면 문제를 해결할 수 있습니다.

class Zoo<T> {
    private val animals = mutableListOf<T>()

    fun getAnimal(index: Int): T {
        return animals[index]
    }

    fun addAnimal(animal: T) {
        animals.add(animal)
    }

    fun moveFrom(zoo: Zoo<T>) {
        animals.addAll(zoo.animals)
        zoo.animals.clear()
    }
}

fun main() {
    val zoo = Zoo<Tiger>()
    zoo.addAnimal(Tiger("Tiger"))
    zoo.addAnimal(Lion("Lion")) // 에러!
}

2. 제네릭과 무공변

제네릭을 사용하는 간단한 예시를 알아보았는데 제네릭을 공부하다보면 자연스럽게 공변, 무공변, 반공변과 같은 어려운 단어를 마주하게 됩니다. 우선 무공변에 대해 먼저 알아보도록 하겠습니다.

 

우선 아래 코드를 살펴보죠.

fun main() {
    val zoo = Zoo<Mammal>()
    zoo.addAnimal(Tiger("Tiger"))
}

전혀 문제가 없는 코드인데요. Tiger는 Mammal를 상속한 클래스이므로 당연합니다. 이렇게 상속관계를 가진 클래스는 상위 클래스 대신 하위 클래스를 대체시켜도 문제가 없죠.

 

그런데 아래와 같은 코드는 문제가 발생합니다.

fun main() {
    val tigersZoo = Zoo<Tiger>()
    tigersZoo.addAnimal(Tiger("Tiger"))
    
    val mammalsZoo = Zoo<Mammal>()
    mammalsZoo.moveFrom(tigersZoo) // Type mismatch 에러!
}

위 코드에서 마지막 줄인 moveFrom에서 Type mismatch 에러가 발생하는데 moveFrom 함수를 살펴보면 인자로 Zoo<T>를 받는 것을 알 수 있습니다. 이 때 mammalsZoo는 Zoo<Mammal>을 인자로 받으려하고 실제로 넘긴 인자는 Zoo<Tiger>입니다. 언뜻보면 상위 클래스 대신 하위 클래스를 자리시킨 것 같은데 이것은 먼저 살펴본 addAnimal 예시와는 조금 다릅니다.

 

헷갈릴 수 있지만 Mammal가 Tiger는 상속관계이지만, Zoo<Mammal> 와 Zoo<Tiger>는 사실 아무 관계가 아닙니다. 이러한 관계를 보고 우리는 Zoo가 무공변하다 라고 말합니다. 이거 근데.. 그냥 상속관계로 인정해줘도 되지 않나 라는 생각이 들게끔 합니다. 하지만 제네릭에서 이러한 관계를 상속관계로 인정해주면 타입 안정성과 관련된 문제가 발생합니다.

 

타입 안정성과 관련된 문제가 발생하는 예시를 설명하기 위해 공변 관계인 자바 배열을 먼저 살펴보겠습니다.

public static void main(String[] args) {
    String[] str = new String[] {"A", "B", "C"};
    Object[] obj = str;
    obj[0] = 1;
}

자바에서는 String과 Object가 상속관계에 있는데 이 때 String[]와 Object[] 역시 상속관계를 유지합니다. 이를 공변하다 라고하는데요. 위 코드는 컴파일 에러가 나지 않습니다. 하지만 실제 실행시켜보면 런타임에 예외가 발생하게 되죠. 이러한 문제때문에 공변한 관계는 주의하지 않으면 문제가 발생할 여지가 많이 생깁니다. 

 

따라서 제네릭에서는 기본적으로 무공변하게끔 설계한 것 같습니다. 그런데.. 사실 앞서 예시로 든 tigersZoo, mammalsZoo는 공변 관계를 유지하더라도 문제가 없으며, 실제로 동작 역시 공변하게 했으면 좋겠는 상황입니다. 이러한 문제를 코틀린에서 해결할 수 있는 방법이 있는데요. 이를 또 알아보도록 하겠습니다.


3. 공변과 반공변

공변의 개념은 위에서 자바 배열을 살펴본 것처럼 우리의 moveFrom 함수도 공변하게 만들어보는 작업을 해보도록 하겠습니다. 이렇게 하기 위해서 코틀린에서는 정말 간단한 키워드를 제공해주는데요. 아래와 같이 코드를 작성하면 됩니다.

fun moveFrom(zoo: Zoo<out T>) { // out 키워드 붙여주기!
    animals.addAll(zoo.animals)
    zoo.animals.clear()
}

moveFrom의 T 앞에 변성 어노테이션이라고 불리는 out만 딱 붙여주면 위에서 Type mismatch 에러가 더이상 발생하지 않습니다.out을 붙이게 되면 Zoo<Mammal>가 Zoo<Tiger>의 상위 타입으로 간주되는 것이죠.

 

그렇다면 out을 붙이는 것이 무슨 의미일까요? out이라는 키워드는 인자로 받은 T 타입이 생산자의 역할만 할 수 있다는 뜻입니다. 즉, 데이터를 꺼내기만 할 수 있다는 뜻이죠. 아래와 같이 T가 데이터를 소비하게끔 하면 에러가 발생합니다.

fun moveFrom(zoo: Zoo<out T>) {
    animals.addAll(zoo.animals)
    zoo.addAnimal(this.getAnimal(0)) // 에러발생!
}

자바 배열 예시에서 살펴본 것처럼 공변 관계를 만들면 타입 안정성 문제가 생기는데 사실 타입 안정성 문제가 생기는 경우는 소비자의 역할을 하게 되었을 때 입니다. 따라서 공변 관계 + 생산자 역할을 부여해주는 out 키워드에서는 타입 안정성과 공변 관계 생성을 문제없이 할 수 있게 되는 것이죠.

 

이와 반대로 반공변이라는 개념이 있는데 이는 상위, 하위클래스가 반대로 적용되는 경우를 말합니다. 우리 예시에서는 Zoo<Mammal>가 Zoo<Tiger>의 하위 클래스가 되는 것이죠. 코틀린에서는 out 키워드와 반대인 in 키워드를 제네릭 타입 앞에 붙여줌으로써 적용해줄 수 있습니다. in 키워드를 붙이게 되면 out 키워드를 사용했던 것과 반대로 소비자의 역할만 하게 됩니다. 이 때도 타입 안정성의 문제를 해결하며 반공변 관계를 생성해줄 수 있게 되죠!

 

in과 out 키워드의 경우 함수나 클래스 제네릭 타입 어디에도 붙일 수 있습니다.


4. 제네릭 제약

현재는 Zoo<T>라고 작성되어 있으므로 T 타입에는 어느 타입이든 들어올 수 있습니다. 만약 Animal 타입과 그 하위 타입만 들어오게 할 수 있게 하려면 어떻게 해야할까요? 코틀린에서는 다음과 같이 제약을 부여할 수 있습니다.

class Zoo<T: Animal> // T는 Animal과 그 하위타입이어야한다!
class Zoo<T> where T: Animal, T: Comparable<T> // 조금 더 복잡한 제네릭 제약 방식

두 가지 방식 중 첫번째 방식은 자바와 비슷하고 두 번째 방식은 조금 특이하죠? 여러 가지 제약을 붙이기 위해서 코틀린에서는 두 번째 방식처럼 사용할 수 있습니다.


5. 타입소거와 refied

자바에서는 하위호환성을 위해 제네릭이 런타임에 타입 정보가 소거됩니다. 코틀린에서도 이는 마찬가지입니다. 그렇지만 가끔은 타입 정보를 런타임에도 알고 싶은 경우가 있을 수도 있죠. 코틀린에서는 이를 refied를 사용하면 가능합니다. 예시 코드로 살펴보죠!

inline fun <reified T> List<*>.hasInstanceOf(): Boolean {
    return this.filterIsInstance<T>().isNotEmpty()
}

refied는 inline 함수와 조합해서 사용할 수 있는데, 이런 함수는 컴파일러가 함수의 바이트 코드를 함수가 사용되는 모든 곳에 복사하도록 만듭니다. refied type과 함께 인라인 함수가 호출되면 컴파일러는 type argument로 사용된 실제 타입을 알고 만들어진 바이트 코드를 직접 클래스에 대응되도록 바꿔줍니다. 따라서 T는 런타임과 바이트코드에서 실제 타입으로 변경된다고 합니다.


6. 나가며 💨

이번 글에서는 제네릭에 대해 간단히 알아보고, 이에 따라 나오는 개념인 공변에 대해 추가로 알아보았습니다. 또한 코틀린에서는 이러한 개념들을 어떻게 사용할 수 있는지에 대해 알아보았습니다.

반응형
복사했습니다!