Kotlin 제네릭 실전 가이드: 7가지 핵심 패턴 총정리
프로그래밍에서 Kotlin 제네릭은 타입 안전성과 코드 재사용성을 동시에 보장하는 핵심 도구입니다. List<String>을 선언하면 컴파일러가 문자열 외의 타입이 들어오는 것을 원천 차단하고, 같은 List 구조를 Int, User 등 어떤 타입에든 재활용할 수 있죠. Java 시절부터 제네릭을 써왔더라도, Kotlin이 제공하는 in/out 변성 키워드와 reified 타입 파라미터 같은 고유 기능을 제대로 이해하지 않으면 Kotlin 제네릭의 진정한 힘을 절반도 활용하지 못하는 셈입니다.
실제 프로젝트에서 제네릭을 잘 다루면 보일러플레이트 코드가 대폭 줄어들고, API 설계가 명확해지며, 런타임 오류를 컴파일 시점에 잡아낼 수 있습니다. 반대로 제네릭을 어설프게 쓰면 Any로 캐스팅하는 코드가 넘쳐나고, ClassCastException이 프로덕션에서 터지는 악몽을 겪게 됩니다. 이 글에서는 기초 문법부터 변성, 스타 프로젝션, reified까지 체계적으로 정리하고, 실전에서 바로 적용할 수 있는 7가지 핵심 패턴을 코드와 함께 소개합니다.
Kotlin 제네릭 기초: 클래스와 함수의 타입 파라미터
제네릭의 출발점은 타입 파라미터입니다. 클래스나 함수를 정의할 때 구체적인 타입을 고정하지 않고, 사용하는 쪽에서 타입을 지정하도록 하는 것이죠. Kotlin에서는 꺾쇠괄호 <T>로 타입 파라미터를 선언합니다.
제네릭 클래스 만들기
가장 단순한 제네릭 클래스를 하나 만들어 보겠습니다.
class Box<T>(val value: T) {
fun get(): T = value
fun <R> transform(f: (T) -> R): Box<R> {
return Box(f(value))
}
}
// 사용
val intBox = Box(42) // Box<Int> — 타입 추론
val strBox = Box("Hello") // Box<String>
val lenBox = strBox.transform { it.length } // Box<Int>
Box<T>는 어떤 타입이든 하나의 값을 감싸는 컨테이너입니다. T는 관례적으로 Type의 약자를 쓰지만, E(Element), K(Key), V(Value) 등 의미에 맞는 이름을 자유롭게 사용할 수 있습니다. Kotlin의 강력한 타입 추론 덕분에 Box<Int>(42)처럼 타입을 명시하지 않아도 컴파일러가 알아서 추론합니다.
위 코드에서 transform 메서드에 주목하세요. 클래스 레벨의 타입 파라미터 T와 별개로 메서드 자체의 타입 파라미터 R을 선언했습니다. 이처럼 클래스와 함수의 타입 파라미터는 독립적으로 공존할 수 있으며, 이를 통해 입력 타입과 출력 타입이 다른 변환 로직을 유연하게 표현할 수 있습니다.
제네릭 함수 선언
클래스뿐 아니라 함수도 독립적인 타입 파라미터를 가질 수 있습니다.
fun <T> singletonList(item: T): List<T> {
return listOf(item)
}
fun <T> T.toSingletonList(): List<T> {
return listOf(this)
}
// 사용
val list1 = singletonList("Kotlin") // List<String>
val list2 = 42.toSingletonList() // List<Int>
두 번째 예시처럼 확장 함수에도 제네릭을 결합할 수 있습니다. 이 조합은 Kotlin에서 매우 자주 쓰이는 패턴으로, 표준 라이브러리의 let, run, also 같은 스코프 함수가 대표적인 예입니다. 제네릭 확장 함수를 잘 활용하면 기존 클래스를 수정하지 않고도 타입 안전한 유틸리티 메서드를 자유롭게 추가할 수 있습니다.
타입 제약으로 범위 좁히기
모든 타입을 받아들이는 제네릭은 유연하지만, 때로는 특정 조건을 만족하는 타입만 허용해야 합니다. 상한 바운드(upper bound)를 지정하면 됩니다.
// T는 반드시 Comparable을 구현해야 함
fun <T : Comparable<T>> maxOf(a: T, b: T): T {
return if (a > b) a else b
}
maxOf(3, 7) // OK — Int는 Comparable<Int> 구현
maxOf("apple", "banana") // OK — String도 Comparable
// maxOf(listOf(1), listOf(2)) // 컴파일 에러 — List는 Comparable 아님
상한 바운드를 지정하면 함수 본문에서 해당 타입의 메서드를 자유롭게 사용할 수 있습니다. 위 예시에서 T : Comparable<T> 제약 덕분에 > 연산자(실제로는 compareTo)를 컴파일 에러 없이 호출할 수 있습니다.
두 개 이상의 제약이 필요하면 where 절을 사용합니다.
fun <T> processData(data: T): String
where T : CharSequence,
T : Comparable<T> {
return if (data.length > 10) "긴 데이터: $data" else "짧은 데이터: $data"
}
processData("Hello World") // OK — String은 CharSequence이자 Comparable
// processData(42) // 에러 — Int는 CharSequence가 아님
where 절은 타입 파라미터가 여러 인터페이스를 동시에 만족해야 할 때 유용합니다. 하나의 상한만 필요하면 <T : Bound> 문법으로 충분하고, 두 개 이상일 때 where를 사용한다고 기억해 두세요.
변성(Variance) 완전 이해: out과 in 키워드
Kotlin 제네릭에서 가장 헷갈리는 주제가 바로 변성(Variance)입니다. 핵심 질문은 이것입니다. “Dog가 Animal의 하위 타입이면, List<Dog>도 List<Animal>의 하위 타입인가?” 결론부터 말하면, 경우에 따라 다릅니다. 그리고 그 경우를 결정하는 것이 바로 out과 in 키워드입니다.

