Skip to main content

Command Palette

Search for a command to run...

TypeScript 배우기 - 7. 제네릭

Published
6 min read

본 글은 TypeScript 핸드북 Type Manipulation / Generics의 내용을 정리한 글입니다.

재사용 가능한 코드를 만들기 위해 여러 언어에서 제네릭을 사용합니다. 제네릭을 사용하면 단일 유형이 아닌 다양한 유형에서 작동할 수 있는 코드를 만들 수 있습니다. 이를 통해 코드를 좀 더 효율적으로 작성할 수 있게 됩니다.

기초

먼저 제네릭을 이해기 위해 TypeScript에서 identity()라는 인자 값을 그대로 반환하는 함수를 살펴볼 것입니다.

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

number 유형의 인자를 받아 그대로 반환합니다. 이 함수는 쉽고 어떻게 동작할지도 바로 알 수 있습니다. 하지만 이 함수는 number 유형만 처리가 가능합니다. 다른 유형도 처리 가능하도록 any를 써볼 수 도 있습니다.

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

이제 JavaScript의 기본 특성(any)으로 인해 모든 유형의 인자를 받아 반환하는 함수가 되었습니다. 하지만 TypeScript는 유형 추적이 장점이니 만큼 any 유형을 지양해야 합니다. 이를 제네릭으로 개선할 수 있습니다.

제네릭은 일반화의 개념으로 이해하면 됩니다.

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

이제 identity() 함수가 받는 유형과 반환하는 유형이 실제 유형이 아니라 Type이라는 제네릭 유형를 사용하게 되었습니다. 이 함수를 실제 사용하기 전에는 단지 Type이 될 수 있는 모든 유형이 후보가 됩니다.

이 후보는 아래의 코드에서 Typestring으로 명시되면서 Type --> string이 됩니다.

let output = identity<string>("myString");

위의 코드에 의해 identity() 함수는 다음의 코드처럼 동작하게 됩니다.

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

image.png

다른 정적 언어와 다르게 TypeScript는 JavaScript 코드를 생성하고 유형 표현은 사라지므로 실제로는 identity() 함수에 아무런 변화가 없습니다. 이와는 다르게 Java나 C#과 같은 정적 언어는 컴파일 수행중 함수가 사용되는 지점에서 Type --> string으로 변환된 identity() 함수를 생성합니다.

제네릭 유형를 유추할 수 있는 경우 제네릭 유형 표현을 생략할 수 있습니다. identity() 함수의 경우 함수 인자 유형에 의해 반환 유형이 유추 되므로 생략할 수 있습니다.

let output = identity("myString");

제네릭 유형 변수 동작

제네릭 유형(여기서는 Type)의 자리에 결국에 실제 유형이 사용될 터이지만 제네릭을 사용한 코드에서는 알 수 없으므로 마치 any 유형 처럼 동작합니다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
              ~~~~~~~~~~
> Property 'length' does not exist on type 'Type'.
  return arg;
}

제네릭 유형 Type은 아직 유형이 특정되지 않았으므로 length라는 속성이 있는지 알 수 없습니다. 그러므로 이 오류는 정상적이고 안전한 코드를 만들 수 있도록 하는 유용한 오류입니다. 우리는 TypeScript에게 좀 힌트를 알려줘야 합니다. 다음의 코드를 보시죠.

function loggingIdentity<Type>(arg: Type[]): Type[] {
  console.log(arg.length);
  return arg;
}

이제 arg는 제네릭 유형 Type인 배열을 의미하게 되었습니다. 배열이므로 length 속성에 접근할 수 있습니다. 이제 오류가 없어졌고 정상적인 코드라고 TypeScript는 인정합니다.

loggingIdentity() 함수는 이제 어떠한 유형이든 상관없이 배열 형태일 경우 정확히 그 배열 길이를 잘 출력하는 함수가 되었습니다.

Type[]Array<Type>과 같으므로 다음처럼 코드를 표현할 수 도 있습니다.

function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
  console.log(arg.length); // Array has a .length, so no more error
  return arg;
}

제네릭 유형

제네릭 함수의 유형은 함수 선언과 유사하게 유형 매개변수가 먼저 나열되는 비제네릭 함수의 유형과 유사합니다.

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

let myIdentity: <Type>(arg: Type) => Type = identity;

<Type>(arg: Type) => Type 형태를 identity()가 가지므로 정상적인 코드입니다. 사용은 다음처럼 할 수 있습니다.

let value = myIdentity<number>(5);

유형 변수의 수와 사용되는 방식이 일치하면 제네릭 유형 매개변수 (여기서는 Type) 이름을 동일하게 맞출 필요가 없습니다. 즉, 다음의 코드도 정상 코드입니다.

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

let myIdentity: <Input>(arg: Input) => Input = identity;

TypeScript에서는 객체 리터럴 유형의 호출 서명으로도 작성할 수 있게 허용합니다.

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

let myIdentity: { <Type>(arg: Type): Type } = identity;

그러므로 인터페이스로도 이를 표현할 수 있습니다.

