9 분 소요

제네릭이란

제네릭이란 함수나 인터페이스, 타입 별칭, 클래스 등을 다양한 타입과 함께 동작하도록 만들어주는 아주아주 놀랍고 특별한 기능이다.

제네릭의 필요성

다음과 같이 다양한 타입의 매개변수를 받고 해당 매개변수를 그대로 반환하는 함수가 하나 필요하다고 가정해보자.

function func(value: any) {
  return value;
}

let num = func(10);
// any 타입

let str = func("string");
// any 타입

우선 다양한 타입의 매개변수를 제공받아야 하기 때문에 value의 타입을 any 타입으로 설정한다. 물론 unknown으로 정의해도 괜찮다.

이 함수는 인수로 전달한 값을 그냥 그대로 반환하는 단순한 함수이기 때문에 위 코드에서 num 변수는 값 10이 저장되고, str 변수는 문자열 "string"이 저장된다. 하지만 그럼에도 불구하고 numstr변수는 any 타입이 된다. func 함수의 반환값 타입이 return value;를 기준으로 추론되었기 때문이다.

이는 다음과 같은 상황에 문제가 발생한다.

function func(value: any) {
  return value;
}

let num = func(10);
let str = func("string");

num.toUpperCase();

num에는 아주 분명하게 number 타입의 값 10이 저장되어 있지만 any 타입으로 추론되어 있기 때문에 toUpperCase와 같은 string 타입 메소드를 실행해도 타입스크립트가 오류를 감지하지 못한다. 따라서 이 코드대로 구현할 경우 무사히 컴파일되고 이후 런타임 오류를 발생시키는 아주 위험한 상태가 된다.

그렇다면 만약 any 타입이 아닌 unknown 타입으로 정의한다면 어떻게 될까.

function func(value: unknown) {
  return value;
}

let num = func(10);
// unknown 타입

let str = func("string");
// unknown 타입

num.toUpperCase(); // ❌
num.toFixed(); // ❌

func는 매개변수의 타입이 unknown이기 때문에 반환값도 unknown으로 추론된다. 이럴 경우 10의 값을 갖고있는 num변수에 toUpperCase 같은 다른 타입의 메소드를 실행하는 경우는 방지할 수 있다.

하지만 또 다른 문제가 발생한다. toFixed 같은 올바른 타입의 메소드 호출마저 오류로 판단하게 되는 것이다. 따라서 num 변수에 10이 저장될 것이 아주 분명함에도 불구하고 다음과 같이 타입 좁히기를 사용해야하는 비효율적인 상황이 생긴다.

function func(value: unknown) {
  return value;
}

let num = func(10);
// unknown 타입

let str = func("string");
// unknown 타입

if (typeof num === "number") {
  num.toFixed();
}

사실 우리가 원하는 것은 꽤나 간단하다. 그냥 어떠한 함수에 인수로 number 타입의 값을 전달하면 반환값의 타입도 number가 되고, 인수에 string 타입의 값을 전달하면 반환값의 타입도 string이 되는 적응형 함수를 만들고 싶은 것 뿐이다. 하지만 지금까지 만든 건 여러 타입을 받을 수는 있지만 반환값은 그렇게 뱉지 못하는 함수들 뿐이었다.

이럴 때 사용하는 것이 제네릭이다. func 함수를 제네릭 함수로 만들면 이 문제를 간단히 해결할 수 있다.


제네릭 함수

제네릭 함수는 모든 타입의 값을 다 적용할 수 있는 범용적인 함수이다. 다음과 같이 제네릭 함수를 선언할 수 있다.

function func<T>(value: T): T {
  return value;
}

let num = func(10); // number 타입

함수 이름 뒤에 꺽쇠(<>)를 열고 타입을 담는 변수인 타입 변수 T를 선언한다. 그리고 매개변수와 반환값의 타입을 이 타입변수 T로 설정한다.

T에 어떤 타입이 할당될 지는 함수가 호출될 때 결정된다. func(10) 처럼 number 타입의 값을 인수로 전달할 경우 매개변수 value에 number 타입의 값이 저장되면서 T가 number 타입으로 추론된다. 이렇게 T가 number 타입으로 추론됨에 따라 반환값은 T, 즉 number 타입이 된다.

