목차
- 통념: "타입을 모르면 일단 제네릭으로 빼면 된다"
- 1. 함수 제네릭은 "반환 타입이 입력에 종속될 때"만 써라
- 2. 제약(
extends)을 안 걸면 제네릭은unknown과 같다 - 3.
keyof로 객체 키 안전하게 뽑기 - 4. 여러 키를 한 번에 뽑을 땐 매핑 타입
- 5. 클래스 제네릭 — 컬렉션이 아니면 다시 한 번 생각해라
- 6. 인터페이스 제네릭 — API 응답 래퍼의 정석
- 7. 조건부 타입으로 반환 타입 분기
- 8. 기본 타입 인자(Default Type Parameters)
- 9. 추론을 막지 마라 — 명시적 타입 인자는 마지막 수단
- 10. 제네릭을 빼는 게 정답일 때
- 코드리뷰 체크리스트
src/utils/fetcher.ts:14:28 - error TS2322:
Type 'unknown' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type
which could be unrelated to 'unknown'.
14 return data as T;
~
물론, 이 에러를 본 적이 있다면 typescript 제네릭 사용법에 대한 흔한 오해 하나가 깔려 있는 거다. "제네릭은 그냥 <T> 박고 as T로 꺼내면 된다"는 통념. 컴파일러가 친절하게 알려준다. T는 호출자가 정하는 거지, 함수 안에서 강제할 수 있는 게 아니라고. 그런데도 많은 코드베이스에서 제네릭이 사실상 any처럼 쓰이고 있다. 모양만 타입스크립트인 자바스크립트.
예를 들어, 3년 가까이 백엔드 코드를 만지면서, 제네릭으로 깔끔하게 정리됐다고 생각한 코드가 6개월 뒤에 다시 부서지는 걸 여러 번 봤다. 거의 다 똑같은 원인이었다. 제약(extends)을 안 걸었거나, 추론을 막아놓고 매번 <T>를 직접 박거나, 함수 인자에 제네릭을 쓸 이유가 없는데도 굳이 쓴 경우.
그래서, 이 글은 "제네릭이 좋다"는 결론을 또 반복하려는 게 아니다. 오히려 다들 좋다고 하는 패턴 중에 별로인 것들, 그리고 그 자리에 들어가야 할 다른 패턴 10가지를 정리한다.
통념: "타입을 모르면 일단 제네릭으로 빼면 된다"
그러나, 가장 자주 보는 안티패턴이다. 함수 인자 타입을 정하기 애매하면 <T>를 박는다. 이게 왜 문제냐.
// 흔히 보는 코드
function logAndReturn<T>(value: T): T {
console.log(value);
return value;
}
logAndReturn("hello"); // T = "hello"
logAndReturn(42); // T = 42
겉보기엔 멀쩡하다. 그런데 이 함수 안에서 value로 할 수 있는 게 뭐냐. console.log뿐이다. T에 어떤 제약도 없으니, .toUpperCase()도 못 부르고 .length도 못 읽는다. 그러면 그냥 unknown을 받는 게 의도를 더 명확히 드러낸다.
function logAndReturn(value: unknown): unknown {
console.log(value);
return value;
}
제네릭은 호출자에게 타입을 돌려줘야 할 때만 의미가 있다. 위 함수는 입력 타입을 그대로 반환하니까 제네릭이 맞긴 하다. 다만 그게 실제로 호출자에게 가치를 주는지 물어봐야 한다. logAndReturn("hi")의 반환을 "hi" 리터럴 타입으로 받아서 뭘 할 건가? 대부분 아무것도 안 한다. 그럴 거면 unknown이 정직하다.
1. 함수 제네릭은 "반환 타입이 입력에 종속될 때"만 써라
특히, 판단 기준은 단순하다. 함수 시그니처에서 반환 타입이 입력 타입에 따라 달라져야 한다면 제네릭이다. 아니면 아니다.
// O: 반환이 입력에 종속됨
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// X: 반환이 입력과 무관 — 제네릭 쓸 이유 없음
function countItems<T>(arr: T[]): number {
return arr.length;
}
// 위 코드는 그냥 이렇게 쓰면 된다
function countItems(arr: unknown[]): number {
return arr.length;
}
countItems에 <T>를 박은 코드를 코드리뷰에서 자주 본다. "혹시 나중에 쓸 수도 있으니까"가 이유다. 안 쓴다. 6개월 뒤에 본인이 와서 "이거 왜 제네릭이지" 한다.
2. 제약(extends)을 안 걸면 제네릭은 unknown과 같다
또한, 이게 두 번째로 자주 부서지는 지점이다. T만 박아두고 안에서 value.id를 읽으려고 한다.
// 컴파일 에러: Property 'id' does not exist on type 'T'
function getId<T>(value: T): string {
return value.id;
}
extends로 제약을 걸어야 한다.
function getId<T extends { id: string }>(value: T): string {
return value.id;
}
getId({ id: "u-1", name: "kim" }); // OK
getId({ name: "kim" }); // 에러 — id가 없음
제약을 거는 순간 제네릭은 "구조적 부분집합"을 표현하는 도구가 된다. { id: string }을 만족하는 모든 객체. 이게 typescript 제네릭 사용법의 실제 본질에 가깝다.
3. keyof로 객체 키 안전하게 뽑기
제네릭과 keyof를 같이 쓰는 게 실무에서 가장 자주 만나는 패턴이다. lodash.get 같은 함수를 타입 안전하게 만들고 싶을 때.
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: "u-1", age: 32, active: true };
const id = pick(user, "id"); // string
const age = pick(user, "age"); // number
const oops = pick(user, "email"); // 에러 — 키가 없음
게다가, 여기서 핵심은 K extends keyof T. K를 따로 빼야 호출 시점에 정확한 키 리터럴이 잡힌다. K를 안 빼고 key: keyof T로 쓰면 반환 타입이 T[keyof T]로 뭉개진다. 즉 모든 값 타입의 유니온. 위 예시면 string | number | boolean이 된다.
4. 여러 키를 한 번에 뽑을 땐 매핑 타입
Pick 비슷한 걸 직접 만들어야 할 때.
function pickMany<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const k of keys) {
result[k] = obj[k];
}
return result;
}
const slim = pickMany(user, ["id", "age"]);
// slim: { id: string; age: number }
Pick<T, K>는 표준 유틸리티 타입이지만, 내부 구현은 매핑 타입 { [P in K]: T[P] }다. 이걸 알고 있으면 비슷한 변형을 직접 만들 수 있다. 예를 들어 Optional<T, K> 같은 것.
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
DB 모델에서 일부 필드만 optional로 풀고 싶을 때 자주 쓴다.
5. 클래스 제네릭 — 컬렉션이 아니면 다시 한 번 생각해라
클래스에 제네릭을 박는 건 함수보다 더 신중해야 한다. 컬렉션(Stack<T>, Queue<T>, Repository<T>)이 아니면 대부분 함수 제네릭으로 충분하다.
class Cache<T> {
private store = new Map<string, T>();
set(key: string, value: T): void {
this.store.set(key, value);
}
get(key: string): T | undefined {
return this.store.get(key);
}
}
const userCache = new Cache<{ id: string; name: string }>();
userCache.set("u-1", { id: "u-1", name: "kim" });
여기서 자주 망가지는 지점: 하나의 캐시 인스턴스에 여러 타입을 섞어 쓰고 싶어지는 순간. 그 순간 Cache<unknown>을 만들고 get 결과를 매번 as User로 캐스팅하기 시작한다. 그러면 제네릭의 의미가 사라진다. 차라리 타입별로 인스턴스를 분리하거나, key에 타입 정보를 인코딩하는 다른 패턴(브랜드 키)을 쓰는 게 낫다.
6. 인터페이스 제네릭 — API 응답 래퍼의 정석
백엔드 API 응답 형태가 일정하다면 인터페이스 제네릭이 거의 무조건 들어간다.
interface ApiResponse<T> {
ok: boolean;
data: T;
error?: { code: string; message: string };
}
interface User {
id: string;
email: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
실제로, 이건 별 거 없어 보이지만, data가 모든 응답에서 동일한 위치에 있다는 계약을 타입으로 강제한다. 백엔드 응답 포맷이 바뀌면 한 군데만 고치면 된다. 단, fetch().json()은 실제로 any를 반환하니까 런타임 검증(zod 같은)을 같이 안 쓰면 타입은 거짓말이 된다. 이게 흔히 빠지는 함정이다.
7. 조건부 타입으로 반환 타입 분기
infer까지 가면 입문자가 멈추는 지점인데, 의외로 실무에서 자주 쓴다. 함수의 반환 타입만 뽑고 싶을 때.
type AsyncReturn<F> = F extends (...args: any[]) => Promise<infer R> ? R : never;
async function getUser() {
return { id: "u-1", name: "kim" };
}
type U = AsyncReturn<typeof getUser>;
// U = { id: string; name: string }
표준 라이브러리에 Awaited<ReturnType<F>>가 있어서 직접 만들 일은 줄었다. 다만 infer의 동작 원리를 알아두면 라이브러리 타입을 읽을 때 막히지 않는다.
8. 기본 타입 인자(Default Type Parameters)
이걸 안 쓰면 호출자가 매번 <>를 박아야 한다.
interface Paginated<T, M = { total: number }> {
items: T[];
meta: M;
}
// 기본 메타로 충분한 경우
const list1: Paginated<User> = {
items: [],
meta: { total: 0 },
};
// 메타를 확장하고 싶은 경우
const list2: Paginated<User, { total: number; nextCursor: string }> = {
items: [],
meta: { total: 0, nextCursor: "abc" },
};
기본값 덕분에 80%의 호출은 짧아지고, 20%만 명시적으로 확장한다. 좋은 API 설계의 기본.
9. 추론을 막지 마라 — 명시적 타입 인자는 마지막 수단
가장 흔한 오해. <T>를 매번 직접 박는 게 "안전한" 코드라고 생각하는 경우.
// 굳이 이렇게 안 써도 된다
const result = pick<typeof user, "id">(user, "id");
// 추론이 알아서 한다
const result = pick(user, "id");
명시적 타입 인자가 필요한 경우는 두 가지뿐이다.
| 상황 | 예시 | 이유 |
|---|---|---|
| 추론할 인자가 없는 함수 | JSON.parse<User>("{}") (실제론 안 됨, 가정) |
입력만으로 T를 결정할 수 없음 |
| 추론 결과를 의도적으로 넓히고 싶을 때 | Array<string | number> |
좁게 추론된 걸 일부러 넓힘 |
나머지는 추론에 맡겨라. 코드가 짧아지고 리팩터링이 쉬워진다.
10. 제네릭을 빼는 게 정답일 때
반면, 마지막 패턴이자 가장 중요한 메시지. 제네릭이 들어간 코드를 보고 "이거 빼도 되겠는데" 싶으면 빼라.
// Before — 제네릭이 의미 없음
function logError<T extends Error>(err: T): void {
console.error(err.message);
}
// After — Error만으로 충분
function logError(err: Error): void {
console.error(err.message);
}
T를 호출자에게 돌려주지도 않고, 안에서 Error의 인터페이스만 쓴다면 그냥 Error를 받는 게 명확하다. 제네릭이 많을수록 좋은 코드가 아니다. 호출자에게 타입을 돌려줘야 할 때만 쓰는 도구다.
코드리뷰 체크리스트
지금 코드베이스에 제네릭이 너무 많다고 느껴진다면, 다음 세 가지를 PR에서 점검해라.
<T>만 박혀 있고extends가 없는 함수 — 정말 모든 타입을 받아도 되는지, 아니면 제약이 빠진 건지 확인한다.- 함수 안에서 T를 한 번도 안 쓰는데 반환 타입에만 T가 있는 경우 — 거의 다
as T캐스팅이 숨어 있다. 런타임 검증으로 바꿔라. - 호출부에서 매번
<>를 명시적으로 박는 제네릭 함수 — 추론이 안 되는 시그니처일 가능성이 높다. 인자 순서나 형태를 바꿔서 추론되게 만들어라.
반면, 다음엔 zod와 제네릭을 같이 써서 런타임 검증과 컴파일 타입을 한 번에 묶는 패턴을 정리해볼 생각이다.
관련 글
- 쿠버네티스 HPA 설정 Node.js 앱 비교 — CPU vs 메모리 vs KEDA – Node.js 앱을 HPA로 스케일링할 때 후보는 셋이다. CPU 기반, 메모리 기반, KEDA. 어느 게 맞는지 세 가지 축으로 비교하고…
- TypeScript ESM CommonJS 오류 해결 — require/import 충돌 정리 – TypeScript ESM CommonJS 오류 해결의 핵심은 단 두 줄이다. package.json type 한 줄, tsconfig m…
- React Query staleTime cacheTime 설정으로 네트워크 요청 70% 줄이기 – staleTime을 0으로 둔 프로젝트에서 페이지 전환마다 같은 API가 3번씩 호출되는 문제가 있었다. 데이터 성격별로 캐싱 정책을 분리…