Typescript - Generic에 대해서 (제네릭 유스케이스)(2)

April 23, 2019

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

언제 제네릭을 사용할까?

제네릭은 데이터를 type에 좀더 안전한 방식으로 할당하는 데에 큰 유연성을 제공하나, 이러한 추상화가 의미가 없는 경우, 즉 여러 유형을 사용할 수 있는 코드를 단순화 하거나 최소화 할때 사용해서는 안됩니다.

코드의 반복을 저장하기 위해서 여기저기서 코드베이스에 적절한 use case를 찾을 수 있을 것입니다. 일반적으로 제네릭 사용 여부를 결정할때는 두가지 기준을 충족할 수 있어야 합니다.

  1. 함수, 인터페이스 또는 클래스가 다양한 데이터 유형에서 작동이 필요할때 입니다.
  2. 함수, 인터페이스 또는 클래스가 여러 위치에서 해당 데이터 유형을 사용하는 경우입니다.

프로젝트를 진행하는 초기에는 제네릭 사용에 대한 충족 요건이 성립하지 않을 수도 있습니다. 그러나, 프로젝트가 점점 커지면서 고도화 혹은 구성 요소의 기능이 확장되는 경우가 많습니다. 이러한 경우 리팩토링 과정에서 제네릭을 도입한다면 다양한 데이터 유형을 충족시키기 위해 구성요소를 복제하는 것보다 더 깨끗한 코드를 만들 수 있는 대안이 될 수 있습니다.

우리는 이 두가지 기준이 충족되는 사용 사례에 대해서 더 살펴보겠습니다. 그렇게 하기 전에, Typescript내에서 제공하는 제네릭의 다른기능 에 대해 살펴보도록 하겠습니다.

제네릭 제약 (Generic Constraints)

때때로 우리는 각 type 변수에서 수용할 수 있는 type의 양을 제네릭의 제약사항이 하는 것과 정확히 일치시키려고 하는 경향이 있습니다. 우리는 몇가지 방법으로 제네릭의 제약 조건을 사용할 수 있습니다.

제약조건을 사용하여 type의 특성을 보장

때로는 제네릭 형식에서 특정 속성이 해당 형식에 정의가 되어있어야 합니다. 뿐만 아니라 컴파일러는 명시적으로 변수를 type 변수를 정의하지 않는 한 특정 속성이 존재한다는 것을 인식하지 못합니다.

좋은 예로는 .length 속성을 사용할 수 있다고 가정하는 문자열이나 배열을 사용하는 경우 입니다. identity() 함수를 다시 꺼내서, 인수의 길이를 기록해보겠습니다.

// 해당 구문은 에러를 반환할 것입니다.
function identitiy<T>(arg: T): T {
	console.log(arg.length);
	return arg;
}

이 구문에서 컴파일러는 실제로 T.length 속성을 가지고 있다는 것을 알지 못할 것입니다. 만약 우리가 type 변수에 필요한 속성이 있는 경우, 해당 속성을 인터페이스로 확장하는것으로 해결할 수 있습니다. 다음을 살펴보겠습니다.

interface Length {
	length: number;
}

function identitiy<T extends Length>(arg: T): T {
	// legnth 속성이 호출될 것입니다.
	console.log(arg.length);
	return arg;
}

T는 대괄호 안에 extends 키워드 뒤에 유형을 제한하여 사용합니다. 우리는 Length라는 인터페이스내에서 속성을 구현하는 모든 유형을 지원할 수 있다고 컴파일러에 명시하고 있습니다.

이제 컴파일러는 .length 를 지원하지 않는 유형으로 함수를 호출할 때 알려줄 수 있습니다. 뿐만 아니라, .length는 이제 해당 속성을 구현하는 type에서 인식되고 사용할 수 있습니다.

참고: 제약조건을 쉼표로 구분하여 여러 유형에서 확장 할수도 있습니다. ex) <T extends Length, Type2, Type3>

명시적인 배열 지원

이는 .length 속성 문제 해결에 대한 다른 방법으로써, type 변수를 다음과 같이 배열로 정의가 가능합니다.

// 배열의 타입으로 T를 선언함으로써 length를 인식할 수 있도록 합니다.
function identity<T>(arg: T[]): T[] {
	console.log(arg.length);
	return arg;
}

// 또는
function identity<T>(arg: Array<T>): Array<T> {
	console.log(arg.length);
	return arg;
}

