Notice
Recent Posts
Recent Comments
Link
250x250
반응형
«   2025/12   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

백고등어 개발 블로그

코틀린 강의 8강: 제네릭 본문

코틀린 강의

코틀린 강의 8강: 제네릭

백고등어 2025. 10. 28. 17:39
728x90
반응형

8강: 제네릭

제네릭이란?

제네릭(Generics)은 타입을 파라미터화하여 코드의 재사용성을 높이고 타입 안정성을 보장하는 기능입니다. 컬렉션에서 이미 제네릭을 사용해왔습니다: List<String>, Map<String, Int> 등.

제네릭 클래스

기본 제네릭 클래스

class Box<T>(val value: T) {
    fun get(): T = value
}

fun main() {
    val intBox = Box<Int>(10)
    val stringBox = Box<String>("Kotlin")
    
    println(intBox.get())     // 10
    println(stringBox.get())  // Kotlin
    
    // 타입 추론
    val doubleBox = Box(3.14)  // Box<Double>로 추론
    println(doubleBox.get())   // 3.14
}

여러 타입 파라미터

class Pair<K, V>(val key: K, val value: V) {
    fun printInfo() {
        println("Key: $key, Value: $value")
    }
}

fun main() {
    val pair1 = Pair<String, Int>("Age", 25)
    val pair2 = Pair("Name", "김코틀린")  // 타입 추론
    
    pair1.printInfo()  // Key: Age, Value: 25
    pair2.printInfo()  // Key: Name, Value: 김코틀린
}

제네릭 함수

fun <T> printItem(item: T) {
    println("Item: $item")
}

fun <T> createList(vararg items: T): List<T> {
    return items.toList()
}

fun main() {
    printItem(10)         // Item: 10
    printItem("Hello")    // Item: Hello
    
    val numbers = createList(1, 2, 3, 4, 5)
    val words = createList("apple", "banana", "cherry")
    
    println(numbers)  // [1, 2, 3, 4, 5]
    println(words)    // [apple, banana, cherry]
}

제네릭 제약(Generic Constraints)

상한 제약(Upper Bound)

타입 파라미터가 특정 타입의 하위 타입이어야 할 때 사용합니다:

fun <T : Number> sumNumbers(a: T, b: T): Double {
    return a.toDouble() + b.toDouble()
}

fun main() {
    println(sumNumbers(10, 20))      // 30.0
    println(sumNumbers(3.5, 2.5))    // 6.0
    // println(sumNumbers("a", "b"))  // 컴파일 오류!
}

Comparable 제약

정렬 가능한 타입만 받도록 제약:

fun <T : Comparable<T>> max(a: T, b: T): T {
    return if (a > b) a else b
}

fun main() {
    println(max(10, 20))           // 20
    println(max("apple", "banana")) // banana
    println(max(3.14, 2.71))       // 3.14
}

여러 제약 조건

where 절을 사용하여 여러 제약을 지정할 수 있습니다:

interface Printable {
    fun print()
}

fun <T> processItem(item: T) where T : Comparable<T>, T : Printable {
    item.print()
}

공변성과 반공변성

무공변(Invariant)

기본적으로 제네릭 타입은 무공변입니다:

class Container<T>(var value: T)

fun main() {
    val stringContainer = Container("Hello")
    // val anyContainer: Container<Any> = stringContainer  // 오류!
}

공변성(Covariance) - out

out 키워드를 사용하면 하위 타입 관계를 유지합니다:

class Producer<out T>(val value: T) {
    fun produce(): T = value
    // fun consume(value: T) {}  // 오류! out 위치에서는 소비 불가
}

fun main() {
    val stringProducer: Producer<String> = Producer("Kotlin")
    val anyProducer: Producer<Any> = stringProducer  // OK!
    
    println(anyProducer.produce())  // Kotlin
}

실제 예시:

fun printAll(items: List<Any>) {
    for (item in items) {
        println(item)
    }
}

fun main() {
    val strings = listOf("a", "b", "c")
    printAll(strings)  // OK! List<T>는 공변이므로 (List<out T>)
}

반공변성(Contravariance) - in

in 키워드를 사용하면 하위 타입 관계가 반대로 됩니다:

class Consumer<in T> {
    fun consume(value: T) {
        println("Consumed: $value")
    }
    // fun produce(): T  // 오류! in 위치에서는 생산 불가
}

fun main() {
    val anyConsumer = Consumer<Any>()
    val stringConsumer: Consumer<String> = anyConsumer  // OK!
    
    stringConsumer.consume("Kotlin")
}

PECS 원칙

Producer Extends, Consumer Super:

  • 생산자(Producer): out 사용 (공변성)
  • 소비자(Consumer): in 사용 (반공변성)
fun <T> copy(from: List<out T>, to: MutableList<in T>) {
    for (item in from) {
        to.add(item)
    }
}

