TypeScript를 한동안 사용하고 있지만 제네릭이 완전히 이해하지 못한 채 스택 오버플로에서 복붙하는 것처럼 느껴진다면, 이 글이 그것을 바로잡아 줄 것입니다. 제네릭은 TypeScript를 "타입 어노테이션이 있는 JavaScript"에서 진정으로 표현력 있는 타입 시스템으로 격상시키는 기능입니다. 한번 이해가 되면 어디서나 볼 수 있게 되고 — 까다로운 상황에서 any로 기본 설정하는 대신 자연스럽게 손이 가게 됩니다. TypeScript 핸드북의 제네릭 챕터에서 전체 스펙을 다루지만, 이 글은 실제 코드베이스에서 실제로 사용하는 패턴에 집중합니다.

any의 문제

제네릭은 특정 문제를 해결하기 위해 존재합니다: 함수나 데이터 구조가 여러 타입으로 동작하길 원하지만 그 과정에서 타입 정보를 버리고 싶지 않을 때. 순진한 해결책은 any입니다 — 무엇을 잃었는지 깨달을 때까지는 괜찮아 보입니다:

ts
// any 사용 — TypeScript는 무엇이 나오는지 모름
function identity(arg: any): any {
  return arg;
}

const result = identity('hello');
// result는 'any'로 타입화됨 — string 타입을 잃었습니다
// TypeScript는 이것을 잡지 못합니다:
result.toFixed(2); // 컴파일 타임에 에러 없음, 런타임에 크래시

타입 정보는 들어가지만 나오지 않습니다. result로 무엇을 하든 완전히 체크되지 않습니다. 그것은 TypeScript 코드베이스가 아닙니다 — 추가 단계만 있는 JavaScript입니다. 제네릭은 이렇게 말할 수 있게 해주어 이것을 해결합니다: "아직 정확한 타입은 모르지만, 들어오는 것이 같은 타입으로 나가야 합니다."

ts
// 제네릭 사용 — T가 함수를 통해 흐름
function identity<T>(arg: T): T {
  return arg;
}

const result = identity('hello');
// result는 'string'으로 타입화됨 ✅
result.toFixed(2); // ❌ TypeScript가 이것을 잡음: 'string' 타입에 'toFixed' 속성이 없습니다

const count = identity(42);
// count는 'number'로 타입화됨 ✅

<T>는 타입 파라미터를 선언합니다 — 타입을 위한 변수라고 생각하세요. TypeScript는 전달하는 인수에서 T가 무엇인지 추론하므로 명시적으로 작성할 필요가 거의 없습니다. identity<string>('hello')도 작동하지만, identity('hello')도 마찬가지입니다 — TypeScript가 알아냅니다.

실제 제네릭 함수

identity 함수는 표준 교육 예제이지만 실제로 작성할 것은 아닙니다. 실제 코드베이스에서 실제로 등장하는 제네릭 함수들은 다음과 같습니다:

ts
// 타입 안전한 배열 first/last 헬퍼
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

function last<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

const firstUser = first(users);       // UserProfile | undefined
const lastOrder = last(orders);       // Order | undefined
const firstNum  = first([1, 2, 3]);   // number | undefined

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

// TypeScript는 T를 Order로, K를 string으로 추론
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending']은 Order[] ✅

TypeScript가 함수 호출 방식에서 자동으로 TK를 추론하는 것을 주목하세요. 호출 사이트에 단 하나의 명시적 타입 어노테이션도 작성하지 않고 반환 값에 완전한 타입 안전성을 얻습니다. 그것이 보상입니다.

제네릭 인터페이스와 타입

여기서 제네릭은 일상 작업에 없어서는 안 될 존재가 됩니다. API와 통신하는 모든 코드베이스는 결국 몇 가지 제네릭 래퍼 타입이 필요합니다. 항상 보게 될(그리고 작성하게 될) 것들은 다음과 같습니다:

ts
// API 응답 래퍼 — 모든 엔드포인트에서 사용
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// 페이지네이션된 목록 응답
interface PaginatedResult<T> {
  items: T[];
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
}