불변성(Invariance) — 기본 동작
기본적으로 Kotlin과 Java의 제네릭은 불변(invariant)입니다. MutableList<Dog>와 MutableList<Animal> 사이에는 어떤 상속 관계도 없습니다.
open class Animal
class Dog : Animal()
class Cat : Animal()
fun addAnimal(list: MutableList<Animal>) {
list.add(Cat()) // Cat도 Animal이니까 추가 가능
}
val dogs: MutableList<Dog> = mutableListOf(Dog())
// addAnimal(dogs) // 컴파일 에러!
만약 이 코드가 컴파일된다면 어떤 일이 벌어질까요? addAnimal 안에서 Cat을 추가하면 dogs 리스트에 고양이가 들어가게 됩니다. 이후 dogs[1]을 Dog으로 사용하는 순간 ClassCastException이 터지겠죠. 컴파일러는 바로 이런 사고를 막기 위해 기본적으로 불변성을 강제합니다. 읽기와 쓰기를 모두 허용하는 MutableList 같은 타입에서는 이 제약이 반드시 필요합니다.
공변성(Covariance) — out 키워드
하지만 읽기만 하는 경우는 어떨까요? 리스트에서 값을 꺼내기만 하고 절대 넣지 않는다면, List<Dog>를 List<Animal>로 취급해도 안전합니다. 꺼낸 Dog를 Animal로 다루는 것은 업캐스팅이니까요. 이것이 공변성(covariance)이며, Kotlin에서는 out 키워드로 표현합니다.
// Kotlin의 List는 interface List<out E>로 선언되어 있음
val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs // OK! out 덕분에 가능
// 생산자(Producer) 인터페이스 직접 만들기
interface Source<out T> {
fun next(): T // T를 반환(생산)만 함 — OK
// fun add(item: T) // T를 파라미터로 받음(소비) — 컴파일 에러!
}
out은 “이 타입 파라미터는 출력(반환) 위치에만 쓰인다”는 약속입니다. 값을 생산(produce)하기만 하므로, 하위 타입에서 상위 타입 방향으로의 대입이 안전합니다. Java에서는 이를 ? extends T 와일드카드로 매번 표현해야 하는데, Kotlin은 선언 한 번으로 끝납니다.
반공변성(Contravariance) — in 키워드
반대 방향도 있습니다. 값을 소비하기만 하는 경우, 상위 타입을 하위 타입 위치에 대입할 수 있습니다.
// Comparable은 T를 파라미터로 받아 비교(소비)
val animalComparator: Comparable<Animal> = Comparator { a, b ->
a.hashCode() - b.hashCode()
}
// Animal을 비교할 수 있으면 Dog도 비교 가능
val dogComparator: Comparable<Dog> = animalComparator // OK!
// 소비자(Consumer) 인터페이스 직접 만들기
interface Sink<in T> {
fun consume(item: T) // T를 파라미터로 받음(소비) — OK
// fun produce(): T // T를 반환(생산) — 컴파일 에러!
}
in은 “이 타입 파라미터는 입력(파라미터) 위치에만 쓰인다”는 약속입니다. 값을 소비(consume)하기만 하므로, 상위 타입에서 하위 타입 방향으로의 대입이 안전합니다. Animal을 처리할 수 있는 로직이라면 Dog도 당연히 처리할 수 있으니까요.
영어권에서는 이를 PECS(Producer Extends, Consumer Super) 원칙으로 부릅니다. Kotlin 식으로 바꾸면 생산자는 out, 소비자는 in입니다. 키워드 자체가 데이터의 흐름 방향(나가는 out, 들어오는 in)을 직관적으로 알려주므로, Java의 extends/super보다 훨씬 기억하기 쉽습니다.
선언 지점 변성 vs 사용 지점 변성
Java에서는 제네릭 타입을 사용하는 쪽에서 ? extends T, ? super T 와일드카드를 매번 지정해야 합니다. 이를 사용 지점 변성(use-site variance)이라고 합니다.
// Java — 매번 와일드카드 지정 (번거로움)
void printAll(List<? extends Animal> animals) { ... }
void addDog(List<? super Dog> dogs) { ... }
반면 Kotlin은 클래스 선언 시점에 out/in을 붙이는 선언 지점 변성(declaration-site variance)을 지원합니다. 한 번 선언하면 사용하는 모든 곳에서 자동으로 적용되므로 코드가 훨씬 깔끔합니다.
// Kotlin — 선언 시점에 한 번만 (interface List<out E>)
fun printAll(animals: List<Animal>) {
animals.forEach { println(it) }
}
val dogs: List<Dog> = listOf(Dog(), Dog())
printAll(dogs) // OK! List 선언에 out이 있으므로 별도 와일드카드 불필요
물론 Kotlin에서도 필요하면 사용 지점 변성을 쓸 수 있습니다. MutableList처럼 선언 지점에서 변성을 지정할 수 없는 경우(읽기와 쓰기를 모두 하므로)에 유용합니다.
// 이 함수에서는 from을 읽기만 하겠다는 의미
fun copy(from: MutableList<out Animal>, to: MutableList<Animal>) {
for (item in from) {
to.add(item)
}
}
val dogs = mutableListOf(Dog(), Dog())
val animals = mutableListOf<Animal>()
copy(dogs, animals) // OK — from에 out을 붙였으므로 가능
세 가지 변성을 한 줄로 요약하면 다음과 같습니다.
- 불변(무표시):
T를 읽기/쓰기 모두 사용, 하위 타입 대입 불가. 예:MutableList<T> - 공변(out):
T를 반환(생산)만 사용, 하위 타입 → 상위 타입 대입 가능. 예:List<out E> - 반공변(in):
T를 파라미터(소비)만 사용, 상위 타입 → 하위 타입 대입 가능. 예:Comparable<in T>
스타 프로젝션과 타입 소거
스타 프로젝션(*) 활용법
타입 파라미터를 전혀 모르거나 신경 쓰지 않을 때 스타 프로젝션 *을 사용합니다. Java의 ? 와일드카드와 비슷하지만, 의미가 조금 더 정밀합니다.
fun printSize(list: List<*>) {
println("크기: ${list.size}")
for (item in list) {
println(item) // item은 Any?로 취급됨
}
}
printSize(listOf(1, 2, 3)) // OK
printSize(listOf("a", "b", "c")) // OK
List<*>은 “어떤 타입인지는 모르지만, 한 가지 특정 타입의 리스트”를 의미합니다. List<Any?>와 혼동하기 쉬운데, 차이가 있습니다. List<Any?>는 “어떤 타입이든 다 넣을 수 있는 리스트”이고, List<*>는 “타입은 모르지만 특정 타입 하나의 리스트”입니다. 읽을 때는 Any?로 가져올 수 있지만, MutableList<*>에 값을 쓰는 것은 불가능합니다.
val mutableList: MutableList<*> = mutableListOf(1, 2, 3)
val item = mutableList[0] // OK — Any?로 반환
// mutableList.add(4) // 컴파일 에러! 타입을 모르니 추가 불가
// mutableList.add("hello") // 역시 에러
스타 프로젝션은 타입 정보가 필요 없는 범용 함수(크기 확인, 비우기, 출력 등)를 작성할 때 주로 사용합니다. 타입별로 다른 처리가 필요하다면 스타 프로젝션 대신 구체적인 타입 파라미터를 사용하는 것이 좋습니다.
타입 소거의 한계와 대처법
JVM에서 제네릭은 타입 소거(type erasure)를 통해 구현됩니다. 컴파일 시점에는 List<String>과 List<Int>가 구분되지만, 런타임 바이트코드에서는 둘 다 그냥 List로 변합니다. 제네릭이 Java 5에서 도입될 때 기존 코드와의 하위 호환성을 위해 채택된 방식인데, 이 때문에 몇 가지 실질적인 제약이 생깁니다.
fun checkType(obj: Any) {
if (obj is List<*>) { } // OK — 스타 프로젝션은 가능
// if (obj is List<String>) { } // 컴파일 에러! 런타임에 타입 인자 확인 불가
}
// 함수 오버로딩도 제약
// fun process(list: List<String>) { } // JVM 시그니처가 같아서
// fun process(list: List<Int>) { } // 컴파일 에러 발생
런타임에 제네릭 타입 인자를 is로 검사할 수 없고, 제네릭 타입 인자만 다른 함수를 오버로딩할 수도 없습니다. 이런 제약은 Java에서도 동일하게 존재하는데, Kotlin은 reified라는 우아한 해결책을 제공합니다.
reified 타입 파라미터로 타입 소거 극복하기

