Generic 이해하기
주제 선정 이유
개발을 하다보면 이미 작성된 코드를 분석해야할 상황에 자주 놓이게 됩니다. 가장 쉽게 이런 상황에 놓이는 경우는 보통 라이브러리를 사용해야 할 때 일 것입니다. 라이브러리에서 원하는 기능의 API를 찾아 사용하기 위해서는 매개변수와 반환값의 타입을 참조하여 대략적으로나마 어떤 기능을 위해 만들어진것인지, 어떻게 사용해야 하는지 힌트를 얻는 과정이 필요하였습니다. 이 과정 중, 제너릭이 들어가기만 하면 해석하는데 어려움을 겪고는 하였습니다. 아래에 java코드 하나를 예시로 들겠습니다.
public static <T extends Comparable<? super T>> List<T> sorted(List<T> list) { ... }
List<String>정도로만 제너릭을 사용해 왔던 저는 이해할 수 없는 것들 뿐이었습니다. <>안의 <>는 무엇이며 물음표는 또 무엇인지, sorted라는 메소드명 전까지인 반환타입은 어디서부터 어디까지인 것인지.. 모든게 의문이었습니다.
typescript에서도 마찬가지 였습니다.
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);
갑자기 등장하는 | 와 화살표 함수란 무엇인지..
제너릭을 명확히 해석하기 위한 공부를 해야하겠다고 느끼고 이를 주제로 삼게되었습니다.
Generic의 개념
먼저 제너릭은 무엇인지, 제너릭을 사용하면 어떤 장점이 있는지부터 간단히 알아보겠습니다.
- 정의
제네릭(Generic)이란 타입을 일반화하는 것을 의미합니다.
'타입의 일반화'란 클래스 내부에서 개별적으로 타입을 지정하는 것이 아니라, 외부에서 지정하게끔 일반화시켜 두는것을 뜻하며
'외부에서 지정한다'는 사용자가 제너릭 클래스의 인스턴스를 생성할 때, 또는 제너릭 메소드를 호출할 때 타입을 정한다는 것을 의미합니다. - 장점
- 재사용성
제네릭 타입은 여러 타입의 파라미터를 삽입해 객체를 생성할 수 있기 때문에 코드를 간결하게 하고 재사용성을 높입니다. - 컴파일 시 타입 에러 발견
컴파일 이후 런타임 단계에서 타입 문제가 발생될 가능성을 방지해줍니다. - 형변환의 과정 생략
제네릭 타입은 컴파일 시 컴파일러에 의해 자동으로 검사 되어 타입으로 변환되므로 불필요하게 코드에서 타입 캐스팅을 해줄 필요가 없습니다. - 개발 시 생산성 향상
IDE가 해당 객체의 타입을 알고 있기 때문에 .을 입력하여 해당 객체의 속성 힌트를 볼 수 있습니다.
- 재사용성
정적 타입 언어라면 제너릭이 쓰일 수 있습니다. 여기서는 java와 typescript에서의 제너릭을 정리하고자 합니다.
제너릭타입에 관하여 공통적으로 설명되는 개념이 많기때문에 우선 java를 기준으로 정리한 다음, 특징적으로 typescript에서 살펴볼 내용을 덧붙이도록 하겠습니다.
제너릭클래스
// PaperBox클래스, PlasticBox클래스 등을 따로 만들 필요없이 하나의 Box제너릭클래스를 사용하면 됩니다.
class Box<T>{
public T material;
Box(T material){ this.material = material; }
}
Box<Paper> paperBox = new Box<Paper>(new Paper());
Box<Plastic> plasticBox = new Box<Plastic>(new Plastic());
제너릭 클래스 사용시 고려사항
- 제네릭 타입 간에는 상하위의 관계 없음
class Cardboard extends Paper { ... }
Box<Paper> cardboardBox = new Box<Cardboard>(new Cardboard()); // error
Cardboard가 Paper의 하위라고 하더라도 Box<Cardboard>는 Box<Paper>의 하위가 아니며, 전혀 상관없는 타입으로 인식됩니다. 이러한 타입간의 계층관계를 '함께 변하지 않는다'라고 하여 불공변(invariant)이라하며, 기본적으로 제네릭은 불공변입니다.
참고로 '함께 변한다'라는 공변(covariant)으로는 배열이 있으며, Cardboard가 Paper의 하위라면 Cardboard[] 또한 Paper[]의 하위가 됩니다.
- 제네릭 타입의 배열 생성 불가
class Box<T> {
T[] tArr1; // 선언은 OK
T[] tArr2 = new T[3]; // 생성은 error
}
new 연산자는 컴파일 시점에서 메모리를 확보하는 역할을 하는데, 이는 타입 변수가 무엇인지를 알아야 가능합니다. 컴파일 시점에서 타입 T가 무엇인지 알 수 없기 때문에 제네릭으로 배열을 생성할 수 없게됩니다.
해결법으로는, 일단 Object 배열로 만든 후 'T[]'로 형변환하는 방법 등이 있습니다.
- static 변수/메소드 선언 불가
class Box<T>{
static T material; // error
static T compare(T m1, T m2) {...} // error
static <T> T compare(T m1, T m2) {...} // OK (제너릭메소드)
}
제너릭 타입은 인스턴스가 생성되어야 정해지는 반면 static 멤버는 인스턴스에 소속된 멤버가 아니여서 인스턴스 생성 전에 이미 메모리에 올라가 있게 됩니다. 따라서 material과 compare은 타입을 특정할 수 없는 상태가 되므로 error가 발생합니다. 다만 static메소드의 경우에는, 제너릭을 사용하여 작성이 가능하지만(제너릭 메소드) 이때의 제너릭 타입 T는 인스턴스 생성 시 지정될 제너릭타입 T와는 별개입니다.
아래에서 자세히 정리하겠습니다.
제너릭메소드
클래스나 인터페이스에 제네릭을 사용하는 것처럼 메소드에도 제네릭을 적용할 수 있으며 다음과 같은 형태를 가집니다.
제너릭 메소드의 타입은 메소드 호출 시 지정됩니다.
// 접근제어자 <제네릭타입> 반환타입 메소드명(매개변수타입 매개변수명) { ... }
public <T> T genericMethod(T param) { ... }
<String>genericMethod("param");
genericMethod("param"); // OK ("param"을 통해 타입 추론이 되므로 <String>은 생략 가능)
제너릭 메소드 사용시 고려사항
- 제네릭 타입 간에는 상하위 관계 없음
이는 제너릭이 불공변이기에 제너릭 클래스에서건 제너릭 메소드에서건 마찬가지입니다. - 제네릭 클래스 내부에서도 정의 가능
제너릭 메소드는 일반 클래스 내부에서 뿐만 아니라 제너릭 클래스 내부에서도 있을 수 있습니다. 다만 이 경우, 클래스와 메소드의 제너릭 타입 각각은 전혀 상관없는 별개입니다. 같은 타입이름 <T>를 사용하였다 하더라도 그러합니다. 관련된 예시코드가 있습니다.
class Box<T> {
public T getMaterial(T material){ ... } // 메소드에 제너릭이 따로 없다면, T는 클래스의 제너릭 타입 T이다.
public <T> T getID(T id){ ... } // 메소드에 제너릭이 있다면, 제너릭 메소드 타입 T는 클래스의 제너릭 타입 T와 다르다.
// 즉, public <S> S getID(S id){ ... }의 형태와 같다
}
결론적으로 클래스의 제네릭 타입은 전역 변수처럼 쓰이고 메소드의 제네릭 타입은 지역 변수처럼 해당 메소드 안에서만 사용된다고 보면 됩니다.
3) static이 가능
static변수와 달리 static메소드에는 제너릭을 사용할 수 있습니다. 제너릭 메소드의 제너릭 타입은 지역변수처럼 사용되기 때문에, 프로그램이 실행되어 static메소드가 메모리에 올라갈 때 타입 지정 없이 메소드의 틀만 공유될 수 있습니다. 이후에 메소드 호출 시 타입을 지정하면 됩니다.
public class Box<T> {
public void method(T param) { ... }; // OK 클래스의 T를 받아옴 (like 전역변수)
public static void sort(T param) { ... }; // error 클래스의 T를 가져오지 못함 (static이 먼저 로드)
public static <T> void genericMethod(T param) { ... }; // OK 클래스의 T와 별개 (like 지역변수)
public static <S> T genericMethod2(S param){ ... } // error 리턴타입 T는 클래스의 T, static은 가져오지 못 함
}
와일드카드
제너릭의 타입 변수는 불공변 때문에 단 하나의 타입만 허용합니다. 이로 인해 다음과 같은 상황이 발생할 수 있습니다.
// 특정 타입이 아닌 여러가지 타입을 담고자 모든 객체의 상위인 Object를 타입으로 지정하였으나..
public void printObjectList(List<Object> list) { ... }
List<Integer> list = Arrays.asList(1, 2, 3);
printObjectList(list); // error List<Interger>는 List<Object>의 하위타입이 아닌 별개의 타입이므로 컴파일 에러가 발생
이러한 문제점을 해결하기 위하여 와일드카드가 도입되었습니다. 와일드카드는 기호 '?'를 사용하며, 모든 타입을 대신할 수 있습니다.
public void printList(List<?> list) { ... }
List<Integer> list1 = Arrays.asList(1, 2, 3);
printList(list1); // OK
List<String> list2 = Arrays.asList("a", "b", "c");
printList(list2); // OK
와일드카드와 함께 extends와 super 키워드를 사용하여 받을 수 있는 타입에 제약을 줄 수도 있습니다.
- <? extends T>
Upper Bounded Wildcard. T와 T의 하위 타입만 받을 수 있습니다.- <? super T>
Lower Bounded Wildcard. T와 T의 상위 타입만 받을 수 있습니다.
class Steal { ... }
class Paper extends Material { ... }
class Box<T extends Material> { // <? extends Material>의 ?에다 T라는 이름 붙여놓은 것
public T material;
Box(T material){ this.material = material; }
}
new Box<Steal>(new Steal()); // error Steal은 Material의 자식이 아님
new Box<Paper>(new Paper()); // OK
TypeScript에서 제너릭 특이점
java와 달리 typeScript만의 제너릭 특징이 있다면 이것은 대부분 type을 다루는 방식에 차이가 있기 때문에 그렇습니다.
java와의 가장 큰 차이점 몇가지를 꼽아 보면 다음과 같습니다.
- 함수를 하나의 타입 취급
함수 자체를 어떻게 타입으로 표현하느냐를 살펴보려면, 우선 함수 내의 매개변수와 반환값을 어떻게 타입으로 표기하느냐부터 알아보아야하겠습니다.
// 기명함수
function fn(x: number, y: number): number { return x + y }
// 익명함수
const fn: (x: number, y: number) => number = function (x: number, y: number): number { return x + y }
// 화살표함수
const fn: (x: number, y: number) => number = (x, y) => x + y
함수는 (매개변수명: 매개변수타입) => 반환값타입
으로 표현될 수 있음을 알 수 있습니다.
이를 바탕으로하여 함수타입을 인자로 받는 콜백함수 표기법을 살펴보도록 하겠습니다.
// 콜백함수
function hello(fn: (greetMsg: string) => void) {
fn("Hello");
}
// hello는 함수fn을 인자로 받아 그 함수fn에 "Hello" 값을 전달해 실행하는 함수입니다.
// 이때, 함수 fn은 string타입을 인자로 받아 void를 반환하는 함수입니다.
hello((s: string): void => { console.log(s); });
// (s: string): void => { console.log(s); }가 hello에 전달된 함수 fn이 되며,
// fn은 string타입을 받아 void를 반환한다는 조건을 충족하여야 합니다.
// hello는 함수fn에 "Hello" 값을 전달해 실행하므로 콘솔에 Hello가 찍혀나오게 됩니다.
위 예시에서 보듯, 인자로 받는 함수fn은 그 매개타입과 반환타입이 고정되게 됩니다. 어느 함수든 받을 수 있는 와일드카드(any)와 같은 함수를 사용하고 싶다면 (...args: any[]) => any
과 같이 표현하면 됩니다.
- 클래스 보다는 인터페이스 혹은 타입앨리어스를 이용하여 새로운 타입을 정의
// 제네릭인터페이스
interface Person<T> {
name: string;
age: number;
info: T; // 제네릭 타입
}
const p1: Person<string> = {
name: 'john',
age: 25,
info: 'healthy' // 제네릭 타입에의해 지정됨
};
// 타입앨리어스(type alias)
type Box<T> = T[];
const numberBox: Box<number> = [1, 2, 3, 4, 5];
const stringBox: Box<string> = ['1', '2', '3', '4', '5'];
- 제너릭 메소드의 형태
java에서와 달리, <>를 함수명 앞에다 쓰면 '타입 단언'이 되어버리므로 함수명 뒤에다가 <>를 적어 제너릭을 나타냅니다.
// function 메소드명<제네릭타입>(매개변수명: 매개변수타입): 반환타입 { ... }
function toArray<T>(a: T, b: T): T[] { ... }
const toArray = <T>(a: T, b: T): T[] => { ... } // 화살표 함수로 제네릭을 표현할 경우
toArray<string>('1', '2');
toArray(1, 2); // '1', '2'을 통해 타입 추론이 되므로 <String>은 생략 가능
-
여러개의 타입을 허용하는 유니온(|)
유니온을 사용하면 ?나 extends 없이도 원하는 타입들만 허용하게 할 수 있습니다. 위에서 예시로 들었던 toArray함수에 적용시켜보면,toArray<string | number>(1, '2');
와 같이 사용할 수 있습니다. -
여러 키워드를 통한 타입가공
typescript에는 타입을 가공하기 위한 다양한 키워드들이 있습니다. 예를들어 infer를 사용하면 타입에 조건을 추가할 수 있고, keyOf/typeOf 연산자를 이용하면 필요한 타입만을 가져올 수 있습니다. TS 유틸리티 타입(Particial, Required 등)을 활용할 수도 있습니다. 이를 활용한 다양한 타입이 제너릭으로 사용될 수 있을 것입니다.
마무리
대략적으로나마 제너릭에 대해 알아보았으니, 처음으로 돌아가 해석을 목표로 했던 코드를 살펴보겠습니다.
public static <T extends Comparable<? super T>> List<T> sorted(List<T> list) { ... }
이제는 이것이 제너릭메소드이며, <T extends Comparable<? super T>>
가 제너릭타입, List<T>
가 리턴타입, 매개변수타입 또한 List<T>
임을 알 수 있습니다. 즉, sorted() 메소드는 타입 T를 요소로 하는 List를 매개변수로 받는데 이 T는 자신 또는 조상클래스에서 Comparable을 구현하고 있어야 한다고 해석 할 수 있습니다.
typeScript코드도 살펴보도록하겠습니다.
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);
이것 또한 제너릭 메소드이며, <S>
가 제너릭타입, S 또는 (() => S)
가 매개변수타입, 리턴타입은 S와 Dispatch<SetStateAction<S>>가 담긴 배열
이라는 것을 알 수 있습니다. useState()는 타입 S 혹은 S를 리턴하는 함수를 매개변수로 입력받습니다. 반환타입인 Dispatch는 제너릭타입의 값을 받아 실행(void를 반환하면 보통 실행로직)시키는 함수이며, 여기서는 SetStateAction라는 또다른 타입이 Dispatch의 제너릭으로 입력되었습니다.