// 구체적인 타입으로 사용
interface UserProfile {
  id: number;
  name: string;
  email: string;
  avatarUrl: string;
}

interface Order {
  id: number;
  userId: number;
  total: number;
  status: 'pending' | 'shipped' | 'delivered';
}

// 반환 타입이 완전히 타입화됨 — 하위에서 캐스팅 불필요
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function listOrders(page: number): Promise<PaginatedResult<Order>> {
  const res = await fetch(`/api/orders?page=${page}`);
  return res.json();
}

const response = await getUser(1);
response.data.email;        // ✅ string
response.data.avatarUrl;    // ✅ string

const result = await listOrders(1);
result.items[0].status;     // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages;          // ✅ number
실제 API 페이로드에서 이런 타입을 구축할 때, JSON to TypeScript 도구가 샘플 응답에서 내부 타입 (UserProfile, Order)을 자동으로 생성할 수 있습니다. 그런 다음 그것들을 제네릭 래퍼에 연결합니다.

제네릭 제약

때로는 여러 타입을 받아들이고 싶지만 절대적으로 어떤 타입도 아닐 때. 제네릭 제약은 타입 파라미터가 무엇을 가져야 하는지 지정할 수 있게 해줍니다. 문법은 T extends SomeType입니다 — "T는 SomeType에 할당 가능해야 합니다"를 의미합니다:

ts
// 제약 없이 — TypeScript는 T에 id 속성이 있는지 모름
function findById<T>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ❌ 'T' 타입에 'id' 속성이 없습니다
}

// 제약 사용 — T는 최소한 id: number 필드가 있어야 함
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id); // ✅
}

// id: number가 있는 모든 타입과 작동
const user  = findById(users, 42);   // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined

// 또 다른 일반적인 제약 — T는 객체여야 함 (프리미티브 제외)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
  return { ...defaults, ...partial };
}

// T는 name: string과 email: string이 있어야 함
function formatContact<T extends { name: string; email: string }>(contact: T): string {
  return `${contact.name} <${contact.email}>`;
}

// 해당 두 필드가 있는 모든 객체에서 작동 — UserProfile, Employee, 무엇이든
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅

제약은 T를 정확히 { id: number }로 잠그지 않습니다 — T최소한 그 형태를 가져야 한다는 것입니다. 따라서 10개의 필드가 있는 전체 UserProfile을 전달해도 괜찮습니다. 이것은 구조적 타이핑으로, TypeScript의 가장 강력하고 잘 문서화된 기능 중 하나입니다.

keyof 제약

TypeScript 표준 라이브러리와 실제 코드베이스에서 가장 유용한 제네릭 패턴 중 하나는 제네릭과 keyof를 결합하는 것입니다. 객체 타입의 속성 이름을 받아들이고 반환 타입이 해당 속성과 일치하도록 보장하는 함수를 작성할 수 있게 해줍니다.

ts
// keyof T는 T의 모든 키들의 문자열 리터럴 유니온
// K extends keyof T는 K가 그 키들 중 하나여야 함을 의미
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: UserProfile = {
  id: 1,
  name: 'Alice',
  email: '[email protected]',
  avatarUrl: 'https://example.com/avatar.png'
};

const name  = getProperty(user, 'name');  // string ✅
const id    = getProperty(user, 'id');    // number ✅
const email = getProperty(user, 'email'); // string ✅

getProperty(user, 'password'); // ❌ '"password"' 타입의 인수는
                               //    'keyof UserProfile' 타입의 파라미터에 할당할 수 없습니다

// 실제 사용 사례: 제네릭 정렬 함수
function sortBy<T>(items: T[], key: keyof T): T[] {
  return [...items].sort((a, b) => {
    const av = a[key];
    const bv = b[key];
    return av < bv ? -1 : av > bv ? 1 : 0;
  });
}

const byName = sortBy(users, 'name');   // ✅ 이름으로 정렬
const byId   = sortBy(users, 'id');     // ✅ id로 정렬
sortBy(users, 'nonexistent');           // ❌ 컴파일 타임에 잡힘