inline과 reified의 결합
Kotlin의 reified 키워드는 inline 함수와 결합하여 타입 소거 문제를 극복합니다. 일반적인 제네릭 함수에서는 런타임에 T가 무엇인지 알 수 없지만, reified를 사용하면 런타임에도 T의 실제 타입 정보에 접근할 수 있습니다.
// 일반 제네릭 — T의 타입을 런타임에 알 수 없음
fun <T> isTypeNormal(value: Any): Boolean {
// return value is T // 컴파일 에러!
return false
}
// reified — T의 타입을 런타임에도 알 수 있음
inline fun <reified T> isType(value: Any): Boolean {
return value is T // OK!
}
println(isType<String>("hello")) // true
println(isType<Int>("hello")) // false
어떻게 가능할까요? inline 함수는 호출 지점에 함수 본문이 그대로 복사(인라인)됩니다. 이때 reified 타입 파라미터는 실제 타입으로 치환됩니다. 즉, 컴파일러가 isType<String>("hello")를 "hello" is String으로 변환하는 것입니다. 타입 소거가 일어나기 전에 구체적인 타입이 이미 코드에 박혀 있으므로, 런타임에도 타입 정보를 그대로 사용할 수 있습니다.
reified를 사용할 때 반드시 알아야 할 제약 사항이 있습니다.
inline함수에서만 사용할 수 있습니다. 일반 함수나 클래스의 타입 파라미터에는reified를 붙일 수 없습니다.reified타입 파라미터를 가진 함수는 Java에서 직접 호출할 수 없습니다. 인라인은 Kotlin 컴파일러의 기능이므로 Java 쪽에서는 지원되지 않습니다.- 인라인 함수가 너무 크면 호출 지점마다 코드가 복사되어 바이트코드가 부풀어 오를 수 있습니다. 핵심 로직만 인라인하고 나머지는 별도의 비인라인 함수로 분리하는 것이 좋습니다.
reified 실전 활용 사례
reified가 빛을 발하는 대표적인 활용 사례를 살펴보겠습니다.
JSON 역직렬화 간소화
// reified 없이 — 매번 클래스 객체를 전달해야 함
fun <T> fromJson(json: String, clazz: Class<T>): T {
return objectMapper.readValue(json, clazz)
}
val user = fromJson(jsonStr, User::class.java) // 장황함
// reified 사용 — 타입만 지정하면 끝
inline fun <reified T> fromJson(json: String): T {
return objectMapper.readValue(json, T::class.java)
}
val user = fromJson<User>(jsonStr) // 깔끔!
// 확장 함수와 결합하면 더 자연스러움
inline fun <reified T> String.parseAs(): T {
return objectMapper.readValue(this, T::class.java)
}
val user = jsonStr.parseAs<User>()
Class<T> 인자를 전달하는 보일러플레이트가 사라지고, 타입 추론과 결합하면 호출 코드가 한 줄로 줄어듭니다.
로거 자동 생성
inline fun <reified T> logger(): Logger {
return LoggerFactory.getLogger(T::class.java)
}
class UserService {
private val log = logger<UserService>()
fun findUser(id: Long) {
log.info("사용자 조회: $id")
}
}
클래스마다 LoggerFactory.getLogger(UserService::class.java)를 반복 작성하는 대신, reified로 한 줄로 줄일 수 있습니다.
타입 기반 필터링
val mixed: List<Any> = listOf(1, "hello", 2.0, "world", 3)
val strings = mixed.filterIsInstance<String>() // ["hello", "world"]
val ints = mixed.filterIsInstance<Int>() // [1, 3]
Kotlin 표준 라이브러리의 filterIsInstance가 바로 reified를 활용한 대표적인 함수입니다. 이처럼 표준 라이브러리 자체가 reified를 적극 활용하고 있으므로, 같은 패턴으로 프로젝트에 맞는 유틸리티를 만들 수 있습니다.
실전에서 바로 쓰는 Kotlin 제네릭 7가지 패턴
이제 지금까지 배운 개념을 실전에 적용할 차례입니다. 프로젝트에서 바로 활용할 수 있는 7가지 패턴을 하나씩 살펴보겠습니다.

