이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.
작성 시점: 2018-04-14
이 글은 원저자의 허락을 맡아 Star-Projections and How They Work 글을 번역한 것입니다.
Star-projections 가 어떻게 작동하는지 알고 싶나요? 또는 왜 그들이 함수 파라미터와 반환 타입을 변경하는지 알고 싶나요? 또는 왜 때때로 그들 없이 실제 값을 얻을 수 있는지 알고 싶나요?
이 시리즈의 첫번째 게시글이었던 An Illustrated Guide to Covariance and Contravariance in Kotlin 에서는 variance 를 표시하는 두 가지의 간단하고 이해하기 쉬운 방법을 찾아내었고, 코틀린에서 일반적인 클래스와 인터페이스 상속에 있어서 어떻게 적용되는지 알아보았습니다.
두 번째 게시글이었던 The Ins and Outs of Generic Variance in Kotlin 에서는 이 두 가지의 법칙이 generics 에서 어떻게 다뤄지는지, Type projection 의 타입을 알아내고 작동하는지 알아보았습니다.
세번째이자 마지막 게시글에서는, 같은 두 세부적인 법칙을 모든 종류의 generic 에 수용 가능한 특별한 상황에 적용할 것입니다.

무엇이 멋진지 알고 싶나요? Star-projections 는 이 케이스를 관리할 수 있는 유일한 방법입니다! 이 글에서는 문제를 해결할 수 있는 세 가지 길을 알아볼 것입니다. 따라가면, star-projections 이 작동하는 방향에 대해 정확히 이해할 수 있을 것입니다.
준비되었나요? 그러면 시작해보죠!
프로그램을 개발할 때에, 어느 종류의 generic 라고 하더라도 수용할 수 있는 함수를 원할 때가 많습니다. 예제로, 지난 글에도 사용되었던 Group 인터페이스가 있다고 해보죠.
interface Group<T> {
fun insert(item: T): Unit
fun fetch(): T
}
우리는 모든Group 를 추상적으로 수용할 수 있는 함수를 만들려고 합니다. 우리는 이 것들을 수용하길 원합니다.:
Group<Dog>Group<Animal>Group<Int>Group<String>Group<Group<Number>>Group<Whatever>다른 말로 말하자면, 상상할 수 있는 Group 의 모든 가능한 종류의 부모 타입을 의미하는 'SuperGroup' 를 만들기 원합니다.

어떻게 하면 이 것을 타입에 안전하게 달성할 수 있을까요?
글쎄요, 몇 가지 해결법을 떠올릴만한 세부적인 규칙을 사용하죠. 아래의 두 가지 룰입니다.
- A subtype must accept at least the same range of types as its supertype declares.
- 하위 유형은 상위 유형이 선언하는 것과 동일한 유형 범위를 수용해야 합니다.
- A subtype must return at most the same range of types as its supertype declares.
- 하위 유형은 상위 유형이 선언하는 것과 동일한 유형 범위까지 반환해야 합니다.
기억하세요 - 소속 관계를 확실히 하기 위해, 하위 유형이 진정한 하위 유형이 되고, 상위 유형이 진정한 상위 유형이 되기 위해 이 관계는 두 규칙을 모두 지켜야 합니다.
지난 글에서 처럼, 우리가 원하는 관계를 꺼내 이 규칙들에 맞출 것입니다. 그래서 이 케이스에서는 모든 종류의 group 는 super group (SuperGroup 이름을 가지고 있는) 의 하위 유형이 되길 원합니다. 따라서 해당 사실을 표현하는 규칙을 다시 작성해봅시다.
- A subtype Every kind of
Groupmust accept at least the same set of types as its supertypeSuperGroupdeclares.- 하위 유형 모든 종류의
Group은 상위 유형SuperGroup이 선언하는 것과 동일한 유형 셋을 가져야 합니다.- A subtype Every kind of
Groupmust return at most the same set of types as its supertypeSuperGroupdeclares.- 하위 유형 모든 종류의
Group은 상위 유형SuperGroup이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다.

이제 이 규칙이 우리의 상황에 맞게 되어있기 때문에, 이 두 가지를 만족시킬 방법을 찾아야 합니다. 어떻게 하면 될까요?
Every kind of
Groupmust accept at least the same set of types asSuperGroupdeclares.모든 종류의
Group은SuperGroup이 선언하는 것과 동일한 유형 셋을 가져야 합니다.
모든 종류의 Group는 SuperGroup 이 선언하는 것과 동일한 유형 셋을 가져야 하니, SuperGroup 는 가능하면 가장 작은 범위를 선언합니다. 모든 유형은 상위 유형이 되어야 합니다.

