Skip to content

제네릭 (Generic)

TypeScript의 제네릭은 함수, 클래스, 인터페이스 등에 타입을 매개변수처럼 전달할 수 있는 기능이다. 즉, 한 번의 정의로 다양한 타입을 유연하게 처리할 수 있다.

타입을 위한 변수를 만들어 놓고 함수나 클래스가 실제로 사용될 때 그 변수에 타입을 전달하는 개념이다.


제네릭의 장단점

구분설명
장점- 코드 재사용성 증가
- 타입 안정성과 유연성 확보
- 명시적인 타입 표현 가능
단점- 문법이 처음엔 어렵게 느껴질 수 있음
- 복잡한 타입 구조 시 가독성 저하
- 대규모 프로젝트에서 컴파일 시간 증가 가능

1. 타입 변수 (Type Variable)

타입 자리에 들어올 타입을 대신 표현하는 타입 변수를 정의할 수 있다. 제네릭 함수나 클래스 정의 시 지정되고, 실제 사용 시 구체적인 타입으로 대체된다.

ts
function genericFunction<T>(arg: T): T {
  return arg;
}

interface GenericInterface<T> {
  value: T;
}

class GenericClass<T> {
  constructor(public value: T) {}
}
  • 일반적으로 T(Type), U, K, V 등의 식별자를 사용한다.
  • T는 관습적으로 Type의 약자다.

2. 제네릭을 사용하지 않은 코드

제네릭이 없다면 같은 구조의 함수를 타입별로 중복 작성해야 한다.

ts
// 터입별 배열
let numbers: number[] = [1, 2, 3, 4, 5];
let strings: string[] = ["a", "b", "c"];

// number 전용 함수
function getFirstElement(arr: number[]) {
  if (!arr.length) {
    return undefined;
  }
  return arr[0];
}

// string 전용 함수
function gerFirstStringElement(arr: string[]) {
  if (!arr.length) {
    return undefined;
  }
  return arr[0];
}

// 각각의 타입에 맞는 함수 호출
const firstNumber = getFirstElement(numbers);
const firstString = gerFirstStringElement(strings);

3. 제네릭 함수로 통합

하나의 제네릭 함수로 위의 중복된 코드를 통합할 수 있다.

ts
// 제네릭을 사용한 함수
function getFirstElement<T>(arr: T[]): T | undefined {
  if (!arr.length) {
    return undefined;
  }

  return arr[0];
}

// number와 string 배열
let numbers: number[] = [1, 2, 3, 4, 5];
let strings: string[] = ["a", "b", "c"];

// 타입 추론에 의해 자동으로 타입 결정됨
const firstNumber = getFirstElement(numbers); // number | undefined
const firstString = getFirstElement(strings); // string | undefined
  • 하나의 함수로 여러 타입 배열을 처리할 수 있다. TypeScript가 전달된 인수 타입을 보고 자동으로 추론한다.

4. DOM API에서의 제네릭 활용

document.querySelector()useRef() 같은 브라우저/React API도 내부적으로 제네릭을 활용한다.

ts
const div = document.querySelector<HTMLDivElement>("#myDiv");
const button = document.querySelector<HTMLButtonElement>("#myButton");

button?.click(); // 타입이 보장되어 안전하게 접근 가능
  • querySelector<HTMLButtonElement>처럼 요소 타입을 직접 명시하면 선택된 요소가 올바른 타입으로 인식되어 .click(), .value 등의 속성을 안전하게 사용할 수 있다.

5. 제네릭 인터페이스

ts
// 문자열 타입 딕셔너리
interface strDict {
  [key: string]: string;
}

let strObj: strDict = {
  name: "Binyard",
};

// 숫자 타입 딕셔너리
interface numDict {
  [key: number]: number;
}

let numObj: numDict = {
  age: 30,
};

ts
interface Dict<T> {
  [key: string]: T;
}

let strObj: Dict<string> = {
  name: "Binyard",
};

let numObj: Dict<number> = {
  age: 30,
};
  • Dict<T>를 정의하면 key는 문자열이지만 value는 어떤 타입(T)으로든 유연하게 대응 가능하다.

6. 제네릭 인터페이스 (다중 타입 매개변수)

제네릭은 여러 개의 타입 매개변수를 동시에 가질 수도 있다.

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

let entry1: Entry<string, number> = {
  key: "age",
  value: 30,
};

let entry2: Entry<number, string[]> = {
  key: 1,
  value: ["red", "green", "blue"],
};
  • 이런 형태는 실제로 Map<K, V>, Record<K, V> 등에서 자주 사용된다.

7. 제네릭 클래스 예제

ts
// 제네릭 클래스
class Item<T> {
  #content: T | null;

  constructor() {
    this.#content = null;
  }

  // T 타입의 값만 설정 가능
  setItem(value: T) {
    this.#content = value;
  }

  // 저장된 값을 반환
  getItem(): T | null {
    return this.#content;
  }
}

// number 타입을 저장하는 인스턴스
const numberItem = new Item<number>();
numberItem.setItem(100);
console.log(numberItem.getItem()); // 100

// string 타입을 저장하는 인스턴스
const stringItem = new Item<string>();
stringItem.setItem("Hello");
console.log(stringItem.getItem()); // Hello

8. 제네릭 인터페이스 + 클래스 구현

ts
// 공통 모델
interface User {
  id: number;
  name: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

// 공통 저장소 인터페이스
interface Store<T> {
  findById(id: number): T | undefined;
  save(item: T): void;
}

// User 저장소
class UserRepository implements Store<User> {
  #users: User[] = []; // private 필드

  // 사용자 ID로 조회
  findById(id: number): User | undefined {
    return this.#users.find((user) => user.id === id);
  }

  // 사용자 추가
  save(user: User): void {
    this.#users.push(user);
  }
}

// Product 저장소
class ProductRepository implements Store<Product> {
  #products: Product[] = [];

  findById(id: number): Product | undefined {
    return this.#products.find((product) => product.id === id);
  }

  save(product: Product): void {
    this.#products.push(product);
  }
}

// 인스턴스 생성
const userRepo = new UserRepository();
const productRepo = new ProductRepository();

userRepo.save({
  id: 1,
  name: "Bin",
});

userRepo.save({
  id: 2,
  name: "Milou",
});

productRepo.save({
  id: 20,
  price: 200,
  name: "Mouse",
});

console.log(userRepo.findById(1)); // { id: 1, name: 'Bin' }
console.log(userRepo.findById(3)); // undefined
console.log(productRepo.findById(20)); // { id: 20, price: 200, name: "Mouse" }

9. 제네릭에 제약 추가 extends

특정 속성을 반드시 포함해야 하는 타입만 받도록 제약을 걸 수 있다.

ts
interface WithId {
  id: number;
}

interface Store<T extends WithId> {
  findById(id: number): T | undefined;
  save(item: T): void;
}
  • T extends WithId로 지정하면, id 속성을 가진 타입만 Store<T>에 전달할 수 있다.
  • 즉, id가 없는 객체를 저장하려고 하면 컴파일 단계에서 에러가 발생한다.