패턴 1: 제네릭 Repository로 CRUD 보일러플레이트 줄이기
데이터 접근 계층에서 엔티티마다 비슷한 CRUD 코드를 반복 작성하고 있다면, 제네릭 Repository가 해답입니다. 타입 파라미터에 엔티티 타입과 ID 타입을 지정하면, 공통 로직을 한 번만 작성하고 모든 엔티티에 재사용할 수 있습니다.
interface Entity<ID> {
val id: ID
}
abstract class BaseRepository<T : Entity<ID>, ID : Any> {
protected val store = mutableMapOf<ID, T>()
fun findById(id: ID): T? = store[id]
fun findByIdOrThrow(id: ID): T {
return store[id]
?: throw NoSuchElementException("ID $id not found")
}
fun save(entity: T): T {
store[entity.id] = entity
return entity
}
fun deleteById(id: ID): Boolean = store.remove(id) != null
fun findAll(): List<T> = store.values.toList()
}
// 구체 엔티티와 Repository
data class User(
override val id: Long,
val name: String
) : Entity<Long>
class UserRepository : BaseRepository<User, Long>() {
fun findByName(name: String): User? {
return store.values.find { it.name == name }
}
}
기본 CRUD는 상위 클래스가 처리하고, 각 Repository는 도메인에 특화된 쿼리 메서드만 추가하면 됩니다. findById의 반환 타입이 User로 정확히 추론되므로 캐스팅이 전혀 필요 없습니다. 새로운 엔티티가 추가될 때마다 BaseRepository를 상속하기만 하면 되므로, 코드량이 80% 가까이 줄어듭니다.
패턴 2: Result 래퍼로 에러 처리 우아하게
함수의 성공과 실패를 예외 대신 타입으로 표현하면, 호출 쪽에서 에러 처리를 잊지 않도록 강제할 수 있습니다. 여기서 out 변성과 Nothing 타입이 핵심 역할을 합니다.
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
val isSuccess: Boolean get() = this is Success
fun getOrNull(): T? = when (this) {
is Success -> data
is Failure -> null
}
fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(data))
is Failure -> this // Nothing은 모든 타입의 하위 타입
}
fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
is Success -> transform(data)
is Failure -> this
}
}
// 사용 예시
fun fetchUser(id: Long): Result<User> {
return try {
val user = userRepository.findByIdOrThrow(id)
Result.Success(user)
} catch (e: Exception) {
Result.Failure(e)
}
}
val displayName = fetchUser(1L)
.map { it.name.uppercase() }
.getOrNull() ?: "Unknown"
Failure가 Result<Nothing>을 사용하는 이유에 주목하세요. Nothing은 Kotlin의 모든 타입의 하위 타입이므로, out 공변성에 의해 Result<Nothing>은 Result<User>, Result<String> 등 어떤 Result<T>에든 대입할 수 있습니다. 덕분에 map의 Failure 분기에서 별도의 캐스팅 없이 this를 그대로 반환할 수 있는 것입니다.
패턴 3: reified로 의존성 검색 간소화
프레임워크 없이도 간단한 의존성 컨테이너를 reified로 구현할 수 있습니다. 타입을 키로 사용하여 서비스를 등록하고 검색하는 패턴입니다.
class ServiceLocator {
private val services = mutableMapOf<String, Any>()
inline fun <reified T : Any> register(instance: T) {
services[T::class.qualifiedName!!] = instance
}
inline fun <reified T : Any> get(): T {
val key = T::class.qualifiedName!!
return services[key] as? T
?: throw IllegalStateException(
"${T::class.simpleName} is not registered"
)
}
}
// 사용
val locator = ServiceLocator()
locator.register(UserRepository())
locator.register(OrderService())
val repo = locator.get<UserRepository>() // 타입 안전하게 검색
reified 덕분에 Class<T> 인자를 전달하지 않아도 런타임에 정확한 타입 정보로 서비스를 등록하고 검색할 수 있습니다. 소규모 프로젝트나 테스트 환경에서 DI 프레임워크 없이도 충분히 활용할 수 있는 패턴입니다.
패턴 4: 타입 안전한 이벤트 시스템
이벤트 기반 아키텍처에서 이벤트 타입별로 핸들러를 자동 분류하는 시스템을 reified로 구현할 수 있습니다.
class EventBus {
private val handlers =
mutableMapOf<KClass<*>, MutableList<(Any) -> Unit>>()
inline fun <reified T : Any> on(noinline handler: (T) -> Unit) {
val list = handlers.getOrPut(T::class) { mutableListOf() }
@Suppress("UNCHECKED_CAST")
list.add(handler as (Any) -> Unit)
}
inline fun <reified T : Any> emit(event: T) {
handlers[T::class]?.forEach { it(event) }
}
}
// 이벤트 클래스 정의
data class UserCreated(val userId: Long, val name: String)
data class OrderPlaced(val orderId: String, val amount: Double)
// 사용
val bus = EventBus()
bus.on<UserCreated> { println("새 사용자: ${it.name}") }
bus.on<OrderPlaced> { println("주문 접수: ${it.orderId}") }
bus.emit(UserCreated(1L, "김철수")) // "새 사용자: 김철수"
bus.emit(OrderPlaced("ORD-001", 29900.0)) // "주문 접수: ORD-001"
이벤트 타입별로 핸들러가 자동 분류되므로, 수동 타입 검사나 when 분기가 필요 없습니다. 새로운 이벤트 타입을 추가할 때도 기존 코드를 수정하지 않아도 되니 확장에 매우 유리합니다.
패턴 5: 제네릭 확장 함수로 컬렉션 유틸리티
제네릭과 확장 함수를 결합하면 프로젝트 전반에서 재사용할 수 있는 컬렉션 유틸리티를 만들 수 있습니다.
// 정렬 여부 확인 — Comparable 제약 활용
fun <T : Comparable<T>> List<T>.isSorted(): Boolean {
return zipWithNext().all { (a, b) -> a <= b }
}
// 커스텀 키 기준 중복 제거 + 순서 유지
fun <T, K> List<T>.distinctByKeeping(selector: (T) -> K): List<T> {
val seen = mutableSetOf<K>()
return filter { seen.add(selector(it)) }
}
// 청크 단위 변환
fun <T, R> List<T>.mapChunked(
size: Int,
transform: (List<T>) -> R
): List<R> {
return chunked(size).map(transform)
}
// 사용 예시
val numbers = listOf(1, 3, 5, 7)
println(numbers.isSorted()) // true
data class Product(val id: Int, val category: String)
val products = listOf(
Product(1, "전자"), Product(2, "전자"), Product(3, "가구")
)
val unique = products.distinctByKeeping { it.category }
// [Product(1, "전자"), Product(3, "가구")]
타입 제약(T : Comparable<T>)을 적절히 활용하면, 해당 제약을 만족하지 않는 타입에 대해서는 컴파일러가 자동으로 에러를 잡아줍니다. 예를 들어 Comparable을 구현하지 않은 타입의 리스트에서 isSorted()를 호출하면 컴파일 단계에서 바로 걸립니다.
패턴 6: 제네릭 Mapper로 계층 간 변환 자동화
실무에서 엔티티를 DTO로 변환하는 작업은 매우 빈번합니다. 제네릭 Mapper 인터페이스를 정의하면 계층 간 변환 로직을 일관되게 관리할 수 있습니다.
interface Mapper<in I, out O> {
fun map(input: I): O
}
// 확장 함수로 리스트 매핑 지원
fun <I, O> Mapper<I, O>.mapList(inputs: List<I>): List<O> {
return inputs.map { map(it) }
}
// 양방향 매퍼
interface BiMapper<A, B> {
fun mapForward(input: A): B
fun mapReverse(input: B): A
}
// 구체 매퍼 구현
data class UserEntity(val id: Long, val firstName: String,
val lastName: String, val email: String)
data class UserDto(val id: Long, val displayName: String,
val email: String)
class UserDtoMapper : Mapper<UserEntity, UserDto> {
override fun map(input: UserEntity): UserDto {
return UserDto(
id = input.id,
displayName = "${input.lastName} ${input.firstName}",
email = input.email
)
}
}
// 사용
val mapper = UserDtoMapper()
val entities = listOf(
UserEntity(1, "철수", "김", "[email protected]"),
UserEntity(2, "영희", "이", "[email protected]")
)
val dtos = mapper.mapList(entities)
// [UserDto(1, "김 철수", ...), UserDto(2, "이 영희", ...)]
Mapper<in I, out O>에서 변성을 주목하세요. 입력 타입 I는 in(소비), 출력 타입 O는 out(생산) 위치에만 등장하므로 변성을 선언할 수 있습니다. 덕분에 Mapper<Animal, String>을 Mapper<Dog, Any>로 대입하는 유연한 사용이 가능합니다.
패턴 7: 다중 제약(where)으로 정교한 타입 설계
복합적인 요구사항이 있는 시스템에서는 where 절로 여러 인터페이스를 동시에 요구할 수 있습니다. 컴파일 타임에 제약을 강제하므로 런타임 에러를 사전에 차단합니다.
interface Cacheable {
val cacheKey: String
val ttlSeconds: Int
}
interface Identifiable<ID> {
val id: ID
}
class SmartCache<T, ID>
where T : Cacheable,
T : Identifiable<ID> {
private val cache = mutableMapOf<String, Pair<T, Long>>()
fun put(entity: T) {
val expiry = System.currentTimeMillis() +
entity.ttlSeconds * 1000L
cache[entity.cacheKey] = entity to expiry
}
fun get(key: String): T? {
val (entity, expiry) = cache[key] ?: return null
if (System.currentTimeMillis() < expiry) return entity
cache.remove(key)
return null
}
fun getById(id: ID): T? {
return cache.values
.filter { System.currentTimeMillis() < it.second }
.map { it.first }
.find { it.id == id }
}
}
// 두 인터페이스를 모두 구현한 엔티티만 캐시 가능
data class CachedUser(
override val id: Long,
val name: String,
override val cacheKey: String = "user:$id",
override val ttlSeconds: Int = 300
) : Cacheable, Identifiable<Long>
val cache = SmartCache<CachedUser, Long>()
cache.put(CachedUser(1L, "김철수"))
val user = cache.getById(1L)
where 절이 “캐시 가능하면서 동시에 ID로 식별 가능한 엔티티”라는 복합 제약을 컴파일 타임에 강제합니다. Cacheable만 구현하고 Identifiable을 빠뜨린 클래스를 넣으려 하면 즉시 컴파일 에러가 발생하므로, 설계 의도가 코드 자체에 녹아들어 별도의 런타임 검증이 필요 없습니다.
자주 하는 실수와 주의사항
마지막으로 Kotlin 제네릭을 사용할 때 흔히 빠지는 함정과 주의할 점을 정리합니다.
- 불필요한 as 캐스팅 남발: 제네릭을 제대로 설계하면
as캐스팅이 거의 필요 없습니다. 코드에 캐스팅이 많다면 제네릭 구조를 재검토할 신호입니다. - Any로 도피: 타입 파라미터 대신
Any를 쓰면 제네릭의 의미가 사라집니다. 구체적인 타입 정보를 최대한 유지해야 컴파일러의 도움을 받을 수 있습니다. - 과도한 타입 파라미터: 한 클래스에 타입 파라미터가 3~4개를 넘어가면 가독성이 급격히 떨어집니다. 설계를 분리하거나 타입 별칭(typealias)을 적극 활용하세요.
- 변성 방향 혼동:
out은 데이터가 “나가는(반환)” 방향,in은 “들어오는(파라미터)” 방향입니다. 키워드 자체가 데이터 흐름 방향을 알려준다고 기억하면 헷갈리지 않습니다. - reified 함수 비대화:
reified는 반드시inline과 함께 쓰므로, 함수 본문이 커지면 호출 지점마다 코드가 복사되어 바이트코드가 팽창합니다. 타입 정보가 필요한 핵심 로직만 인라인하고, 나머지는 별도 함수로 분리하세요. - 스타 프로젝션 오용:
List<*>은 편리하지만, 타입 정보를 모두 잃습니다. 타입별로 다른 처리가 필요한 곳에서는 구체적인 타입 파라미터를 사용하는 것이 안전합니다.
제네릭에 대한 더 깊은 내용이 필요하다면 Kotlin 공식 문서의 Generics 섹션을 참고하시기 바랍니다. 특히 변성 관련 설명이 다양한 예시와 함께 잘 정리되어 있어, 이 글에서 다룬 내용을 보충하기에 좋습니다.
마무리
Kotlin 제네릭은 처음에는 복잡해 보이지만, 핵심 원리를 이해하면 코드의 품질을 한 단계 끌어올리는 강력한 도구가 됩니다. out은 생산자, in은 소비자, reified는 타입 소거 극복이라는 세 가지 키워드만 확실히 기억해도 대부분의 실전 상황을 커버할 수 있습니다.
오늘 소개한 7가지 패턴 중 프로젝트에 적합한 것을 하나 골라 바로 적용해 보세요. 제네릭 Repository나 Result 래퍼처럼 작은 것부터 시작하면, 점차 제네릭이 자연스러운 코딩 습관으로 자리 잡을 것입니다. 반복적인 캐스팅과 보일러플레이트에서 벗어나 타입 안전성과 재사용성을 동시에 잡는 경험을 직접 느껴 보시기 바랍니다.
자주 묻는 질문
Kotlin 제네릭에서 in과 out 키워드는 어떤 차이가 있나요?
in과 out은 Kotlin 고유의 변성(variance) 키워드입니다. out은 타입 파라미터를 출력(반환) 위치에서만 사용하도록 제한하고, in은 입력(소비) 위치에서만 사용하도록 제한합니다. 이 키워드를 제대로 이해하지 않으면 Kotlin 제네릭의 진정한 힘을 절반도 활용하지 못하게 됩니다.
Kotlin 제네릭의 reified 타입 파라미터는 언제 사용하나요?
reified는 인라인 함수에서 제네릭 타입 정보를 런타임에도 유지할 수 있게 해주는 Kotlin 고유 기능입니다. 일반적으로 JVM의 타입 소거로 인해 런타임에 제네릭 타입을 알 수 없지만, reified를 사용하면 타입 검사나 캐스팅을 안전하게 수행할 수 있습니다. 이를 통해 Any로 캐스팅하는 불안전한 코드를 줄이고 ClassCastException 같은 런타임 오류를 방지할 수 있습니다.
Kotlin 제네릭에서 타입 제약(upper bound)은 어떻게 설정하나요?
타입 파라미터 선언 시 상한 바운드(upper bound)를 지정하면 특정 조건을 만족하는 타입만 허용할 수 있습니다. 모든 타입을 받아들이는 제네릭은 유연하지만, 때로는 특정 인터페이스나 클래스를 상속한 타입으로 범위를 좁혀야 하며, 이를 통해 해당 타입이 제공하는 메서드를 안전하게 호출할 수 있습니다.