또한 타입 변수 T(꼭 T가 아니어도 됨)에 할당할 타입을 직접 명시하는 방법도 가능하다.

function func<T>(value: T): T {
  return value;
}

let arr = func<[number, number, number]>([1, 2, 3]);

위 코드의 흐름은 다음과 같다.

  1. T[number, number, number] 튜플 타입이 할당된다.
  2. 매개변수 value와 반환값 타입이 모두 해당 타입이 된다.

만약 위 코드에서 타입 변수에 할당할 타입을 튜플 타입으로 설정하지 않았다면 T가 number[] 타입으로 추론되었을 것이다. ([1, 2, 3] 배열을 인수로 받았을 때 타입스크립트는 항상 일반적이고 좀 더 범용적인 타입으로 추론하기 때문이다.)

따라서 타입 변수에 할당하고 싶은 특정 타입이 존재한다면 함수 호출과 함께 꺽쇠를 열고 직접 타입을 명시해주는 것이 좋다. 그렇지 않은 대다수의 상황에서는 알아서 잘 추론되기 때문에 굳이 타입 변수를 설정하지 않아도 상관없다.

타입 변수 응용 사례

2개의 타입 변수

만약 2개의 타입 변수가 필요한 상황이라면 다음과 같이 2개의 타입 변수를 사용해도 무방하다.

function swap<T, U>(a: T, b: U) {
  return [b, a];
}
const [a, b] = swap("1", 2); // T: string, U: number

다양한 배열 타입

function returnFirstValue<T>(data: T[]) {
  return data[0];
}

let num = returnFirstValue([0, 1, 2]);
// number

let str = returnFirstValue([1, "hello", "name"]);
// number | string

매개변수 data의 타입을 T[]로 설정했기 때문에 배열이 아닌 값은 인수로 전달할 수 없으며, 배열을 인수로 전달할 경우 T는 배열의 요소 타입으로 할당된다. 그리고 T는 배열의 요소에 따라 최적의 공통 타입으로 추론된다.

배열의 첫 번째 요소 타입만

위 사례에서 만약 반환값의 타입을 배열의 첫 번째 요소의 타입이 되도록 하려면 다음과 같이 튜플 타입과 나머지 파라미터 (...)를 사용하면 된다.

function returnFirstValue<T>(data: [T, ...unknown[]]) {
  return data[0];
}

let str = returnFirstValue([1, "hello", "name"]);
// number

함수 매개변수의 타입을 정의할 때 튜플 타입을 이용해 첫 번째 요소의 타입을 T, 그리고 나머지 요소의 타입을 ...unknown[]으로 길이도 타입도 상관없도록 정의한다. 이럴 경우 배열의 첫 번째 값만 신경쓰고 이를 통해 반환값의 타입을 결정하게 된다.

타입 변수 제한

타입 변수를 제한한다는 것은 함수를 호출할 때 인수로 전달할 수 있는 값의 범위에 제한을 두는 것을 의미한다.

다음은 타입 변수를 적어도 length 프로퍼티를 갖는 객체 타입으로 제한한 예시이다.

function getLength<T extends { length: number }>(data: T) {
  return data.length;
}

getLength("123"); // ✅
getLength([1, 2, 3]); // ✅
getLength({ length: 1 }); // ✅
getLength(undefined); // ❌
getLength(null); // ❌

타입 변수를 제한할 때에는 확장(extends)를 이용한다.

위와 같이 T extends { length: number }라고 정의하면 T는 { length: number } 객체 타입의 서브타입이 된다. 즉, T는 무조건 number 타입의 프로퍼티 length를 가지고 있는 타입이 된다.


제네릭 인터페이스

제네릭은 인터페이스에도 적용할 수 있다. 다음과 같이 인터페이스에 타입 변수를 선언해 사용하면 된다.

interface KeyPair<K, V> {
  key: K;
  value: V;
}

키페어를 저장하는 객체의 타입을 제네릭 인터페이스로 정의한 모습이다.

다음과 같이 여러 변수의 타입으로 사용할 수 있다.

let keyPair: KeyPair<string, number> = {
  key: "key",
  value: 0,
};

let keyPair2: KeyPair<boolean, string[]> = {
  key: true,
  value: ["1"],
};

