[Typescript] Typescript 의 Generic Type

Generic type 이란?

Generic 은 정적 타입 언어를 사용하던 사람에게는 매우 친숙한 단어이다. 여기에서 Generic 은 함수나 클래스를 선언할 때 타입을 고정하지 않고, 사용 때 명시해 타입을 유연하게 사용할 수 있게 해준다. 예를 들어 선언과 동시에 타입을 선언한다고 하면 다음과 같은 것이다.

function sum (str1: string, str2: string): string {
    return str1 + str2;
}

위의 코드를 보면 str1이라는 string 타입의 인자와 str2 라는 string 타입의 인자를 받아서 합쳐준 후, string 으로 반환해주는 sum 이라는 함수가 있다. 이 것만 봤을 때는 크게 문제는 없어 보인다. 그런데 만약 number 형의 인자를 받아서 처리를 해야 하는 경우가 생겼다면 어떻게 변화가 되어야 할까?

function sumString (str1: string, str2: string): string {
    return str1 + str2;
}
function sumNumber (num1: number, num2: number): number {
    return num1 + num2;
}

아마도 위처럼 처리되는 것에 따라 함수 자체를 나눠주거나 타입을 아래 처럼 any 로 처리를 해야할 수 있다.

function sum (str1: any, str2: any): any {
    return str1 + str2;
}

하지만 이렇게 되는 경우의 문제점이 있다. 위처럼 단순한 코드에서는 타입이 무엇이든 신경을 안 쓸순 있다. (물론 사실 이렇게 쓴다면 타입스크립트 보단 그냥 자바스크립트만 쓰는 게 더 나은 것 같다) 하지만 만약 그 규모가 커진다고 한다면 정확한 타입 추정이 가능해야 한다. 또한 그 타입에 따라 사용할 수 있는 고유의 메소드들 역시 다를 수 있는데, 타입이 명확하지 않으면 일단 런타임이든 아니면 그 이전이든 에러를 뿜어낼 것이다. 예를 들어 sum 이라는 함수에서 return 해준 결과가 string 이라고 생각하고 그 string 의 특정 문자열을 구하려고 한다면 런타임 에러가 뜨는 것을 볼 수 있다.

function sum (str1: any, str2: any): any {
    return str1 + str2;
}
sum(1, 23).indexOf('2');    // Error!

혹은 여러개의 함수가 유기적으로 얽혀있는 모듈 중에 하나의 기능이 예상치 못한 결과를 반환해주어 예상치 못한 결과를 가져올 수 있을 것이다.

function sum (str1: any, str2: any): any {
    return str1 + str2;
}
sum(1, 23).length;  // undefined

물론 위와 같이 예상치 못한 결과, 에러 등도 그 이유겠지만, 무엇보다 타입스크립트에서 지원해주는 타입 추론에 대한 안정성이 떨어진다는 것이 제일 큰 단점이다. 이때 generic 타입을 사용하게 되면 타입 추론의 안정성과 위 예제와 같은 유연성을 동시에 가져갈 수 있다.

Generic type 의 장점

일단 Generic 타입의 장점으로는 실행 시 타입을 선언할 수 있다는 점에서 오는 재사용성이 있다. 위처럼 string, number 와 같이 명시적으로 써줄 필요 없이 실행하는 곳에서 원하는 타입을 선언해주면 된다. 또한 그렇게 타입을 선언함으로써 타입의 안정성을 보장 받을 수 있다.

// 여기에서 캐스팅 코드에 대해서는 밑에서 다시 설명한다. 
function sum<T> (str1: T, str2: T): T {
    return String(str1) + String(str2);
}

위와 같이 선언함과 동시에 타입을 선언해주는 것이 아니라 유연하게 사용할 수 있게 실행과 동시에 타입을 선언할 수 있도록 했다. 위의 코드는 단순하다. sum 이라는 함수는 무엇인지 모를 타입 T 를 받아 T라는 타입을 가진 인자를 2개 받은 후, 더해서 T 라는 타입으로 반환시켜준다. 여기에서 T 는 타입 매개변수(type parameter) 또는 제네릭 타입 변수(generic type variables) 라 부르며 타입이 정해져 있지 않은 가상의 타입으로서 임의의 알바벳 혹은 단어를 선언해도 된다.

위와 같은 함수 선언을 제네릭 함수 선언이라고 표현하다.

