타입스크립트를 쓰다 보면 제네릭을 마주치는 일이 정말 많습니다. 라이브러리의 타입 정의를 읽을 때, API 응답 타입을 만들 때, 공통 함수를 작성할 때 등 어디에서든 <T> 같은 꺾쇠괄호가 등장하는데요, 그런데 다른 사람이 작성한 제네릭은 대충 읽을 수 있어도, 막상 직접 작성하려면 막막한 경우가 많습니다.
이번 글에서는 제네릭이 왜 필요한지부터 실무에서 자주 만나는 패턴까지 단계별로 살펴보겠습니다.
미리 요점만 콕 집어보면?
제네릭이 왜 필요한지 이해하려면, 제네릭 없이 코드를 작성했을 때 어떤 불편함이 생기는지 먼저 살펴보는 것이 좋습니다.
배열의 첫 번째 요소를 반환하는 간단한 함수를 만든다고 해봅시다. string 배열이라면 이렇게 작성할 수 있습니다.
function getFirstString(arr: string[]): string {
return arr[0];
}
그런데 number 배열도 처리해야 한다면 어떻게 해야 할까요? 같은 로직의 함수를 하나 더 만들어야 합니다.
function getFirstNumber(arr: number[]): number {
return arr[0];
}
로직은 완전히 같은데 타입만 다르다는 이유로 함수가 계속 늘어납니다. boolean 배열, 객체 배열까지 대응하려면 끝이 없어지겠죠.

그렇다면 매개변수 타입을 any로 바꾸면 어떨까요? 중복 문제는 해결됩니다.
function getFirst(arr: any[]): any {
return arr[0];
}
const result = getFirst([1, 2, 3]);
result.toUpperCase(); // 에러가 안 남
any를 쓰면 타입 정보가 사라질뿐 아니라, 타입스크립트의 검사 자체가 대부분 우회됩니다. result가 number인데도 toUpperCase()를 호출해도 타입스크립트가 경고해 주지 않습니다. 결국 런타임에서야 에러가 터지게 되는데, 이렇게 되면 타입스크립트를 쓰는 의미가 퇴색되겠죠.
바로 이런 상황에서 필요한 것이 제네릭입니다. 타입의 중복도 없애고, 타입 안전성도 유지할 수 있는 방법이기 때문입니다.
제네릭의 필요성을 확인했으니, 이제 실제 문법을 살펴보겠습니다. 함수, 인터페이스, 그리고 제약 조건까지 단계적으로 알아볼게요.

제네릭 함수는 꺾쇠괄호(<T>)로 타입 매개변수를 선언합니다. T는 호출 시점에 전달되는 값에 따라 타입이 결정됩니다.
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = getFirst([1, 2, 3]); // T는 number로 추론
const str = getFirst(["a", "b", "c"]); // T는 string으로 추론
getFirst([1, 2, 3])을 호출하면 타입스크립트가 인자를 보고 T를 number로 추론합니다. 덕분에 반환값 num도 number 타입으로 정확하게 잡히죠. 아까 any를 썼을 때와 달리, num.toUpperCase()를 쓰면 컴파일 단계에서 에러가 발생합니다.
타입 추론이 원하는 대로 되지 않을 때는 직접 타입을 명시할 수도 있습니다.
const value = getFirst<string>(["a", "b", "c"]);
하지만 대부분 타입스크립트가 알아서 추론해 주기 때문에 특별한 이유가 없다면 명시하지 않아도 괜찮습니다.
제네릭은 함수뿐 아니라 인터페이스와 타입 별칭에도 적용할 수 있습니다. 특히 구조는 같지만 내부 데이터 타입만 달라지는 경우에 유용합니다.
interface KeyPair<K, V> {
key: K;
value: V;
}
const pair1: KeyPair<string, number> = {
key: "age",
value: 27,
};
const pair2: KeyPair<string, boolean> = {
key: "isActive",
value: true,
};
하지만 함수와 한 가지 다른 점이 있습니다. 제네릭 함수는 호출 시 인자를 보고 타입을 추론할 수 있지만, 제네릭 인터페이스나 타입 별칭은 함수처럼 호출 인자를 통해 즉시 추론되는 경우가 적기 때문에, 사용하는 위치에 따라 타입 인수를 직접 명시하는 경우가 많습니다.
타입 별칭에서도 동일하게 사용할 수 있습니다.
type Pair<K, V> = {
key: K;
value: V;
};
제네릭은 기본적으로 어떤 타입이든 받을 수 있습니다. 하지만 실무에서는 아무 타입이나 받으면 안 되는 경우가 많은데요, 이때 extends 키워드로 타입의 범위를 제한할 수 있습니다.
function getLength<T extends { length: number }>(data: T): number {
return data.length;
}
getLength("hello"); // ✅ string은 length 속성이 있음
getLength([1, 2, 3]); // ✅ 배열은 length 속성이 있음
getLength({ length: 5 }); // ✅ 직접 정의한 객체도 가능
getLength(123); // ❌ number에는 length가 없어서 에러
T extends { length: number }는 “T는 반드시 number 타입의 length 속성을 가져야 한다”는 제약입니다. 이 조건을 만족하지 않는 타입이 들어오면 컴파일 단계에서 에러가 발생합니다. 이처럼 제약 조건을 활용하면 제네릭의 유연함은 유지하면서도, 필요한 속성이나 메서드가 있다는 것을 보장받을 수 있습니다.
문법을 익혔으니, 이제 실무에서 제네릭이 어떻게 쓰이는지 살펴보겠습니다. 가장 흔하게 만나는 두 가지 패턴을 소개해 보겠습니다.
실무에서 백엔드 API를 호출하면, 응답 구조가 보통 일정한 형태를 따릅니다. 상태 코드와 메시지는 공통이고, 실제 데이터 부분만 엔드포인트 마다 달라지는 경우가 많죠.
interface ApiResponse<T> {
success: boolean;
statusCode: number;
data: T;
message: string;
}
이렇게 data 부분만 제네릭으로 열어두면, 하나의 인터페이스로 모든 API 응답을 커버할 수 있습니다.
interface User {
id: number;
name: string;
}
interface Post {
id: number;
title: string;
content: string;
}
type UserResponse = ApiResponse<User>;
type PostListResponse = ApiResponse<Post[]>;