이때 주의해야 할 점은 변수의 타입을 제네릭 인터페이스를 사용해 정의할 때 반드시 꺽쇠와 함께 타입 변수에 할당할 타입을 명시해주어야 한다. 앞서 제네릭 함수 같은 경우에는 매개변수에 제공되는 값의 타입을 기준으로 타입 변수의 타입을 추론할 수 있었지만, 인터페이스는 마땅히 추론할 수 있는 값이 없기 때문이다.

인덱스 시그니처 + 제네릭 인터페이스

제네릭 인터페이스는 인덱스 시그니처와 함께 사용하면 기존보다 훨씬 더 유연한 객체 타입을 정의할 수 있다.

interface Map<V> {
  [key: string]: V;
}

let stringMap: Map<string> = {
  key: "value",
};

let booleanMap: Map<boolean> = {
  key: true,
};

한 개의 타입 변수 V를 갖는 제네릭 인터페이스를 정의했다. 이 인터페이스는 인덱스 시그니처로 key타입은 string, value의 타입은 V인 모든 객체 타입을 포함하는 타입이다.

활용 예시

우선 다음 코드를 살펴보자.

interface Student {
  type: "student";
  school: string;
}

interface Developer {
  type: "developer";
  skill: string;
}

interface User {
  name: string;
  profile: Student | Developer;
}

function goToSchool(user: User) {
  if (user.profile.type !== "student") {
    console.log("잘 못 오셨습니다");
    return;
  }

  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User = {
  name: "개발자홍길동",
  profile: {
    type: "developer",
    skill: "typescript",
  },
};

const studentUser: User = {
  name: "학생홍길동",
  profile: {
    type: "student",
    school: "가톨릭대학교",
  },
};

위 코드에선 학생을 의미하는 Student 타입과 개발자를 의미하는 Developer 타입을 정의했다. 두 타입은 모두 string literal 타입의 type 프로퍼티를 갖고 있고, 서로소 유니온 타입이다.

그리고 그 아래 학생일 수도 개발자일 수도 있는 User 타입을 정의했다. 특정 객체가 학생이라면 profile 프로퍼티에 Student 타입의 객체가, 그렇지 않다면 Developer 타입의 객체가 저장될 것이다.

그 아래 학생 유저만 이용할 수 있는 함수 goToSchool을 선언했다. 이 함수에서는 User 타입의 객체를 받아 타입을 좁혀 이 User가 학생일 때에만 “등교 완료”를 콘솔에 출력한다.

위 코드는 현재는 별 문제가 없지만 만약 학생만 할 수 있는 기능이 점점 많아지고 여러 함수가 다음과 같이 타입을 좁혀가며 사용하게 된다면 매번 함수를 선언할 때마다 조건문을 사용해야하는 불편함이 생길 것이다. 또한 이러한 타입 좁히기 코드는 모두 중복 코드가 될 것이다.

이럴 때 제네릭 인터페이스를 활용하면 좋다. 다음과 같이 User 인터페이스를 제네릭 인터페이스로 바꾼다.

interface Student {
  type: "student";
  school: string;
}

interface Developer {
  type: "developer";
  skill: string;
}

interface User<T> {
  name: string;
  profile: T;
}

function goToSchool(user: User<Student>) {
  const school = user.profile.school;
  console.log(`${school}로 등교 완료`);
}

const developerUser: User<Developer> = {
  name: "개발자홍길동",
  profile: {
    type: "developer",
    skill: "typescript",
  },
};

const studentUser: User<Student> = {
  name: "학생홍길동",
  profile: {
    type: "student",
    school: "가톨릭대학교",
  },
};

이렇게 goToSchool 함수의 매개변수 타입을 User로 정의해 학생 유저만 이 함수의 인수로 전달하도록 제한할 수 있다. 결과적으로 함수 내부의 타입 좁히기를 없애 훨씬 간결한 코드를 작성할 수 있다.

제네릭 타입 별칭

인터페이스와 마찬가지로 타입 별칭에도 제네릭을 적용할 수 있다.

type Map2<V> = {
  [key: string]: V;
};

let stringMap2: Map2<string> = {
  key: "string",
};

제네릭 타입 별칭을 사용할 때에도 제네릭 인터페이스와 마찬가지로 타입으로 정의될 때 반드시 타입 변수에 설정할 타입을 명시해 주어야 한다.


제네릭 클래스

클래스에 제네릭을 사용하면 위와 마찬가지로 중복 코드를 줄일 수 있는 유연한 클래스를 만들 수 있다.

우선 제네릭이 아닌 간단한 number 타입의 리스트를 생성하는 클래스를 하나 만들어보자.

class NumberList {
  constructor(private list: number[]) {}

  push(data: number) {
    this.list.push(data);
  }

  pop() {
    return this.list.pop();
  }

  print() {
    console.log(this.list);
  }
}

const numberList = new NumberList([1, 2, 3]);

뭔가 코드를 보고 살짝 감이 왔을 수도 있다. 만약… String 타입을 대상으로 리스트를 만들고 push, pop, print를 하고싶다면, 똑같이 타입만 number에서 string으로 바뀐 StringList를 다음과 같이 만들어줘야 할 것이다.

class NumberList {
  constructor(private list: number[]) {}
	(...)
}

class StringList {
  constructor(private list: string[]) {}

	push(data: string) {
    this.list.push(data);
  }

  pop() {
    return this.list.pop();
  }

  print() {
    console.log(this.list);
  }
}

const numberList = new NumberList([1, 2, 3]);
const stringList = new StringList(["1", "2", "3"]);

매우 비효율적이다. 다른 타입의 리스트를 만들고 싶거나, 이러한 모든 리스트 클래스에 새로운 메소드라도 추가되거나 로직이 수정이라도 되는 날에는 끔찍한 막노동이 시작될 것이다. 이럴 때 다음과 같이 제네릭 클래스를 사용해 여러 타입의 리스트를 생성할 수 있는 범용적 클래스를 정의할 수 있다.

class List<T> {
  constructor(private list: T[]) {}

  push(data: T) {
    this.list.push(data);
  }

  pop() {
    return this.list.pop();
  }

  print() {
    console.log(this.list);
  }
}

const numberList = new List([1, 2, 3]);
const stringList = new List(["1", "2"]);

제네릭 클래스는 생성자를 통해 타입 변수 T의 타입을 추론할 수 있기 때문에 생성자에 인수로 전달하는 값이 존재할 경우 제네릭 인터페이스나 별칭처럼 타입 변수에 타입을 할당하는 과정을 생략해도 된다. 만약 그래도 타입 변수의 타입을 직접 설정하고 싶다면 물론 가능하다. 다음과 같이 타입 변수의 타입을 설정할 수 있다.

class List<T> {
  constructor(private list: T[]) {}

  (...)
}

const numberList = new List<number>([1, 2, 3]);
const stringList = new List<string>(["1", "2"]);

Promise로 보는 제네릭 클래스 예시

타입스크립트에서 자주 사용하는 타입인 Promise는 제네릭 클래스로 구현되어 있다. 따라서 새로운 Promise를 생성할 때 다음과 같이 타입 변수에 할당할 타입을 직접 설정해주면 해당 타입이 바로 resolve 결과값의 타입이 된다.

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    // 결과값 : 20
    resolve(20);
  }, 3000);
});

