본문으로 건너뛰기
TypeScript 제네릭 가이드

# TypeScript 제네릭(Generics) 완벽 가이드

Table of Contents

제네릭(Generics)이란?

제네릭은 C#이나 Java 같은 언어에서 강력한 도구로 사용되던 기능으로, TypeScript에서도 타입을 파라미터화하여 코드의 재사용성을 극대화하는 핵심 기능입니다. 단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 만들면서도, any 타입을 쓸 때와 달리 **타입 안전성(Type Safety)**을 잃지 않는다는 점이 가장 큰 장점입니다.

기본 제네릭 함수

먼저 가장 기본적인 형태의 제네릭 함수를 살펴보겠습니다.

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

// 1. 명시적 타입 지정
const output1 = identity<string>('Hello'); // 타입이 string으로 고정됨
const output2 = identity<number>(42);      // 타입이 number로 고정됨

// 2. 타입 추론 (Type Inference) - 권장
const output3 = identity('TypeScript'); // TypeScript가 자동으로 string으로 추론
const output4 = identity(100);          // TypeScript가 자동으로 number로 추론

:::tip 대부분의 경우 TypeScript가 문맥을 통해 제네릭 타입을 자동으로 추론하므로, 굳이 <string>처럼 명시적으로 타입을 적어줄 필요는 없습니다. 추론에 맡기면 코드가 훨씬 간결해집니다. :::

제네릭으로 배열 다루기

제네릭은 배열이나 리스트 형태의 데이터를 다룰 때 그 진가를 발휘합니다.

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

// Array<T> 문법도 동일하게 작동합니다
function getLast<T>(arr: Array<T>): T | undefined {
  return arr[arr.length - 1];
}

const numbers = [1, 2, 3, 4, 5];
const firstNum = getFirst(numbers); // number | undefined

const names = ['Alice', 'Bob', 'Charlie'];
const firstName = getFirst(names); // string | undefined

제네릭 제약 조건 (Generic Constraints)

“모든 타입”을 허용하는 것이 항상 좋은 것은 아닙니다. 때로는 특정 속성이나 메서드를 가진 타입만 받도록 제한해야 할 때가 있습니다. 이때 extends 키워드를 사용합니다.

interface Lengthwise {
  length: number;
}

// T는 반드시 length 속성을 가진 타입이어야 함
function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length); // 이제 length 속성에 안전하게 접근 가능
  return arg;
}

logLength('Hello');             // 성공: string은 length가 있음
logLength([1, 2, 3]);           // 성공: array는 length가 있음
logLength({ length: 10 });      // 성공: 객체에 length가 있음
// logLength(100);              // 오류: number에는 length가 없음

keyof를 활용한 객체 속성 제약

객체의 속성을 안전하게 가져오기 위해 keyof 연산자와 함께 사용할 수 있습니다.

// K는 T의 키(key) 중 하나여야 함
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = {
  name: 'Alice',
  age: 30,
  email: 'alice@example.com'
};

const name = getProperty(person, 'name'); // string 반환
const age = getProperty(person, 'age');   // number 반환
// getProperty(person, 'address');        // 오류: 'address'는 person의 키가 아님

제네릭 클래스

데이터를 저장하거나 관리하는 클래스를 만들 때 제네릭은 필수적입니다.

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

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

  remove(item: T): void {
    const index = this.items.indexOf(item);
    if (index > -1) {
      this.items.splice(index, 1);
    }
  }

  getAll(): T[] {
    return [...this.items]; // 안전하게 복사본 반환
  }
}

// 숫자 저장소
const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(5);

// 문자열 저장소
const stringStore = new DataStore<string>();
stringStore.add('TypeScript');

다중 제네릭 타입

여러 개의 독립적인 타입 매개변수가 필요할 때는 쉼표로 구분하여 정의합니다.

// 두 객체를 병합하는 함수
function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const merged = merge(
  { name: 'Alice' },
  { age: 30 }
);
// 반환 타입: { name: string } & { age: number }

실전 활용 패턴

1. API 응답 래퍼 (Response Wrapper)

백엔드 API의 응답 구조를 정의할 때 가장 많이 사용되는 패턴입니다.

// 기본값이 있는 제네릭 인터페이스
interface ApiResponse<Data = unknown> {
  statusCode: number;
  message: string;
  data: Data;
}

interface UserProfile {
  id: number;
  username: string;
}

// 구체적인 타입 적용
const response: ApiResponse<UserProfile> = {
  statusCode: 200,
  message: 'Success',
  data: {
    id: 1,
    username: 'dev_kim'
  }
};

2. 유틸리티 타입 구현 (Result Pattern)

에러 처리를 우아하게 하기 위한 Result 타입을 구현할 수 있습니다.

type Result<T, E = Error> = 
  | { success: true; value: T }
  | { success: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: '0으로 나눌 수 없습니다.' };
  }
  return { success: true, value: a / b };
}

const result = divide(10, 0);
if (result.success) {
  console.log(result.value);
} else {
  console.error(result.error); // '0으로 나눌 수 없습니다.'
}

모범 사례 (Best Practices)

  1. 의미 있는 이름 사용: 단순한 경우에는 T, U도 좋지만, 복잡해지면 TData, TResponse, TItem처럼 의미를 명확히 드러내는 이름을 사용하세요.
  2. 가능한 한 제약 걸기: any처럼 동작하지 않도록, extends를 사용해 가능한 한 타입을 좁혀주는 것이 좋습니다.
  3. 기본 타입(Default Type) 제공: interface Wrapper<T = string>처럼 자주 사용되는 타입을 기본값으로 제공하면 사용하기 편리해집니다.

마치며

제네릭은 TypeScript를 단순한 “타입 있는 자바스크립트”에서 “견고한 아키텍처를 위한 언어”로 격상시키는 핵심 기능입니다. 처음에는 문법이 낯설 수 있지만, 익숙해지면 라이브러리 코드를 읽거나 재사용 가능한 유틸리티를 만들 때 없어서는 안 될 도구가 될 것입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# Zero Downtime 데이터베이스 마이그레이션: 점검 공지 없이 스키마 변경하기

게시:

서비스 중단 없이 운영 DB 스키마를 변경하는 방법인 Expand-Contract 패턴을 상세히 다룹니다. 테이블 락을 피하는 전략, 하위 호환성 유지, 그리고 gh-ost 같은 도구를 활용하여 수천만 건의 데이터를 안전하게 마이그레이션하는 실전 노하우를 공유합니다.

읽기