전체 목록
TypeScriptMedium#089

타입스크립트의 제네릭(Generic)이란 무엇이며, 어떻게 활용하나요?

#TypeScript#제네릭#타입시스템

답변 포인트

타입을 파라미터처럼 사용하는 개념을 생각해보세요.

정답 및 해설

빠른 요약

제네릭은 타입을 매개변수처럼 사용하여 재사용 가능한 컴포넌트/함수를 만드는 기능입니다. 기본 예시: function identity<T>(arg: T): T { return arg; } 활용: - 배열/컬렉션 유틸리티 함수 (map, filter 등) - API 응답 타입 제네릭 래퍼 - 상태 관리 (useState<T>) 제약 조건(constraint): function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] 제네릭을 사용하면 any 타입 없이 타입 안전성을 유지하면서 유연한 코드를 작성할 수 있습니다.

제네릭(Generic)은 타입을 매개변수처럼 사용하여 다양한 타입에 대해 동작하는 재사용 가능한 컴포넌트를 만드는 TypeScript의 핵심 기능입니다. 제네릭을 사용하면 타입 안정성을 유지하면서도 코드 중복을 줄이고 유연한 API를 설계할 수 있습니다. C++의 템플릿, Java의 제네릭과 유사한 개념입니다.

제네릭이 필요한 이유

문제: any를 사용하면 타입 안정성 손실

TypeScript
// any 사용 - 타입 체크가 전혀 되지 않음
function identity(arg: any): any {
  return arg;
}

const result = identity("hello");
result.toUpperCase();  // OK
result.toFixed(2);     // 런타임 에러! 하지만 TypeScript는 모름

해결: 제네릭으로 타입 안정성 확보

TypeScript
// 제네릭 사용 - 타입 안정성 보장
function identity<T>(arg: T): T {
  return arg;
}

const strResult = identity<string>("hello");  // T = string
strResult.toUpperCase();  // OK
strResult.toFixed(2);     // TS 에러! string에는 toFixed가 없음

const numResult = identity<number>(42);  // T = number
numResult.toFixed(2);     // OK

// 타입 추론으로 생략 가능
const inferred = identity("hello");  // T는 string으로 추론

기본 문법

TypeScript
// 제네릭 타입 매개변수는 관례적으로 대문자 사용
// T, U, V ... 또는 의미있는 이름 (Item, Key, Value, Element 등)

// 함수
function first<T>(arr: T[]): T {
  return arr[0];
}

// 화살표 함수 (tsx 파일에서는 <T,> 또는 <T extends unknown> 사용)
const first = <T>(arr: T[]): T => arr[0];
const first = <T,>(arr: T[]): T => arr[0];  // tsx 파일

// 여러 타입 매개변수
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}
const result = pair<string, number>("age", 25);  // [string, number]

제네릭 함수

TypeScript
// 배열에서 특정 조건의 요소 찾기
function findItem<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
  return arr.find(predicate);
}

const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const found = findItem(users, user => user.id === 1);
// found의 타입: { id: number, name: string } | undefined

// 배열 변환
function map<T, U>(arr: T[], transform: (item: T) => U): U[] {
  return arr.map(transform);
}

const names = map(users, user => user.name);  // string[]
const ids = map(users, user => user.id);      // number[]

제네릭 인터페이스

TypeScript
// API 응답 래퍼 타입
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// 사용 예시
interface User {
  id: number;
  name: string;
  email: string;
}

