제네릭(Generics) 이란?
다양한 타입의 객체들을 다루는 메서드나 클래스에 대해 컴파일 시 타입 체크를 해주는 기능이다. 타입 에러를 런타임이 아니라 컴파일 타임에 알 수 있기에 코드 안정성을 높일 수 있다.
제네릭을 사용하는 가장 대표적인 인터페이스로 `List<out E>`가 있는데, 리스트 자료구조에 담는 타입을 컴파일 시에 체크하여 타입 안정성을 제공해주기 위함이다. 또한 제네릭을 사용하면 이미 리스트의 요소가 제네릭에 명시된 타입이라는 것을 보장해주기 때문에 타입 체크와 형변환을 생략할 수 있게 된다.
번외로, Java의 경우에는 초기에 제네릭을 제공하지 않아 이전 버전과의 호환성을 위해 `List` 타입 역시 제네릭 없이 사용할 수 있지만, Kotlin의 경우에는 언어가 만들어 질 때 부터 제네릭을 제공했기 때문에 `List` 구현체들을 생성하기 위해서 무조건 제네릭을 명시해주어야 한다.
제네릭을 활용하여 무언가를 담는 쟁반 클래스를 만들어보자.
class Tray<T> {
private val items: MutableList<T> = mutableListOf()
fun putItem(item: T) {
this.items.add(item)
}
fun getFirstItem(): T? {
return this.items.firstOrNull()
}
fun moveFrom(otherTray: Tray<out T>) {
this.items.addAll(otherTray.items)
otherTray.items.clear()
}
}
"<>" 기호를 통해 제네릭을 사용할 수 있으며, 이 안에 선언한 변수를 타입 변수(type variable)이라고 한다. 클래스에 선언된 타입 변수는 클래스 내부에서 사용할 수 있다. 위의 코드에서는 프로퍼티, 함수 파라미터, 함수 반환값에 타입 변수를 사용한 모습이다.
변성(Variance)
공변(Co-Variant)과 무공변(불공변, In-Variant)
변성을 설명하기 위해 Java의 배열을 예로 들어보자.
public static void main(String[] args) {
String[] strs = new String[]{"a", "b", "c"};
Object[] objs = strs;
objs[1] = 1;
}
위 코드에서 `String`은 `Object`의 하위 타입이기 때문에 `String[]` 타입의 `strs`은 `Object[]` 타입으로 선언된 변수에 초기화 될 수 있다. 즉, A가 B의 하위 타입일 때, A배열은 B배열의 하위타입이 가능하다. 이렇게 상속 관계를 보존하는 것을 "공변" 이라고 말한다.
하지만 위 코드는 런타임 시에 `ArrayStoreException`이 발생하게 된다. 실제로 `objs`에 초기화 되어있는 타입은 `String[]` 인데, `int` 타입을 배열의 요소로 초기화하기 때문이다. 공변은 이렇듯 위험한 코드를 야기할 수 있다.
반면 `List`의 경우에는 공변하지 않다.
public static void main(String[] args) {
List<String> strs = List.of("a", "b", "c");
List<Object> objs = strs;
}
위의 코드는 컴파일 에러가 발생한다. `String`은 `Object`의 하위 타입임에도 불구하고, `objs`는 `List<Object>` 타입이기 때문에 `List<String>` 타입으로 초기화될 수가 없다. 기본적으로 제네릭은 상속관계를 보존하지 않는 "무공변성"을 지니기 때문이다. 즉, `List<Object>`와 `List<String>`은 서로 아예 아무런 관계가 없는 타입이 되어버린다.
Kotlin 제네릭의 변성
Kotlin의 제네릭 역시 "무공변성"을 특징으로 한다. 한번 예시를 들어보자.
abstract class Food(val name: String) {
abstract fun eat()
}
class Hamburger(name: String): Food(name) {
override fun eat() { println("${name} 햄버거 냠냠") }
}
class Fried(name: String): Food(name) {
override fun eat() { println("${name} 튀김 냠냠") }
}
음식 클래스를 만들었고, 햄버거와 튀김 클래스를 음식 클래스를 상속받아 만들었다. 이제 위에서 만들었던 쟁반 클래스에 햄버거를 담아보자.
fun main() {
val foodTray = Tray<Food>()
val hamburgerTray = Tray<Hamburger>()
hamburgerTray.putItem(Hamburger("와퍼"))
foodTray.moveFrom(hamburgerTray)
}
음식 쟁반과 햄버거 쟁반을 만들었고 햄버거 쟁반에는 와퍼 하나를 담았다. 햄버거 쟁반에 있는 내용물을 음식 쟁반으로 옮기는 코드이다. 하지만 위의 코드에서 `moveFrom()` 함수를 호출하는 부분은 컴파일 에러가 발생한다.
`Tray`의 `moveFrom`()`` 함수는 `Tray<T>`를 인자로 받는다. 따라서 `foodTray`는 `moveFrom()` 함수의 인자로 `Tray<Food>`를 받을 수 있게 된다. 만약 제네릭이 공변하다면 `Hamburger`는 `Food`의 하위타입이기 때문에 타입을 보존시켜 `Tray<Food>` 타입은 `Tray<Hamburger>`타입도 가능하게 된다. 하지만 제네릭은 무공변하기 때문에 `Tray<Food>` 과 `Tray<Hamburger>` 타입은 서로 아예 상관 없는 타입이 되어버린다. 따라서 Type missmatch 컴파일 에러가 발생하게 되는 것이다.
Kotlin 제네릭을 공변하게 만들기
위의 예시에서 사실상 `Tray<Food>` 타입에 `Tray<Hamburger>`타입이 인자로 들어가더라도 전혀 문제될 것이 없다. 쉽게 말하면 음식 쟁반에 햄버거를 담아도 전혀 상관이 없다. 따라서 상속 관계를 보존하도록 공변하게 만들어보자.
fun main() {
val foodTray = Tray<Food>()
val hamburgerTray = Tray<Hamburger>()
foodTray.putItem(Hamburger("와퍼"))
foodTray.moveFrom(hamburgerTray)
}
class Tray<T> {
private val items: MutableList<T> = mutableListOf()
fun putItem(item: T) {
this.items.add(item)
}
fun getFirstItem(): T? {
return this.items.firstOrNull()
}
fun moveFrom(otherTray: Tray<out T>) {
this.items.addAll(otherTray.items)
otherTray.items.clear()
}
}
`moveFrom()` 함수 파라미터에 선언된 제네릭에 `out` 이라는 변성 어노테이션(variance annotation)을 달아주어 타입 변수 `T`에 대해서 상속 관계를 보존하도록 했다. 이러면 `T` 하위의 모든 클래스들이 타입변수로 가능하다. 따라서 `Food` 타입의 하위 타입인 `Hamburger` 타입도 타입 파라미터에 상속 관계가 보존되어 `Tray<Food>` 타입을 인자로 받는 `moveFrom()` 함수에는 `Tray<Hamburger>` 타입도 인자로 받을 수 있게 된다.
단, `out`을 통해 타입 파라미터를 공변하게 만들면, 해당 파라미터는 타입 안정성을 위해 생상자 역할만 수행할 수 있다. 쉽게 설명하면 해당 파라미터로부터 값을 가져오는(생성하는) 것만 가능하고, 해당 파라미터로 값을 내주는(소비하는) 것은 불가능하다는 소리이다. 더 쉽게 설명하면 공변된 인자의 함수를 호출 할 때, 해당 함수가 매개변수가 필요한 함수라면(값을 소비해야 하는 함수라면) 사용하지 못하게 된다.
이게 왜 그래야 하는지 다음과 같은 상황을 살펴보자. `moveFrom()`의 반대 기능인 `moveTo()`라는 함수를 만들어보며 이해해보자.
fun moveTo(otherTray: Tray<out T>) {
otherTray.items.addAll(this.items)
this.items.clear()
}
다른 쟁반을 받아서 기존 쟁반에 있던 아이템을 다른 쟁반으로 옮겨주는 코드이다. 이 코드는 컴파일 에러가 난다. 공변으로 설정한 파라미터 "`otherTray`"가 "기존 쟁반에 있던 아이템" 값을 소비하고 있기 때문이다.
이게 왜 문제가 되냐면, 아래와 같은 상황을 예로 들어보자.
fun main() {
val foodTray = Tray<Food>()
val hamburgerTray = Tray<Hamburger>()
hamburgerTray.putItem(Hamburger("와퍼"))
foodTray.moveFrom(hamburgerTray)
val friedTray = Tray<Fried>()
friedTray.putItem(Fried("감자"))
foodTray.moveTo(friedTray)
}
처음에 음식 쟁반에는 햄버거 쟁반으로부터 햄버거 하나를 옮겨왔다. 그리고 이 음식 쟁반의 내용물을 감자 튀김 쟁반으로 옮기도록 했다. 이는 당연히 불가능하다.
`friedTray`의 경우 `items` 프로퍼티는 `MutableList<Fried>` 타입이다. 근데, 값을 소비할 수가 있게 만들어버리면 `items`의 요소에 `Hamburger` 타입이 들어갈 수가 있게 되어버린다. 이는 당연히 런타임 시 오류가 난다. `MutableList<Fried>`는 `Hamburger`를 넣을 수 없기 때문이다. 따라서 공변으로 설정했다면, 해당 파라미터는 값을 소비하지는 못한다.
Kotlin 제네릭을 반공변(Contra-Variant)하게 만들기
위의 상황은 반대로 생각해야 한다. 음식 쟁반에서 감자 튀김 쟁반으로 옮기는 것이 아니라, 감자 튀김 쟁반에서 음식 쟁반으로 옮기는 것이 가능하도록 해야할 것이다.
`out`의 반대말은 `in`이다. `in` 역시 변성 어노테이션으로, 제네릭이 사용되는 파라미터에 대해 반공변(Contra-Variant)하게 만든다. 공변이 타입의 상속관계를 보존했다면, 반공변은 타입의 상속관계를 역전하는 것을 의미한다. 한번 `moveTo()` 함수를 반공변 하도록 수정해보자.
fun moveTo(otherTray: Tray<in T>) {
otherTray.items.addAll(this.items)
this.items.clear()
}
이제 함수의 매개변수는 `T`타입과 `T`타입의 상위 타입이 들어올 수 있게 된다. 따라서 해당 매개변수는 대해서 소비자로써 사용할 수 있게 된다.
fun main() {
val foodTray = Tray<Food>()
val hamburgerTray = Tray<Hamburger>()
hamburgerTray.putItem(Hamburger("와퍼"))
foodTray.moveFrom(hamburgerTray)
val friedTray = Tray<Fried>()
friedTray.putItem(Fried("감자"))
friedTray.moveTo(foodTray)
}
와퍼 하나가 담긴 햄버거 쟁반으로부터 음식 쟁반으로 옮겼다. 그리고 튀김 쟁반에 감자 튀김을 담아서 음식 쟁반으로 보냈다. `moveTo()` 함수의 파라미터는 반공변하기에, `Fried` 타입의 상위 타입인 `Food` 타입이 들어갈 수가 있게 된다. 그리고 이 파라미터를 소비하여 `Fried` 타입의 `item`을 `Food` 타입만 들어가는 `items`에 넣을 수 있게 되는 것이다.
fun moveTo(otherTray: Tray<in T>) {
this.items.addAll(otherTray.items)
...
}
코드를 반대로 바꿔서 위와 같이 `othetTray`를 생산자로써 사용하려고 한다면, 공변일 때 처럼 당연히 컴파일 에러가 난다.
변성을 주는 위치
선언 지점 변성(declaration-site variance)
만약 어떤 클래스가 타입변수에 대해서 소비만 하는 것이 명백하다면 일일이 메서드 혹은 프로퍼티마다 변성 어노테이션을 사용하는 것은 불필요하다. 예를 들어보자.
class PutTray<in T> {
private val items: MutableList<T> = mutableListOf()
fun putItem(item: T) {
this.items.add(item)
}
fun putAll(items: List<T>) {
this.items.addAll(items)
}
}
말은 안되지만 꺼내는 건 안되고 넣는 것만 가능한 쟁반이 있다고 해보자. 이럴 경우, 타입 파라미터를 사용하는 부분이 소비하는 부분밖에 없기 때문에 `PutTray` 클래스 선언부에 변성 어노테이션 `in`을 사용하는 것이 가능하다.
class GetTray<out T> {
private val items: MutableList<T> = mutableListOf()
fun getItem(): T? {
return this.items.firstOrNull()
}
fun getItems(): List<T> {
return this.items
}
}
반대로 꺼내는 것만 가능한 쟁반은 타입 파라미터를 생산만 하기 때문에 클래스 선언부에 변성 어노테이션 `out`을 사용하는 것이 가능하다.
사용 지점 변성(use-site variance)
반면 사용 지점 변성은 위에서 공변과 반공변을 다뤘을 때처럼 사용하는 지점에 변성을 주는 것을 말한다.
fun moveFrom(otherTray: Tray<out T>) {
this.items.addAll(otherTray.items)
otherTray.items.clear()
}
사용 지점 변성을 사용하게 되면, 변성 어노테이션이 붙은 파라미터에 대해서는 타입을 제한하게 된다. 이를 Type Projection 이라는 용어로 부른다. 예를 들어, `out`을 통해 공변하게 만들었다면, 공변 상태의 파라미터는 타입 파라미터를 생산하는 역할만 수행할 수 있다. 소비가 제한되게 되는 것이다.
위의 `moveFrom()` 함수에서는 `otherTray`에 변성 어노테이션을 통해 공변하도록 만들었고, 공변한 파라미터는 생산자로써 작동할 수밖에 없게 된다.
정리하자면 다음과 같다.
선언 지점 변성
- 클래스 선언부에 변성 어노테이션 사용
- 공변일 경우 타입 파라미터를 생산만 가능
- 반공변일 경우 타입 파라미터를 소비만 가능
사용 지점 변성
- 프로퍼티, 함수 등의 사용 지점에 변성 어노테이션 사용
- 공변된 파라미터는 생산자 역할만 수행 가능
- 반공변된 파라미터는 소비자 역할만 수행 가능
제네릭 함수
제네릭을 클래스에만 사용할 수 있는 건 아니다. 함수에도 제네릭을 사용할 수 있다.
class SingleTray<T>(
var item: T
) {
fun changeItem(item: T) {
this.item = item
}
companion object {
fun <I> createTray(item: I): SingleTray<I> {
return SingleTray(item)
}
}
}
물건 하나만을 담을 수 있는 `SingleTray` 클래스가 있다고 해보자. 동반 객체를 사용하여 클래스의 인스턴스를 만드는 정적 팩터리 메서드를 만들 때, 위와 같이 제네릭 함수를 활용하여 파라미터의 타입과, 리턴되는 `SingleType`의 타입 파라미터를 맞춰줄 수가 있다.
단, 제네릭 클래스에 선언된 타입 파라미터와 제네릭 함수에 선언된 타입 파라미터는 다른 타입 파라미터다. 설명을 위해 `changeItem()`이라는 함수를 제네릭 함수로 한번 바꿔보자.
fun <T> changeItem(item: T) {
this.item = item
}
위와 같이 제네릭 함수로 바꾸면 예외가 발생한다. 이는 `SingleTray` 클래스 선언부에 작성한 타입 파라미터 `T`와, 제네릭 함수 선언부에 작성한 타입 파라미터 `T`가 다르기 때문에 발생하는 타입 오류이다.
보면 `T#1`, `T#2`로 타입이 다른 것을 확인할 수 있다. 이는 제네릭 클래스에 선언된 타입 파라미터와 제네릭 함수에 선언된 타입 파라미터가 다르다는 것을 알 수 있다.
위와 같이 클래스 타입 파라미터와, 함수 타입 파라미터의 이름을 똑같이 만들면 섀도잉(Shadowing)이 일어난다. 이름이 섀도잉되면 실제 이름은 같지만, 대상은 다르기 때문에 혼란을 야기할 수 있다. 따라서 타입 파라미터의 이름은 각기 다르게 설정하는 것이 좋다. `createTray()` 정적 팩토리 메서드에 타입 파라미터 명을 `I`로 정한 것도 이 때문이다.
제네릭 제약
만약 위에서 만든 `SingleTray`는 너무 작아서 크기가 작은 `Food` 타입만 넣을 수 있다고 가정해보자.
fun <I> createTray(item: I): SingleTray<I> {
if (item !is Food) throw IllegalArgumentException("물건이 너무 큽니다")
return SingleTray(item)
}
위와 같이 팩토리 메서드에서 타입을 검증하는 방법도 가능하긴 하다. 하지만 실제 `item`에는 제약이 없기 때문에 어느 타입이 들어오든 컴파일 에러는 발생하지 않는다. 따라서 다른 개발자가 아무 타입이나 가능한 지 알고 `Food` 이외의 타입을 넣게 되면 런타임 에러가 발생하게 된다.
이럴 경우, 타입 파라미터 자체에 `Food` 타입만 가능하도록 제약을 걸어준다면, 생성 조건도 보다 명확해지고 컴파일 시점에 에러를 잡을 수 있을 것이다.
class SingleTray<T: Food>(
var item: T
) {
companion object {
fun <F: Food> createTray(item: F): SingleTray<F> {
return SingleTray(item)
}
}
}
제약을 거는 것은 쉽다. 프로퍼티, 함수 파라미터에 타입을 지정하는 문법처럼 타입 파라미터에 타입을 지정해주면 된다. 위 코드는 정적 팩토리 메서드 뿐만 아니라, 클래스 타입 파라미터에도 당연히 제약을 걸 수 있다.
만약 여러 조건으로 제약을 걸어야 한다면, `where` 키워드를 사용하면 된다. 음식이면서 마실 수 있는(`Drinkable`) 물건만 가능하다고 가정한다면 다음과 같이 제약을 걸 수 있다.
class SingleTray<T> (
var item: T
) where T: Food, T:Drinkable {
companion object {
fun <F> createTray(item: F) : SingleTray<F> where F: Food, F:Drinkable {
return SingleTray(item)
}
}
}
클래스와 함수 모두 선언부의 마지막 부분에 `where` 키워드를 통해서 타입 파라미터에 대해 여러가지 제약을 명시하면 된다.
타입 소거와 Star Projection
타입 소거(Type Erasure)
이전에 번외로 말했지만, Java에서는 제네릭을 언어 초창기부터 지원하지는 않았다. Java 5버전 부터 제네릭을 지원하기 시작했고, 이 때문에 이전 버전으로 작성된 코드의 호환성을 유지해야 했다. 기존 코드에는 타입 파라미터 자체가 없었기 때문에, 호환성을 위해 JVM에서는 결국 런타임 때 타입 정보를 제거하게 된다. 런타임 때 타입 정보를 제거하면 타입 정보가 없는 코드와 동일하게 처리할 수 있기 때문이다.
Kotlin 역시 JVM 위에서 동작하기 때문에 런타임 때 제네릭으로 설정한 타입 정보는 없어진다. 이를 타입 소거(Type erasure)라고 한다.
fun asStringList(any: Any): List<String> {
if (any is List<String>) return any
else throw IllegalArgumentException()
}
`Any` 타입을 받아서 `List<String>` 타입으로 변환하는 함수를 작성했다. 하지만 이 함수는 컴파일 에러가 발생한다.
타입이 지워졌기 때문에 런타임 시점에는 `String` 타입인지 확인할 수가 없는 것이다.
제네릭을 사용한다고 하더라도 역시 마찬가지이다.
fun <T> asList(any: Any): List<T> {
if (any is List<T>) return any
else throw IllegalArgumentException()
}
이번엔 명시된 타입 파라미터의 리스트로 변환하는 함수이다. 이 역시 위와 동일한 컴파일 에러가 발생한다. 타입이 소거되어 런타임 때 타입 정보를 알 수가 없기 때문이다.
Star Projection
위 상황에서 만약 타입은 상관 없고 그냥 단지 List 타입인지만 확인하고자 한다고 해보자. 이럴 경우 Star Projection을 사용할 수 있다.
Star Projection을 사용하면 소거된 타입은 어느 타입이 들어오든 상관 없고, `List`인지만 구분할 수 있다. 아래의 코드를 보자.
fun asAnyList(any: Any): List<*> {
if (any is List<*>) return any
else throw IllegalArgumentException()
}
`Any` 타입을 받아 리스트로 변환하는 함수이다. "`*`"은 Star Projection으로, 위와 같이 사용하면 타입 파라미터가 어느 타입이든 상관 없이는 `List` 타입만을 나타내게 된다. 따라서 컴파일 에러 없이 `any`가 `List` 타입인지 확인이 가능해진다.
따라서 아래와 같은 코드는 가능하다.
fun asStringList(any: Any): List<String> {
if (any is List<*>) {
if (any.none { it !is String }) {
return any.map { it as String }
} else throw IllegalArgumentException()
} else throw IllegalArgumentException()
}
우선 `List`인지 확인 하고, `List`라면 모든 요소가 `String`인지 확인하여` List<String>`으로 만드는 것이다.
Reified Type Parameter
만약에 `Any`타입을 받아서 타입 파라미터로 제공된 클래스로 변환하는 함수를 만들어야 한다고 해보자.
fun <T> convert(any: Any): T {
if (any is T) return any
else throw IllegalArgumentException()
}
위와 같이 함수를 작성하고 싶을 것이다. 하지만 이는 타입 소거 때문에 역시 컴파일 에러가 발생한다.
그래서 대안으로 아래와 같은 코드를 작성할 수도 있다.
fun <T> convertByClass(any: Any, clazz: Class<*>): T {
if (any::class.java.isAssignableFrom(clazz)) return any as T
else throw IllegalArgumentException()
}
Java 리플렉션을 사용해서 `Class` 객체를 파라미터로 받아서, 해당 클래스 타입으로 변환하는 함수로 우회할 수는 있다.
하지만 타입 파라미터를 받아서 타입 파라미터로 변환하는 위의 `convert()` 함수가 더 깔끔하고, 리플렉션도 사용하지 않기 때문에 속도도 더 빠를 것이다. 이렇게 제네릭 타입 파라미터를 함수 내에서 사용해야 할 경우, `reified` 타입 파라미터와 `inline` 함수를 함께 사용하면 된다.
inline fun <reified T> convert(any: Any): T {
if (any is T) return any
else throw IllegalArgumentException()
}
타입 파라미터 앞에 `reified` 키워드를 사용하면 런타임때도 타입 정보를 유지할 수가 있게 된다. 단, `inline` 함수와 같이 사용되어야 한다.
`inline` 함수는 함수의 본문을 함수 호출 지점으로 옮겨 작동되는 함수이다.
fun sum(a: Int, b: Int): Int {
return a + b
}
inline fun inlineSum(a: Int, b: Int): Int {
return a + b
}
fun main() {
val a = 5
val b = 10
sum(a, b)
inlineSum(a, b)
}
위와 같은 코드가 있을 때, Java 코드로 디컴파일 해보면 아래와 같이 나온다.
public final class InlineFunKt {
public static final int sum(int a, int b) {
return a + b;
}
public static final int inlineSum(int a, int b) {
int $i$f$inlineSum = 0;
return a + b;
}
public static final void main() {
int a = 5;
int b = 10;
sum(a, b);
int $i$f$inlineSum = false;
int var10000 = a + b;
}
// $FF: synthetic method
public static void main(String[] var0) {
main();
}
}
그냥 `sum()` 함수를 사용했을 때는 함수를 호출하는 것을 볼 수 있다. 하지만 `inlineSum()` 함수를 호출했을 때는 `main()` 함수에서 `inlineSum()` 함수를 호출하는 지점으로 `inlineSum()` 함수의 본문이 그대로 복사된 것을 볼 수 있다.
따라서 `inline` 함수가 함수 본문을 호출하는 지점으로 옮기는 특성을 이용하여 `reified` 타입 파라미터의 타입 정보를 유지할 수 있게 된다. 이와 같이 사용할 경우 장점은 다음과 같이 정리해볼 수 있다.
1. 런타임에 타입 정보 사용이 가능하기에 is나 as와 같은 연산자를 사용하여 타입을 검사하거나 변환할 수 있다.
2. 런타임에 리플렉션을 사용하지 않으므로 성능이 향상된다.
3. 함수를 호출할 때 타입 정보를 전달할 필요가 없어지므로 함수 호출이 더 직관적이고 읽기 쉬워진다.
Type Alias 사용
제네릭을 사용하게 되면 불가피하게 타입이 길어질 수 있다.
val accountStat: Map<LocalDateDto, List<AccountInfoDto>> = mutableMapOf()
요구사항 중에 날짜로 그룹지어 회원의 통계를 나타내야 한다고 했을 때, 위와 같은 `Map`을 생성하여 해결할 수도 있을 것이다. 근데 타입이 너무 길다.
이럴 경우에는 `typealias`를 사용하는 것도 좋다.
typealias AccountInfoGroupByDate = Map<LocalDateDto, List<AccountInfoDto>>
fun main() {
val accountStat: AccountInfoGroupByDate = mutableMapOf()
}
위와 같이 타입에 대한 별칭을 부여하고, 실제 타입 선언은 별칭으로 하면 좀 더 간단하고 가독성 있는 코드를 작성할 수 있을 것이다.