제네릭 — 공변, 반공변, star projection까지
제네릭은 타입 안전성과 코드 재사용을 동시에 잡는 기능이다. 자바의 제네릭과 개념은 비슷하지만, 코틀린은
out과in키워드로 변성(variance)을 더 직관적으로 표현한다. 면접에서 "공변과 반공변의 차이"를 설명하라는 질문은 정말 자주 나오니, 이번에 확실히 정리하자.
제네릭 기본 — 타입 파라미터
// 제네릭 클래스
class Box<T>(val value: T)
val intBox = Box(42) // Box<Int>
val stringBox = Box("코틀린") // Box<String>
// 제네릭 함수
fun <T> singletonList(item: T): List<T> = listOf(item)
val list = singletonList("hello") // List<String>
타입 파라미터 제약(Upper Bound)
// T는 Comparable을 구현해야 한다
fun <T : Comparable<T>> max(a: T, b: T): T {
return if (a > b) a else b
}
max(3, 5) // OK — Int는 Comparable<Int> 구현
max("abc", "def") // OK — String도 Comparable
// max(listOf(1), listOf(2)) // 컴파일 에러! List는 Comparable 아님
where 절 — 여러 제약 동시 지정
// T가 Comparable이면서 동시에 Serializable이어야 할 때
fun <T> processData(data: T) where T : Comparable<T>, T : java.io.Serializable {
// data는 compareTo()와 Serializable 메서드 모두 사용 가능
println("처리: $data")
}
변성(Variance) — 왜 필요한가?
문제를 먼저 이해하자. Cat이 Animal의 하위 타입일 때, List<Cat>은 List<Animal>의 하위 타입일까?
open class Animal
class Cat : Animal()
class Dog : Animal()
// 만약 List<Cat>이 List<Animal>의 하위 타입이라면?
fun addAnimal(animals: MutableList<Animal>) {
animals.add(Dog()) // Dog도 Animal이니까 추가 가능
}
val cats: MutableList<Cat> = mutableListOf(Cat(), Cat())
// addAnimal(cats) // 만약 이게 가능하면?
// cats[2]는 Dog! — Cat 리스트에 Dog가 들어감 → 타입 안전성 파괴
이것이 바로 변성(variance) 문제다. 코틀린은 out과 in으로 이 문제를 해결한다.
out — 공변(Covariance)
out은 "이 타입 파라미터를 출력(반환)으로만 사용한다"는 선언이다. 이렇게 하면 하위 타입 관계가 유지된다.
// out T — T를 출력으로만 사용 (Producer)
interface Producer<out T> {
fun produce(): T // OK — T를 반환
// fun consume(item: T) // 컴파일 에러! T를 파라미터로 받을 수 없음
}
class CatProducer : Producer<Cat> {
override fun produce(): Cat = Cat()
}
// Cat이 Animal의 하위 타입이면,
// Producer<Cat>도 Producer<Animal>의 하위 타입
val animalProducer: Producer<Animal> = CatProducer() // OK!
PECS의 PE — Producer Extends
자바에서는 ? extends Animal으로 표현하던 것을 코틀린은 out으로 간결하게 표현한다.
// 코틀린의 List는 이미 out으로 선언되어 있다
public interface List<out E> : Collection<E> {
// E를 반환만 하고 입력으로 받지 않음
operator fun get(index: Int): E
}
// 그래서 이것이 가능하다
val cats: List<Cat> = listOf(Cat(), Cat())
val animals: List<Animal> = cats // OK! List<out E>이므로
in — 반공변(Contravariance)
in은 "이 타입 파라미터를 입력(소비)으로만 사용한다"는 선언이다. 이렇게 하면 하위 타입 관계가 역전된다.
// in T — T를 입력으로만 사용 (Consumer)
interface Consumer<in T> {
fun consume(item: T) // OK — T를 파라미터로 받음
// fun produce(): T // 컴파일 에러! T를 반환할 수 없음
}
class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
println("동물 처리: $item")
}
}
// Animal이 Cat의 상위 타입이면,
// Consumer<Animal>은 Consumer<Cat>의 하위 타입 (역전!)
val catConsumer: Consumer<Cat> = AnimalConsumer() // OK!
catConsumer.consume(Cat()) // AnimalConsumer가 Cat도 처리 가능
PECS의 CS — Consumer Super
자바의 ? super Cat을 코틀린은 in으로 표현한다.
// Comparable은 in으로 선언되어 있다
public interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
변성 정리표
| 키워드 | 변성 | 자바 대응 | 하위 타입 관계 | 사용 위치 |
|---|---|---|---|---|
out | 공변 | ? extends T | 유지 | 반환 타입만 |
in | 반공변 | ? super T | 역전 | 파라미터만 |
| 없음 | 무공변 | T | 없음 | 어디든 |
쉽게 기억하는 법은 이렇다.
- out → 밖으로 나간다 → 반환(출력)
- in → 안으로 들어온다 → 파라미터(입력)
선언 지점 변성 vs 사용 지점 변성
선언 지점 변성(Declaration-site variance) — 코틀린의 방식
클래스를 정의할 때 변성을 지정한다. 한 번 선언하면 사용하는 곳에서 매번 지정할 필요 없다.
// 선언 지점에서 out 지정 — 모든 사용 지점에 적용
interface Source<out T> {
fun next(): T
}
// 사용할 때마다 변성을 지정할 필요 없음
fun demo(source: Source<String>) {
val objects: Source<Any> = source // 자동으로 공변
}
사용 지점 변성(Use-site variance) — 자바의 방식도 지원
클래스를 정의할 때 변성을 결정할 수 없는 경우, 사용하는 곳에서 지정할 수 있다.
// Array는 무공변 — 읽기와 쓰기 모두 가능해야 하므로
class Array<T>(val size: Int) {
operator fun get(index: Int): T = ...
operator fun set(index: Int, value: T) { ... }
}
// 사용 지점에서 변성 지정
fun copy(from: Array<out Any>, to: Array<Any>) {
// from은 out — 읽기만 가능
for (i in from.indices) {
to[i] = from[i] // from에서 읽기 OK
// from[i] = to[i] // from에 쓰기 — 컴파일 에러!
}
}
val ints: Array<Int> = arrayOf(1, 2, 3)
val anys: Array<Any> = arrayOf("", "", "")
copy(ints, anys) // OK — Array<out Any>로 사용
star projection(*) — 타입을 모를 때
*는 "타입 인자를 모르지만, 안전하게 사용하고 싶다"는 의미다. 자바의 ?(와일드카드)에 해당한다.
// 타입 인자를 모르는 상황
fun printList(list: List<*>) {
for (item in list) {
println(item) // Any?로 읽을 수 있음
}
}
printList(listOf(1, 2, 3))
printList(listOf("a", "b"))
star projection의 동작 규칙
// out으로 선언된 타입의 * → out Nothing이 아니라 out Any?
// List<*>는 List<out Any?>와 같음 — 읽기 가능, 쓰기 불가
val list: MutableList<*> = mutableListOf(1, 2, 3)
val first: Any? = list[0] // OK — Any?로 읽기
// list.add(4) // 컴파일 에러! 무슨 타입인지 몰라서 쓰기 불가
// in으로 선언된 타입의 * → in Nothing
// Comparable<*>는 Comparable<in Nothing>과 같음
star projection vs Any?
val anyList: List<Any?> = listOf(1, "hello", null) // 어떤 타입이든 넣은 리스트
val starList: List<*> = listOf(1, 2, 3) // 타입을 모르는 리스트
// 차이가 드러나는 지점
fun addItem(list: MutableList<Any?>) {
list.add("아무거나") // OK — Any?는 뭐든 넣을 수 있음
}
fun unknownAdd(list: MutableList<*>) {
// list.add("아무거나") // 컴파일 에러! *는 타입을 모르므로 쓰기 불가
}
타입 소거와 reified
JVM에서 제네릭의 타입 정보는 런타임에 사라진다(타입 소거).
val intList = listOf(1, 2, 3)
val stringList = listOf("a", "b")
// 런타임에는 둘 다 그냥 List — 타입 인자 정보가 없음
// if (intList is List<Int>) // 경고! 타입 소거로 검사 불가
if (intList is List<*>) // OK — *로만 검사 가능
reified로 해결
inline 함수에서 reified를 사용하면 타입 소거를 우회할 수 있다.
// reified — 인라이닝 시 실제 타입으로 대체
inline fun <reified T> filterByType(items: List<Any>): List<T> {
return items.filter { it is T }.map { it as T }
}
val mixed = listOf(1, "hello", 2, "world", 3.14)
val strings = filterByType<String>(mixed) // ["hello", "world"]
val ints = filterByType<Int>(mixed) // [1, 2]
reified에 대한 자세한 내용은 inline 함수 글에서 다뤘다.
제네릭의 실무 활용 예시
제네릭 확장 함수
// 안전한 캐스팅
inline fun <reified T> Any?.asOrNull(): T? = this as? T
val value: Any = "코틀린"
val str: String? = value.asOrNull<String>() // "코틀린"
val num: Int? = value.asOrNull<Int>() // null
제네릭 sealed class
// API 응답을 타입 안전하게 모델링
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// Nothing이 out T의 하위 타입이므로 Error와 Loading을 어디서든 사용 가능
fun handleResult(result: Result<String>) {
when (result) {
is Result.Success -> println(result.data)
is Result.Error -> println(result.exception.message)
Result.Loading -> println("로딩 중...")
}
}
타입 안전한 빌더
// 제네릭 + 함수 타입으로 빌더 패턴
class RequestBuilder<T> {
var url: String = ""
var parser: ((String) -> T)? = null
fun build(): Request<T> = Request(url, parser!!)
}
inline fun <reified T> request(block: RequestBuilder<T>.() -> Unit): Request<T> {
return RequestBuilder<T>().apply(block).build()
}
정리 — 면접에서 기억할 포인트
- out(공변) — Producer. 반환 타입으로만 사용.
List<Cat>→List<Animal>가능 - in(반공변) — Consumer. 파라미터로만 사용.
Consumer<Animal>→Consumer<Cat>가능 - 선언 지점 변성 — 클래스 정의에서 한 번 지정. 사용할 때마다 반복 불필요
- 사용 지점 변성 —
Array<out Any>처럼 사용 시점에 변성 지정 - star projection(*) — 타입을 모를 때. 읽기는
Any?로, 쓰기는 불가 - 타입 소거 — JVM에서 런타임에 타입 인자 정보 사라짐.
reified로 우회 가능 - where — 여러 상한 제약 동시 지정