목표·선수 지식: 함수·props와 union을 알고 있다면 입력과 출력의 타입 관계를 유지하며 API 경계의 실제 값을 검사합니다. 본문의 짧은 문법 블록은 서로 독립된 부분 예제입니다. 같은 파일에 모두 합치면 중복 선언이 생길 수 있습니다.
핵심 요약
TypeScript 제네릭은 어려운 고급 문법이라기보다, 나중에 타입을 넣기 위해 비워두는 자리입니다. React props나 API 응답처럼 구조는 같고 안에 들어가는 데이터 타입만 달라질 때 제네릭을 사용하면 any보다 안전하게 타입을 재사용할 수 있습니다.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
TypeScript를 배우다 보면 어느 순간 <T> 같은 문법을 만나게 됩니다. 처음 보면 함수 같기도 하고, HTML 태그 같기도 해서 갑자기 코드가 어려워 보입니다. 하지만 제네릭을 이해할 때 가장 먼저 볼 것은 문법 자체가 아니라 “왜 이런 방식이 필요한가”입니다.
실무에서 자주 만나는 문제는 생각보다 단순합니다. 여러 컴포넌트나 API 응답에서 전체 구조는 비슷한데, 안에 들어가는 데이터 타입만 달라지는 경우가 많습니다. User 목록도 있고 Product 목록도 있고 Post 목록도 있지만, 목록을 렌더링하는 방식이나 서버 응답의 바깥 구조는 거의 같습니다. 이때 매번 타입을 새로 만들면 중복이 늘어나고, any를 쓰면 타입 안정성이 사라집니다.

제네릭이 처음에 어렵게 느껴지는 이유
제네릭이 낯선 가장 큰 이유는 타입을 값처럼 다루는 느낌을 주기 때문입니다. 일반적으로 함수에는 값이 들어갑니다. 예를 들어 getUser(id)처럼 호출하면 id라는 값이 들어갑니다. 그런데 제네릭에서는 ApiResponse<User>처럼 타입이 들어갑니다. 이 차이가 처음에는 어색하게 느껴집니다.
문법보다 먼저 봐야 할 핵심 개념
제네릭을 쉽게 말하면 “타입이 들어갈 빈자리”입니다. 어떤 타입이 들어올지 지금은 정하지 않고, 사용하는 시점에 정할 수 있도록 자리를 비워두는 방식입니다. 그래서 T라는 글자 자체가 중요한 것이 아닙니다. T는 관례적으로 많이 쓰는 이름일 뿐이고, 핵심은 타입을 나중에 끼워 넣을 수 있다는 점입니다.
예를 들어 선물 상자를 생각해볼 수 있습니다. 상자의 모양은 같지만 안에 책이 들어갈 수도 있고, 컵이 들어갈 수도 있고, 옷이 들어갈 수도 있습니다. 제네릭도 비슷합니다. 바깥 구조는 유지하면서 안에 들어가는 타입만 바꿔 끼울 수 있습니다.
제네릭은 타입을 나중에 넣는 자리다
TypeScript에서 제네릭은 함수, 타입, 인터페이스, 컴포넌트가 특정 타입 하나에 고정되지 않도록 도와줍니다. 대신 사용할 때 타입을 전달해서 그 타입에 맞게 동작하도록 만듭니다.
여기서 자주 비교되는 것이 any입니다. any도 어떤 값이든 받을 수 있습니다. 하지만 any는 타입 정보를 포기하는 방식입니다. 반면 제네릭은 다양한 타입을 받을 수 있으면서도, 한 번 들어온 타입 정보를 끝까지 유지합니다.

