Kim-Baek 개발자 이야기

Kotlin 제네릭스 완벽 가이드 | in, out, reified 마스터하기 본문

개발/java basic

Kotlin 제네릭스 완벽 가이드 | in, out, reified 마스터하기

김백개발자 2025. 12. 27. 11:32
반응형

이 글을 읽으면: Java 제네릭의 복잡함을 넘어 Kotlin의 강력한 타입 시스템을 배울 수 있습니다. 공변성(out), 반공변성(in), reified 타입 파라미터까지 실전 예제로 완벽하게 마스터하세요.


📌 목차

  1. 들어가며 - Java 제네릭의 한계
  2. 제네릭 기본 - 타입 파라미터
  3. 타입 제약 - where, upper bound
  4. 공변성 out - 생산자
  5. 반공변성 in - 소비자
  6. reified - 타입 소거 해결
  7. 스타 프로젝션 *
  8. 실전 패턴 모음
  9. 마무리 - 다음 편 예고

들어가며 - Java 제네릭의 한계

Java 제네릭을 사용할 때 이런 경험 있으신가요?

// Java - 타입 소거 때문에 런타임에 타입 확인 불가
public <T> void process(List<T> list) {
    // if (list instanceof List<String>) { }  // ❌ 컴파일 에러
    // T instance = new T();  // ❌ 불가능
}

// Java - 공변성 문제
List<String> strings = new ArrayList<>();
// List<Object> objects = strings;  // ❌ 컴파일 에러