기본 타입 파라미터

함수 파라미터에 기본값이 있을 수 있는 것처럼, 타입 파라미터에도 기본값을 줄 수 있습니다. 이것은 거의 항상 특정 타입으로 사용되는 제네릭 타입이 있을 때 유용하지만, 그렇지 않은 경우를 위한 유연성을 유지하고 싶을 때입니다.

ts
// 기본 타입 파라미터 — 지정하지 않으면 T의 기본값은 string
interface Cache<T = string> {
  get(key: string): T | undefined;
  set(key: string, value: T, ttlMs?: number): void;
  delete(key: string): void;
  clear(): void;
}

// T를 지정하지 않으면 기본값(string) 사용
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined

// 다른 T 지정
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined

// 또 다른 유용한 예: 타입이 지정된 페이로드가 있는 이벤트 이미터
interface TypedEvent<TPayload = void> {
  subscribe(handler: (payload: TPayload) => void): () => void;
  emit(payload: TPayload): void;
}

// 페이로드가 없는 이벤트 — 기본값 void로 API가 깔끔하게 유지됨
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // 인수 불필요

// 페이로드가 있는 이벤트
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });

내장 제네릭 유틸리티 타입

TypeScript는 위에서 배운 제네릭을 사용하여 구현된 내장 유틸리티 타입 세트를 제공합니다. 사용 방법을 이해하는 것은 가질 수 있는 가장 실용적인 TypeScript 기술 중 하나입니다. 다음은 출력 타입을 보여주는 실제 예제가 있는 핵심 세트입니다:

ts
interface UserProfile {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'viewer';
  passwordHash: string;
  createdAt: Date;
}

// Partial<T> — 모든 필드가 선택적이 됨 (PATCH 페이로드에 좋음)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }

// Required<T> — 모든 필드가 필수가 됨 (Partial의 역)
interface DraftConfig {
  apiUrl?: string;
  timeout?: number;
  maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }

// Pick<T, K> — 명명된 필드만 유지
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// 목록 뷰에서 사용 — UI가 필요한 것만 전송

// Omit<T, K> — 명명된 필드 제외
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// API 응답에 포함하기에 안전

// Record<K, V> — 타입이 지정된 딕셔너리 / 맵
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }

const permissions: PermissionMap = {
  admin:  ['read', 'write', 'delete', 'admin'],
  user:   ['read', 'write'],
  viewer: ['read']
};

// ReturnType<T> — 함수가 반환하는 것을 추론 (타입을 자동으로 동기화 유지)
function buildUserSession(user: UserProfile) {
  return {
    token: crypto.randomUUID(),
    userId: user.id,
    role: user.role,
    expiresAt: new Date(Date.now() + 3_600_000)
  };
}

type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// buildUserSession을 변경하면 UserSession이 자동으로 업데이트됨 ✅

기존 JavaScript를 TypeScript로 변환하고 이런 타입을 처음부터 구축하고 있다면, JS to TypeScript 도구가 초기 타입을 제자리에 놓는 데 도움이 될 수 있으므로 그 위에 유틸리티 타입을 레이어링하기 시작할 수 있습니다.

현실적인 엔드투엔드 예제: 타입이 지정된 API 클라이언트

타입이 지정된 API 레이어를 구축할 때마다 사용하는 패턴입니다. 제네릭 함수, 제네릭 인터페이스, 유틸리티 타입을 fetch 호출부터 데이터를 소비하는 컴포넌트까지 엔드투엔드 타입 안전성을 제공하는 것으로 결합합니다:

ts
// 응답 봉투 — 모든 API 응답을 감쌈
interface ApiResponse<T> {
  data: T;
  meta: {
    requestId: string;
    duration: number;
  };
}

// 에러 봉투 — 실패 시 API가 보내는 것
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

// 제네릭 fetch 래퍼
async function fetchApi<T>(
  url: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  const res = await fetch(url, {
    headers: { 'Content-Type': 'application/json', ...options?.headers },
    ...options
  });

  if (!res.ok) {
    const err: ApiError = await res.json();
    throw new Error(`[${err.code}] ${err.message}`);
  }

  return res.json();
}

