Typescript - Generic에 대해서(1)

April 23, 2019

이글은 Medium의 "Typescript Generics Explained" 을 번역한 글입니다.

Generic: 추상 형태의 type

Typescript에서 제네릭을 구현하게 되면, 다양한 유형의 type들을 요소에 전달하여 코드의 추상화 및 재사용을 용이하게 할 수 있습니다. 제네릭은 Typescript 내의 함수, 인터페이스 및 클래스에 적용이 가능합니다.

이 포스팅에서는 제네릭이 무엇인지, 그리고 각 항목에 어떻게 사용될 수 있는지를 설명하고, 코드를 추상화하는 다양한 use case를 확인합니다.

The Hello World of Generics

제네릭의 개념을 간단하게 설명하기 위해서 다음의 함수를 살펴보겠습니다.

function identity(arg: number): number {
    return arg;
}

identity() 함수는 단순히 하나의 number를 인수로 취해서 number 형태의 변수를 리턴하게끔 되어 있습니다. identity 함수의 목적은 전달한 number 형태의 arg 인자를 리턴하는 것입니다. function 내에서는 타입을 고정하고, 해당 타입에 대해서만 함수를 사용할 수 있도록 하는 것입니다. 이러한 함수는 확장성이 뛰어나지는 않으나, typescript에서 일반적으로 사용되는 형태입니다.

우리는 실제로 number 라는 type을 유동적으로 any 라는 타입으로 변경할 수 있으나, 그 과정에서 어떤 타입이 리턴되어야 하는지에 대한 정의를 할 수 없으며, 컴파일러 과정에서 이러한 코드로 인해 강타입의 장점을 사용할 수 없게 됩니다.

이 과정에서, 우리가 정말로 필요로 하는 것은 특정 유형에 대해서 작동하는 identity() 함수이며, 제네릭을 사용하면 이러한 형태를 수정할 수 있게 됩니다. 다음에 설명드리는 코드는 위의 코드와 동일한 함수이며, 이번에는 제네릭을 이용한 type 변수가 포함되어 있는 경우입니다.

function identity<T>(arg: T): T {
	return arg;
}

함수의 이름 뒤에 타입변수 <T> 가 포함되어 있습니다. T는 이제 identity 함수에 전달하려는 type 표시자이며, number type 대신 arg에 해당 type이 할당 됩니다. T 는 이제 type으로써 작동하게 됩니다.

참고: Type 변수는 type 매개 변수 또는 generic parameter라고도 합니다. 해당 포스팅에서는 Typescript 문서에 표기된대로 type 변수라는 용어를 사용하겠습니다.

T는 Type을 나타내며, 제네릭을 정의할 때 첫번째 type 변수의 이름으로 제네릭을 통해 사용됩니다. 그러나 실제로 T는 유효한 이름으로 대체될 수 있습니다. 뿐만 아니라, 우리는 한개의 type 변수에 국한되지 않고, 우리가 정의하고자 하는 어떠한 것들도 가져올 수 있습니다. T 다음에 U 변수를 소개하고 함수를 확장시켜 보겠습니다.

function identities<T, U> (arg1: T, arg2: U): T {
	return arg1;
}

이제 U라는 type 변수를 추가하여 두가지 제네릭 type을 지원하는 identities() 함수를 만들었습니다. 해당 함수는 두가지 제네릭 type을 가지고 있지만, 리턴되는 type은 T로 유지됩니다. 이제 함수가 두가지 유형을 채택하고, arg1 매개 변수와 동일한 type을 반환할 수 있습니다.

그러나 두가지 type의 객체 (T, U)를 반환하려면 어떻게 해야할까요? 이를 해결할 수 있는 방법은 여러가지가 있습니다. 이를 해결하기 위해서 Array(튜플) 형태를 사용할 수 있습니다.

function identities<T, U>(arg1: T, arg2: U): [T, U] {
	return [arg1, arg2];
}

이제 identities 함수는 T인자와 U인자로 구성된 튜플을 반환하게 됩니다. 그러나, 이를 typescript의 interface를 이용하여 코드를 좀더 가독성 있게 변경 할 수 있습니다.

Generic interface

generic을 이용하여 interface를 사용할 수 있습니다. identities() 와 함께 사용될 전형적인 interface를 작성해 보겠습니다.

interface Identities<V, W> {
	id1: V,
	id2: W
}

