본문 바로가기

[TS] 제네릭 / 데코레이터

[TS] 제네릭 / 데코레이터

제네릭(Generic)

제네릭은 C#과 Java와 같이 재사용이 가능한 컴포넌트를 생성하는 주요 도구 중 하나이다.

  • 타입스크립트의 제네릭(Generic)은 코드 재사용성을 높이고 타입 안정성을 보장하는 기능이다.
  • 제네릭을 사용하면 함수나 클래스를 작성할 때, 사용될 데이터의 타입을 미리 지정하지 않고, 이후에 함수나 클래스를 호출할 때 인자로 전달된 데이터의 타입에 따라 자동으로 타입을 추론하게 된다.

 

제네릭의 필요성

아래의 printLog 함수는 파라미터로 text를 받고 있으며, 반환 값으로 text를 리턴하고 있다.

  • 이를 제네릭 없이 구현한다면 아래와 같이 구현할 수 있을 것이다.
function printLog(text) {
	return text;
}

 

첫 번째 함수는 printLog 함수에 특정 타입을 주어 작성한 코드이다.

  • 타입은 명시되었지만, string 타입 외에 다른 타입이 들어온다면 컴파일 에러가 난다.
function printLog(text: string): string {
	return text;
}

printLog('hello'); // 정상
printLog(123); //에러

 

이를 해결하기 위해서 중복으로 함수를 선언하는 방법이 있을 것이다.

  • 그러나 이 방법은 타입의 가독성 및 유지보수성이 나빠진다.
  • 타입을 다르게 받기 위해 같은 코드를 타입만 바꿔서 명시하는 것이기 때문이다.
function printLog(text: string): string {
	return text;
}

function printLogNumber(text: number): number {
	return text;
}

printLog('hello'); // 정상
printLogNumber(123); //정상

 

또는 | 연산자를 이용해 유니온 타입으로 선언하는 방법이 있을 것이다.

  • 이 방법은 들어가는 인수는 해결이 되지만, 함수 내에서 결국 string과 number가 둘 다 접근할 수 있는 API만 제공한다.
  • 이 외에는 타입이 정확히 추론되지 않기 때문에 사용할 수 없다.
function printLog(text: string | number) {
	return text;
}

 

이어 any 타입을 사용해 작성한 코드이다.

  • 이 방법은 어떤 타입이든 받을 수 있지만 실제로 함수가 반환할 때 어떤 타입인지 추론할 수 없게 된다.
function printLog(text: any): any {
	return text;
}

 

따라서 제네릭을 사용하게 될 필요성이 생긴다.

 

제네릭

제네릭을 사용해 작성하게 되면 타입을 불문하고 동작한다. 아래에도 나오지만, 인터페이스나 클래스에도 제네릭을 사용할 수 있다.

  • 제네릭을 사용하면 여러 종류의 타입을 지원하는 일반적인 함수나 클래스를 작성할 수 있다.
  • 아래의 코드에서 함수의 매개변수인 text는 제네릭 타입 매개변수 T로 타입화되어있다.
    • T는 함수가 어떤 타입의 인자를 받을지 호출할 때 결정된다.
    • printLog 함수에 T라는 타입 변수를 추가했다.
      • T는 유저가 준 파라미터의 타입을 캡처하고, 이 정보를 나중에 사용할 수 있게 한다.
    • 여기에서는 T를 반환 타입으로 다시 사용한다.
      • 따라서 파라미터와 반환 타입이 같은 타입을 사용하고 있는 것을 확인할 수 있다.
function printLog<T>(text: T): T {
	return text;
}

 


T를 쓰는 이유?

제네릭에서 T라는 식별자를 사용하는 것은 일반적인 관례이다. 

  • T는 "Type"의 약자로 사용되며, 제네릭 타입 매개변수의 이름을 나타낸다.
  • T는 타입 매개변수의 이름일 뿐이며, 실제로는 어떤 이름이든 사용할 수 있지만 T는 일반적으로 코드의 가독성을 높이고 일반적인 관례를 따르기 위해 사용된다.

 

printLog 함수는 타입을 불문하고 동작하므로 제네릭이라 할 수 있다.

  • any를 쓰는 것과는 다르게 인수와 반환 타입에 string을 사용한 첫 번째 printLog 함수만큼 정확하다.
  • 즉 타입을 추론할 수 있게 된다.

 

이렇게 제네릭을 작성하고 나면 아래와 같이 작성할 수 있다.

  • 여기서 함수를 호출할 때의 인수 중 하나로써 T를 string 타입으로 명시해 주고 타입 주변을 <>로 감싸주었다.
const str = printLog<string>('hello');

 

return 값이 없는 함수

 

