"<T>가 나오는 순간 뇌가 멈췄습니다"
TypeScript 문법을 공부하다가 함수까지는 괜찮았습니다.
그런데 갑자기 꺾쇠 괄호 <T>가 등장하더니, 코드가 외계어처럼 보이기 시작했습니다.
function echo<T>(arg: T): T {
return arg;
}
"그냥 any 쓰면 되는 거 아니야? 왜 굳이 저런 복잡한 기호를 써야 해?"
저는 제네릭을 '고수들만 쓰는 어려운 문법'이라고 단정 짓고 피해 다녔습니다.
하지만 라이브러리를 쓰거나 실제 코드를 보면 온통 제네릭 투성이였습니다.
결국 피할 수 없는 산이었습니다.
처음엔 뭐가 이해가 안 갔나? (모호함의 공포)
저는 프로그래밍에서 "타입을 정한다"는 건 "확실하게 하는 것"이라고 생각했습니다.
string, number처럼 딱 정해져야 마음이 편했죠.
그런데 제네릭은 "뭐가 들어올지 모른다"면서도 any는 아니라고 합니다.
"모르는데 어떻게 타입 체크를 해? 그게 any랑 뭐가 달라?"
이 모순적인 개념이 받아들여지지 않았습니다.
어떤 포인트에서 이해가 됐나? (투명 스티커 비유)
이걸 "자판기와 투명 스티커"에 비유하니 이해가 됐습니다.
any: 검은 봉지입니다. 안에 뭐가 들었는지 아예 안 보입니다. 사과를 넣었는데 꺼내보니 벽돌일 수도 있습니다. (불안함)- Generic (
<T>): 투명한 비닐봉지입니다. 아직 뭐가 들어갈지는 모르지만, "넣은 그대로 보인다"는 건 확실합니다. 사과를 넣으면 빨간 사과가 보이고, 배를 넣으면 노란 배가 보입니다.
// any: 넣을 땐 맘대로지만, 꺼낼 땐 뭔지 모름 (위험)
function anyBox(item: any): any {
return item;
}
const box1 = anyBox(10); // box1은 any 타입.
// Generic: 넣는 순간 타입이 결정됨 (안전)
function genericBox<T>(item: T): T {
return item;
}
const box2 = genericBox(10); // box2는 number 타입! (TS가 알아냄)
제네릭은 "타입을 미리 정하는 게 아니라, 사용하는 시점에 타입을 변수처럼 넘겨주는 것"이었습니다. 즉, "타입을 위한 변수(Type Variable)"였던 겁니다.
해결 과정 - 제네릭 활용 3단계
1단계 - 함수에 적용하기 (유연한 함수)
가장 흔한 예제는 API 호출 함수입니다. 서버 응답이 어떤 모양일지 함수를 만들 땐 모르지만, 쓸 땐 알 수 있으니까요.
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
// 사용할 때 타입(T)을 '주입'해줌
interface User { name: string }
const user = await fetchJson<User>('/api/user');
// 이제 user.name 자동 완성이 됩니다!
2단계 - 제약 걸기 (extends)
T라고 해서 아무거나 다 받는 건 싫을 때가 있습니다.
"최소한 length 속성은 있어야 해!"라고 조건을 걸 수 있습니다.
// T는 반드시 length가 있는 타입이어야 함 (string, array 등)
function logLength<T extends { length: number }>(arg: T) {
console.log(arg.length);
}
logLength("hello"); // OK (문자열은 length 있음)
logLength([1, 2]); // OK (배열도 length 있음)
logLength(10); // Error! (숫자는 length 없음)
이 extends 키워드가 제네릭의 꽃입니다. 무한한 자유가 아니라 안전한 자유를 보장해주니까요.
3단계 - React 컴포넌트 (Select 박스)
리액트에서 공용 컴포넌트 만들 때 제네릭이 필수입니다.
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
}
// <T,> : 화살표 함수에서 제네릭 쓸 때 JSX 태그랑 헷갈리지 말라고 콤마 붙임
const Select = <T,>({ options, value, onChange }: SelectProps<T>) => {
return (
// ... 구현
);
};
// 사용: 문자열 선택기
<Select<string> options={["A", "B"]} value="A" ... />
// 사용: 숫자 선택기
<Select<number> options={[1, 2]} value=1 ... />
제네릭이 없었다면 StringSelect, NumberSelect를 따로 만들거나 any로 도배해야 했을 겁니다.
깊이 파고들기 - 유틸리티 타입의 비밀
우리가 자주 쓰는 Partial<T>, Pick<T, K>, Record<K, T>...
이런 내장 유틸리티 타입들도 까보면 다 제네릭으로 만들어져 있습니다.
// 실제 Partial의 정의 (비슷함)
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
"모든 속성(keyof T)을 순회(in)하면서 물음표(?)를 붙여라."
제네릭을 이해하면 이런 유틸리티 타입을 직접 만들어 쓸 수도 있습니다. (이걸 '타입 체조'라고 부르죠.)
infer 키워드 (제네릭 안의 타입을 꺼내오기) 제대로 파보기
제네릭의 끝판왕, infer입니다.
이것은 "조건부 타입(Conditional Type)" 안에서 쓰이는데, "타입을 유추해서 변수처럼 뽑아내라"는 뜻입니다.
가장 유명한 ReturnType 유틸리티가 이렇게 만들어졌습니다.
// T가 함수라면, 그 리턴 타입을 R이라고 부르고, R을 반환해라. 아니면 Any.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
function getUser() { return { name: "Kim", age: 30 }; }
// UserType은 자동으로 { name: string, age: number }가 됨!
type UserType = MyReturnType<typeof getUser>;
이걸 이해하면 라이브러리 코드를 읽을 때 "신세계"가 열립니다. "입력된 함수가 뱉는 타입을 뽑아서, 다음 함수의 인자 타입으로 써라" 같은 마법이 가능해집니다.
8. Case Study: JSON 파서 만들기 (Recursive Generics)
회사 내부용 Axios 래퍼를 만들 때였습니다.
API 응답이 항상 timestamp, code, data 구조로 오는데, data 안에 또 다른 중첩된 객체가 들어올 수 있었습니다.
문제
제네릭을 한 겹만 썼더니 깊은 곳의 타입이 깨졌습니다.
해결 - 재귀적 제네릭 (Recursive Generics)
타입도 자기 자신을 호출할 수 있습니다.
type JsonValue = string | number | boolean | null | JsonArray | JsonObject;
interface JsonObject { [key: string]: JsonValue; } // 나 자신을 참조
interface JsonArray extends Array<JsonValue> {} // 나 자신을 참조
// 사용
const data: JsonObject = {
user: {
posts: [ { id: 1, title: "Hello" } ] // 무한히 깊어져도 타입 체크 가능
}
};
이렇게 정의해두면 data.user.posts[0].title까지 자동 완성이 지원됩니다.
제네릭은 구조(Structure)를 정의하는 언어입니다.
9. Tip: 제네릭 이름 짓기 (T 말고 다른 거)
T, U, V... 수학 시간도 아니고 너무 헷갈립니다.
코드의 가독성을 위해 의미 있는 이름을 쓰세요.
T->TData(데이터)R->TResult(결과)P->TProps(속성)E->TError(에러)
function fetchAPI<TData, TError>(url: string) { ... }
훨씬 읽기 좋습니다. 팀원을 배려하세요.
제네릭 제약조건 심화 (keyof) 제대로 이해하기
"객체에서 특정 키의 값만 뽑아오는 함수"를 만들 때 keyof가 필수입니다.
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
const user = { name: "Alice", age: 25 };
getProperty(user, "name"); // OK
getProperty(user, "email"); // Error: "email"은 "name" | "age"에 없음!
이걸 통과하면 여러분은 제네릭 중급자입니다.
K extends keyof T는 "K는 T가 가진 열쇠(Key)들 중 하나여야 한다"는 뜻입니다. 오타 방지에 최고입니다.
11. Case Study: Pick과 Omit 직접 구현하기
자주 나오는 질문입니다. "Pick을 직접 구현해보세요."
// 1. Mapped Type: K에 있는 것들만 루프 돌림
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
// 2. Exclude 활용: 전체 키에서 K를 뺀 나머지를 구함
type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;
이걸 외울 필요는 없지만, 원리를 알면 복잡한 비즈니스 로직 타입을 설계할 때 큰 도움이 됩니다. "API 응답에서 민감한 필드만 빼고 프론트에 넘겨주는 타입" 같은 걸 짤 때 말이죠.