일상 코딩을 위한 TypeScript 제네릭: 실용 가이드

Frontend2026-09-21TryQuickToolBox

어떤 타입과도 작동하는 함수를 작성 중이지만 타입 안전성을 잃고 싶지 않으신가요? any를 사용할 수도 있지만, 이는 TypeScript의 목적을 무색하게 만듭니다. 제네릭을 사용하면 타입 정보를 유지하면서 여러 타입과 작동하는 코드를 작성할 수 있습니다. 이 가이드는 일상 코드에서 제네릭을 복잡하게 만들지 않고 사용하는 방법을 보여줍니다.

제네릭이란?

제네릭은 단일 타입이 아닌 다양한 타입과 작동하는 재사용 가능한 컴포넌트를 만드는 방법입니다. 입력의 타입을 캡처하여 출력에 사용함으로써 유연성을 희생하지 않고 타입 안전성을 보장합니다.

제네릭을 타입 매개변수라고 생각하세요. 함수가 값 매개변수를 받는 것처럼, 제네릭 함수는 타입 매개변수를 받습니다. 예를 들어, 항등 함수는 다음과 같습니다:

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

여기서 T는 타입 매개변수입니다. identity<string>("hello")를 호출하면 TypeScript는 T가 string임을 추론하고 반환 타입은 string입니다. 숫자를 전달하면 숫자를 반환합니다. 이는 any를 사용하는 것보다 더 정확합니다.

any를 사용하지 않는 이유

any를 사용하면 타입 검사가 비활성화됩니다. 자동 완성, 리팩토링 지원을 잃고 컴파일러가 실수를 잡을 수 없습니다. 제네릭은 타입 정보를 코드 전체에 흐르게 하여 유연성과 안전성을 모두 얻을 수 있습니다.

실제 제네릭 함수

배열의 첫 번째 요소를 반환하는 함수라는 일반적인 시나리오를 살펴보겠습니다.

function firstElement<T>(arr: T[]): T | undefined {
  return arr[0];
}

제네릭 없이 function firstElement(arr: any[]): any를 작성할 수 있지만 타입을 잃게 됩니다. 제네릭을 사용하면 string[]을 전달할 경우 반환 타입은 string | undefined입니다.

여러 타입 매개변수를 사용할 수도 있습니다. 예를 들어, 콜백을 사용하여 배열을 새 배열로 매핑하는 함수:

function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

이는 기본적으로 TypeScript 표준 라이브러리에서 Array.prototype.map이 타입 지정된 방식입니다.

제네릭 인터페이스와 타입 별칭

제네릭은 함수에만 국한되지 않습니다. 재사용 가능한 데이터 구조를 정의하기 위해 제네릭 인터페이스와 타입 별칭을 만들 수 있습니다.

interface Box<T> {
  value: T;
}

const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };

이는 API 응답, 상태 관리 또는 모든 컨테이너 타입에 유용합니다.

기본 타입을 제공할 수도 있습니다:

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}

이제 타입 인수 없이 ApiResponse를 사용하면 기본값으로 unknown이 적용됩니다.

제네릭 제약

때로는 제네릭과 함께 사용할 수 있는 타입을 제한하고 싶을 수 있습니다. extends 키워드를 사용하여 제약을 추가할 수 있습니다.

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

이제 T는 length 속성을 가져야 합니다. getLength("hello") 또는 getLength([1,2,3])를 호출할 수 있지만 getLength(42)는 호출할 수 없습니다.

제약은 공통된 형태를 공유하는 객체로 작업할 때 강력합니다.

클래스와 함께 제네릭 사용하기

클래스도 제네릭이 될 수 있습니다. 일반적인 예는 제네릭 컬렉션입니다:

class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }
}

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
const popped = stack.pop(); // number | undefined

이렇게 하면 숫자 스택에 숫자만 푸시할 수 있습니다.

실제 예: 타입 안전한 이벤트 이미터

제네릭을 사용하여 이벤트 이름을 페이로드 타입에 매핑하는 간단한 이벤트 이미터를 만들어 보겠습니다.

type EventMap = Record<string, any>;

class EventEmitter<T extends EventMap> {
  private listeners: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};

  on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners[event]?.forEach(listener => listener(payload));
  }
}

// 사용법
interface MyEvents {
  login: { userId: string };
  logout: { userId: string };
}

const emitter = new EventEmitter<MyEvents>();
emitter.on('login', (payload) => {
  console.log(payload.userId); // string
});
emitter.emit('login', { userId: '123' }); // OK
// emitter.emit('login', { userId: 123 }); // Error

이 패턴은 이벤트 이름과 페이로드에 대한 완전한 타입 안전성을 제공하여 런타임 오류를 줄입니다.

일반적인 함정과 모범 사례

비교: 제네릭 vs. any vs. 유니온 타입

접근 방식 타입 안전성 유연성 사용 사례
제네릭 높음 높음 타입이 보존된 재사용 가능한 컴포넌트
any 없음 높음 빠른 프로토타이핑, JS 마이그레이션
유니온 타입 중간 중간 알려진 타입의 제한된 집합

제네릭은 유연성과 안전성 사이의 균형을 이루어 라이브러리와 공유 유틸리티에 이상적입니다.

FAQ

유니온 타입 대신 제네릭을 언제 사용해야 하나요?

입력과 출력 간의 타입 관계가 중요할 때 제네릭을 사용하세요. 유니온 타입은 값이 고정된 타입 집합 중 하나일 수 있지만 특정 타입이 작업 전반에 걸쳐 보존될 필요가 없을 때 사용합니다.

화살표 함수와 함께 제네릭을 사용할 수 있나요?

네, 하지만 .tsx 파일에서는 구문이 까다로울 수 있습니다. JSX 모호성을 피하기 위해 후행 쉼표를 사용하여 const identity = <T,>(arg: T): T => arg;로 작성할 수 있습니다.

제네릭이 객체만 허용하도록 제한하려면 어떻게 하나요?

T extends object 또는 T extends Record<string, unknown>를 사용하여 T가 객체 타입임을 보장하세요.

제네릭은 TypeScript 타입 시스템의 초석입니다. 일상 코드에서 사용하면 유연하고 안전한 함수, 인터페이스, 클래스를 작성할 수 있습니다. 간단한 제네릭 함수부터 시작하여 필요에 따라 점차 제약과 더 복잡한 패턴을 통합하세요. 더 많은 개발자 도구를 원하시면 JSON Formatter를 확인하여 JSON 데이터를 빠르게 검증하고 포맷하세요.