Identities라는 인터페이스에 VW를 type 변수로 사용하여 어떤 문자 (혹은 올바른 영숫자 이름의 조합)이 유효한 유형인지를 설명합니다. 사용자가 직접 호출하는 것에는 의미가 없으며, 그 외의 경우 generic interface의 용도로 사용됩니다.

우리는 이제 이 interface를 반환할 interface로 적용하여 반환하는 유형을 준수하여 작성할 수 있게 됩니다. 더 명확하게 하기 위해서 해당 인수와 유형을 console.log로 확인해 보겠습니다.

function identities<T, U> (arg1: T, arg2: U): Identities<T, U> {
	 console.log(arg1 + ": " + typeof (arg1));
   console.log(arg2 + ": " + typeof (arg2));
	
	let identities: Identities<T, U> = {
		id1: arg1,
		id2: arg2
	};

	return identities;
}

identities() 함수에서 수행하는 작업은 TU 형식을 함수 및 Identities 인터페이스로 전달하여 인수의 type과 관련한 리턴할 유형을 정의할 수 있게 만들었습니다.

참고: Typescript 프로젝트를 컴파일 하고 제네릭을 찾으면 아무것도 찾을 수 없습니다. 제네릭은 javascript내에서는 지원되지 않기 때문에 transpiler 상에서는 빌드된 코드에서 확인이 어렵습니다. 제네릭은 코드 형식에 안전한 추상화를 보장할 수 있도록 하는 개발 안정망 역할만 할 뿐입니다.

Generic class

또한, 우리는 class의 속성 및 메소드의 관점에서 class를 만들 수 있습니다. Generic class는 지정된 데이터 형식이 전체 클래스에 일관적으로 사용될 수 있도록 해줍니다. 예를 들면, 다음과 같은 규칙이 React Typescript 프로젝트에서 사용되는 것을 보았을 것입니다.

type Props = {
	className?: string
}

type State = {
	submitted?: bool
}

class MyComponent extends React.Component<Props, State> {

}

우리는 이 컴포넌트에서 제네릭을 사용하여 React component내의 props 및 state의 type을 안전하게 확인 할 수 있습니다.

Class내의 제네릭 구문은 우리가 지금까지 확인해온 것과 유사합니다. 프로그래머의 profile에 대해 다양한 유형을 처리할 수 있도록 만든 Programmer 클래스를 확인해 보겠습니다.

class Programmer<T> {
	private languageName: string;
	private languageInfo: T;

	constructor(lang: string) {
		this.languageName = lang;
	}
}

let programmer1 = new Programmer<Language.Typescript>("Typescript");
let programmer2 = new Programmer<Language.Rust>("Rust");

Programmer 클래스의 경우, T는 프로그래밍 언어의 메타 데이터 type 변수이므로, languageInfo 프로퍼티에 다양한 언어의 유형을 할당 할 수 있습니다. 모든 언어는 필연적으로 다른 메타 데이터를 가지고 있기 때문에, 각자 다른 유형의 메타정보가 필요합니다.

interface내 type arugment에 대한 참고

위의 예제에서 다음 패턴을 사용하여 새로운 Programmer 클래스를 인스턴스화 할때, 특정 언어 유형에 <>를 사용했습니다.

let myObj = new className<Type>("args");

클래스를 인스턴스화 하기 위해서, 컴파일러가 할당할 언어 type을 추측하기 위해 할 수 있는 일은 많지 않습니다. 해당 type을 전달하는 것은 의무사항이나, 함수를 사용하게 되면 컴파일러에서 제네릭을 사용할 유형을 추측 가능하게 됩니다. 이는 개발자가 제네릭을 사용하는 가장 일반적인 방법이기도 합니다.

명확하게 알기 위해서, identities() 함수를 다시 참조합니다. 이와 같이 함수를 호출하면 문자열과 숫자 유형을 TU에 각각 할당하게 됩니다.

let result = identities<string, number>("argument 1", 100);

그러나, 컴파일러가 자동으로 이러한 type을 선택하게끔 해서 명확한 코드를 만들도록 하는 것이 일반적입니다. <>를 생략하고, 다음 문장을 작성하면 됩니다.

let result = identities("argument 1". 100);

컴파일러는 인수 type을 선택하고, 개발자가 명시적으로 정의할 필요 없이 TU에 할당합니다.

주의사항: 인수가 입력되지 않은 일반 return type이 있는 경우 컴파일러에서 형식을 명시적으로 정의해야 합니다.

...