TypeScript 제네릭 완벽 가이드 — 제약 조건부터 유틸리티 타입까지

제네릭이 필요한 이유

같은 로직을 다양한 타입에 적용하고 싶을 때, any를 쓰면 타입 안전성이 사라집니다. 제네릭(Generics)은 타입을 매개변수화하여 재사용성과 타입 안전성을 동시에 확보하는 기법입니다.

이 글에서는 제네릭 함수, 인터페이스, 클래스, 제약 조건, 유틸리티 타입, 조건부 타입까지 단계별로 다룹니다.

제네릭 함수 기본

제네릭 함수는 호출 시 타입이 결정됩니다. 꺾쇠괄호 안에 타입 매개변수를 선언합니다.

// any를 사용한 경우: 타입 정보 손실
function identityAny(value: any): any {
  return value;
}
const resultAny = identityAny("hello"); // 타입: any — 자동완성 불가

// 제네릭을 사용한 경우: 타입 정보 보존
function identity<T>(value: T): T {
  return value;
}

// 호출 시 타입이 자동 추론됨
const str = identity("hello");      // 타입: string
const num = identity(42);           // 타입: number
const arr = identity([1, 2, 3]);    // 타입: number[]

// 명시적 타입 지정도 가능
const explicit = identity<string>("world"); // 타입: string

// 여러 타입 매개변수
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}
const p = pair("name", 30); // 타입: [string, number]
console.log(p); // ["name", 30]

제네릭 인터페이스와 타입

인터페이스와 타입 별칭에도 제네릭을 적용할 수 있습니다. API 응답, 컬렉션 구조 등에서 자주 사용됩니다.

// 제네릭 인터페이스: API 응답 래퍼
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

// 다양한 응답 타입에 재사용
interface User {
  id: number;
  name: string;
  email: string;
}

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

// 동일한 래퍼, 다른 데이터 타입
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "홍길동", email: "hong@example.com" },
  status: 200,
  message: "성공",
  timestamp: new Date(),
};

const productResponse: ApiResponse<Product[]> = {
  data: [
    { id: 1, title: "노트북", price: 1500000 },
    { id: 2, title: "마우스", price: 35000 },
  ],
  status: 200,
  message: "성공",
  timestamp: new Date(),
};

// 제네릭 타입 별칭: 결과 타입
type Result<T, E = Error> = 
  | { success: true; data: 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, data: a / b };
}

const result = divide(10, 3);
if (result.success) {
  console.log(result.data.toFixed(2)); // "3.33" — data가 number로 좁혀짐
} else {
  console.log(result.error); // error가 string으로 좁혀짐
}

제네릭 제약 조건 (extends)

제네릭 타입에 제약을 걸어 특정 속성이나 메서드가 있는 타입만 허용할 수 있습니다.

// 제약 조건: length 속성이 있는 타입만 허용
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(`길이: ${value.length}`);
  return value;
}

logLength("hello");      // 길이: 5 — string은 length 있음
logLength([1, 2, 3]);    // 길이: 3 — 배열은 length 있음
// logLength(42);         // 에러! number에는 length 없음

// keyof 제약: 객체의 키만 허용
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "홍길동", age: 30, email: "hong@example.com" };
const name = getProperty(user, "name");   // 타입: string
const age = getProperty(user, "age");     // 타입: number
// getProperty(user, "phone");            // 에러! "phone"은 키가 아님

// 여러 제약 조건 결합
interface Identifiable {
  id: number;
}
interface Nameable {
  name: string;
}

function displayEntity<T extends Identifiable & Nameable>(entity: T): string {
  return `[${entity.id}] ${entity.name}`;
}

console.log(displayEntity({ id: 1, name: "홍길동", email: "hong@test.com" }));
// "[1] 홍길동"

제네릭 클래스

클래스에 제네릭을 적용하면 타입 안전한 컬렉션이나 서비스 계층을 만들 수 있습니다.

// 제네릭 스택 구현
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(10);
numberStack.push(20);
numberStack.push(30);
console.log(numberStack.pop());  // 30
console.log(numberStack.peek()); // 20
console.log(numberStack.size);   // 2

// 문자열 스택
const stringStack = new Stack<string>();
stringStack.push("첫 번째");
stringStack.push("두 번째");
console.log(stringStack.pop()); // "두 번째"

// 제네릭 리포지토리 패턴
interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private store: Map<number, T> = new Map();

  save(entity: T): void {
    this.store.set(entity.id, entity);
  }

  findById(id: number): T | undefined {
    return this.store.get(id);
  }

  findAll(): T[] {
    return Array.from(this.store.values());
  }

  delete(id: number): boolean {
    return this.store.delete(id);
  }
}

interface Product extends Entity {
  title: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, title: "키보드", price: 89000 });
productRepo.save({ id: 2, title: "모니터", price: 450000 });

console.log(productRepo.findById(1)); // { id: 1, title: "키보드", price: 89000 }
console.log(productRepo.findAll().length); // 2

유틸리티 타입

TypeScript는 제네릭 기반의 내장 유틸리티 타입을 제공합니다. 기존 타입을 변환하여 새로운 타입을 만들 때 사용합니다.

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  createdAt: Date;
}

// Partial: 모든 속성을 선택적으로
type UpdateUserDto = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; createdAt?: Date; }

function updateUser(id: number, updates: Partial<User>): void {
  console.log(`사용자 ${id} 업데이트:`, updates);
}
updateUser(1, { name: "새이름" }); // 일부 속성만 전달 가능

// Required: 모든 속성을 필수로
type RequiredUser = Required<User>;

// Pick: 특정 속성만 선택
type UserSummary = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string; }

// Omit: 특정 속성 제외
type UserWithoutDates = Omit<User, "createdAt">;
// { id: number; name: string; email: string; age: number; }

// Record: 키-값 쌍 타입
type UserRoles = Record<string, "admin" | "user" | "guest">;
const roles: UserRoles = {
  hong: "admin",
  kim: "user",
  lee: "guest",
};

// Readonly: 모든 속성을 읽기 전용으로
type ImmutableUser = Readonly<User>;
const frozenUser: ImmutableUser = {
  id: 1,
  name: "홍길동",
  email: "hong@test.com",
  age: 30,
  createdAt: new Date(),
};
// frozenUser.name = "변경"; // 에러! readonly 속성

// Extract / Exclude: 유니온 타입 필터링
type AllStatus = "active" | "inactive" | "pending" | "deleted";
type ActiveStatus = Extract<AllStatus, "active" | "pending">;
// "active" | "pending"
type ArchivedStatus = Exclude<AllStatus, "active" | "pending">;
// "inactive" | "deleted"

실전 팁

  • 제네릭 이름 컨벤션: T(Type), K(Key), V(Value), E(Element) 등 의미 있는 약어를 사용합니다. 복잡한 경우 TData, TError처럼 접두사를 붙입니다.
  • 과도한 제네릭 금지: 타입 매개변수가 한 번만 사용되면 제네릭이 필요 없을 수 있습니다. 단순함을 유지합니다.
  • 기본 타입 매개변수: type Result<T, E = Error>처럼 기본값을 제공하면 호출 시 생략할 수 있습니다.
  • keyof + 인덱스 접근 타입: 객체의 키와 값 타입을 안전하게 다루는 핵심 패턴입니다.
  • 유틸리티 타입 조합: Partial, Pick, Omit을 조합하면 DTO, 폼 타입 등을 기존 인터페이스에서 빠르게 파생할 수 있습니다.

이 글이 도움이 되었나요?