interface GenericIdentityFn {
  <Type>(arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn = identity;

제네릭 유형 대신 실제 유형을 적용하고 싶을 수 도 있습니다.

interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

myIdentity은 인터페이스에 의해 Type 제네릭 유형이 number 유형이 되었고 identity()함수의 Type 제네릭 유형에 number가 들어갈 수 있으므로 이 코드 역시 올바른 코드입니다.

제네릭 클래스

제네릭 함수와 마찬가지로 클래스에도 제네릭을 사용할 수 있습니다.

class GenericNumber<NumType> {
  zeroValue: NumType;
  add: (x: NumType, y: NumType) => NumType;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

NumType 제네릭 유형에는 number뿐만 아니라 string도 가능하므로 string으로 클래스를 사용할 수도 있습니다.

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
  return x + y;
};

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

제네릭 제약 조건

앞의 코드에서 length 속성을 사용하고자 했지만 제네릭 유형을 특정하지 않는 이상 불가능 하다는 것을 알 수 있었습니다.

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
              ~~~~~~~~~~
> Property 'length' does not exist on type 'Type'.
  return arg;
}

하지만 extends 키워드를 사용하면 이제 가능해 집니다.

interface Lengthwise {
  length: number;
}

function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // Now we know it has a .length property, so no more error
  return arg;
}

이를 제네릭 제약 조건이라고 하며 Type 제네릭 유형은 이제 반드시 length 속성이 있는 유형만 허용하게 됩니다.

loggingIdentity(3);
                ~
> Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.

숫자 3은 number 유형이고 length을 가지고 있지 않으므로 오류가 발생합니다. 이 오류는 loggingIdentity() 함수가 정상적으로 수행하기 위해 필요한 오류가 됩니다.

하지만 length 속성을 제공하는 모든 유형은 loggingIdentity() 함수를 사용할 수 있습니다.

loggingIdentity({ length: 10, value: 3 });

제네릭 제약 조건에서 유형 매개변수 사용

keyof 등의 키워드를 제약 조건에 사용하면 유형 검사를 좀 더 강화할 수 있습니다.

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

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a");
getProperty(x, "m");
                ~ 
> Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.

getProperty() 함수는 인자의 속성을 반환하는 함수입니다. obj[key]를 통해 어떤 key의 속성도 반환할 수 있지만 제네릭 매개변수를 통해 extends keyof Type으로 Key 제네릭 유형을 제한 헀고, 그것으로 key로 받아서 x의 속성에 없는 "m"의 경우 오류로 처리합니다. 훌륭하지 않나요?

제네릭에서 클래스 유형 사용

다음의 경우와 같이 팩토리를 만들 때 생성자 함수로 클래스 유형을 참조해야 할 수 있습니다.

function create<Type>(c: { new (): Type }): Type {
  return new c();
}

고급 예제는 다음과 같습니다.

class BeeKeeper {
  hasMask: boolean = true;
}

class ZooKeeper {
  nametag: string = "Mikle";
}

class Animal {
  numLegs: number = 4;
}

class Bee extends Animal {
  keeper: BeeKeeper = new BeeKeeper();
}

class Lion extends Animal {
  keeper: ZooKeeper = new ZooKeeper();
}

function createInstance<A extends Animal>(c: new () => A): A {
  return new c();
}

createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;

이러한 패턴은 mixins 패턴을 강화하는데 사용됩니다.

정리

오늘은 TypeScript의 제네릭을 학습했습니다. 제네릭은 많은 언어에서 사용하는 방식이므로 제네릭 사용법을 익히면 다른 언어에서도 제네릭을 잘 사용할 수 있게 됩니다. 또한 제네릭을 효과적으로 사용하면 다양한 유형을 처리하기 위한 중복 코드를 없앨 수 있습니다.

34 views

More from this blog

개발, 테스트, 운영에서의 도커 활용

핵심 원칙: "한 번 빌드하고, 어디서든 실행한다 (Build once, run anywhere)" 도커의 가장 큰 장점은 환경 일관성입니다. 동일한 도커 이미지를 사용하여 개발, 테스트, 운영 환경을 구성함으로써 "제 PC에서는 됐는데..." 하는 문제를 최소화할 수 있습니다. 1. 개발 단계 (Development) 목표: 빠른 코드 변경 반영, 쉬운 디버깅, 실제 운영 환경과 유사한 환경 구성. Docker 사용 방안: Dockerf...

May 9, 20256 min read15

[EF Core] 데이터 삭제 시 소프트 삭제 적용

DB에서 데이터를 삭제하면 일반적으로 복구할 수 없습니다. 또한 관계에 따라 영구 삭제 자체가 어려울 수도 있습니다. 그래서 데이터를 영구 삭제하는 대신 IsDeleted 속성을 true로 주고 IsDeleted 속성을 필터링해서 조회하는 방법을 사용하기도 합니다. 이를 소프트 삭제라고 합니다. 그런데 EF에서 알아서 데이터 삭제 시 소프트 삭제를 하고 쿼리시 IsDeleted 속성을 체크해서 삭제한 데이터를 제외한 데이터만 쿼리하게 하는 ...

Mar 18, 20243 min read19

[EF Core] ValueConverter를 이용해서 엔터티 속성의 도메인 관리

EF Core를 사용하면서 문자열 길이 등의 특성을 일일이 지정하는 것은 번거롭습니다. ... [MaxLength(32)] public string? 제목 { get; set; } 엔터티가 한 개일 때는 상관이 없으나 제목 유형이 여러 엔터티에 사용될 경우 유형을 지정하기 번거롭습니다. 속성 유형을 도메인으로 관리하면 참 편할텐데요, ValueConverter를 이용할 수 있습니다. 그런데 이것을 인터페이스 정적 추상를 사용해서 다음처럼 ...

Mar 16, 20242 min read8

디모이 블로그

154 posts

.NET 관련 기술을 선호하고 새로운 언어를 배우는데 관심이 있습니다.