혹은 타입 추론 기능을 활용해서 작성할 수 있다.

  • 타입 추론을 이용해 코드를 작성하면 타입이 복잡해질 경우 컴파일러가 타입을 유추할 수 없게 되므로 사용할 수 없다.
  • 아래는 타입 추론 기능을 활용해 작성한 코드이다.
  • 전달하는 인수에 따라 컴파일러가 자동으로 T의 값을 정하는 방법이다.
  • 이는 타입이 복잡해져 컴파일러가 타입을 유추할 수 없게 되는 경우에는 사용할 수 없다.
const str = printLog('hello');

 

return 값이 없는 함수

 

인터페이스와 제네릭

인터페이스에도 제네릭을 사용할 수 있다.

  • 이와 같이 작성하면 Item 인터페이스를 사용하여 만든 객체는 name의 값으로 어떤 타입이 들어갈지만 작성을 해주면 인터페이스를 여러 개 만들지 않고도 재사용을 할 수 있게 된다.
interface Item<T> {
	name: T;
	stock: number;
	selected: boolean;
}

 

이런 식으로 여러 개의 객체를 만들어 낼 수 있게 된다.

const obj: Item<string> = { 
	name: "T-shirts",
	stock: 2, 
	selected: false
};

const obj: Item<number> = { 
	name: 2044512,
	stock: 2, 
	selected: false
};

 

클래스와 제네릭

제네릭을 사용하는 TypeScript에서 팩토리를 생성할 때 생성자 함수로 클래스 타입을 참조해야 한다.

  • 팩토리(Factory)는 객체를 생성하기 위한 메서드 또는 클래스로, 팩토리 패턴은 객체 생성을 추상화하여 객체의 생성 로직을 캡슐화하고, 클라이언트가 구체적인 객체 생성 과정을 알 필요 없이 객체를 생성할 수 있도록 돕는 디자인 패턴이다. : 레퍼런스 
  • GenericNumber 클래스의 인스턴스를 생성하고, 타입 매개변수 T를 number로 지정하여 myGenericNumber 변수에 할당함으로써 myGenericNumber 인스턴스는 number 타입에 특화된 제네릭 클래스로 생성되었다.
class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

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

 

  • 물론 string이나 훨씬 복잡한 객체를 사용할 수 있다.
  • (아래는 객체 타입에 특화된 제네릭 클래스를 생성한 코드)
class GenericNumber<T> {
  zeroValue: T;
  add: (x: T, y: T) => T;
}

class Person {
  constructor(public name: string, public age: number) {}  // 🟣 설명(1)
}

let myGenericPerson = new GenericNumber<Person>();
myGenericPerson.zeroValue = new Person('', 0);

myGenericPerson.add = function (x, y) {
  return new Person(x.name + y.name, x.age + y.age);
};

let person1 = new Person('Alice', 25);
let person2 = new Person('Bob', 30);
let combinedPerson = myGenericPerson.add(person1, person2);

console.log(
  `Combined Person: Name: ${combinedPerson.name}, Age: ${combinedPerson.age}`
);

 

 

🟣 설명(1)

class TestClass {
  private name: string;

  constructor(name: string) {
    this.name = name;
  }
}

// 이렇게도 쓸 수 있다.

class TestClass {
  constructor(private name: string) { }
}

 

제네릭 타입 변수

제네릭을 사용하기 시작하면, printLog와 같은 제네릭 함수를 만들 때, 컴파일러가 함수 본문에 제네릭 타입화된 매개변수를 쓰도록 강요한다.

  • 앞서 본 printLog 함수를 예시로 보면 console.log(text.length);를 작성하게 되면 컴파일 에러가 난다.
  • 왜냐하면 개발자가 string 타입이 아닌 number 타입을 보낼 수도 있기 때문에, T에는 .length가 있다는 것을 추론할 수 없기 때문이다.
function printLog<T>(text: T): T {
	console.log(text.length);
	return text;
}

 

이때는 제네릭에 타입을 줘서 유연하게 함수의 타입을 정의해 줄 수 있다.

  • 이 제네릭 함수 코드는 일단 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받는다.
  • 따라서 제네릭 타입이 배열이기 때문에, .length를 허용하게 된다.
  • printLog<T>에서 <T>는 제네릭 타입 매개변수를 선언하는 부분이다. 
    • 이를 통해 함수 내에서 사용되는 타입을 외부에서 지정할 수 있다.
function printLog<T>(text: T[]): T[] {
	console.log(text.length);
	return text;
}

// printLog<string[]>(['mango', 'doyu', 'love'])); 아님! string[]이 아니고 string을 넣어야함.
console.log(printLog<string>(['mango', 'doyu', 'love']));
// 3
// [ 'mango', 'doyu', 'love' ]

 

혹은 다음과 같이 조금 더 명시적으로 작성이 가능하다.

function printLog<T>(text: Array<T>): Array<T> {
	console.log(text.length);
	return text;
}

 