// 해결책 - 와일드카드 (복잡함)
public void print(List<? extends Object> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

// Java - 불변성 강제
List<String> strings = Arrays.asList("a", "b");
List<? extends CharSequence> sequences = strings;  // OK (읽기만)
// sequences.add("c");  // ❌ 컴파일 에러

Kotlin은 이렇게 간단합니다:

// Kotlin - reified로 런타임 타입 확인
inline fun <reified T> process(list: List<T>) {
    if (list is List<String>) {  // ✅ 가능!
        // ...
    }
}

// Kotlin - out으로 공변성
fun print(list: List<out Any>) {  // List<String>도 OK
    for (item in list) {
        println(item)
    }
}

// Kotlin - in으로 반공변성
fun addStrings(list: MutableList<in String>) {
    list.add("Hello")  // ✅ 가능
}

실제 프로젝트에서 겪은 문제:

상황: API 응답을 제네릭으로 처리하려고 함
문제: 런타임에 응답 타입을 알 수 없어서 역직렬화 실패
해결: Kotlin의 reified를 사용해 타입 정보 유지

오늘은 Kotlin의 제네릭을 완전히 마스터해보겠습니다!


제네릭 기본 - 타입 파라미터

제네릭 클래스

// 기본 제네릭 클래스
class Box<T>(val value: T) {
    fun get(): T = value
}

fun main() {
    val intBox = Box(42)
    println(intBox.get())  // 42
    
    val stringBox = Box("Kotlin")
    println(stringBox.get())  // Kotlin
    
    // 타입 추론
    val autoBox = Box(3.14)  // Box<Double>
}

제네릭 함수

// 제네릭 함수
fun <T> singletonList(item: T): List<T> {
    return listOf(item)
}

fun <T> swap(a: T, b: T): Pair<T, T> {
    return b to a
}

fun main() {
    val list = singletonList(42)
    println(list)  // [42]
    
    val (first, second) = swap("A", "B")
    println("$first, $second")  // B, A
}

여러 타입 파라미터

class Pair<K, V>(val key: K, val value: V) {
    override fun toString() = "$key: $value"
}

fun <K, V> createPair(key: K, value: V): Pair<K, V> {
    return Pair(key, value)
}

fun main() {
    val pair1 = Pair("name", "규철")
    val pair2 = Pair(1, "첫 번째")
    
    println(pair1)  // name: 규철
    println(pair2)  // 1: 첫 번째
}

Java와 비교

// Java - 타입 파라미터 명시
Box<String> box = new Box<>("Kotlin");
List<Integer> list = Arrays.asList(1, 2, 3);

// 다이아몬드 연산자 (Java 7+)
Box<String> box = new Box<>("Kotlin");
// Kotlin - 타입 추론
val box = Box("Kotlin")  // Box<String>
val list = listOf(1, 2, 3)  // List<Int>

타입 제약 - where, upper bound

Upper Bound (상한)

// Number 또는 그 하위 타입만
fun <T : Number> sum(a: T, b: T): Double {
    return a.toDouble() + b.toDouble()
}

fun main() {
    println(sum(10, 20))      // 30.0
    println(sum(3.14, 2.86))  // 6.0
    
    // println(sum("A", "B"))  // ❌ 컴파일 에러
}

여러 제약 - where

// 여러 인터페이스 구현 필요
fun <T> process(item: T) where T : Comparable<T>, T : CharSequence {
    println("Length: ${item.length}")
    println("Compare: ${item.compareTo(item)}")
}

fun main() {
    process("Kotlin")  // String은 Comparable & CharSequence
    
    // process(42)  // ❌ Int는 CharSequence 아님
}

실전 예제 - 정렬 가능한 컬렉션

fun <T : Comparable<T>> List<T>.sorted(): List<T> {
    return this.sortedWith(compareBy { it })
}

fun <T : Comparable<T>> findMax(list: List<T>): T? {
    return list.maxOrNull()
}

fun main() {
    val numbers = listOf(5, 2, 8, 1, 9)
    println(numbers.sorted())  // [1, 2, 5, 8, 9]
    println(findMax(numbers))  // 9
    
    val words = listOf("Kotlin", "Java", "Python")
    println(words.sorted())  // [Java, Kotlin, Python]
}

실전 예제 - Repository 패턴

interface Entity {
    val id: Long
}

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

class Repository<T : Entity> {
    private val items = mutableListOf<T>()
    
    fun save(item: T) {
        items.add(item)
    }
    
    fun findById(id: Long): T? {
        return items.find { it.id == id }
    }
    
    fun findAll(): List<T> = items.toList()
}

fun main() {
    val userRepo = Repository<User>()
    userRepo.save(User(1, "규철"))
    userRepo.save(User(2, "영희"))
    
    println(userRepo.findById(1))  // User(id=1, name=규철)
    println(userRepo.findAll())
}

공변성 out - 생산자

문제 상황

fun main() {
    val strings: List<String> = listOf("A", "B", "C")
    
    // List<String>은 List<Any>의 하위 타입?
    // val objects: List<Any> = strings  // Kotlin에서는 OK!
    
    // 하지만 가변 리스트는?
    val mutableStrings: MutableList<String> = mutableListOf("A")
    // val mutableObjects: MutableList<Any> = mutableStrings  // ❌ 에러
    // 왜? mutableObjects.add(123)을 하면 타입 안전성 깨짐
}

out - 공변성 선언

"이 타입은 생산만 하고 소비하지 않습니다"

// 읽기 전용 (생산자)
interface Producer<out T> {
    fun produce(): T
    // fun consume(item: T)  // ❌ out 위치에서 in 사용 불가
}

class StringProducer : Producer<String> {
    override fun produce(): String = "Kotlin"
}

fun printProducer(producer: Producer<Any>) {
    println(producer.produce())
}

fun main() {
    val stringProducer = StringProducer()
    printProducer(stringProducer)  // ✅ OK (공변성)
}

실전 예제 - API 응답

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 handleUserResult(result: Result<User>) {
    when (result) {
        is Result.Success -> println("User: ${result.data}")
        is Result.Error -> println("Error: ${result.message}")
        Result.Loading -> println("Loading...")
    }
}

fun handleAnyResult(result: Result<Any>) {
    when (result) {
        is Result.Success -> println("Data: ${result.data}")
        is Result.Error -> println("Error: ${result.message}")
        Result.Loading -> println("Loading...")
    }
}

fun main() {
    val userResult: Result<User> = Result.Success(User(1, "규철"))
    
    // 공변성 덕분에 가능
    handleAnyResult(userResult)
}

List는 공변적

fun main() {
    val strings: List<String> = listOf("A", "B", "C")
    
    // List<out T>로 선언되어 있어서 가능
    val objects: List<Any> = strings  // ✅ OK
    
    // 하지만 추가는 불가 (읽기 전용)
    // objects.add(123)  // List는 불변이므로 add 자체가 없음
}

반공변성 in - 소비자

in - 반공변성 선언

"이 타입은 소비만 하고 생산하지 않습니다"

// 쓰기 전용 (소비자)
interface Consumer<in T> {
    fun consume(item: T)
    // fun produce(): T  // ❌ in 위치에서 out 사용 불가
}

class AnyConsumer : Consumer<Any> {
    override fun consume(item: Any) {
        println("Consuming: $item")
    }
}

fun processStrings(consumer: Consumer<String>) {
    consumer.consume("Kotlin")
    consumer.consume("Java")
}

fun main() {
    val anyConsumer = AnyConsumer()
    
    // 반공변성 덕분에 가능
    processStrings(anyConsumer)  // ✅ OK
}

실전 예제 - Comparator

data class Person(val name: String, val age: Int)

fun main() {
    val people = listOf(
        Person("규철", 30),
        Person("영희", 25),
        Person("철수", 35)
    )
    
    // Comparator<in T>는 반공변적
    val anyComparator = Comparator<Any> { a, b ->
        a.hashCode().compareTo(b.hashCode())
    }
    
    // Person을 비교하는데 Comparator<Any> 사용 가능
    val sorted = people.sortedWith(anyComparator)
    println(sorted)
}

실전 예제 - 이벤트 핸들러

interface ClickEvent
interface DoubleClickEvent : ClickEvent

interface EventHandler<in T> {
    fun handle(event: T)
}

class GeneralClickHandler : EventHandler<ClickEvent> {
    override fun handle(event: ClickEvent) {
        println("Click handled")
    }
}

fun registerDoubleClickHandler(handler: EventHandler<DoubleClickEvent>) {
    val event = object : DoubleClickEvent {}
    handler.handle(event)
}

fun main() {
    val generalHandler = GeneralClickHandler()
    
    // ClickEvent 핸들러를 DoubleClickEvent에 사용 (반공변성)
    registerDoubleClickHandler(generalHandler)
}

PECS 원칙 (Producer Extends Consumer Super)

// Producer - out (공변)
fun copy(from: List<out Number>, to: MutableList<Number>) {
    for (item in from) {
        to.add(item)
    }
}

// Consumer - in (반공변)
fun fill(list: MutableList<in Int>, value: Int) {
    list.add(value)
}

fun main() {
    val ints = listOf(1, 2, 3)
    val numbers = mutableListOf<Number>()
    
    copy(ints, numbers)  // List<Int> -> List<out Number>
    println(numbers)     // [1, 2, 3]
    
    val anys = mutableListOf<Any>()
    fill(anys, 42)       // MutableList<Any> -> MutableList<in Int>
    println(anys)        // [42]
}

reified - 타입 소거 해결

타입 소거 문제

// ❌ 일반 제네릭 - 런타임에 타입 정보 없음
fun <T> isType(value: Any): Boolean {
    // return value is T  // ❌ 컴파일 에러
    return false
}

reified - 타입 정보 유지

inline 함수에서만 사용 가능

// ✅ reified - 런타임에 타입 확인 가능
inline fun <reified T> isType(value: Any): Boolean {
    return value is T
}

fun main() {
    println(isType<String>("Kotlin"))  // true
    println(isType<String>(42))        // false
    println(isType<Int>(42))           // 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, "A", 2, "B", 3.14, "C")
    
    val strings = mixed.filterIsInstance<String>()
    println(strings)  // [A, B, C]
    
    val numbers = mixed.filterIsInstance<Int>()
    println(numbers)  // [1, 2]
}

실전 예제 - JSON 파싱

import com.google.gson.Gson

inline fun <reified T> String.fromJson(): T {
    return Gson().fromJson(this, T::class.java)
}

data class User(val id: Long, val name: String)

fun main() {
    val json = """{"id":1,"name":"규철"}"""
    
    // 타입 명시 없이 파싱 가능
    val user = json.fromJson<User>()
    println(user)  // User(id=1, name=규철)
}

실전 예제 - Activity 시작 (Android)

// Android 예제
inline fun <reified T : Activity> Context.startActivity() {
    val intent = Intent(this, T::class.java)
    startActivity(intent)
}

// 사용
// startActivity<MainActivity>()
// startActivity<SettingsActivity>()

실전 예제 - API 클라이언트

class ApiClient {
    inline fun <reified T> get(url: String): T {
        val response = performRequest(url)
        return parseJson<T>(response)
    }
    
    inline fun <reified T> parseJson(json: String): T {
        return Gson().fromJson(json, T::class.java)
    }
    
    private fun performRequest(url: String): String {
        // HTTP 요청 수행
        return """{"id":1,"name":"규철"}"""
    }
}

fun main() {
    val client = ApiClient()
    
    // 타입 추론으로 간단하게
    val user: User = client.get("/users/1")
    println(user)
}

스타 프로젝션 *

기본 사용법

타입을 모를 때 사용하는 와일드카드

fun printList(list: List<*>) {
    for (item in list) {
        println(item)  // item은 Any? 타입
    }
}

fun main() {
    printList(listOf(1, 2, 3))
    printList(listOf("A", "B", "C"))
    printList(listOf(User(1, "규철")))
}

out Any? vs *

fun main() {
    val list1: List<*> = listOf(1, 2, 3)
    val list2: List<out Any?> = listOf(1, 2, 3)
    
    // 둘 다 읽기는 가능
    val item1: Any? = list1.first()
    val item2: Any? = list2.first()
    
    // 하지만 MutableList에서는 차이
    val mutable: MutableList<*> = mutableListOf(1, 2, 3)
    // mutable.add(4)  // ❌ 컴파일 에러 (타입 모름)
}

실전 예제 - 제네릭 검증

fun validate(list: List<*>): Boolean {
    return list.isNotEmpty() && list.all { it != null }
}

fun main() {
    println(validate(listOf(1, 2, 3)))           // true
    println(validate(listOf("A", null, "B")))    // false
    println(validate(emptyList<Any>()))          // false
}

실전 패턴 모음

패턴 1: Result 타입

sealed class Result<out T, out E> {
    data class Success<T>(val value: T) : Result<T, Nothing>()
    data class Failure<E>(val error: E) : Result<Nothing, E>()
}

inline fun <T, E, R> Result<T, E>.map(transform: (T) -> R): Result<R, E> {
    return when (this) {
        is Result.Success -> Result.Success(transform(value))
        is Result.Failure -> Result.Failure(error)
    }
}

inline fun <T, E, R> Result<T, E>.flatMap(
    transform: (T) -> Result<R, E>
): Result<R, E> {
    return when (this) {
        is Result.Success -> transform(value)
        is Result.Failure -> Result.Failure(error)
    }
}

fun main() {
    val result: Result<Int, String> = Result.Success(42)
    
    val doubled = result.map { it * 2 }
    println(doubled)  // Success(value=84)
    
    val chained = result.flatMap { 
        Result.Success(it.toString())
    }
    println(chained)  // Success(value=42)
}

패턴 2: 캐시 구현

class Cache<K, V> {
    private val map = mutableMapOf<K, V>()
    
    fun get(key: K): V? = map[key]
    
    fun put(key: K, value: V) {
        map[key] = value
    }
    
    fun getOrPut(key: K, defaultValue: () -> V): V {
        return map.getOrPut(key, defaultValue)
    }
}

fun main() {
    val cache = Cache<String, User>()
    
    cache.put("user1", User(1, "규철"))
    
    val user = cache.getOrPut("user2") {
        User(2, "영희")  // 없으면 생성
    }
    
    println(user)
}

패턴 3: 빌더 패턴

class QueryBuilder<T> {
    private val conditions = mutableListOf<(T) -> Boolean>()
    
    fun where(condition: (T) -> Boolean): QueryBuilder<T> {
        conditions.add(condition)
        return this
    }
    
    fun execute(items: List<T>): List<T> {
        return items.filter { item ->
            conditions.all { it(item) }
        }
    }
}

fun <T> query() = QueryBuilder<T>()

fun main() {
    data class Product(val name: String, val price: Int, val stock: Int)
    
    val products = listOf(
        Product("노트북", 1500000, 5),
        Product("마우스", 30000, 50),
        Product("키보드", 100000, 20)
    )
    
    val result = query<Product>()
        .where { it.price >= 50000 }
        .where { it.stock >= 10 }
        .execute(products)
    
    println(result)
    // [Product(name=노트북, price=1500000, stock=5), 
    //  Product(name=키보드, price=100000, stock=20)]
}

패턴 4: 타입 안전한 DSL

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HTML {
    private val children = mutableListOf<Element>()
    
    fun <T : Element> tag(element: T, init: T.() -> Unit): T {
        element.init()
        children.add(element)
        return element
    }
    
    fun body(init: Body.() -> Unit) = tag(Body(), init)
    
    override fun toString() = children.joinToString("\n")
}

@HtmlDsl
abstract class Element {
    protected val children = mutableListOf<String>()
    abstract val tag: String
    
    operator fun String.unaryPlus() {
        children.add(this)
    }
    
    override fun toString() = "<$tag>${children.joinToString("")}</$tag>"
}

@HtmlDsl
class Body : Element() {
    override val tag = "body"
    
    fun h1(init: H1.() -> Unit) {
        val h1 = H1()
        h1.init()
        children.add(h1.toString())
    }
    
    fun p(init: P.() -> Unit) {
        val p = P()
        p.init()
        children.add(p.toString())
    }
}

@HtmlDsl
class H1 : Element() {
    override val tag = "h1"
}

@HtmlDsl
class P : Element() {
    override val tag = "p"
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

fun main() {
    val page = html {
        body {
            h1 { +"Kotlin 제네릭" }
            p { +"강력하고 타입 안전합니다" }
        }
    }
    
    println(page)
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • 제네릭 기본 - 타입 파라미터
  • 타입 제약 - where, upper bound
  • 공변성 out - 생산자 (PECS의 Producer)
  • 반공변성 in - 소비자 (PECS의 Consumer)
  • reified - 타입 소거 해결
  • 스타 프로젝션 - 알 수 없는 타입

다음 편에서 배울 것 📚

11편: 상속과 인터페이스 완벽 가이드 | open, abstract, sealed

  • 클래스 상속 - open, override
  • 추상 클래스 - abstract
  • 인터페이스와 다중 상속
  • Sealed Class - 제한된 클래스 계층
  • Delegation - by 키워드
  • 실전 디자인 패턴

실습 과제 💪

// 1. 제네릭 클래스 만들기
// - Stack<T> 구현
// - Queue<T> 구현

// 2. 공변성/반공변성 활용
// - Repository<out T> 패턴
// - Validator<in T> 패턴

// 3. reified 활용
// - 타입 안전한 JSON 파서
// - 제네릭 Factory 패턴

// 4. 실전 패턴
// - Result 타입 구현
// - Either 타입 구현

자주 묻는 질문 (FAQ)

Q: out과 in은 언제 사용하나요?
A: PECS 원칙 - Producer는 out, Consumer는 in. 읽기만 하면 out, 쓰기만 하면 in.

Q: reified는 왜 inline 함수에서만 되나요?
A: 인라인 함수는 호출 지점에 코드가 복사되므로 타입 정보가 유지됩니다.

Q: List<out T>와 List<*>의 차이는?
A: List<out T>는 T의 상위 타입, List<*>는 알 수 없는 타입입니다.

Q: 제네릭과 성능 관계는?
A: 컴파일 시 타입 소거되므로 런타임 성능 영향 없습니다. reified는 인라인이므로 약간의 코드 증가 가능.


관련 글


💬 댓글로 알려주세요!

  • 제네릭을 어디에 활용하고 계신가요?
  • out/in이 헷갈리셨나요?
  • 이 글이 도움이 되셨나요?
반응형
Comments