제네릭(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로 추론제네릭으로 배열 다루기
제네릭은 배열이나 리스트 형태의 데이터를 다룰 때 그 진가를 발휘합니다.
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)
- 의미 있는 이름 사용: 단순한 경우에는
T,U도 좋지만, 복잡해지면TData,TResponse,TItem처럼 의미를 명확히 드러내는 이름을 사용하세요. - 가능한 한 제약 걸기:
any처럼 동작하지 않도록,extends를 사용해 가능한 한 타입을 좁혀주는 것이 좋습니다. - 기본 타입(Default Type) 제공:
interface Wrapper<T = string>처럼 자주 사용되는 타입을 기본값으로 제공하면 사용하기 편리해집니다.
마치며
제네릭은 TypeScript를 단순한 “타입 있는 자바스크립트”에서 “견고한 아키텍처를 위한 언어”로 격상시키는 핵심 기능입니다. 처음에는 문법이 낯설 수 있지만, 익숙해지면 라이브러리 코드를 읽거나 재사용 가능한 유틸리티를 만들 때 없어서는 안 될 도구가 될 것입니다.