promise.then((response) => {
  // response는 number 타입
  console.log(response);
});

promise.catch((error) => {
  if (typeof error === "string") {
    console.log(error);
  }
});

하지만 아쉽게도 reject 함수에 인수로 전달하는 값, 즉 실패의 결과값 타입은 정의할 수 없다. 실패의 결과값은 unknown 타입으로 고정되어 있기 때문에 catch 메소드에서 사용하려면 타입 좁히기를 사용해야 한다.

만약 어떤 함수가 Promise 객체를 반환한다면 함수의 반환값 타입을 위해 다음과 같이 작성할 수 있다.

function fetchPost() {
  return new Promise<Post>((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "제목",
        content: "본문",
      });
    }, 3000);
  });
}

이렇게 작성하면 반환값 타입을 통해 함수의 타입이 자동으로 추론된다. 만약 직관적으로 명시하고 싶다면 물론 가능하다.

function fetchPost(): Promise<Post> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        id: 1,
        title: "게시글 제목",
        content: "게시글 본문",
      });
    }, 3000);
  });
}

출처

위 포스트는 이정환 님의 인프런: 한 입 크기로 잘라먹는 타입스크립트(TypeScript)를 수강한 뒤 복습 차원에서 저의 생각을 정리 및 추가하여 업로드했음을 알립니다.

댓글남기기