interface Post {
  id: number;
  title: string;
  content: string;
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

async function fetchPosts(): Promise<ApiResponse<Post[]>> {
  const response = await fetch('/api/posts');
  return response.json();
}

// 타입 안전하게 사용
const userResponse = await fetchUser(1);
console.log(userResponse.data.name);  // string - 타입 확인됨
console.log(userResponse.data.title); // TS 에러! User에는 title 없음

제네릭 클래스

TypeScript
// 타입 안전한 스택(Stack) 자료구조
class Stack<T> {
  private items: T[] = [];

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

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

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push("hello");  // TS 에러! string은 number 스택에 추가 불가

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");

제네릭 타입 별칭

TypeScript
// 공통 페이지네이션 타입
type PaginatedResponse<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNext: boolean;
};

// 상태 관리용 타입
type AsyncState<T> = {
  data: T | null;
  loading: boolean;
  error: string | null;
};

// 사용 예시
const userState: AsyncState<User> = {
  data: null,
  loading: true,
  error: null,
};

// 키-값 맵 타입
type Dictionary<T> = {
  [key: string]: T;
};

const errorMessages: Dictionary<string> = {
  NOT_FOUND: "리소스를 찾을 수 없습니다",
  UNAUTHORIZED: "인증이 필요합니다",
};

제네릭 제약(Constraints)

타입 매개변수에 제약을 추가하여 특정 속성을 가진 타입만 허용합니다.

TypeScript
// T는 length 속성을 가져야 함
function getLength<T extends { length: number }>(arg: T): number {
  return arg.length;
}

getLength("hello");     // OK (string은 length 있음)
getLength([1, 2, 3]);   // OK (array는 length 있음)
getLength(42);           // TS 에러! number는 length 없음

// keyof를 이용한 제약
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", age: 30 };
const name = getProperty(user, "name");   // string
const id = getProperty(user, "id");       // number
const xyz = getProperty(user, "xyz");     // TS 에러! 존재하지 않는 키

// 인터페이스 확장을 통한 제약
interface Identifiable {
  id: number;
}

function findById<T extends Identifiable>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const found = findById(users, 1);  // { id: number, name: string } | undefined

제네릭 기본값

TypeScript
// 기본 타입 지정
interface Container<T = string> {
  value: T;
}

const strContainer: Container = { value: "hello" };     // T = string (기본값)
const numContainer: Container<number> = { value: 42 };  // T = number

// 복잡한 기본값 예시
type EventHandler<T = Event> = (event: T) => void;

const clickHandler: EventHandler = (e) => { /* e는 Event */ };
const customHandler: EventHandler<MouseEvent> = (e) => { /* e는 MouseEvent */ };

실무 활용 패턴

React useState와 제네릭

TypeScript
import { useState } from 'react';

// useState는 내부적으로 제네릭 사용
// function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]

const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);

// 타입 추론으로 생략 가능
const [count, setCount] = useState(0);  // number로 추론

커스텀 훅에서 제네릭 활용

TypeScript
import { useState, useEffect } from 'react';

// 재사용 가능한 API 페치 훅
function useFetch<T>(url: string): AsyncState<T> {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => setState({ data, loading: false, error: null }))
      .catch(error => setState({ data: null, loading: false, error: error.message }));
  }, [url]);

  return state;
}

// 사용
const { data: user, loading } = useFetch<User>('/api/users/1');
const { data: posts } = useFetch<Post[]>('/api/posts');
// user의 타입은 User | null, posts의 타입은 Post[] | null

Redux 스타일 상태 관리

TypeScript
// 제네릭 리듀서 패턴
type Action<T extends string, P = void> = P extends void
  ? { type: T }
  : { type: T; payload: P };

type UserAction =
  | Action<'SET_USER', User>
  | Action<'CLEAR_USER'>
  | Action<'SET_LOADING', boolean>;

function userReducer(state: AsyncState<User>, action: UserAction): AsyncState<User> {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, data: action.payload, loading: false };  // payload는 User 타입
    case 'CLEAR_USER':
      return { data: null, loading: false, error: null };
    case 'SET_LOADING':
      return { ...state, loading: action.payload };  // payload는 boolean 타입
  }
}

유틸리티 함수들

TypeScript
// 안전한 배열 청크 분할
function chunk<T>(arr: T[], size: number): T[][] {
  return arr.reduce((result, _, index) => {
    if (index % size === 0) result.push(arr.slice(index, index + size));
    return result;
  }, [] as T[][]);
}

const chunks = chunk([1, 2, 3, 4, 5], 2);  // number[][]
// [[1, 2], [3, 4], [5]]

// 객체 배열에서 특정 키로 그룹화
function groupBy<T, K extends keyof T>(arr: T[], key: K): Map<T[K], T[]> {
  return arr.reduce((map, item) => {
    const groupKey = item[key];
    const group = map.get(groupKey) ?? [];
    return map.set(groupKey, [...group, item]);
  }, new Map<T[K], T[]>());
}

const grouped = groupBy(users, 'role');  // Map<string, User[]>

정리

구분문법설명
기본 함수 제네릭function fn<T>(arg: T): T입출력 타입을 동일하게 유지
인터페이스 제네릭interface Api<T> { data: T }API 응답 등 컨테이너 타입
클래스 제네릭class Stack<T> { ... }타입 안전한 자료구조
타입 별칭 제네릭type State<T> = { data: T }유연한 타입 조합
제약 (Constraint)<T extends { id: number }>특정 속성 보유 타입으로 제한
keyof 제약<K extends keyof T>객체의 키 타입으로 제한
기본값<T = string>지정 없을 때 기본 타입 사용
여러 매개변수<T, U, V>복수의 독립적인 타입 매개변수

핵심: 제네릭은 "타입을 변수처럼 다루는 것"입니다. any와 달리 타입 정보가 유지되므로, 재사용성과 타입 안정성을 동시에 달성할 수 있습니다.

관련 질문

같은 카테고리/태그 기준