본문 바로가기
Kotlin

[Kotlin] Generics - in과 out 확실하게 알기

by Nhahan 2025. 4. 16.

Generics의 inout은 코틀린이 타입 시스템을 더 안전하게 만들기 위한 장치다.
그런데 inout 키워드는 처음 배울 때 확실하게 알기 어렵다(나만 그랬나?). 그래서 정리해본다.

1. 시작하기에 앞서, Generics는 왜 쓸까?

먼저 제네릭이 왜 필요한지 짚고 넘어가야한다. 제네릭은 타입을 마치 함수의 파라미터처럼 사용하는 기능이다. 이렇게 하면 다양한 타입에서 작동하면서도 타입 안정성을 보장하는 재사용 가능한 코드를 만들 수 있다.

예를 들어, List<String>은 문자열만 담는 리스트, List<Int>는 정수만 담는 리스트가 된다.
List<T>라는 하나의 틀을 만들어 놓고, 필요할 때마다 T 자리에 원하는 타입을 넣어 쓰는 것이다. 이렇게 하면 잘못된 타입이 들어가는 걸 컴파일 시점에 미리 막을 수 있어 안전하다.

2. 문제의 시작: 타입 관계의 복잡성

바로 여기서 inout이 필요한 이유가 나온다. 클래스들 사이에는 상속 관계가 있다.

open class Animal
class Dog : Animal()

예를 들어, DogAnimal의 하위 타입이다.

그렇다면, List<Dog>List<Animal> 사이에는 어떤 관계가 있을까? DogAnimal의 하위 타입이니까, List<Dog>List<Animal>의 하위 타입이라고 할 수 있을까?

val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs // 이게 안전할까? 🤔

이 질문에 대한 답이 바로 가변성(Variance)이다. 코틀린은 inout 키워드로 이 관계를 명확하고 안전하게 정의한다.

3. out 키워드: 반환 타입 정의 (공변성, Covariance)

    • 키워드: out
    • 개념: 제네릭 타입 파라미터 T 앞에 out을 붙이면, 해당 클래스/인터페이스 안에서 T오직 반환 타입으로만 사용될 수 있다는 뜻이다. 즉, 이 클래스는 T 타입의 객체를 생산(produce)하거나 내보내는(out) 역할만 한다. 내부적으로 T 타입의 값을 변경하거나 소비(consume)하지 않는다.
    • 비유: 과일 생산 농장 (Farm<out Fruit>)
      • 이 농장은 오직 Fruit만 생산해서 출하(out)한다. 외부에서 어떤 과일을 마음대로 이 농장에 넣을 수 없다.
      • 사과 농장(Farm<Apple>)은 Farm<Fruit>의 한 종류이다.

AppleFruit의 하위 타입이면, Farm<Apple>Farm<Fruit>의 하위 타입이다. (공변성)

  • 타입 관계: AB의 하위 타입일 때, 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>)가 필요한 곳에 대신 사용할 수 있다. 어차피 모든 과일을 처리할 수 있으니 당연히 사과도 처리할 수 있는 것이다.

AppleFruit의 하위 타입인데, 반대로 Juicer<Fruit> 타입이 Juicer<Apple>의 하위 타입이 된다. (반공변성)

  • 타입 관계: AB의 하위 타입일 때, 오히려 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)

  • 키워드: 없음
  • 개념: 제네릭 타입 파라미터 Tinout위치 모두에서 사용될 수 있다.
  • 비유: 일반적인 상자 (Box<T>)
    • 이 상자에는 T 타입 물건을 넣을 수도 있고(in), 꺼낼 수도(out) 있다.
    • 사과 상자(Box<Apple>)는 과일 상자)(Box<Fruit>)가 아니고, 그 반대도 아니다. 서로 아무 관계가 없다. 왜냐하면 과일 상자에 사과 상자를 넣으면 오렌지를 넣으려고 할 수도 있고(in), 사과 상자를 과일 상자처럼 쓰면 사과가 아닌 다른 과일이 나울 수도 있기 때문이다.
  • 타입 관계: AB의 하위 타입이더라도, 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>는 관계없음).

댓글