Generics의 in
과 out
은 코틀린이 타입 시스템을 더 안전하게 만들기 위한 장치다.
그런데 in
과 out
키워드는 처음 배울 때 확실하게 알기 어렵다(나만 그랬나?). 그래서 정리해본다.
1. 시작하기에 앞서, Generics는 왜 쓸까?
먼저 제네릭이 왜 필요한지 짚고 넘어가야한다. 제네릭은 타입을 마치 함수의 파라미터처럼 사용하는 기능이다. 이렇게 하면 다양한 타입에서 작동하면서도 타입 안정성을 보장하는 재사용 가능한 코드를 만들 수 있다.
예를 들어, List<String>
은 문자열만 담는 리스트, List<Int>
는 정수만 담는 리스트가 된다.List<T>
라는 하나의 틀을 만들어 놓고, 필요할 때마다 T
자리에 원하는 타입을 넣어 쓰는 것이다. 이렇게 하면 잘못된 타입이 들어가는 걸 컴파일 시점에 미리 막을 수 있어 안전하다.
2. 문제의 시작: 타입 관계의 복잡성
바로 여기서 in
과 out
이 필요한 이유가 나온다. 클래스들 사이에는 상속 관계가 있다.
open class Animal
class Dog : Animal()
예를 들어, Dog
는 Animal
의 하위 타입이다.
그렇다면, List<Dog>
와 List<Animal>
사이에는 어떤 관계가 있을까? Dog
와 Animal
의 하위 타입이니까, List<Dog>
도 List<Animal
>의 하위 타입이라고 할 수 있을까?
val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs // 이게 안전할까? 🤔
이 질문에 대한 답이 바로 가변성(Variance)이다. 코틀린은 in
과 out
키워드로 이 관계를 명확하고 안전하게 정의한다.
3. out
키워드: 반환 타입 정의 (공변성, Covariance)
- 키워드:
out
- 개념: 제네릭 타입 파라미터
T
앞에out
을 붙이면, 해당 클래스/인터페이스 안에서T
는 오직 반환 타입으로만 사용될 수 있다는 뜻이다. 즉, 이 클래스는T
타입의 객체를 생산(produce)하거나 내보내는(out
) 역할만 한다. 내부적으로T
타입의 값을 변경하거나 소비(consume)하지 않는다. - 비유: 과일 생산 농장 (
Farm<out Fruit>
)- 이 농장은 오직
Fruit
만 생산해서 출하(out
)한다. 외부에서 어떤 과일을 마음대로 이 농장에 넣을 수 없다. - 사과 농장(
Farm<Apple>
)은Farm<Fruit>
의 한 종류이다.
- 이 농장은 오직
Apple
이 Fruit
의 하위 타입이면, Farm<Apple>
은 Farm<Fruit>
의 하위 타입이다. (공변성)
- 타입 관계:
A
가B
의 하위 타입일 때,Producer<A>
는Producer<B>
의 하위 타입이 된다. (타입 관계 그대로 유지) - 안전성:
out
키워드가 붙으면, 코틀린 컴파일러는 해당 타입(T
)를 파라미터로 받는 함수를 만드는 것을 막아준다. 왜냐하면Producer<Fruit>
타입 변수에Producer<Apple>
객체를 할당했는데, 만약add(Fruit)
이라는 함수가 있다면Orange
를 넣는 것을 시도할 수 있기 때문이다.Producer<Apple>
입장에서는Orange
가 파라미터로 들어오면 안된다. - 예제:
// T 타입 객체를 생산(get)만 하는 인터페이스 interface Source<out T> { fun get(): T // fun set(value: T) // 컴파일 에러! out 위치가 아닌 곳(파라미터)에서 T를 사용하려 함 } fun main() { val stringSource: Source<String> = object : Source<String> { override fun get(): String = "안녕하세요" } // String은 Any의 하위 타입이고, Source는 out T 이므로 공변 관계 성립! val anySource: Source<Any> = stringSource // 가능! 👍 val result: Any = anySource.get() // Any 타입으로 받지만, 실제론 String 객체 println(result) // 출력: 안녕하세요 }
- 실제 코틀린 예시:
List<out E>
인터페이스.List
는 read-only이고,get(index)
메소드처럼 원소를 반환하는(out
) 역할만 한다.add()
메소드는 없다. 그래서List<String>
은List<Any>
타입으로 안전하게 다룰 수 있다.
4. in
키워드: 파라미터 타입 정의 (반공변성, Contravariance)
- 키워드:
in
- 개념: 제네릭 타입 파라미터
T
앞에in
을 붙이면, 해당 클래스/인터페이스 안에서T
는 오직 파라미터 타입으로만 사용될 수 있다는 뜻이다. 즉, 이 클래스는T
타입의 객체를 소비(consume)하거나 받아들이는(in
) 역할만 한다. 내부적으로T
타입의 객체를 외부에 노출(반환)하지 않는다. - 비유: 모든 과일용 주스기 (
Juicer<in Fruit>
)- 이 주스기는 어떤 과일(
Fruit
)이든 받아서(in
) 주스를 만든다. 특정 과일만 뱉어내지 않는다. - 모든 과일용 주스기(
Juicer<Fruit
)는 사과 전용 주스기(Juicer<Apple>
)가 필요한 곳에 대신 사용할 수 있다. 어차피 모든 과일을 처리할 수 있으니 당연히 사과도 처리할 수 있는 것이다.
- 이 주스기는 어떤 과일(
Apple
이 Fruit
의 하위 타입인데, 반대로 Juicer<Fruit>
타입이 Juicer<Apple>
의 하위 타입이 된다. (반공변성)
- 타입 관계:
A
가B
의 하위 타입일 때, 오히려Consumer<B>
가Consumer<A>
의 하위 타입이 된다. (타입 관계가 반대) - 안전성:
in
키워드가 붙으면, 코틀린 컴파일러는 해당 타입(T
)을 반환하는 함수를 만드는 것을 막아준다. 왜냐하면Consumer<Apple>
타입 변수에Consumer<Fruit>
객체를 할당했는데, 만약get(): Apple
같은 함수가 있다면 무엇을 반환해야 할까? 이전에 예를 들어,Orange
를 받아서 처리했는데, 또Apple
을 반환할 수는 없을 것이다. - 예제:
// T 타입 객체를 소비(put)만 하는 인터페이스 interface Sink<in T> { fun put(value: T) // fun get(): T // 컴파일 에러! in 위치가 아닌 곳(반환 타입)에서 T를 사용하려 함 } fun main() { val anySink: Sink<Any> = object : Sink<Any> { override fun put(value: Any) { println("받은 값: $value") } } // String은 Any의 하위 타입이고, Sink는 in T 이므로 반공변 관계 성립! val stringSink: Sink<String> = anySink // 가능! 👍 stringSink.put("코틀린 재밌다!") // Any를 받을 수 있는 곳에 String을 넣는 것은 안전 // 출력: 받은 값: 코틀린 재밌다! }
- 실제 코틀린 예시:
Comparable<in T>
인터페이스.compareTo(other: T)
메소드는T
타입의 다른 객체를 파라미터(in
)로 받아서 비교한다.Comparable<Any>
(어떤 객체든 비교 가능)는Comparable<String>
(문자열만 비교 가능)이 필요한 곳에 안전하게 사용될 수 있다.
5. 번외: 아무것도 안붙이면? (불공변성, Invariance)
- 키워드: 없음
- 개념: 제네릭 타입 파라미터
T
가in
과out
위치 모두에서 사용될 수 있다. - 비유: 일반적인 상자 (
Box<T>
)- 이 상자에는
T
타입 물건을 넣을 수도 있고(in
), 꺼낼 수도(out
) 있다. - 사과 상자(
Box<Apple>
)는 과일 상자)(Box<Fruit>
)가 아니고, 그 반대도 아니다. 서로 아무 관계가 없다. 왜냐하면 과일 상자에 사과 상자를 넣으면 오렌지를 넣으려고 할 수도 있고(in
), 사과 상자를 과일 상자처럼 쓰면 사과가 아닌 다른 과일이 나울 수도 있기 때문이다.
- 이 상자에는
- 타입 관계:
A
가B
의 하위 타입이더라도,ReadWriteBox<A>
와ReadWriteBox<B>
사이에는 아무런 상하위 관계가 없다. - 예제:
interface ReadWriteBox<T> { fun get(): T fun put(value: T) } fun main() { val stringBox: ReadWriteBox<String> = object : ReadWriteBox<String> { private var value: String? = null override fun get(): String = value ?: "" override fun put(value: String) { this.value = value } } // val anyBox: ReadWriteBox<Any> = stringBox // 컴파일 에러! 불공변 // val stringBox2: ReadWriteBox<String> = anyBox // 컴파일 에러! 불공변 }
- 실제 코틀린 예시:
MutableList<E>
.get(index)
(out
) 메소드와add(element: E)
(in
) 메소드를 모두 가지므로 불공변이다.MutableList<String>
을MutableList<Any>
로 다룰 수 없다.
정리
out T
: 생산자(Producer). 읽기 전용(Read-only). 공변성 (List<String>
은List<Any>
이다).in T
: 소비자(Consumer). 쓰기 전용(Write-only). 반공변성 (Comparable<Any>
는Comparable<String>
이다).T
: 생산자 + 소비자. 읽기/쓰기 가능(Read/Write). 불공변성 (MutableList<String>
과MutableList<Any>
는 관계없음).
'Kotlin' 카테고리의 다른 글
[Kotlin] reified 키워드 (0) | 2025.04.03 |
---|---|
[Kotlin in Action] 10.2 리플렉션: 실행 시점에 코틀린 객체 내부 관찰 (0) | 2025.04.03 |
[Kotlin in Action] 10.1 annotation 선언과 적용 (0) | 2025.04.03 |
[Kotlin] 스타 프로젝션(*)과 Any의 차이 (0) | 2025.04.03 |
[Kotlin] 코틀린 선언 지점 변성과 자바 와일드카드 비교 (0) | 2025.04.03 |
댓글