코틀린 에서 이 타입이 정확히 어떤 것인지 알고 있나요?
정답은 Nothing 유형입니다! Nothing 는 아래의 상황을 대응하는 마법같은 유형입니다.
그래서 우리의 SuperGroup 에서 모든 곳에 쓰이는 파라미터는 인수(argument) 로서 사용하고, Nothing 를 사용합니다.

자, 이제 규칙 첫번째를 다루었습니다. 반이나 도착했습니다!
Every kind of
Groupmust return at most the same set of types asSuperGroupdeclares.모든 종류의
Group은 상위 유형SuperGroup이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다.
자, 이제 규칙 #2를 따르면, 우리는 모든 종류의 Group 가 SuperGroup 이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다. 이 규칙을 참으로 하려면, SuperGroup 는 모든 타입이 하위 유형이 될 수 있도록 가능한 큰 범위를 선언해야 합니다.

코틀린에서 유형 계층의 맨 윗 부분은 무엇인가요? 모든 유형의 상위 유형은 무엇인가요? 아마도 당신이 아는 듯이, Any? 일 것입니다.
규칙 #2 를 만족시키기 위해서는 SuperGroup 는 결과로서 유형 파라미터를 명시하기 위해 모든 함수에 Any? 를 반환해야 합니다.

참 쉽죠?
한번 보세요!
처음부터 사용한 두 개의 간단한 규칙으로, 우리는 어떤 종류의 Group이든 수용할 수 있는 일반적 유형을 만들었습니다.
정리하자면, 유형 파라미터를 수용하거나 반환하는 모든 함수를 위해 우리의 SuperGroup 는 아래와 같은 특성을 가질 필요가 있습니다.
Nothing 을 수용하고Any? 을 반환다른 말로 하자면, 인터페이스는 다음과 같이 보일겁니다.
interface SuperGroup {
fun insert(item: Nothing): Unit
fun fetch(): Any?
}
코드에 이 인터페이스를 생성해 Group 로 상속하는 것은 좋지만, 작동하지 않을 것입니다.
왜일까요?
Because, as you might recall, Kotlin does not support contravariant argument types in normal class and interface inheritance. But good news - we can achieve the same thing with a type projection!
왜냐하면, Kotlin 은 일반 클래스와 인터페이스를 상속할 때에 Contravariant argument type (반변 유형 타입) 을 지원하지 않기에 사용할 수 없습니다. 하지만 좋은 소식이 있습니다. 우리는 type projection 을 통해 같은 일을 달성할 수 있습니다.
Nothing 를 수용하고 Any? 를 반환하는 Type projection 을 어떻게 만들 수 있을까요?
흥미롭게도, 이를 해결하기 위한 두 가지 접근 방법이 있습니다.
양쪽의 방법에서, 우리는 다른 각도로 바라보지만 같은 일을 달성할 수 있습니다. 각 케이스에 맞는 효율적인 정의는 다음과 같습니다.

함수 서명이 보이십니까? 같지 않나요?
왜 저렇게 되는지 궁금하다면, 이를 기억하세요.
Any?각 케이스에 맞는 유형 매개변수를 같이 넣는다면, 같은 함수 서명을 얻을 수 있습니다.

하지만, 우리에게는 세번째 옵션이 있습니다.
Group 또는 Group<out Any?> 보다 Group<*> 를 이용해보세요. 다른 두 방법과 같은 효율적인 인터페이스를 제공합니다.