any와 제네릭의 결정적인 차이
any를 쓰면 당장은 편해 보입니다. 타입 오류가 거의 나지 않기 때문입니다. 하지만 그만큼 TypeScript가 도와줄 수 있는 것도 줄어듭니다. 자동완성도 약해지고, 잘못된 속성에 접근해도 미리 잡아주지 못합니다.
function returnValue(value: any) {
return value;
}
const result = returnValue("hello");
result.toFixed();
위 코드에서 result는 문자열인데도 toFixed()를 호출할 수 있는 것처럼 보입니다. any를 사용했기 때문에 TypeScript가 이 값이 문자열인지 숫자인지 더 이상 추적하지 못하기 때문입니다.
제네릭을 사용하면 같은 유연함을 유지하면서도 타입 정보는 잃지 않을 수 있습니다.
function returnValue<T>(value: T): T {
return value;
}
const text = returnValue("hello");
const count = returnValue(10);
여기서 const로 받은 text는 문자열 리터럴 타입 "hello"로, count는 숫자 리터럴 타입 10으로 추론됩니다. 각각 string과 number에 할당할 수 있는 더 구체적인 타입입니다. 같은 함수가 여러 타입을 받을 수 있지만, 각각의 타입 정보는 그대로 유지됩니다. 이것이 any와 제네릭의 가장 중요한 차이입니다.
가장 단순한 제네릭 예시로 감 잡기
제네릭을 처음 배울 때 자주 나오는 예시가 identity 함수입니다. 입력받은 값을 그대로 반환하는 함수입니다. 기능만 보면 특별할 것이 없지만, 제네릭의 감각을 잡기에는 좋습니다.
function identity<T>(value: T): T {
return value;
}
const name = identity("Kim");
const age = identity(30);
const isActive = identity(true);
이 함수에서 T는 실제 값이 아닙니다. 타입이 들어갈 자리입니다. 이 예제의 identity("Kim")에서는 T가 문자열 리터럴 타입 "Kim"으로, identity(30)에서는 숫자 리터럴 타입 30으로 추론됩니다. identity(true)의 결과도 리터럴 타입 true입니다. 호출하는 값에 따라 타입이 자연스럽게 정해지는 것입니다.
타입 정보가 유지된다는 의미
중요한 점은 반환값의 타입도 입력값의 타입과 연결된다는 것입니다. string을 넣으면 string이 나오고, number를 넣으면 number가 나옵니다. TypeScript는 이 연결 관계를 알고 있기 때문에 이후 코드에서도 올바른 자동완성과 타입 검사를 제공할 수 있습니다.
제네릭은 단순히 여러 타입을 받기 위한 문법이 아닙니다. “들어온 타입과 나가는 타입의 관계”를 표현하는 방법입니다. 이 관점으로 보면 React props나 API 응답 타입에서도 제네릭이 왜 필요한지 자연스럽게 이어집니다.
React props에서 제네릭이 필요한 순간
React를 사용하다 보면 비슷한 목록 컴포넌트를 여러 번 만들게 됩니다. User 목록을 보여주는 컴포넌트, Product 목록을 보여주는 컴포넌트, Post 목록을 보여주는 컴포넌트가 따로 생길 수 있습니다. 그런데 자세히 보면 구조는 거의 같습니다. 배열을 받고, 각 아이템을 화면에 렌더링합니다.
type User = {
id: number;
name: string;
};
type Product = {
id: number;
title: string;
price: number;
};
User와 Product는 데이터 구조가 다릅니다. 하지만 “items 배열을 받아서 각각 렌더링한다”는 컴포넌트의 형태는 같습니다. 이럴 때 제네릭을 사용하면 같은 List 컴포넌트를 여러 데이터 타입에 맞게 재사용할 수 있습니다.
import type { ReactNode } from "react";
type GenericListProps<T> = {
items: readonly T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
};
function GenericList<T>({ items, getKey, renderItem }: GenericListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
부분 코드 경로: src/GenericList.tsx. getKey와 renderItem의 item은 items와 같은 T로 추론됩니다. key는 같은 목록의 형제 항목 사이에서 고유하고 순서가 바뀌어도 유지되는 id를 사용합니다. 아래 사용 예는 같은 파일의 JSX 설명 예이며 userList/productList를 화면에서 반환하는 위치는 기존 부모 컴포넌트에 연결합니다.
여기서 GenericListProps<T>는 items 배열의 타입과 renderItem 함수의 item 타입을 같은 T로 묶습니다. 즉 items가 User 배열이면 renderItem의 item도 User가 됩니다. items가 Product 배열이면 renderItem의 item도 Product가 됩니다.
// 같은 목록 안에서 중복되지 않고 정렬 후에도 유지되는 id를 사용합니다.
const users = [{ id: 1, name: "민지" }];
const products = [{ id: "keyboard", title: "키보드" }];
const userList = (
<GenericList
items={users}
getKey={(user) => user.id}
renderItem={(user) => <span>{user.name}</span>}
/>
);
const productList = (
<GenericList
items={products}
getKey={(product) => product.id}
renderItem={(product) => <span>{product.title}</span>}
/>
);
이 방식의 장점은 renderItem 안에서 타입 추론이 살아난다는 점입니다. user와 product는 각각 전달한 배열의 원소 구조로 추론됩니다. 그래서 없는 속성에 접근하면 TypeScript가 미리 알려주고, 사용할 수 있는 속성은 자동완성으로 확인할 수 있습니다.
API 응답 타입에서 제네릭 활용하기
제네릭이 특히 유용한 또 다른 상황은 API 응답 타입입니다. 서버 응답은 보통 일정한 공통 구조를 가집니다. 예를 들어 상태값, 메시지, 실제 데이터가 함께 내려오는 형태입니다.

type ApiResponse<T> = {
status: "success" | "error";
message: string;
data: T;
};
이 타입에서 status와 message는 공통입니다. 달라지는 것은 data입니다. 어떤 API는 User를 반환하고, 어떤 API는 Post를 반환하고, 어떤 API는 Product 배열을 반환할 수 있습니다. 이때 data 자리에 들어갈 타입만 나중에 정할 수 있도록 만든 것이 ApiResponse<T>입니다.
type User = {
id: number;
name: string;
email: string;
};
type Post = {
id: number;
title: string;
content: string;
};
type UserResponse = ApiResponse<User>;
type PostResponse = ApiResponse<Post>;
type ProductListResponse = ApiResponse<Product[]>;
이렇게 작성하면 응답의 바깥 구조는 한 번만 정의하고, 실제 데이터 타입만 바꿔서 사용할 수 있습니다. 타입을 매번 새로 만들 필요가 줄어들고, 응답 구조가 바뀌었을 때도 공통 타입 하나를 중심으로 관리할 수 있습니다.
any로 처리했을 때 생기는 문제
API 응답을 any로 처리하면 처음에는 코드가 빠르게 작성됩니다. 하지만 이후에 data 안에 어떤 속성이 있는지 TypeScript가 알 수 없게 됩니다. 예를 들어 User 응답이라고 생각하고 data.name을 사용했는데 실제 타입이 다르더라도, 컴파일 단계에서 오류를 잡기 어렵습니다.
type ApiResult<T> =
| { status: "success"; data: T }
| { status: "error"; message: string };
type User = { id: number; name: string; email: string };
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
return (
"id" in value &&
typeof value.id === "number" &&
Number.isSafeInteger(value.id) &&
"name" in value &&
typeof value.name === "string" &&
"email" in value &&
typeof value.email === "string"
);
}
function parseUserResult(value: unknown): ApiResult<User> {
if (typeof value !== "object" || value === null || !("status" in value))
throw new Error("응답 객체가 아닙니다.");
if (value.status === "success" && "data" in value && isUser(value.data))
return { status: "success", data: value.data };
if (
value.status === "error" &&
"message" in value &&
typeof value.message === "string"
)
return { status: "error", message: value.message };
throw new Error("응답 형식이 맞지 않습니다.");
}
async function fetchUser(): Promise<ApiResult<User>> {
const response = await fetch("/api/user");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw: unknown = await response.json();
return parseUserResult(raw);
}
// 브라우저의 /api/user가 위 계약을 반환할 때 연결하는 부분 코드입니다.
// const result = await fetchUser();
// if (result.status === "success") console.log(result.data.name);
이 코드에서는 result.data가 User 타입이라는 정보가 유지됩니다. 그래서 name, email 같은 속성은 자동완성되고, 존재하지 않는 속성에 접근하면 TypeScript가 오류를 알려줍니다. 제네릭을 사용하는 이유는 단순히 타입을 예쁘게 정리하기 위해서가 아니라, 실제 작업 중 실수를 줄이기 위해서입니다.
제네릭은 언제 쓰면 좋을까
제네릭을 모든 곳에 사용할 필요는 없습니다. 오히려 타입이 한 가지로 고정되어 있고 재사용할 일이 없다면 일반 타입을 쓰는 편이 더 읽기 쉽습니다. 제네릭은 반복되는 구조가 보일 때 꺼내는 도구에 가깝습니다.
- 구조는 같고 안에 들어가는 타입만 달라질 때
- 함수의 입력 타입과 반환 타입이 서로 연결되어야 할 때
- React props에서 같은 컴포넌트를 여러 데이터 타입에 재사용하고 싶을 때
- API 응답처럼 공통 포맷은 유지하고 data 타입만 바꾸고 싶을 때
- any를 쓰지 않고 유연한 타입을 표현하고 싶을 때
반대로 타입이 하나뿐이고 앞으로도 바뀔 가능성이 없다면 제네릭을 억지로 넣지 않아도 됩니다. 제네릭은 코드를 더 복잡하게 만들기 위한 문법이 아니라, 중복과 불안정한 타입 처리를 줄이기 위한 선택지입니다.
T, K, U 같은 이름은 꼭 정해져 있을까
제네릭 예시에서는 T를 가장 많이 봅니다. T는 보통 Type의 약자로 사용됩니다. 하지만 반드시 T만 써야 하는 것은 아닙니다. 상황에 따라 Item, Data, Response처럼 더 의미 있는 이름을 사용할 수도 있습니다.
type ApiResponse<Data> = {
status: string;
message: string;
data: Data;
};
입문 단계에서는 T 하나로 시작해도 충분합니다. 다만 제네릭이 여러 개 등장하거나 의미를 더 분명히 하고 싶다면 짧은 문자보다 설명적인 이름이 읽기 쉬울 때도 있습니다. 중요한 것은 이름 자체가 아니라, 그 자리가 “나중에 들어올 타입”이라는 점입니다.
과정 마무리 실습
상품 타입과 검색 함수를 만들고 unknown 입력의 유효성을 검사하세요.
완료 기준: strict 검사 통과, 잘못된 입력 거절, 불필요한 any 없이 분기별 타입 확인을 완료합니다.
이어서 공부할 과정: React 첫 글
직접 확인하는 경계와 재현 방법
앞의 ApiResponse<T>는 두 상태 모두 data를 갖는 단순 계약입니다. 오류 응답에 data가 없다면 위 ApiResult<T>처럼 분리해야 합니다. HTTP 오류·JSON 파싱 오류·응답 구조 오류는 서로 다른 실패입니다. 이 검사는 parseUserResult의 세 경로를 로컬 값으로 확인하며 실제 서버 호출은 수행하지 않습니다. GenericList는 getKey로 항목의 안정적인 id를 받습니다. 배열 위치를 key로 쓰지 않으므로 정렬·삽입 뒤에도 같은 항목의 key를 유지합니다. id의 목록 내 고유성은 데이터 규칙으로 보장해야 합니다.
아래는 확인 실습의 전체 파일입니다. 빈 실습 폴더의 루트를 기준으로 표시된 경로에 모두 저장하거나 같은 내용의 ZIP을 내려받으세요.
현재 실습 ZIP
check.ts
type ApiResult<T> =
| { status: "success"; data: T }
| { status: "error"; message: string };
type User = { id: number; name: string; email: string };
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
return (
"id" in value &&
typeof value.id === "number" &&
Number.isSafeInteger(value.id) &&
"name" in value &&
typeof value.name === "string" &&
"email" in value &&
typeof value.email === "string"
);
}
function parseUserResult(value: unknown): ApiResult<User> {
if (typeof value !== "object" || value === null || !("status" in value))
throw new Error("응답 객체가 아닙니다.");
if (value.status === "success" && "data" in value && isUser(value.data))
return { status: "success", data: value.data };
if (
value.status === "error" &&
"message" in value &&
typeof value.message === "string"
)
return { status: "error", message: value.message };
throw new Error("응답 형식이 맞지 않습니다.");
}
async function fetchUser(): Promise<ApiResult<User>> {
const response = await fetch("/api/user");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const raw: unknown = await response.json();
return parseUserResult(raw);
}
// 브라우저의 /api/user가 위 계약을 반환할 때 연결하는 부분 코드입니다.
// const result = await fetchUser();
// if (result.status === "success") console.log(result.data.name);
const result = parseUserResult({
status: "success",
data: { id: 1, name: "Kim", email: "k@example.com" },
});
if (result.status !== "success" || result.data.name !== "Kim")
throw new Error("성공");
const failed = parseUserResult({ status: "error", message: "없음" });
if (failed.status !== "error") throw new Error("오류 분기");
let rejected = false;
try {
parseUserResult({
status: "success",
data: { id: "1", name: "Kim", email: "x" },
});
} catch {
rejected = true;
}
if (!rejected) throw new Error("잘못된 User 허용");
console.log("PASS success, error, invalid payload");
tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"noEmitOnError": true,
"types": []
},
"files": ["check.ts"]
}
package.json
{
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/check.js"
},
"devDependencies": {
"typescript": "7.0.2"
}
}
npm install
npm run build
npm start
실제 확인 결과:
PASS success, error, invalid payload
검증 환경은 Node.js v24.19.0, TypeScript 7.0.2입니다. 설치되어 있던 의존성으로 타입 검사와 위 실행을 확인했습니다. 새 환경의 npm install, 실제 서버 응답, 브라우저 클릭·모바일 화면은 이 검증에 포함하지 않습니다.
공식 출처 확인일: 2026-09-12. generics.html · rendering-lists
. 완성형 뷰어와 같은 코드이며 README와 검증 기록을 포함합니다.
정리: 제네릭은 타입을 재사용하는 방법이다
TypeScript 제네릭은 처음 보면 낯설지만, 핵심은 단순합니다. 타입을 바로 고정하지 않고 나중에 넣을 수 있도록 비워두는 것입니다. 그래서 같은 구조에 여러 타입을 적용해야 할 때 유용합니다.
any는 타입 검사를 포기하고 유연함을 얻는 방식입니다. 반면 제네릭은 유연함을 유지하면서도 타입 정보를 잃지 않습니다. 이 차이를 이해하면 왜 실무에서 제네릭을 사용하는지 훨씬 분명해집니다.
React props에서는 GenericList처럼 같은 컴포넌트를 User, Product, Post에 재사용할 수 있습니다. API 응답 타입에서는 ApiResponse<T>처럼 공통 응답 구조를 한 번만 만들고 data 타입만 바꿔 사용할 수 있습니다. 제네릭을 처음 배울 때는 어려운 타입 체계로 확장하기보다, “구조는 같고 안에 들어가는 타입만 다른가?”라는 질문부터 떠올리면 충분합니다.
같이 읽으면 좋은 글
이 글이 도움이 되었나요?
TypeScript 학습 순서
필수 11개 · 전체 11개
읽음 기록 관리
전체 과정 목차 (11개)
- 필수 학습 · TypeScript란 무엇인가: JavaScript와 차이 정리
- 필수 학습 · TypeScript 기본 타입: 객체와 함수까지 strict로 익히기
- 필수 학습 · TypeScript 타입 좁히기: union null unknown 안전 검증
- 필수 길잡이 · TypeScript 실무 로드맵
- 필수 학습 · TypeScript as const 사용법: union 타입을 자동 생성하는 기준
- 필수 학습 · React TypeScript 기본 구조: tsx 파일과 props 타입 기준
- 필수 학습 · React TypeScript optional props 기본값 처리: undefined·null 차이
- 필수 학습 · TypeScript children 타입: ReactNode와 PropsWithChildren 차이
- 필수 학습 · TypeScript 제네릭 쉽게 이해하기: props와 API 응답 타입으로 감 잡기 현재 글
- 필수 학습 · TypeScript 고급 상태 모델링: 판별 유니온과 never 타입 테스트
- 필수 학습 · TypeScript satisfies 사용법: 타입 지정·as const와 비교하기
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.