위의 두가지 구현이 모두 작동하기 때문에, 컴파일러에서 arg와 함수의 리턴 type이 배열 형식임을 알릴 수 있습니다.

제약조건을 활용한 Object내의 key 확인

제약조건에 대한 유용한 사용법은 다른 구문을 사용하여 객체에 key가 존재하는지 확인하는 것입니다. (extends keyof 사용) 다음 예제에서는 함수에 전달되는 객체에 키가 존재하는지 여부를 확인합니다.

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
	return obj[key];
}

첫번째 인수는 값을 가져오는 객체이고, 두번째 인수는 해당 값의 키입니다. 리턴되는 type은 T[K]와의 관계를 설명하지만, 해당 함수는 정의된 반환 유형이 존재하지 않는 경우에도 작동합니다.

제네릭이 여기에서 하는 일은 객체의 키가 존재하는지 확인하여 런타임 중에 오류가 발생하지 않도록 방지하는 것입니다. 이것은 단순히 let value = obj[key];와 같은 것을 호출하는 것입니다.

여기서 getProperty 함수는 typescript_info 객체에서 속성을 가져오기 위해서 다음 예제에서처럼 간단하게 호출 가능합니다.

// 우리는 난이도에 대한 속성을 얻을 수 있습니다.
enum Difficulty {
   Easy,
   Intermediate,
   Hard
}
// 객체를 정의함으로써 속성을 얻을 수 있습니다.
let typescript_info = {
   name: "Typescript",
   superset_of: "Javascript",
   difficulty: Difficulty.Intermediate,
 }
// typescript_info에서 값을 검색하기 위해 getProperty를 호출합니다.
let superset_of: Difficulty = 
   getProperty(typescript_info, 'difficulty');

이 예제는 또한 getProperty로 얻은 Difficulty 프로퍼티의 type을 정의하기 위해서 enum을 사용하였습니다.

더 많은 Generic use case

API Services

API 서비스는 제네릭의 강력한 유즈케이스입니다. 한 클래스에서 API 핸들러를 래핑하고, 다양한 엔드포인트에서 결과를 가져올때, 적절한 type을 할당할 수 있게 됩니다.

예를 들어, 아래와 같이 getRecord() function을 사용한다고 가정합니다. APIService 클래스에서는 레코드의 유형을 인식하지 못하며, 쿼리할 데이터를 인식하지 못합니다. 이를 해결하기 위해서 getRecord() function에 제네릭 반환 type 및 쿼리 유형에 대한 type 표시자로 사용할 수 있게 됩니다.

class APIService extends API {
	public getRecord<T, U> (endpoint: string, params: T[]): U {}
	public getRecords<T, U> (endpoint: string, params: T[]): U[] {}
}

제네릭 메소드는 API 엔드포인트를 쿼리할때 사용되는 모든 유형의 매개변수를 허용할 수 있습니다. U는 그에 대한 return type 입니다.

배열 조작 (Manipulating Arrays)

제네릭을 사용하면 입력되는 배열을 조작 가능합니다. Department 클래스 및 add() 메소드에 대한 제네릭 변수를 사용하는 다음 예제를 보시면, emplyee 데이터베이스에서 항목을 추가 또는 제거할 수 있도록 되어 있습니다.

class Department<T> {
	private employees: Array<T> = new Array<T>();

	public add(employee: T): void {
		this.employees.push(employee);
	}
}

위 클래스는 Department 클래스 별로 employee를 관리할 수 있게 하여 각 Departments 클래스및 employee를 하나의 특정한 type으로 정의할 수 있도록 합니다.

또는 배열을 쉼표로 구분된 문자열로 변환하는 유틸리티 함수 구현이 가능합니다.

function arrayAsString<T>(names:T[]): string { 
   return names.join(", ");
}

클래스 확장

React 클래스에서 제네릭에 대한 제약이 props와 state에 대한 제한을 사용하는 것은 보았으나, Programmer 클래스에 정의된 예제를 보겠습니다.

class Programmer {
    fname: string;
    lname: string;

    constructor(first: string,  last: string) { 
        this.fname = first;
        this.lname = last;
    }
}

function logProgrammer<T extends Programmer>(prog: *T*): void {
    console.log(`${ prog.fname} ${prog.lname}` );
}
const programmer = new Programmer("Ross", "Bulat");
logProgrammer(programmer); // > Ross Bulat

해당 클래스는 완전성과 무결성을 동시에 해결 할 수 있습니다.

...