Generic Tutorial
1. Generic의 필요성
프로그래밍을 하다 보면 같은 기능을 하는데 타입만 다른 코드를 여러 번 작성하게 되는 경우가 많다.
예를 들어, Int 값을 담는 박스와 String 값을 담는 박스를 따로 만든다면 다음과 같다.
class IntBox(val value: Int)
class StringBox(val value: String)
이렇게 만들면 타입마다 클래스를 계속 정의해야 해서 매우 불편하다.
이럴 때 제너릭(Generic)을 사용하면 하나의 코드로 여러 타입을 처리할 수 있다.
class Box<T>(val value: T)
여기서 <T>는 타입 파라미터다. Box는 이제 Int 든 String이든 뭐든지 담을 수 있다.
val intBox = Box(123)
val stringBox = Box("Hi")
이를 통해 사용자는 아래의 효과를 얻는다.
- 코드 재사용성 증가
- 타입 안전성 확보 (잘못된 타입 선언 시, 컴파일 시점에 막힘)
2. Generic 함수 만들기
클래스 뿐 아니라 함수에서도 제너릭을 만들 수 있다.
예를 들어, 두 값을 서로 바꾸는 swap 함수를 만든다고 가정하자.
fun <T> swap(pair: Pair<T, T>): Pair<T, T> {
return Pair(pair.second, pair.first)
}
여기서 <T>는 함수 수준의 타입 파라미터다.
val swappedInt = swap(Pair(1,2)) // Pair<Int, Int>
val swappedString = swap(Pair("A", "B") // Pair<String, String>
/*
(2, 1)
(B, A)
*/
이를 통해 Int, String, Double 등 어떤 타입이든 하나의 함수로 처리가 가능해진다.
3. 타입 제한하기 (Constraints)
제네릭은 편리하지만, 아무 타입이나 다 들어올 수 있다는 점이 문제가 될 수 있다.
예를 들어, 두 숫자의 합을 구하는 함수를 제너릭으로 만든다고 가정하자.
fun <T> add(a: T, b: T): T {
return a + b
}
여기서 '+' 연산자가 가능한 타입(Int, Double 등)만 들어와야 하지만,
현재 제너릭 T는 제한이 없어서 String, Boolean 같은 타입도 들어올 수 있다.
이 같은 경우 제너릭에 제약을 걸어야 한다. Kotlin에서는 'where' 또는 ':'를 이용해 타입 상한(Upper Bound)를 제정한다.
1) Number 상한으로 제한하기
fun <T : Number> sum(a: T, b: T): Double {
return a.toDouble() + b.toDouble()
}
- T : Number > T는 반드시 Number를 상속(=확장)하는 타입이어야 한다. (Java의 extends)
- 따라서, Int / Double / Float는 가능하지만, String / Boolean은 불가능하다.
2) 여러 제약 걸기 (where)
fun <T> printSizeIFList(item: T) where T : List<*>, T: Comparable<T> {
println("Size: ${item.size}")
}
- where T : List<*>, T: Comparable<T> > T는 List이면서 Comparable도 구현해야 함 (다중 계약)
4. 변성 (Variance)
변성은 제너릭 타입을 다른 타입으로 안전하게 변환할 수 있도록 돕는 규칙이다.
1) 무공변성 (기본)
Kotlin은 기본적으로 무공변이다.
val strings: List<String> = listOf("A", "B")
val anys: List<Any> = strings // 오류
- String은 Any의 하위 타입이지만, List<String>은 List<Any>의 하위 타입이 아니다.
- ex. 사과는 과일이지만, 사과 상자와 과일 상자는 전혀 다른 타입으로 취급된다.
2) 공변성 (out)
List는 선언 자체가 interface List<out E>로 되어 있어 공변이다.
val strings: List<String> = listOf("A", "B")
val anys: List<Any> = strings // 가능
- List<String>을 List<Any>로 대입 가능하다.
3) 반공변성 (in)
직접 선언할 때 in 키워드를 붙이면 반공변성이 된다.
class Box<in T> {
fun put(item: T) { println("Added: $item") }
// "fun get(): T" 불가능
}
val anyBox: Box<Any> = Box()
val stringBox: Box<String> = anyBox // 가능
stringBox.put("Hello")
- Box<Any>는 Box<String>의 하위 타입으로 취급 가능
특징
1. 타입 안전성(Type Safety) 보장
예를 들어, List<String>을 그냥 List<Any>로 인정해버린다고 가정하자.
val strings: MutableList<String> = mutableListOf("A", "B")
val anys: MutableList<Any> = strings // 만약 이게 된다면?
anys.add(123) // ❌ Int를 추가할 수 있음
println(strings) // ["A", "B", 123] → 깨짐
- strings는 원래 String만 있어야 하는 리스트인데, 타입이 깨져버린다. (런타임 오류 가능성)
- Kotlin은 이를 막기 위핸 기본적으로 무공변(invariant)으로 설계되어 있다.
2. 안전한 타입 확장
fun addItem(items: MutableList<Any>) {
items.add(123)
}
val strings: MutableList<String> = mutableListOf("Hello", "World")
addItem(strings) // ❌ 컴파일 에러
- MutableList는 읽기 + 쓰기가 가능하다.
- 만약 공변을 허용하면 MutableList<String>에 Int가 들어가서 타입 무결성이 깨진다.
- 그래서 Kotlin은 무공변으로 안전하게 막는다.
fun printAll(items: List<Any>) {
for (item in items) println(item)
}
val strings: List<String> = listOf("Hello", "World")
printAll(strings) // ✅ 정상 동작
- List는 읽기 전용이므로 꺼내기 전용 (생산자) 역할만 한다.
- 공변성(out) 덕분에 List<String>을 List<Any>로 안전하게 사용할 수 있다.
- 타입 무결성을 깨지 않는다. 값을 추가할 수 없기 때문에 문자열만 존재하는 것이다.
5. reified 타입 패러미터
문제. 타입 소거 (Type Erasure)
JVM에서 제너릭은 컴파일 시점에만 타입 정보를 알고, 런타임에는 지워지는 구조다.
즉, 일반적인 제너릭 함수에서는 런타임에 T가 무엇인지 알 수 없다.
fun <T> isString(value: Any): Boolean {
return value is T // ❌ 컴파일 에러
}
- T가 무엇인지 JVM에서는 몰라서 type check가 불가능하다.
해결 : reified + inline
inline fun <reified T> isOfType(value: Any): Boolean {
return value is T // ✅ 이제 가능
}
println(isOfType<String>("Hello")) // true
println(isOfType<Int>("Hello")) // false
- inline 함수 : 호출 시점에 함수 코드가 복사(inline)됨.
- reified : 런타임에도 T 타입 정보를 유지한다.
- 덕분에 is, as 연산, reflection 등에서 사용 가능하다.
실전 예제 : JSON parsing
import com.google.gson.Gson
inline fun <reified T> Gson.fromJson(json: String): T {
return this.fromJson(json, T::class.java)
}
data class User(val name: String, val age: Int)
val json = """{"name":"Alice", "age":25}"""
val user: User = Gson().fromJson(json)
println(user) // User(name=Alice, age=25)
- 제너릭 함수로 어떤 타입이든 JSON을 바로 파싱 가능하다.
- 런타임에도 타입 정보를 유지하므로 안전하다.
※ 오직 Java 만이 JSON을 구현하기 위해 이를 담을 객체를 생성한다. JSON 함수가 호출 될 때 그 값을 객체에 저장한다.
6. 실전 예제 : 제너릭 활용
1) 제너릭 유틸 함수
fun <T> swap(a: T, b: T): Pair<T, T> {
return Pair(b, a)
}
val swappedInts = swap(1, 2)
val swappedStrings = swap("A", "B")
println(swappedInts) // (2, 1)
println(swappedStrings) // (B, A)
- 하나의 함수로 모든 타입 처리가 가능하다.
- 코드 재사용성과 타입 안정성을 모두 확보할 수 있다.
2) 제너릭 API 응답 Wrapper
실무에서는 서버 응답을 동일한 구조로 묶어서 처리할 때 제너릭을 사용한다.
data class ApiResponse<T>(
val status: Int,
val message: String,
val data: T?
)
// 사용자 응답 예시
data class User(val name: String, val age: Int)
val userResponse: ApiResponse<User> = ApiResponse(
status = 200,
message = "Success",
data = User("Alice", 25)
)
println(userResponse)
// ApiResponse(status=200, message=Success, data=User(name=Alice, age=25))
- T가 어떤 타입이든 담을 수 있어 모든 API 응답 구조에 재사용 가능하다.
- 타입 안전성을 유지하면서 코드 중복을 최소화한다.
3) 제너릭 Collection 활용
fun <T> printAll(items: List<T>) {
for (item in items) println(item)
}
printAll(listOf(1,2,3))
printAll(listOf("A", "B", "C")
- 컬렉션 API에서도 동일한 패턴으로 여러 타입 처리가 가능하다.
7. 마무리 & 핵심 요약
- 제너릭의 목적
- 코드 재사용성 증가
- 타입 안정성 확보 (컴파일 시점에서 잘못된 타입을 잡음)
- 타입 제한
- 특정 타입만 허용 가능
- 'where' 또는 ':' 사용
- 변성 (Variance)
- 핵심 : "타입을 넓히되 안전하게"
- 무공변 (invariant) : 기본, 쓰기 가능한 구조에서 타입 안전
- 공변 (out) : 읽기 전용, 타입 확장에 안전
- 반공변 (in) : 소비자, 쓰기 전용
- Kotlin 만의 특징 : reified
- 런타임에도 타입 정보를 유지하도록 한다.
- is, as, JSON 파싱 등에서 활용 가능하다.
참고 자료
- Generics: in, out, where | Kotlin Documentation
- [Kotlin] 한 방에 정리하는 코틀린 제네릭(kotlin generic) - in, out, where, reified :: 준비된 개발자
Powered By. ChatGPT & Gemini
'Backend' 카테고리의 다른 글
| [Backend] 데이터 구조 (HashTable) (0) | 2025.09.11 |
|---|---|
| [Backend] 데이터 구조 (List) (0) | 2025.09.11 |
| [Backend] Template Method 패턴 (1) | 2025.09.03 |
| [Backend] Observer 패턴 (1) | 2025.09.03 |
| [Backend] Singleton 패턴 (1) | 2025.09.03 |