이미 눈치챘듯이, 별 처럼 보이는 asteristk 를 유형 매겨변수로 지정했기 때문에 star-projection라 부릅니다.
그래서, 우리는 모든 종류의 generic 를 받기 위해 세 가지 방법이 있습니다.
<in Nothing><out Any?><*>기술적으로는 세 가지 방법을 사용할 수 있음에도 불구하고 코틀린에서는 관용적으로 Star-projection 으로 이 문제를 해결하고, 다른 방법보다 더 좋게 평가됩니다.
왜일까요?
Star-projections 를 잘 바라보면, 그 것에 대해 아주 많이 생각할 필요가 없습니다. OS의 시스템 터미널에서 ls .txt 같이 를 어떤 것이든 매치해주는 와일드카드처럼 사용했던 사람이라면, 쉽게 생각할 수 있을 것입니다. 비슷하게도, *를 어떠한 종류의 유형 매개변수들을 가질 수 있는 하나의 개념으로 본다는 점에서 특별한 차이는 없습니다.
그러나 그 이상으로 유형 매개변수 제약을 도입하면 몇 가지 매혹적인 차이를 볼 수 있을 것입니다.
한번 살펴보죠!
유형 매개변수 제약은 유형 파라미터가 '상한선' 를 가질 수 있게 generic의 인스턴스를 제약합니다. 예제로, Group 인터페이스가 제약을 가지도록 해보죠.
interface Group<T : Animal> {
fun insert(member: T): Unit
fun fetch(): T
}
유형 파라미터에 : Animal 를 붙이는 것으로, 이제 Animal 유형을 가지거나, Animal 의 하위유형인 Group 를 생성할 수 있습니다. Group 과 Group 는 괜찮지만 Group 와 Group 는 더 이상 유효하지 않습니다. 컴파일러는 그들을 거부할 것이고, 아래의 에러 메세지를 표시할 것입니다.
Type argument is not within its bounds
유형 매개변수가 범위 내에 없습니다.
한번 이 세 개의 접근방법이 제약과 어떻게 작용하는지 살펴봅시다.
in-projection 을 사용하여 Group를 읽어오는 함수가 있습니다.
fun readIn(group: Group<in Nothing>) {
// Inferred type of `item` is `Any?`
val item = group.fetch()
}
Kotlin은 여기서 타입 추론을 시행하고, item 의 타입을 Any? 로 추론합니다.
그렇죠, 이미 item이 'String 이나 Int, Animal 보다 더 일반적인 것으로 될 수 없다'는 유형 매개변수 제약을 추가한 것을 알고 있습니다. 그래서 Kotlin이 대부분의 Animal 에서 fetch() 의 결과를 안전하게 받아옵니다.
하지만 in-projection 는 이 상황을 고려하지 않습니다. 여전히 fetch() 의 결과형으로 Any? 를 사용합니다.
자, 이제 out-projection 를 사용해 Group를 읽어오는 함수가 있습니다. 이미 우리는 out-projection 을 사용하여 모든 유형을 수용할 수 있게 하려면 <out Any?> 를 사용해야 된다는 것을 알고 있습니다. 하지만 유형 매개변수 제약을 도입한 순간, Any? 는 더 이상 작동을 하지 않습니다. 우리가 사용할 수 있는 가장 일반적인 유형은 우리의 유형 매개변수 제약의 상한선에 있습니다.
그래서 이 케이스에서는 대신 <out Any?> 를 사용하여 규칙 #2 를 만족시킬 수 있습니다. 하지만 Kotlin 컴파일러는 Any? 를 유형 매개변수로서 지정하는 것을 허락하지 않습니다. 따라서 우리는 Animal 로 설정해야 합니다.
fun readOut(group: Group<out Animal>) {
// Inferred type of x is `Animal`
val item = group.fetch()
}
자, 우리는 fetch() 의 결과가 Any? 보다는 Animal 를 고려한 것을 볼 수 있습니다. Animal 의 함수와 item의 속성을 모두 사용할 수 있다는 점에서, readOut() 안에 있는 item은 readIn() 보다도 더 많은 일을 수행할 수 있기 때문에 완벽합니다.
마지막으로, star-projection 을 이용하여 Group로부터 읽어오는 비슷한 함수를 작성해봅시다.
fun readStar(group: Group<*>) {
// Inferred type of x is `Animal`
val item = group.fetch()
}
위의 readOut() 와 비슷하게, fetch() 의 결과는 Animal 로 나오게 된 것으로 보아, 이 상황에서 Kotlin 은 유형 매개변수 제약을 수행했습니다. 다시 말해서, Animal 의 함수와 접근 속성들을 다룰 수 있기 때문에 매우 좋습니다.
자, 이제 매혹적인 부분입니다!
readIn(), readOut(), readStar()의 세 가지 다른 함수에 Animal 를 Dog로 제약을 바꾸는 임팩트를 주면 어떻게 될까요?
interface Group<T : Dog> {
fun insert(member: T): Unit
fun fetch(): T
}
아마 이와 같은 상황이 벌어질 것입니다.
readIn() 은 영향받지 않고, item 의 유형을 Any? 라 추론하고 있습니다.
fun readIn(group: Group<in Nothing>) {
// No change - inferred type of `item` is `Any?`
val item = group.fetch()
}
readOut() 는 유형 매개변수에 컴파일 에러가 나오게 됩니다. Group 를 Group 로 바꿔야 한다는 것인데, 만일 바꾸게 되면 item 의 추론 타입은 Dog로 변경되게 됩니다.
// Gotta change the type argument here to `Dog`!
fun readOut(group: Group<out Dog>) {
// Inferred type of x is now `Dog`
val item = group.fetch()
}
하지만 우리의 star-projection는 어떨까요? readStar는 여전히 컴파일되고, item의 추론 타입을 Dog로 자동으로 변환해주었습니다. 훌륭합니다!
// No change to the function signature!
fun readStar(group: Group<*>) {
// Inferred type of x is `Dog`
val item = group.fetch()
}
그래서, star-projection를 사용하면 읽고 이해하기 쉽다는 장점보다도 유형 매개변수 제약을 바꾸는 것에 대해 관용적으로 제어한다는 실질적인 효과가 있습니다.
마지막으로, 혼란이 오는 부분에 대한 일반적인 곳에 대해 정리해봅시다.
왜 <Any?>를 일부 케이스에만 사용할 수 있고, 다른 케이스에서는 <> 를 사용해야 할까요? 예를 들어, List<Any?> 를 수용하는 함수가 있고, List<>를 수용하는 또 다른 함수가 있다고 가정해봅시다.
(참고로, 우리는 List<out Any?> 대신 List<Any?> 를 이용하고 있습니다.)
fun acceptAnyList(list: List<Any?>) {}
fun acceptStarList(list: List<*>) {}
자, 이제 List 를 각자에게 보내봅시다.
val listOfStrings: List<String> = listOf("Hello", "Kotlin", "World")
acceptAnyList(listOfStrings)
acceptStarList(listOfStrings)
만일 시도해보면, 잘 작동하는 것을 확인할 수 있습니다. 두 개의 함수는 List를 수용하고 있습니다. 하지만 Array로 바꾸게 된다면, 컴파일이 되지 않음을 빠르게 알아챌 수 있습니다.
fun acceptAnyArray(array: Array<Any?>) {}
fun acceptStarArray(array: Array<*>) {}
val arrayOfStrings = arrayOf("Hello", "Kotlin", "World")
acceptAnyArray(arrayOfStrings) // Compiler error here
acceptStarArray(arrayOfStrings)
acceptAnyArray() 에 컴파일 에러가 나오게 됩니다.
Required:
Array<Any?>Found:Array<String>
무엇이 벌어지고 있나요?
다시 상기해보면, 일반적으로 generics는 변하지 않습니다. 다른 말로 말하면, Array<Any?> 는 Array 의 상위 유형이 아니기 때문에 오류가 발생하는 것입니다. 이 함수에 전달하는 Array는 Array 가 아닌 Array<Any?> 여야 할 것입니다.
왜 List에서는 작동하지만 Array에는 작동하지 않을까요?
Kotlin Stdlib에는 Array와 다르게 List는 declaration-site variance (선언 위치 변환) 을 사용하여 유형 파라미터를 out로서 명시하고 있습니다. 따라서 List<Any?> 라고 함수의 매개변수를 명시하면 우리가 위에서 본 것 처럼 모든 종류의 generic 를 수용하는 List<out Any?>를 얻을 수 있는 것입니다.
우리는 모든 종류의 generic를 수용하는 세 가지 방법을 찾아냈습니다.
<out Any?><in Nothing><*>Star-projection은 코틀린에 관용적이란 점에서 호응적인 방법입니다. 읽고 이해하기 쉬우며, 유형 매개변수 제약을 바꾸는 것에 대해 관용적으로 제어합니다. 그리고 왜, 어떻게 작동하는지도 알게 되었습니다.
이 것으로, generic에 대한 시리즈를 마칩니다. Kotlin의 variance 에 대한 모든 것을 이해하는 견고한 기초를 세우는 데 도움이되기를 바랍니다. (다른 프로그래밍 언어도 마찬가지입니다!)
앞으로 찾아올 글에서 코틀린의 다른 매혹적인 부분을 탐색하는 것을 기대하고 있습니다! 작성해주길 원하는 다른 주제가 있나요? 코멘트로 남겨주세요.
예전에 이 블로그에서 Generic에 대해서 설명한 시리즈가 있었는데, 그 것으로는 완전히 이해하기에는 부족한 점이 많았고, 원 글이 많은 도움을 주었기 때문에 번역을 해보았습니다.
오역이 있다면 댓글로 알려주세요.