// 타입이 지정된 엔드포인트 함수 — T는 각 호출 사이트에서 설정됨
async function getUser(id: number): Promise<UserProfile> {
  const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
  return data;
}

async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
  const { data } = await fetchApi<PaginatedResult<Order>>(
    `/api/users/${userId}/orders?page=${page}`
  );
  return data;
}

async function createOrder(
  payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
  const { data } = await fetchApi<Order>('/api/orders', {
    method: 'POST',
    body: JSON.stringify(payload)
  });
  return data;
}

// 사용 — 어디에도 캐스팅 없이 완전히 타입화됨
const user = await getUser(42);
console.log(user.email);          // string ✅

const orders = await listOrders(42);
orders.items[0].status;           // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages;                // number ✅

const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id;                      // number ✅
이런 패턴들을 직접 TypeScript 플레이그라운드에서 실험할 수 있습니다 — 코드를 붙여넣고, 변수 위에 마우스를 올려 추론된 타입을 확인하고, 무언가를 깨뜨려서 컴파일러가 무엇을 잡는지 확인하세요. 제네릭을 배우기 위한 가장 빠른 피드백 루프입니다.

흔한 실수들

제네릭에 익숙해진 후 피해야 할 몇 가지 패턴:

  • 제네릭 내부에서 any 사용. function wrap<T>(val: T): any를 작성한다면 목적을 이미 달성한 것입니다. 핵심은 타입이 흐르는 것입니다 — 반환 타입이나 구현 내부에서 any를 사용하면 TypeScript가 더 이상 추적할 수 없습니다.
  • 특정 타입으로 과도하게 제약. function process<T extends UserProfile>를 작성할 때 T extends { id: number }만 필요하다면 너무 제한적입니다. 구현이 작동하게 하는 최소 제약을 사용하세요 — 그렇게 하면 함수가 동일한 형태를 가진 다른 타입에서도 재사용 가능하게 유지됩니다.
  • 유니온으로 충분할 때 제네릭 사용. 함수가 string이나 number를 받고 로직이 각각 다른 경우, 내부에 타입 가드가 있는 function f(arg: string | number)가 제네릭보다 더 깔끔합니다. 제네릭은 모든 타입 변형에 동일한 로직이 적용될 때 빛납니다.
  • 너무 많은 타입 파라미터. <T, U, V, W>를 작성하고 있다면 잠깐 뒤로 물러서세요. 그것은 보통 함수가 너무 많은 것을 하고 있거나 타입이 단일 인터페이스로 표현될 수 있다는 신호입니다. TypeScript 컴파일러 소스 자체가 좋은 참조입니다 — 복잡한 유틸리티도 2~3개 이상의 타입 파라미터가 거의 필요하지 않습니다.

마무리

제네릭은 TypeScript가 "어노테이션된 JavaScript"에서 진정으로 강력한 타입 시스템이 되는 지점입니다. 핵심 아이디어는 간단합니다: 타입을 파라미터로 캡처해서 정보를 잃지 않고 함수나 인터페이스를 통해 흐르게 합니다. 거기서부터 제약은 허용 가능한 것을 좁히고, keyof는 타입 안전한 속성 접근을 제공하며, 내장 유틸리티 타입(Partial, Pick, Omit, Record)은 직접 작성해야 했을 패턴을 처리합니다. 가장 좋은 다음 단계는 실제 프로젝트를 열고, 다른 방법을 몰라서 any를 사용한 함수를 찾아 제네릭으로 대체하는 것입니다. 제네릭에 대한 TypeScript 핸드북유틸리티 타입 참조는 북마크할 가치가 있는 두 페이지입니다. API 페이로드에서 인터페이스를 구축하고 있다면, JSON to TypeScript가 구체적인 타입을 생성할 수 있습니다 — 그런 다음 완전한 엔드투엔드 타입 안전성을 위해 ApiResponse<T>PaginatedResult<T> 같은 제네릭 유틸리티 타입으로 감싸면 됩니다.