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)이란 타입을 일반화하는 것을 의미합니다.
    '타입의 일반화'란 클래스 내부에서 개별적으로 타입을 지정하는 것이 아니라, 외부에서 지정하게끔 일반화시켜 두는것을 뜻하며
    '외부에서 지정한다'는 사용자가 제너릭 클래스의 인스턴스를 생성할 때, 또는 제너릭 메소드를 호출할 때 타입을 정한다는 것을 의미합니다.
  • 장점
    1. 재사용성
      제네릭 타입은 여러 타입의 파라미터를 삽입해 객체를 생성할 수 있기 때문에 코드를 간결하게 하고 재사용성을 높입니다.
    2. 컴파일 시 타입 에러 발견
      컴파일 이후 런타임 단계에서 타입 문제가 발생될 가능성을 방지해줍니다.
    3. 형변환의 과정 생략
      제네릭 타입은 컴파일 시 컴파일러에 의해 자동으로 검사 되어 타입으로 변환되므로 불필요하게 코드에서 타입 캐스팅을 해줄 필요가 없습니다.
    4. 개발 시 생산성 향상
      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());

제너릭 클래스 사용시 고려사항

  1. 제네릭 타입 간에는 상하위의 관계 없음
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[]의 하위가 됩니다.

  1. 제네릭 타입의 배열 생성 불가
class Box<T> {
   T[] tArr1; // 선언은 OK
   T[] tArr2 = new T[3]; // 생성은 error
}

new 연산자는 컴파일 시점에서 메모리를 확보하는 역할을 하는데, 이는 타입 변수가 무엇인지를 알아야 가능합니다. 컴파일 시점에서 타입 T가 무엇인지 알 수 없기 때문에 제네릭으로 배열을 생성할 수 없게됩니다.
해결법으로는, 일단 Object 배열로 만든 후 'T[]'로 형변환하는 방법 등이 있습니다.

  1. 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>은 생략 가능)

제너릭 메소드 사용시 고려사항

  1. 제네릭 타입 간에는 상하위 관계 없음
    이는 제너릭이 불공변이기에 제너릭 클래스에서건 제너릭 메소드에서건 마찬가지입니다.
  2. 제네릭 클래스 내부에서도 정의 가능
    제너릭 메소드는 일반 클래스 내부에서 뿐만 아니라 제너릭 클래스 내부에서도 있을 수 있습니다. 다만 이 경우, 클래스와 메소드의 제너릭 타입 각각은 전혀 상관없는 별개입니다. 같은 타입이름 <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와의 가장 큰 차이점 몇가지를 꼽아 보면 다음과 같습니다.

  1. 함수를 하나의 타입 취급
    함수 자체를 어떻게 타입으로 표현하느냐를 살펴보려면, 우선 함수 내의 매개변수와 반환값을 어떻게 타입으로 표기하느냐부터 알아보아야하겠습니다.
// 기명함수
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 과 같이 표현하면 됩니다.

  1. 클래스 보다는 인터페이스 혹은 타입앨리어스를 이용하여 새로운 타입을 정의
// 제네릭인터페이스
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'];
  1. 제너릭 메소드의 형태
    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>은 생략 가능
  1. 여러개의 타입을 허용하는 유니온(|)
    유니온을 사용하면 ?나 extends 없이도 원하는 타입들만 허용하게 할 수 있습니다. 위에서 예시로 들었던 toArray함수에 적용시켜보면, toArray<string | number>(1, '2');와 같이 사용할 수 있습니다.

  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의 제너릭으로 입력되었습니다.



spookyj

Reference