ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • TypeScript 제네릭 패턴과 활용법 - 클래스, 함수, 메서드 완벽 분석
    Javascript 2024. 5. 15. 21:31

     

     

     

     

    제네릭이란?

    Java에서는 제네릭스(Generics)라고 불리기도 합니다.

     

    언어마다 이 용어의 표현이 다르죠.

     

    TypeScript에서도 제네릭스나 제너릭스라고 불리기도 합니다.

     

    제네릭은 "총칭형(総称型)"이라고도 불리며, 임의의 클래스나 메서드에 대해 임의의 타입을 연결할 수 있는 구조입니다.

     

    자세한 내용은 여기여기에서 확인할 수 있습니다.

    언제 유용할까?

    배열에서 다루는 타입을 지정하고 싶을 때

    예를 들어 배열 변수를 정의할 때, 이 배열이 특정 타입을 다룬다는 것을 알 수 있으면 좋습니다.

     

    그리고 지정된 타입 이외의 값을 설정하려 할 때나, 지정된 타입에 없는 멤버를 설정하려 할 때 오류를 발생시켜주면 더욱 좋습니다.

     

    이러한 기능을 제공해주는 것이 제네릭입니다.

     

    예제 코드를 살펴봅시다.

    // Array에서 제네릭 타입을 지정할 때 사용하는 인터페이스
    interface IFoo {
      foo: string;
      bar: number;
      baz: boolean;
    }
    
    // IFoo 타입의 배열을 선언
    // 아래 코드는 const aFoo: IFoo[]와 동일
    const aFoo: Array<IFoo> = [];
    
    // 이는 설정하려는 값의 타입이 다르므로 오류가 발생합니다.
    // Argument of type 'number' is not assignable to parameter of type 'IFoo'.(2345)
    aFoo.push(1);
    
    // 이 경우에도 설정하려는 값의 타입이 다르므로 오류가 발생합니다.
    // Argument of type 'string' is not assignable to parameter of type 'IFoo'.(2345)
    aFoo.push("foo");
    
    // 변수를 선언할 때 지정한 IFoo 타입에 맞는 데이터이므로 괜찮습니다.
    aFoo.push({
      foo: "foo",
      bar: 100,
      baz: false,
    });

     

    이렇게 Array에서 다룰 타입을 지정할 수 있고, 다른 타입을 다루려고 하면 오류를 반환합니다.

     

    참고로 IFoo 타입의 배열을 선언할 때 아래와 같은 방법으로도 작성할 수 있습니다.

    // IFoo 타입의 배열 선언
    const aFoo: IFoo[] = [];

    함수나 메서드의 인수에 범용성을 부여하고 싶을 때

    TypeScript에서는 메서드 오버로드도 지원하지만, Java나 C# 등의 오버로드와는 다릅니다.

     

    다음 코드는 메서드 오버로드의 예시입니다.

    /**
     * 메서드 인수의 타입이 다른 오버로드 구현 예시
     * 오버로드를 공용 타입으로 구현하고 있습니다.
     */
    
    // 오버로드할 메서드 시그니처를 선언
    interface IFoo {
      // 메서드 시그니처 부분
      foo(arg: string);
      foo(arg: string[]);
    }
    
    class CFoo implements IFoo {
      // 인수를 공용 타입으로 지정하여 범용성을 확보
      foo(arg: string | string[]) {
        console.log(arg);
      }
    }
    
    const cFoo = new CFoo();
    cFoo.foo("foo");
    cFoo.foo(["foo", "bar"]);
    
    // Logs:
    // foo
    // ["foo", "bar"]

     

    이 코드에서는 메서드의 실제 구현이 인수를 공용 타입으로 정의하고 있습니다.

     

    다루고 싶은 타입이 늘어나면 그만큼 공용 타입에 추가해야 하므로 다소 복잡합니다.

     

    이 복잡함을 제네릭을 사용하여 해결할 수 있습니다. 아래는 제네릭을 사용한 구현입니다.

    /**
     * 메서드 인수의 타입이 다른 오버로드 구현 예시
     * 오버로드를 제네릭을 사용하여 구현함으로써 임의의 타입을 다룰 수 있습니다.
     */
    
    // 오버로드할 메서드 시그니처를 선언
    interface IFoo {
      // 메서드 시그니처 부분
      foo<T>(arg: T);
    }
    
    class CFoo implements IFoo {
      foo<T>(arg: T) {
        console.log(arg);
      }
    }
    
    const cFoo = new CFoo();
    cFoo.foo<string>("foo");
    cFoo.foo<number>(100);
    cFoo.foo<{}>({ foo: "foobar" });
    cFoo.foo<string[]>(["foo", "bar"]);
    
    // Logs:
    // foo
    // 100
    // { foo: "foobar" }
    // ["foo", "bar"]

     

    앞의 코드와 마찬가지로 이 구현 방법도 Java나 C#과 같은 오버로드와는 다소 차이가 있지만, 인수를 공용 타입으로 지정하는 것보다 인터페이스가 깔끔해졌습니다.

     

    실제 업무에서 사용할 때는 구체적인 클래스의 역할과 전달되는 인수의 타입에 주의해야 하지만, 제네릭을 사용할 수 있는 상황을 상상할 수 있게 되었습니다.

    보충 설명: 인수의 수가 다른 오버로드

    위의 코드에서는 인수의 수가 같은 경우에만 대응할 수 있습니다.

     

    인수의 수가 다른 오버로드에 대응하려면 제네릭을 사용하지 않는 구현 예시처럼 두 번째 인수 이후를 옵션으로 지정해야 합니다.

     

    제네릭을 사용한 경우에도 동일하게 구현할 수 있습니다.

    /**
     * 메서드 인수의 수가 다른 오버로드 구현 예시
     * 이 경우 제네릭을 사용한 구현에서도 두 번째 인수 이후를 옵션으로 지정해야 합니다.
     */
    
    // 오버로드할 메서드 시그니처를 선언
    interface IFoo {
      // 메서드 시그니처 부분
      foo(arg: string, arg2?: string, arg3?: string);
    }
    
    class CFoo implements IFoo {
      // 두 번째 인수 이후를 옵션으로 지정하여 범용성을 확보
      foo(arg: string, arg2?: string, arg3?: string) {
        console.log(arg);
        if (arg2) {
          console.log(`${arg}, ${arg2}`);
        }
        if (arg3) {
          console.log(`${arg2}, ${arg2}, ${arg3}`);
        }
      }
    }
    
    const cFoo = new CFoo();
    cFoo.foo("foo");
    cFoo.foo("foo", "bar");
    cFoo.foo("foo", "bar", "baz");

    제네릭을 클래스 타입으로

    제네릭은 클래스 타입으로도 사용할 수 있습니다.

    기본

    제네릭을 클래스 타입으로 사용하려면 다음과 같이 클래스 이름 뒤에 <...>를 지정합니다.

    /**
     * 임의의 타입을 다루는 클래스
     * 제네릭으로 지정된 타입은 해당 클래스에서 다루는 타입이 됩니다.
     */
    class GFoo<T> {
      value: T;
      getValue(): T {
        return this.value;
      }
    }
    
    // string을 지정하여 인스턴스를 생성
    // - 이를 통해 GFoo는 string을 다루는 인스턴스로 생성됨
    // - GFoo의 필드인 value에는 string만 설정 가능
    const gFoo = new GFoo<string>();
    gFoo.value = 'Foo';
    
    console.log(gFoo.getValue()); // Foo
    
    // value에 string 이외의 값을 설정하면 오류 발생
    //
    // Type 'boolean' is not assignable to type 'string'.(2322)
    // (property) GFoo<string>.value: string
    gFoo.value = false;
    
    // number도 마찬가지
    //
    // Type 'number' is not assignable to type 'string'.(2322)
    // (property) GFoo<string>.value: string
    gFoo.value = 1;

     

    클래스에 대해 제네릭을 사용하여 타입을 지정하면, 해당 클래스의 인스턴스가 다루는 타입을 임의로 설정할 수 있음을 알 수 있습니다.

     

    제네릭을 사용함으로써 인스턴스 생성 시 다룰 타입의 자유도가 높아졌을 뿐만 아니라 지정한 타입 이외의 값은 오류로 처리하는 엄격함도 얻을 수 있게 되었습니다.

    응용: 여러 개의 타입 인수 지정

    클래스에서 다룰 타입은 여러 개 지정할 수 있습니다.

     

    방법은 타입 인수를 지정할 때 ,로 구분하는 것입니다.

    /**
     * 타입 인수를 여러 개 지정할 수 있는 클래스
     */
    class GFoo<T, R> {
      tValue: T;
      rValue: R;
    
      getTValue(): T {
        return this.tValue;
      }
    
      getRValue(): R {
        return this.rValue;
      }
    }
    
    // 타입 인수를 2개 받는 클래스이므로 인스턴스 생성 시에도 타입을 여러 개 지정
    // 여기서는 string과 number를 다루는 인스턴스를 생성
    const gFoo = new GFoo<string, number>();
    gFoo.tValue = 'Foo';
    gFoo.rValue = 100;
    
    console.log({
      tValue: gFoo.getTValue(),
      rValue: gFoo.getRValue(),
    });
    
    // Logs:
    // { tValue: "Foo", rValue: 100 }

    타입 인수 기본값 설정

    타입 인수에는 기본값을 설정할 수도 있습니다.

     

    기본값을 지정하여 클래스를 정의한 경우:

    1. 타입을 지정하지 않고 인스턴스를 생성하기
    2. 타입을 지정하여 인스턴스를 생성하기

    두 가지 방법으로 인스턴스를 생성할 수 있습니다.

     

    전자의 경우 기본값 타입을 다루는 인스턴스가 생성되고, 후자의 경우 지정된 타입을 다루는 인스턴스가 생성됩니다.

     

    물론 기본값 타입을 지정하여 인스턴스를 생성할 수도 있습니다.

    /**
     * 타입 인수의 기본값을 지정하여 임의의 타입을 다루는 클래스
     */
    class GFoo<T = string> {
      tValue: T;
    
      getTValue(): T {
        return this.tValue;
      }
    }
    
    ///////////////////////////////////////////////////
    // 타입 인수를 지정하지 않고 인스턴스를 생성하기
    ///////////////////////////////////////////////////
    
    // 기본값으로 string을 지정했으므로 타입 인수 없이 인스턴스를 생성할 수 있음
    const gFooDefault = new GFoo();
    gFooDefault.tValue = 'Foo';
    
    console.log({
      tValue: gFooDefault.getTValue(),
    });
    
    // Logs:
    // { tValue: "Foo" }
    
    // 이 상태에서 tValue에 number를 지정하면 오류가 발생
    // Type 'number' is not assignable to type 'string'.(2322)
    // const gFoo: GFoo<string>
    gFooDefault.tValue = 100;
    
    ///////////////////////////////////////////////////
    // 타입 인수를 지정하여 인스턴스를 생성하기
    ///////////////////////////////////////////////////
    
    // 이번에는 타입 인수를 지정하여 인스턴스를 생성
    const gFooNumber = new GFoo<number>();
    gFooNumber.tValue = 100;
    
    console.log({
      tValue: gFooNumber.getTValue(),
    });
    
    // Logs:
    // { tValue: 100 }
    
    // 타입 인수에 number를 지정해 인스턴스를 생성했으므로 string을 설정하려 하면 오류가 발생
    // Type 'string' is not assignable to type 'number'.(2322)
    // (property) GFoo<number>.tValue: number
    gFooNumber.tValue = "Foo";
    
    ///////////////////////////////////////////////////
    // 타입 인수 기본값과 동일한 타입을 지정하여 인스턴스를 생성하기
    ///////////////////////////////////////////////////
    
    // 이번에는 타입 인수 기본값과 동일한 타입을 지정하여 인스턴스를 생성
    const gFooString = new GFoo<string>();
    gFooString.tValue = "Foo";
    
    console.log({
      tValue: gFooString.getTValue(),
    });
    
    // Logs:
    // { tValue: "Foo" }
    
    // 타입 인수에 string을 지정해 인스턴스를 생성했으므로 number를 설정하려 하면 오류가 발생
    // Type 'number' is not assignable to type 'string'.(2322)
    // (property) GFoo<string>.tValue: string
    gFooString.tValue = 100;

    타입 인수에 제약을 두기

    여기서 말하는 "제약"이란 특정 클래스를 상속한 클래스만 허용한다는 의미입니다.

     

    인스턴스를 생성할 때 제네릭 타입 인수에는 임의의 타입을 지정할 수 있지만, 다룰 타입을 제한하고 싶은 경우가 있습니다.

     

    그럴 때 유용한 방법을 소개합니다.

    class BaseFoo {
      value: string;
    }
    
    class ChildFoo extends BaseFoo {
      value2: number;
    }
    
    /**
     * 제네릭으로 다룰 타입을 특정 클래스 또는 그 하위 클래스에 제한
     */
    class GFoo<T extends BaseFoo> {
      tValue: T;
    
      getTValue(): T {
        return this.tValue;
      }
    }
    
    ////////////////////////////////////////////////////
    // 타입 인수에 하위 클래스를 지정하여 인스턴스를 생성하기
    ////////////////////////////////////////////////////
    const gFooChild = new GFoo<ChildFoo>();
    
    // 하위 클래스의 인스턴스를 설정
    gFooChild.tValue = new ChildFoo();
    console.log({
      value: gFooChild.getTValue(),
      instance: gFooChild.getTValue() instanceof ChildFoo,
    });
    
    // Logs:
    // { instance: true, value: ChildFoo }
    
    //--------------------------------------------------
    // 베이스 클래스의 인스턴스를 설정하면 오류 발생
    // (이번 예제에서는 베이스 클래스와 하위 클래스 간 필드에 차이가 있기 때문)
    // (베이스 클래스와 하위 클래스의 필드에 차이가 없으면 오류가 발생하지 않음)
    //
    // Property 'value2' is missing in type 'BaseFoo' but required in type 'ChildFoo'.(2741)
    gFooChild.tValue = new BaseFoo();
    
    ////////////////////////////////////////////////////
    // 타입 인수에 베이스 클래스를 지정해도 인스턴스 생성 가능
    ////////////////////////////////////////////////////
    // 베이스 클래스의 인스턴스를 설정
    const gFooBase = new GFoo<BaseFoo>();
    gFooBase.tValue = new BaseFoo();
    console.log({
      value: gFooBase.getTValue(),
      instance: gFooBase.getTValue() instanceof BaseFoo,
    });
    
    // Logs:
    // { instance: true, value: BaseFoo }
    
    //--------------------------------------------------
    // 하위 클래스의 인스턴스를 설정해도 문제 없음
    gFooBase.tValue = new ChildFoo();
    console.log({
      value: gFooBase.getTValue(),
      instance: gFooBase.getTValue() instanceof ChildFoo,
    });
    
    // Logs:
    // { instance: true, value: ChildFoo }
    
    // 단, 베이스 클래스에 없는 필드에는 접근할 수 없음
    // Property 'value2' does not exist on type 'BaseFoo'. Did you mean 'value'?(2551)
    gFooBase.getTValue().value2 = '#1';
    
    ////////////////////////////////////////////////////
    // BaseFoo를 상속하지 않은 타입은 지정할 수 없음
    ////////////////////////////////////////////////////
    
    // Type 'string' does not satisfy the constraint 'BaseFoo'.(2344)
    const gFooNotExtends = new GFoo<string>();

    제네릭 메서드

    다음과 같은 특징을 가지고 있습니다.

    • 인수, 반환값, 로컬 변수의 타입을 메서드를 호출할 때 결정할 수 있다.
    • 메서드 이름 바로 뒤에 <...> 형식으로 타입 인수를 선언한다.

    제네릭 메서드 타입

    // 타입 인수가 하나인 경우
    function gFuncFoo<T>(arg1: T, arg2: T): T {}
    
    // 타입 인수가 두 개인 경우
    function gFuncFoo<T, U>(arg1: T, arg2: U): U {}

     

     

    예제 코드

    /**
     * 임의의 타입을 인수로 받아 그 인수를 객체로 반환하는 함수
     */
    function gFuncFoo<T>(arg1: T, arg2: T): { arg1: T, arg2: T } {
      return {
        arg1,
        arg2
      };
    }
    console.log(gFuncFoo("foo", "bar"));
    // Logs:
    // { arg1: "foo", arg2: "bar" }
    
    /**
     * 임의의 타입을 여러 개 인수로 받아 그 인수를 튜플로 반환하는 함수
     */
    function gFuncFooMulti<T, U>(arg1: T, arg2: U): [T, U] {
      return [
        arg1,
        arg2
      ];
    }
    console.log(gFuncFooMulti("foo", 100));
    // Logs:
    // ["foo", 100]
    
    class Foo {
      /**
       * 임의의 타입 배열을 인수로 받아 그 배열을 결합해 반환하는 메서드
       */
      static gFuncConcat<T>(data: T[], data2: T[]): T[] {
        return data.concat(data2);
      }
    }
    console.log(Foo.gFuncConcat(["foo"], ["bar"]));
    // Logs:
    // ["foo", "bar"]

     

    이렇게 TypeScript에서 제네릭을 사용하여 다양한 타입의 코드를 쉽게 작성하고, 타입 안전성을 유지하며 재사용 가능한 코드를 작성할 수 있습니다.

Designed by Tistory.