Skip to main content

🎁 제네릭

📖 제네릭이란?

**제네릭(Generic)**은 타입을 파라미터로 받아 재사용 가능한 코드를 작성하는 기능입니다. 타입 안전성을 유지하면서 코드를 일반화합니다.

💡 기본 제네릭

제네릭 클래스

// 타입 파라미터 T
class Box<T>(var value: T) {
fun get(): T = value
fun set(newValue: T) {
value = newValue
}
}

fun main() {
val intBox = Box(123)
println(intBox.get()) // 123

val stringBox = Box("Hello")
println(stringBox.get()) // Hello

// 타입 안전
// intBox.set("world") // ❌ 오류!
}

제네릭 함수

fun <T> printItem(item: T) {
println("아이템: $item")
}

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

fun main() {
printItem(42) // 아이템: 42
printItem("Hello") // 아이템: Hello

val (x, y) = swap(1, 2)
println("$x, $y") // 2, 1
}

🔧 실전 활용

스택

class Stack<T> {
private val items = mutableListOf<T>()

fun push(item: T) {
items.add(item)
}

fun pop(): T? {
return if (items.isNotEmpty()) {
items.removeAt(items.size - 1)
} else {
null
}
}

fun peek(): T? = items.lastOrNull()

fun isEmpty(): Boolean = items.isEmpty()

fun size(): Int = items.size
}

fun main() {
val stack = Stack<Int>()

stack.push(1)
stack.push(2)
stack.push(3)

println(stack.pop()) // 3
println(stack.pop()) // 2
println(stack.peek()) // 1
}

API 응답

data class Response<T>(
val success: Boolean,
val data: T?,
val message: String = ""
)

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

fun main() {
// 성공 응답
val userResponse = Response(
success = true,
data = User(1, "홍길동")
)
println(userResponse)

// 실패 응답
val errorResponse = Response<Product>(
success = false,
data = null,
message = "상품을 찾을 수 없습니다"
)
println(errorResponse)
}

리스트 유틸

fun <T> List<T>.second(): T? {
return if (size >= 2) this[1] else null
}

fun <T> List<T>.swap(i: Int, j: Int): List<T> {
val result = toMutableList()
val temp = result[i]
result[i] = result[j]
result[j] = temp
return result
}

fun main() {
val numbers = listOf(1, 2, 3, 4, 5)

println(numbers.second()) // 2
println(numbers.swap(0, 4)) // [5, 2, 3, 4, 1]
}

🎯 제약 조건

상한 제약

// T는 Number의 하위 타입이어야 함
fun <T : Number> sum(a: T, b: T): Double {
return a.toDouble() + b.toDouble()
}

fun main() {
println(sum(1, 2)) // 3.0
println(sum(1.5, 2.5)) // 4.0

// println(sum("1", "2")) // ❌ 오류!
}

여러 제약

interface Named {
val name: String
}

fun <T> printName(item: T) where T : Named, T : Comparable<T> {
println(item.name)
}

🎨 공변성과 반공변성

out (공변성)

생산자 - 값을 내보내기만 함

class Producer<out T>(private val value: T) {
fun produce(): T = value
// fun consume(value: T) { } // ❌ 불가능
}

fun main() {
val stringProducer: Producer<String> = Producer("Hello")
val anyProducer: Producer<Any> = stringProducer // ✅ 가능

println(anyProducer.produce())
}

in (반공변성)

소비자 - 값을 받기만 함

class Consumer<in T> {
fun consume(value: T) {
println("소비: $value")
}
// fun produce(): T { } // ❌ 불가능
}

fun main() {
val anyConsumer: Consumer<Any> = Consumer()
val stringConsumer: Consumer<String> = anyConsumer // ✅ 가능

stringConsumer.consume("Hello")
}

🎯 실전 예제

저장소 패턴

interface Repository<T> {
fun save(item: T)
fun findById(id: Int): T?
fun findAll(): List<T>
}

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

class UserRepository : Repository<User> {
private val users = mutableMapOf<Int, User>()

override fun save(item: User) {
users[item.id] = item
println("${item.name} 저장됨")
}

override fun findById(id: Int): User? {
return users[id]
}

override fun findAll(): List<User> {
return users.values.toList()
}
}

fun main() {
val repo = UserRepository()

repo.save(User(1, "홍길동"))
repo.save(User(2, "김철수"))

val user = repo.findById(1)
println(user) // User(id=1, name=홍길동)

val allUsers = repo.findAll()
println(allUsers)
}

결과 타입

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

fun divide(a: Int, b: Int): Result<Int> {
return if (b != 0) {
Result.Success(a / b)
} else {
Result.Error("0으로 나눌 수 없습니다")
}
}

fun main() {
val result1 = divide(10, 2)
val result2 = divide(10, 0)

when (result1) {
is Result.Success -> println("결과: ${result1.data}")
is Result.Error -> println("오류: ${result1.message}")
}

when (result2) {
is Result.Success -> println("결과: ${result2.data}")
is Result.Error -> println("오류: ${result2.message}")
}
}

캐시

class Cache<K, V> {
private val cache = mutableMapOf<K, V>()

fun put(key: K, value: V) {
cache[key] = value
}

fun get(key: K): V? {
return cache[key]
}

fun contains(key: K): Boolean {
return cache.containsKey(key)
}

fun clear() {
cache.clear()
}

fun size(): Int = cache.size
}

fun main() {
val cache = Cache<String, User>()

cache.put("user1", User(1, "홍길동"))
cache.put("user2", User(2, "김철수"))

val user = cache.get("user1")
println(user) // User(id=1, name=홍길동)

println("크기: ${cache.size()}") // 2
}

🔍 리파이드 타입

inline과 reified

// 타입 정보 유지
inline fun <reified T> isInstance(value: Any): Boolean {
return value is T
}

inline fun <reified T> filterByType(list: List<Any>): List<T> {
return list.filterIsInstance<T>()
}

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

val mixed = listOf(1, "two", 3, "four", 5)
val strings = filterByType<String>(mixed)
println(strings) // [two, four]
}

🤔 자주 묻는 질문

Q1. 언제 제네릭을 쓰나요?

A: 여러 타입에서 동작하는 코드를 만들 때!

// ❌ 타입마다 중복
class IntBox(var value: Int)
class StringBox(var value: String)

// ✅ 제네릭으로 하나로
class Box<T>(var value: T)

Q2. <T><*>의 차이는?

A: T는 구체적, *는 모든 타입!

fun <T> printList(list: List<T>) {
// T는 하나의 구체적인 타입
}

fun printAnyList(list: List<*>) {
// * 는 모든 타입 (읽기만 가능)
}

fun main() {
val numbers = listOf(1, 2, 3)
printList(numbers) // T = Int
printAnyList(numbers) // * = 모든 타입
}

Q3. out과 in은 언제 쓰나요?

A: 생산/소비 패턴!

// out - 생산자 (값을 내보냄)
interface Producer<out T> {
fun produce(): T
}

// in - 소비자 (값을 받음)
interface Consumer<in T> {
fun consume(value: T)
}

// 둘 다 - 불변
interface Storage<T> {
fun get(): T
fun set(value: T)
}

🎬 마치며

제네릭으로 타입 안전한 재사용 코드를 만드세요!

핵심 정리:
✅ <T>로 타입 파라미터
✅ 타입 안전성 유지
✅ 코드 재사용
✅ out (공변), in (반공변)
✅ reified로 타입 정보 유지

다음 단계: Null 안전성에서 null 처리를 알아보세요!