function sum<T> (str1: T, str2: T) {
    return String(str1) + String(str2);
}

sum<string>('abc', 'efg');

코드를 살펴보면 sum 이라는 함수는 무언지 모를 익명의 타입 T 를 받아 함수의 인자 str1str2 에게 전달을 해준다. 그 후, 함수를 실행할 때, 익명타입 T 에 대해 string 타입을 넘겨준다.

function sum<T> (str1: T, str2: T) {
    return String(str1) + String(str2);
}

sum<string>(123, 'efg');

만약 인자로 string 이 아닌 number 타입을 넘기려고 시도하는 순간 Argument type 123 is no assignableto parameter type string 이라는 문구를 볼 수 있다. 이렇게 타입의 안정성과 재사용할 수 있는 코드를 만들 수 있다. 하지만 아직도 함수 안에서는 타입 캐스팅이 이뤄져 불필요한 성능을 낭비하고 있습니다.

그 부분을 해결하기 위해 오버로드 함수(overload function) 을 이용하며 된다.

function sum<T>(str1: T, str2: T): T;
function sum(str1: any, str2: any) {
    return str1 + str2;
}

sum<string>('123', 'efg');

위와 같이 함수명은 overload 함으로써, 기존의 함수는 익명 타입 T 를 받아 인자들도 동일하게 T 타입을 받는다. 그리고 실제 작성된 함수에서는 각 인자는 any 타입의 인자를 받음으로써, 어떠한 인자가 들어와도 허용할 수 있도록 한다. 여기에서 어떠한 인자가 들어와도 라고 표현했지만, 실제 타입이 T 와 같지 않으면 위와 동일하게 Argument type 123 is no assignableto parameter type string 에러가 난다.

function sum<T>(str1: T, str2: T): T;
function sum(str1: any, str2: any) {
    return str1 + str2;
}

sum<string>(123, 'efg');

반대로 만약 파라미터의 갯수가 일치하지 않거나 타입이 일치하지 않으면 overload signature is not compatible with function implementation 라는 에러가 나는 것은 볼 수 있다.

function sum<T>(str1: T, str2: T): T;
function sum(str1: any, str2: any, str3: string) {
    return str1 + str2;
}

sum<string>('123', 'efg');

이와 같이 오버로드 함수를 이용하게 되면 좀 더 유연한 제네릭 타입을 사용할 수 있다.

유니언 타입으로 확장

여기에서 추가적으로 숫자의 포멧을 만들어 주는 함수를 구현한다고 가정해본다면 다음과 같은 것이다. 실제 사용하기 위해서는 타입 캐스팅이 가능한지 여부 등등 체크를 해야겠지만, 간단한 샘플 코드임으로 여기서는 그러한 코드는 추가하지 않는다. 단순하게 number 형이거나 혹은 number 형으로 타입 캐스팅이 가능한 string 이라고 가정한다.

function generateNumberFormat<T>(num1: T, num2: T): T;
function generateNumberFormat(num1: any, num2: any) {
    const setRegx = /\B(?=(\d{3})+(?!\d))/g;
    const result = Number(num1) + Number(num2);
    
    return result.toString().replace(setRegx, ',')
}

// number 형으로 타입 캐스팅이 가능한 string
generateNumberFormat<string>('1234', '4567');
// number 형
generateNumberFormat<number>(1235, 4567);

그런데 단순한 이 함수에서도 고려해야할 것은 number 형과 타입캐스팅 가능한 string 이 들어올 경우가 있을 것이다. 그러한 경우는 아래와 같이 유니언 타입으로 확장을 해주면 좀더 확장해서 사용할 수 있지 않을까 싶다.

function generateNumberFormat<T extends string | number>(num1: T, num2: T): T;
function generateNumberFormat(num1: any, num2: any) {
    const setRegx = /\B(?=(\d{3})+(?!\d))/g;
    const result = Number(num1) + Number(num2);
    
    return result.toString().replace(setRegx, ',')
}

// number 형으로 타입 캐스팅이 가능한 string
generateNumberFormat<string|number>('1234', 4567);

써놓고 보니 사실 예제가 그렇게 좋은 예제는 아닌 듯 하나, 이후 class의 generic 타입을 다루며 추가적으로 다루는 게 좋을 듯 싶다.

Comments