[Kotlin] Generic

Hood·2025년 3월 5일

Kotlin

목록 보기
14/18
post-thumbnail

✍ 코틀린과 친해지자

공부가 필요한 문법에 대해서 정리한 글입니다.


🔎 Generic이란?

코틀린 코드를 보다 보면 함수나 클래스 이름 옆에 <T> 같은 문법이 붙어 있는 경우를 자주 보게 됩니다.
처음 보면 조금 낯설지만, 이것이 바로 제너릭(Generic) 입니다.

제너릭은 쉽게 말해 타입을 나중에 정할 수 있도록 만드는 기능입니다.
즉, 타입을 직접 고정하지 않고 타입도 하나의 매개변수처럼 다룰 수 있게 해주는 문법이라고 볼 수 있습니다.

이 기능을 사용하면 같은 코드를 여러 타입에 재사용할 수 있고,
잘못된 타입 사용도 컴파일 시점에 막을 수 있어서 코드의 안정성도 높아집니다.


예를 들어 보면

제너릭을 처음 설명할 때는 비유로 이해하는 편이 훨씬 쉽습니다.

1. 일반 클래스

마트에 사과만 담을 수 있는 박스가 있다고 생각해보겠습니다.

class Apple
class AppleBox(val apple: Apple)

이 박스는 이름 그대로 사과만 담을 수 있습니다.
즉, 용도가 하나로 고정된 클래스입니다.


2. 제너릭 클래스

이번에는 마트 주인이 사과 박스를 다용도 박스로 바꾸었다고 생각해보겠습니다.

class Box<T>(val item: T)

여기서 T는 아직 정해지지 않은 타입입니다.
즉, Box는 사과 박스가 될 수도 있고, 바나나 박스가 될 수도 있고, 책 상자가 될 수도 있습니다.

class Apple
class Banana
class Book

fun main() {
    val appleBox = Box(Apple())
    val bananaBox = Box(Banana())
    val bookBox = Box(Book())
}

같은 Box 클래스를 두고도, 안에 들어가는 타입만 바꿔서 다양하게 사용할 수 있습니다.
이것이 제너릭의 가장 기본적인 개념입니다.


제너릭의 장점

1. 타입 안정성

제너릭의 가장 큰 장점은 타입 안정성(Type Safety) 입니다.
즉, 잘못된 타입 사용을 실행 전에 컴파일 단계에서 막아줍니다.

// 제너릭이 없는 경우
class AppleBox(val item: Any)

fun main() {
    val appleBox = AppleBox("책")
    val apple = appleBox.item as String
}

위 코드는 Any를 받기 때문에 사과 박스 안에 "책"도 들어갈 수 있습니다.
이런 방식은 타입이 너무 넓어서, 실제 사용할 때 형변환을 해야 하고 런타임 오류 가능성도 생깁니다.

반면 제너릭을 사용하면 타입이 더 명확해집니다.

class Box<T>(val item: T)

fun main() {
    val appleBox = Box("사과")
    val apple: String = appleBox.item
}

이 경우 item의 타입이 String으로 고정되기 때문에,
불필요한 형변환 없이 안전하게 값을 사용할 수 있습니다.


2. 코드 재사용성

제너릭을 사용하면 같은 구조의 클래스를 여러 타입에 재사용할 수 있습니다.

class Box<T>(val item: T)

fun main() {
    val appleBox = Box("사과")
    val bananaBox = Box("바나나")
    val bookBox = Box("책")
}

만약 제너릭이 없다면 AppleBox, BananaBox, BookBox처럼
비슷한 클래스를 여러 개 만들어야 할 수도 있습니다.

즉, 제너릭은 중복 코드를 줄이고,
하나의 구조를 다양한 타입에 재사용할 수 있게 해줍니다.


가변성(Variance)

제너릭을 공부하다 보면 out, in 같은 키워드를 만나게 됩니다.
이 부분은 처음에는 헷갈릴 수 있지만, 핵심만 보면 생각보다 단순합니다.

기본적으로 코틀린의 제너릭은 무공변(invariant) 입니다.
즉, Box<Apple>Box<Fruit>는 서로 다른 타입으로 취급됩니다.

그런데 상황에 따라 읽기 전용, 쓰기 전용처럼 사용 목적이 분명한 경우에는
out, in을 통해 타입 관계를 더 유연하게 표현할 수 있습니다.


1. 공변성 out

out꺼내기 전용이라고 생각하면 이해하기 쉽습니다.
즉, 이 박스에서는 값을 안전하게 읽어 올 수만 있다는 뜻입니다.

open class Fruit
class Apple : Fruit()
class Banana : Fruit()

class Box<out T>(val item: T)

fun main() {
    val appleBox: Box<Apple> = Box(Apple())
    val fruitBox: Box<Fruit> = appleBox

    println(fruitBox.item)
}

위 코드에서는 Box<Apple>Box<Fruit>로 대입할 수 있습니다.
왜냐하면 AppleFruit의 하위 타입이고,
이 박스는 값을 꺼내기만 하기 때문에 타입 안전성이 깨지지 않기 때문입니다.