ApiResponse<User>는 data가 User 타입이 되고, ApiResponse<Post[ ]>는 data가 Post[ ] 타입이 됩니다. 엔드포인트가 수십 개라도 API 응답 래퍼를 매번 새로 정의할 필요가 없습니다.
프로젝트를 진행하다 보면 배열이나 객체를 다루는 공통 유틸 함수를 만들게 됩니다. 이런 함수에 제네릭을 적용하면 반환 타입까지 정확하게 추론되는 것을 확인할 수 있습니다.
// 배열에서 특정 조건을 만족하는 첫 번째 요소 반환
function findFirst<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
return arr.find(predicate);
}
const users = [
{ name: "홍길동", age: 27 },
{ name: "김철수", age: 30 },
];
const found = findFirst(users, (user
findFirst에 users 배열을 넘기면, 타입스크립트가 T를 { name: string; age: number }로 추론합니다. 덕분에 predicate 콜백의 user 매개변수에서도 user.age에 자동완성이 동작하고, 반환값의 타입도 정확하게 잡힙니다.
객체의 특정 필드를 추출하는 함수도 제네릭과 제약 조건을 조합하면 타입 안전하게 만들 수 있습니다.
function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
return arr.map((item) => item[key]);
}
const names = pluck(users, "name"); // string[]
const ages = pluck(users, "age"); // number[]
K extends keyof T는 “K는 T의 키 중 하나여야 한다”는 제약입니다. 덕분에 존재하지 않는 키를 넘기면 컴파일 에러가 발생하고, 반환 타입도 해당 키의 값 타입에 맞게 자동으로 추론됩니다.
이번 글에서는 제네릭이 필요한 이유부터 기본 문법, 그리고 실무 패턴까지 살펴봤습니다. 처음에는 <T> 같은 꺾쇠괄호가 낯설게 느껴질 수 있지만, 결국 핵심은 하나입니다. 타입을 값처럼 매개변수로 전달해서 중복 없이 다양한 타입에 대응하는 것이죠. 실무에서 반복되는 타입 패턴이 보인다면, 그것이 바로 제네릭을 쓸 타이밍입니다. 특히 API응답 타입이나 공통 유틸 함수처럼 구조는 같고 데이터만 달라지는 상황에서 제네릭은 가장 큰 효과를 발휘합니다.
제네릭에 익숙해지면 타입스크립트가 기본으로 제공하는 유틸리티 타입(Pick, Omit, Partial 등)을 이해하는 것도 훨씬 수월해집니다. 이 유틸리티 타입들도 결국 제네릭으로 만들어진 것이기 때문입니다.
<참고>
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.