전체 목록
TypeScriptMedium#10

제네릭(Generics)이란 무엇이며 어떤 상황에서 유용한가요?

#TS#제네릭#타입안전성
힌트

타입의 재사용성과 타입 안전성을 동시에 확보하는 방법을 생각해보세요.

정답 및 해설

제네릭(Generics)이란 무엇이며 어떤 상황에서 유용한가요?

제네릭(Generics)은 타입을 매개변수처럼 사용하여, 다양한 타입에서 재사용 가능한 컴포넌트(함수, 클래스, 인터페이스)를 만드는 TypeScript의 핵심 기능입니다. 타입 안전성을 유지하면서 유연한 코드를 작성할 수 있게 해줍니다.

제네릭이 필요한 이유

any를 사용할 때의 문제

// any 사용 — 타입 안전성 완전히 포기
function identity(value: any): any {
  return value;
}

const result = identity('hello');
result.toUpperCase(); // 동작하지만...
result.nonExistentMethod(); // 에러가 있어도 컴파일 통과 (위험!)

오버로딩으로 해결하려는 경우 — 코드 중복

function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
function identityBoolean(value: boolean): boolean { return value; }
// 타입마다 별도 함수 — 중복, 비효율적

제네릭으로 해결

// T는 타입 매개변수 (Type Parameter)
function identity<T>(value: T): T {
  return value;
}

// 타입 추론 — TypeScript가 자동으로 T를 추론
const str = identity('hello');   // T = string, str: string
const num = identity(42);        // T = number, num: number

// 명시적 타입 지정
const arr = identity<number[]>([1, 2, 3]);

제네릭 함수

기본 예제

// 배열의 첫 번째 요소 반환
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const firstNum = first([1, 2, 3]);    // number | undefined
const firstStr = first(['a', 'b']);   // string | undefined

// 두 값을 쌍으로 반환
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const p = pair('hello', 42); // [string, number]

실용적인 예제

// 배열 요소를 키로 그룹화
function groupBy<T, K extends string | number | symbol>(
  arr: T[],
  getKey: (item: T) => K
): Record<K, T[]> {
  return arr.reduce((acc, item) => {
    const key = getKey(item);
    if (!acc[key]) acc[key] = [];
    acc[key].push(item);
    return acc;
  }, {} as Record<K, T[]>);
}

const users = [
  { name: 'Alice', role: 'admin' },
  { name: 'Bob', role: 'user' },
  { name: 'Charlie', role: 'admin' },
];

const byRole = groupBy(users, (u) => u.role);
// { admin: [{...}, {...}], user: [{...}] }

제네릭 인터페이스

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

interface User {
  id: number;
  name: string;
  email: string;
}

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

const result = await fetchUser(1);
console.log(result.data.name); // User 타입으로 자동완성 지원
// 페이지네이션 응답
interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  hasNext: boolean;
}

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

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

제네릭 클래스

// 타입 안전한 스택(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];
  }

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

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

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.peek(); // number | undefined

const stringStack = new Stack<string>();
stringStack.push('hello');
// stringStack.push(42); // 에러: number는 string에 할당 불가

제약 조건 (Constraints)

extends 키워드로 타입 매개변수에 제약을 걸 수 있습니다.

// T는 length 프로퍼티를 가져야 함
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength('hello');    // 5 (string은 length 있음)
getLength([1, 2, 3]);  // 3 (array는 length 있음)
// getLength(42);      // 에러: 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', email: 'alice@example.com' };
const name = getProperty(user, 'name');  // string
const id = getProperty(user, 'id');      // number
// getProperty(user, 'age'); // 에러: 'age'는 User의 키가 아님

기본값과 조건부 제네릭

// 기본 타입 매개변수
interface Container<T = string> {
  value: T;
  label: string;
}

const defaultContainer: Container = { value: 'hello', label: '기본' }; // T = string
const numContainer: Container<number> = { value: 42, label: '숫자' };

실무 활용 패턴

useFetch 훅 (React)

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

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

  return state;
}

// 사용
const { data: user, loading } = useFetch<User>('/api/users/1');
// user는 User | null 타입

유틸리티 함수

// null/undefined를 제거하고 타입 좁히기
function filterDefined<T>(arr: (T | null | undefined)[]): T[] {
  return arr.filter((item): item is T => item != null);
}

const maybeUsers: (User | null)[] = [
  { id: 1, name: 'Alice' },
  null,
  { id: 2, name: 'Bob' },
];

const users = filterDefined(maybeUsers); // User[]

정리

사용 위치예제주요 용도
함수function fn<T>(arg: T): T입출력 타입 연결
인터페이스interface Resp<T> { data: T }API 응답, 컨테이너 타입
클래스class Stack<T>자료구조, 서비스 클래스
제약 조건<T extends object>허용 타입 범위 제한

제네릭은 any를 사용하지 않고도 유연한 코드를 작성할 수 있게 해주는 핵심 도구입니다. 처음에는 복잡해 보일 수 있지만, 재사용성과 타입 안전성을 동시에 얻을 수 있어 TypeScript 코드 품질을 크게 향상시킵니다.