fun main() {
    val source: List<String> = listOf("a", "b", "c")
    val destination: MutableList<Any> = mutableListOf()
    
    copy(source, destination)
    println(destination)  // [a, b, c]
}

타입 프로젝션(Type Projection)

사용 지점에서 변성을 지정할 수 있습니다:

fun printAllAny(list: List<out Any>) {
    for (item in list) {
        println(item)
    }
}

fun addAll(list: MutableList<in String>, items: List<String>) {
    for (item in items) {
        list.add(item)
    }
}

스타 프로젝션(Star Projection)

타입을 알 수 없거나 중요하지 않을 때 *를 사용합니다:

fun printList(list: List<*>) {
    for (item in list) {
        println(item)
    }
}

fun main() {
    printList(listOf(1, 2, 3))
    printList(listOf("a", "b", "c"))
    printList(listOf(3.14, 2.71))
}

List<*>는 List<out Any?>와 동일합니다.

구체화된 타입 파라미터(Reified Type Parameters)

인라인 함수에서 reified 키워드를 사용하면 런타임에 타입 정보를 유지할 수 있습니다:

inline fun <reified T> isInstance(value: Any): Boolean {
    return value is T
}

fun main() {
    println(isInstance<String>("Hello"))  // true
    println(isInstance<String>(123))      // false
    println(isInstance<Int>(123))         // true
}

실용적인 예제

inline fun <reified T> List<*>.filterIsInstance(): List<T> {
    val result = mutableListOf<T>()
    for (item in this) {
        if (item is T) {
            result.add(item)
        }
    }
    return result
}

fun main() {
    val mixed = listOf(1, "two", 3, "four", 5.0, "six")
    
    val strings = mixed.filterIsInstance<String>()
    val ints = mixed.filterIsInstance<Int>()
    
    println(strings)  // [two, four, six]
    println(ints)     // [1, 3]
}

실전 예제

제네릭 저장소 패턴

interface Repository<T> {
    fun getAll(): List<T>
    fun getById(id: Int): T?
    fun save(item: T)
    fun delete(id: Int)
}

class InMemoryRepository<T>(
    private val idExtractor: (T) -> Int
) : Repository<T> {
    private val items = mutableMapOf<Int, T>()
    
    override fun getAll(): List<T> = items.values.toList()
    
    override fun getById(id: Int): T? = items[id]
    
    override fun save(item: T) {
        val id = idExtractor(item)
        items[id] = item
    }
    
    override fun delete(id: Int) {
        items.remove(id)
    }
}

data class User(val id: Int, val name: String)
data class Product(val id: Int, val name: String, val price: Int)

fun main() {
    val userRepo = InMemoryRepository<User> { it.id }
    userRepo.save(User(1, "김코틀린"))
    userRepo.save(User(2, "이자바"))
    
    println(userRepo.getAll())
    println(userRepo.getById(1))
    
    val productRepo = InMemoryRepository<Product> { it.id }
    productRepo.save(Product(1, "노트북", 1500000))
    productRepo.save(Product(2, "마우스", 30000))
    
    println(productRepo.getAll())
}

제네릭 결과 타입

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
    object Loading : Result<Nothing>()
}

fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> {
    return when (this) {
        is Result.Success -> Result.Success(transform(data))
        is Result.Error -> this
        is Result.Loading -> this
    }
}

fun fetchUser(id: Int): Result<String> {
    return if (id > 0) {
        Result.Success("User-$id")
    } else {
        Result.Error("잘못된 ID")
    }
}

fun main() {
    val result = fetchUser(1)
        .map { it.uppercase() }
    
    when (result) {
        is Result.Success -> println("성공: ${result.data}")
        is Result.Error -> println("오류: ${result.message}")
        is Result.Loading -> println("로딩 중...")
    }
}

제네릭 사용 시 주의사항

  1. 타입 소거(Type Erasure): 런타임에 제네릭 타입 정보가 제거됩니다. reified를 사용하면 일부 보존 가능합니다.
  2. 배열과 제네릭: 배열은 공변이지만 제네릭 타입의 배열 생성에는 제약이 있습니다.
// val array = Array<T>(10) { ... }  // 일반적으로 불가능
// 대신 List를 사용하거나 reified 사용
  1. null 가능성: 제네릭 타입은 기본적으로 nullable입니다.
fun <T> printItem(item: T) {
    println(item?.toString())  // T는 null일 수 있음
}

fun <T : Any> printNonNull(item: T) {
    println(item.toString())  // T는 null이 아님
}

마치며

이번 강의에서는 코틀린의 제네릭에 대해 알아보았습니다. 공변성(out)과 반공변성(in)의 개념, 그리고 reified 타입 파라미터를 활용한 실용적인 코드 작성 방법을 익혔습니다. 제네릭을 잘 활용하면 타입 안전하면서도 재사용 가능한 코드를 작성할 수 있습니다. 다음 강의에서는 코루틴에 대해 알아보겠습니다.

728x90
반응형