out이 붙은 타입 파라미터는 반환 타입처럼 값을 내보내는 위치에서는 사용할 수 있지만,
함수의 매개변수처럼 값을 받아들이는 위치에서는 사용할 수 없습니다.

fun getItem(): T   // 가능
fun putItem(item: T)   // 불가능

즉, out생산자(Producer) 역할이라고 생각하시면 됩니다.


2. 반공변성 in

in은 반대로 넣기 전용이라고 생각하면 이해하기 쉽습니다.
즉, 이 박스는 값을 꺼내는 용도가 아니라 받아들이는 용도입니다.

open class Fruit
class Apple : Fruit()
class Banana : Fruit()

class Box<in T> {
    fun put(item: T) {
        println("$item 넣기")
    }
}

fun main() {
    val fruitBox: Box<Fruit> = Box()
    val appleBox: Box<Apple> = fruitBox

    appleBox.put(Apple())
}

여기서는 Box<Fruit>Box<Apple>처럼 사용할 수 있습니다.
왜냐하면 Fruit를 받을 수 있는 박스라면, Apple도 당연히 넣을 수 있기 때문입니다.

in이 붙은 타입 파라미터는 함수의 매개변수처럼 값을 받아들이는 위치에서는 사용할 수 있지만,
반환 타입처럼 값을 꺼내는 위치에서는 사용할 수 없습니다.

fun putItem(item: T)   // 가능
fun getItem(): T       // 불가능

즉, in소비자(Consumer) 역할이라고 볼 수 있습니다.


타입 제약 조건

제너릭은 어떤 타입이든 받을 수 있지만,
필요하다면 특정 타입만 허용하도록 제한할 수도 있습니다.

예를 들어 과일만 받을 수 있도록 제한하고 싶다면 다음과 같이 작성할 수 있습니다.

open class Fruit
class Apple : Fruit()
class Banana : Fruit()
class Book

fun <T : Fruit> getFruit(item: T) {
    println("과일: $item")
}

fun main() {
    getFruit(Apple())   // 가능
    getFruit(Banana())  // 가능
    // getFruit(Book()) // 에러
}

<T : Fruit>
“T는 Fruit 또는 Fruit의 하위 타입만 가능하다”는 뜻입니다.

즉, 제너릭을 쓰되 아무 타입이나 다 받는 것이 아니라
원하는 범위 안에서만 타입을 허용할 수 있습니다.


스타 프로젝션 *

제너릭 타입을 다룰 때, 타입을 정확히 알 수 없지만
일단 어떤 타입이든 들어 있는 컬렉션을 받아 처리하고 싶을 때가 있습니다.

이럴 때 사용하는 것이 스타 프로젝션(*) 입니다.

fun printList(list: List<*>) {
    list.forEach { println(it) }
}

fun main() {
    printList(listOf(1, "Hello", 3.5))
}
// 출력
// 1
// Hello
// 3.5

List<*>
“원소 타입은 정확히 모르지만, 어쨌든 어떤 타입의 리스트다” 정도로 이해하면 됩니다.

즉, 읽어 와서 출력하는 것은 가능하지만
구체적인 타입이 무엇인지 알 수 없기 때문에 타입에 맞춘 안전한 쓰기는 제한됩니다.


제너릭에서 자주 쓰는 타입 기호

제너릭에서 사용하는 T, E, K, V 같은 문자는 문법적으로 정해진 것은 아니지만,
관례적으로 아래처럼 많이 사용합니다.

타입 기호의미
TType
EElement
KKey
VValue
NNumber

예를 들어 K, V는 맵 구조를 설명할 때 자주 사용됩니다.

class PairBox<K, V>(val key: K, val value: V)

fun main() {
    val pair = PairBox("이름", "Hood")
    println(pair.key)   // 이름
    println(pair.value) // Hood
}

즉, 이 기호들은 특별한 기능을 갖는 것이 아니라
코드를 읽는 사람이 의미를 쉽게 이해할 수 있도록 돕는 이름이라고 보시면 됩니다.


📌 결론

이번 글에서는 Kotlin의 제너릭에 대해 정리해보았습니다.

제너릭은 타입을 고정하지 않고도 다양한 타입의 객체를 처리할 수 있게 해주는 기능입니다.
덕분에 하나의 클래스나 함수를 여러 타입에 재사용할 수 있고,
잘못된 타입 사용도 컴파일 단계에서 막을 수 있어 안정성도 높아집니다.

정리하면 제너릭은 다음과 같은 장점을 가집니다.

  • 같은 코드를 여러 타입에 재사용할 수 있습니다.
  • 타입 안정성을 높일 수 있습니다.
  • out, in으로 읽기/쓰기 방향을 더 명확하게 표현할 수 있습니다.
  • 타입 제약 조건으로 허용할 타입 범위를 제한할 수 있습니다.

처음에는 <T> 문법이 낯설게 느껴질 수 있지만,
컬렉션, 함수, 클래스에서 반복해서 보다 보면 제너릭이 왜 중요한지 자연스럽게 체감하게 됩니다.

profile
달을 향해 쏴라, 빗나가도 별이 될 테니 👊

0개의 댓글