제네릭 제약 조건

앞서 제네릭 타입 변수 외에도 제네릭 함수에 어느 정도 어떤 타입이 들어올 것인지 힌트를 줄 수 있다.

  • 다시 앞서 본 printLog 함수를 예시로 보면, 인자의 타입에 선언한 T는 아직 어떤 타입인지 구체적으로 정의하지 않았기 때문에 length 코드에서 오류가 난다.
function printLog<T>(text: T): T {
	console.log(text.length);
	return text;
}

 

  • 이럴 때 만약 해당 타입을 정의하지 않고도 length 속성 정도는 허용하려면 아래와 같이 작성한다.
  • 이와 같이 extends 지시자를 이용해 작성하게 되면 타입에 대한 강제는 아니지만 length에 대해 동작하는 인자만 넘겨받을 수 있게 된다. 
    • 즉, string, array 등 .length 메서드가 있는 데이터 타입만 인자로 들어가게 된다.
interface TextLength {
	length: number;
}

function printLog<T extends TextLength>(text: T): T {
	console.log(text.length);
	return text;
}

 

스크린샷
boolean 값을 줬을 때 뜨는 오류

 

혹은 keyof를 이용해서 제약을 줄 수도 있다.

  • 제네릭을 선언할 때 <T extends keyof Item> 부분에서 첫 번째 인자로 받는 객체에 없는 속성들은 접근할 수 없게끔 제한할 수 있다.
interface Item<T> {
  name: T;
  stock: number;
  selected: boolean;
}

function printLog<T extends keyof Item<T>>(text: T): T {
  console.log(text);
  return text;
}

const item: Item<string> = {
  name: 'catToy',
  stock: 10,
  selected: true,
};

printLog('name'); // 출력: 'name', 반환: 'name'
printLog('stock'); // 출력: 'stock', 반환: 'stock'
printLog('selected'); // 출력: 'selected', 반환: 'selected'
// printLog('key'); // 에러: 'key'는 'name', 'stock', 'selected' 중 하나여야 한다.

console.log(item[printLog('name')]); // 출력: 'catToy'
console.log(item[printLog('stock')]); // 출력: 10
console.log(item[printLog('selected')]); // 출력: true

 

 

데코레이터

 

타입스크립트 데코레이터(Decorators)는 JavaScript와 함께 사용되는 기능으로 클래스, 메서드, 프로퍼티 또는 매개변수를 수정하거나 메타데이터를 추가하기 위해 사용된다. 

  • 데코레이터는 주석과 비슷한 방식으로 클래스 및 해당 멤버들을 꾸미는 역할을 한다.
  • 타입스크립트 데코레이터는 @ 기호로 표시되며, 클래스, 메서드, 프로퍼티, 매개변수 앞에 위치할 수 있다.
  • 데코레이터는 함수로 작성되며, 데코레이터가 적용된 대상의 동작을 변경하거나 대상에 대한 추가 정보를 제공하는 역할을 한다.
  • 다음은 타입스크립트에서 데코레이터를 사용하는 몇 가지 예시이다.

 

클래스 데코레이터(Class Decorators)

: 클래스를 수정하거나 클래스에 대한 메타데이터를 추가한다.

function logger(target: Function) {
  console.log('클래스가 생성되었습니다.');
}

@logger
class MyClass {
  // 클래스 멤버들...
}

 

메서드 데코레이터(Method Decorators)

: 클래스 내의 메서드를 수정하거나 메서드에 대한 메타데이터를 추가한다.

function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 메서드 유효성 검사 로직...
}

class MyClass {
  @validate
  myMethod() {
    // 메서드 로직...
  }
}

 

프로퍼티 데코레이터(Property Decorators)

: 클래스의 프로퍼티를 수정하거나 프로퍼티에 대한 메타데이터를 추가한다.

function readonly(target: any, propertyKey: string) {
  Object.defineProperty(target, propertyKey, { writable: false });
}

class MyClass {
  @readonly
  myProperty: string = 'value';
}

 

매개변수 데코레이터(Parameter Decorators)

: 함수 또는 클래스 메서드의 매개변수를 수정하거나 매개변수에 대한 메타데이터를 추가한다.

function log(target: any, propertyKey: string, parameterIndex: number) {
  // 매개변수 로깅 로직...
}

class MyClass {
  myMethod(@log message: string) {
    // 메서드 로직...
  }
}

 

데코레이터는 주로 프레임워크나 라이브러리에서 사용되며, 코드 재사용, 메타프로그래밍, 애스펙트 지향 프로그래밍 등 다양한 목적으로 활용될 수 있다. 타입스크립트의 데코레이터를 사용하면 코드를 더 모듈화하고 유연하게 만들 수 있으며, 코드의 가독성과 유지보수성을 향상시킬 수 있다.
728x90
⬆︎