제네릭을 처음 접했을 때 든 생각은 "이걸 왜 쓰는 거지?"였다. T가 뭔데. K extends keyof T는 또 뭔데. 공식 문서를 읽으면 identity 함수 예제가 나오는데, 그걸 보고 "아 이래서 제네릭이 필요하구나"라고 납득한 사람이 얼마나 될까.
function identity<T>(arg: T): T {
return arg;
}
이 예제의 문제는, 실무에서 이런 코드를 작성할 일이 거의 없다는 거다. 제네릭은 추상적인 개념이 아니라 실무 문제를 풀기 위한 도구인데, 교과서적 예제만 보면 그 연결이 안 된다.
내가 제네릭을 "아, 이거 없으면 안 되겠다"라고 느낀 건 API 응답 타입을 다룰 때였다. 그 경험부터 시작해서, 실무에서 반복적으로 쓰게 되는 패턴 5가지를 정리해봤다.
패턴 1: API 응답 래퍼
백엔드 API 응답이 항상 같은 구조로 내려온다. data, error, message 같은 공통 필드가 있고, 실제 데이터만 달라지는 형태.
// 제네릭 없이 하면 이렇게 된다
interface UserResponse {
data: User;
error: string | null;
message: string;
}
interface ProductResponse {
data: Product;
error: string | null;
message: string;
}
interface OrderResponse {
data: Order;
error: string | null;
message: string;
}API가 10개면 타입도 10개. 필드 하나 바뀌면 10군데를 수정해야 한다. 제네릭으로 바꾸면 이렇다.
interface ApiResponse<T> {
data: T;
error: string | null;
message: string;
}
type UserResponse = ApiResponse<User>;
type ProductResponse = ApiResponse<Product>;
type OrderResponse = ApiResponse<Order>;
여기까지는 많이들 알고 있다. 한 단계 더 나가보면, fetch 함수 자체를 제네릭으로 만들 수 있다.
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
const json = await response.json();
return json as ApiResponse<T>;
}
// 사용할 때 타입이 자동으로 잡힌다
const users = await fetchApi<User[]>("/api/users");
// users.data의 타입: User[]
처음에는 fetchApi<User[]>에서 <User[]>를 직접 넘기는 게 어색했다. 그런데 쓰다 보면 이게 없는 상태가 더 불안해진다. any로 받아서 나중에 타입 단언 하는 것보다 훨씬 안전하다.
패턴 2: 이벤트 핸들러 타입 좁히기
이벤트 핸들러를 props로 넘길 때 제네릭이 유용하다. 특히 공통 컴포넌트를 만들 때.
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
}
function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
return (
<select
value={String(value)}
onChange={(e)=> {
const selected= options.find(
(opt)=> String(opt)= e.target.value
);
if (selected) onChange(selected);
}}
>
{options.map((option) => (
<option key={String(option)} value={String(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}이렇게 만들어 놓으면 사용하는 쪽에서 타입이 자동으로 추론된다.
interface Category {
id: number;
name: string;
}
const categories: Category[] = [
{ id: 1, name: "전자기기" },
{ id: 2, name: "의류" },
{ id: 3, name: "식품" },
];
// onChange의 value 타입이 Category로 추론됨
<Select
options={categories}
value={selectedCategory}
onChange={(category)=> {
console.log(category.id); // 타입 안전
setSelectedCategory(category);
}}
getLabel={(cat)=> cat.name}
/>onChange에서 category의 타입을 따로 명시하지 않아도 Category로 잡힌다. 이게 제네릭의 힘이다. 만약 제네릭 없이 만들었다면 onChange: (value: any) => void가 되어서, 사용하는 곳마다 타입 단언을 해야 한다.
패턴 3: keyof와 조합하기
이게 처음에 가장 헷갈렸던 부분이다. keyof는 객체 타입의 키를 유니온으로 뽑아주는 연산자인데, 제네릭과 합치면 강력해진다.
실무에서 가장 많이 쓰는 케이스는 객체에서 특정 키의 값을 꺼내는 유틸리티다.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = {
name: "홍길동",
age: 28,
email: "hong@example.com",
};
const name = getProperty(user, "name"); // 타입: string
const age = getProperty(user, "age"); // 타입: number
getProperty(user, "phone"); // 컴파일 에러! 'phone'은 키가 아님
이걸 실무에서 어디에 쓰냐면, 폼 상태 관리할 때 많이 쓴다.
interface FormState {
username: string;
email: string;
age: number;
agreed: boolean;
}
function useForm<T extends Record<string, unknown>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const setValue = <K extends keyof T>(key: K, value: T[K]) => {
setValues((prev) => ({ ...prev, [key]: value }));
};
return { values, setValue };
}
// 사용
const { values, setValue } = useForm<FormState>({
username: "",
email: "",
age: 0,
agreed: false,
});
setValue("username", "홍길동"); // OK
setValue("age", "스물여덟"); // 에러! age는 number
setValue("phone", "010-1234"); // 에러! phone은 FormState의 키가 아님
setValue에서 키에 맞는 타입만 넣을 수 있도록 강제된다. 이걸 제네릭 없이 하려면 각 필드마다 setter를 만들거나, any를 써야 한다.
패턴 4: 조건부 타입으로 분기하기
조건부 타입은 제네릭의 심화 과정인데, 실무에서 의외로 쓸 일이 있다. 가장 흔한 케이스는 응답 타입이 요청 파라미터에 따라 달라지는 경우다.
type ApiEndpoint = "/users" | "/products" | "/orders";
type ApiResponseMap = {
"/users": User[];
"/products": Product[];
"/orders": Order[];
};
async function fetchEndpoint<T extends ApiEndpoint>(
endpoint: T
): Promise<ApiResponseMap[T]> {
const response = await fetch(endpoint);
return response.json();
}
// 엔드포인트에 따라 반환 타입이 자동으로 결정된다
const users = await fetchEndpoint("/users"); // User[]
const products = await fetchEndpoint("/products"); // Product[]
이건 엄밀히 말하면 조건부 타입이 아니라 인덱스 접근 타입이지만, 같은 맥락이다. 진짜 조건부 타입(extends ? :)을 쓰는 경우도 있다.
type IsArray<T> = T extends any[] ? true : false;
type Result1 = IsArray<string[]>; // true
type Result2 = IsArray<number>; // false
// 실무 예시: 단건/복수 응답 처리
type SingleOrArray<T, Multiple extends boolean> =
Multiple extends true ? T[] : T;
function fetchItems<M extends boolean>(
multiple: M
): Promise<SingleOrArray<Product, M>> {
// 구현부
}
const single = await fetchItems(false); // Product
const list = await fetchItems(true); // Product[]
솔직히 조건부 타입은 라이브러리를 만들 때 더 많이 쓴다. 일반 서비스 코드에서는 위의 인덱스 접근 타입 정도면 충분한 경우가 많다.
패턴 5: 제네릭 컴포넌트의 forwardRef
이건 좀 구체적인 상황인데, 제네릭 컴포넌트에 forwardRef를 붙이면 타입이 깨지는 문제가 있다. React 팀에서도 인지하고 있는 이슈인데, 해결법은 타입을 직접 오버라이드하는 거다.
// forwardRef의 타입 시그니처가 제네릭을 지원하지 않는 문제
// 해결: 타입 단언 + 래퍼 함수
function fixedForwardRef<T, P= {}>(
render: (props: P, ref: React.Ref<T>) => React.ReactElement
): (props: P & React.RefAttributes<T>) => React.ReactElement {
return React.forwardRef(render) as any;
}
// 사용 예시
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
const List = fixedForwardRef(
<T,>(props: ListProps<T>, ref: React.Ref<HTMLUListElement>) => {
return (
<ul ref={ref}>
{props.items.map((item, i) => (
<li key={i}>{props.renderItem(item)}</li>
))}
</ul>
);
}
);이걸 모르면 "왜 제네릭이 any로 풀리지?"하고 한참 헤맨다. 팀에서 시니어가 알려줬는데, 그때 속이 시원했던 기억이 난다.
제네릭을 어디까지 써야 하는가
여기서 한 가지 주의할 점이 있다. 제네릭이 좋다고 다 제네릭으로 만들면 코드가 읽기 어려워진다.
// 이건 좀 과하다
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
type RecursivePick<T, K extends string> =
K extends `${infer First}.${infer Rest}`
? First extends keyof T
? { [P in First]: RecursivePick<T[First], Rest> }
: never
: K extends keyof T
? { [P in K]: T[P] }
: never;
이런 레벨의 타입 체조는 유틸리티 라이브러리를 만들 때나 필요하다. 서비스 코드에서 이런 걸 쓰면 팀원들이 타입을 이해하는 데 시간을 쓰게 된다.
내 기준은 이렇다. "이 제네릭을 지우고 구체 타입을 2~3개 만들면 중복이 많아지는가?" 그렇다면 제네릭을 쓴다. 아니라면 그냥 구체 타입을 쓴다. 제네릭은 중복을 줄이기 위한 도구이지, 코드를 "고급"스럽게 보이기 위한 도구가 아니다.
사실 제네릭을 "완전히 이해했다"고 말할 수 있는 날이 올지 모르겠다. 가끔 오픈소스 코드를 보면 4단계, 5단계로 중첩된 제네릭이 나오는데, 그걸 보면 아직 갈 길이 멀다는 걸 느낀다. 다만 위의 5가지 패턴만 제대로 쓸 수 있어도 실무에서 제네릭 때문에 막히는 일은 거의 없었다. 나머지는 필요할 때 하나씩 배워가면 된다.
