TypeScript 제네릭 완전 정복: 혼란에서 마스터까지 (실전 패턴 포함)
제네릭은 TypeScript 타입 시스템에서 가장 강력한 기능이에요. 그리고 동시에 가장 오해받는 기능이기도 하죠. <T extends Record<string, unknown>, K extends keyof T> 같은 타입 시그니처를 보고 멘탈이 터진 경험, 다들 있잖아요.
근데 사실 제네릭은 복잡하지 않아요. 그냥 타입을 위한 함수예요. 이게 한 번 찰칵 하고 맞으면 나머지는 다 따라와요. 이 글에서 "제네릭 대충은 아는데…" 수준에서 "타입 세이프한 유틸 라이브러리 만들 수 있다" 수준까지 한 번에 올려볼게요.
제네릭이 정확히 뭔지 (60초 멘탈 모델)
제네릭은 타입 변수예요. 나중에 채워질 타입을 비워두는 빈칸이라고 보면 돼요. 함수 파라미터가 값의 빈칸인 것처럼요.
일반 함수:
function identity(value: string): string { return value; }
이건 문자열만 돼요. 아무 타입이나 되게 하려면?
제네릭 없이 — 타입 정보가 날아감:
function identity(value: any): any { return value; } const result = identity("hello"); // result는 'any' — 쓸모없음
제네릭으로 — 타입이 쭉 흘러감:
function identity<T>(value: T): T { return value; } const result = identity("hello"); // result는 'string' ✅ const num = identity(42); // num은 'number' ✅
T가 제네릭 타입 파라미터예요. identity("hello")를 호출하면 TypeScript가 T = string이라고 추론하고 그 정보를 리턴 타입까지 끌고 가요. 함수를 한 번만 작성하고, 어떤 타입이든 정확하게 작동하면서 타입 안전성도 유지되는 거죠.
이게 전부예요. 진짜로. 앞으로 나올 내용은 죄다 이 한 가지 위에 올라가는 거예요.
기본을 넘어서: 제네릭 제약 조건
제약 없는 제네릭은 아무거나 다 받아요. 항상 원하는 건 아니죠.
문제: 너무 허용적임
function getLength<T>(value: T): number { return value.length; // ❌ 에러: 'T' 타입에 'length' 속성이 없음 }
TypeScript는 T에 length 속성이 있는지 몰라요. 숫자일 수도 있고, 불리언일 수도 있으니까요.
해결: extends 키워드
function getLength<T extends { length: number }>(value: T): number { return value.length; // ✅ 동작 — T에 'length'가 있다고 보장 } getLength("hello"); // ✅ string에 length 있음 getLength([1, 2, 3]); // ✅ array에 length 있음 getLength(42); // ❌ 에러: number에는 length 없음
extends 키워드는 제약 조건을 달아주는 거예요. "T한테 최소한 이 속성은 있어야 해"라고 TS한테 알려주는 거죠. 최소 스펙이라고 생각하면 편해요.
실전 패턴: API 응답 래퍼
interface ApiResponse<T> { data: T; status: number; timestamp: string; } interface User { id: string; name: string; email: string; } interface Product { id: string; title: string; price: number; } type UserResponse = ApiResponse<User>; // { data: User; status: number; timestamp: string; } type ProductResponse = ApiResponse<Product>; // { data: Product; status: number; timestamp: string; }
인터페이스 하나로 무한 재사용. data 필드가 각 유스케이스에 맞게 타입 세이프해요.
다중 타입 파라미터
제네릭은 여러 파라미터를 가질 수 있어요. 함수에 여러 인수가 있는 것처럼:
function pair<A, B>(first: A, second: B): [A, B] { return [first, second]; } const result = pair("hello", 42); // [string, number]
실전 패턴: 타입 세이프 이벤트 이미터
type EventMap = { userLogin: { userId: string; timestamp: Date }; pageView: { url: string; referrer: string }; purchase: { productId: string; amount: number }; }; class TypedEventEmitter<Events extends Record<string, any>> { private handlers: Partial<{ [K in keyof Events]: Array<(payload: Events[K]) => void>; }> = {}; on<K extends keyof Events>( event: K, handler: (payload: Events[K]) => void ): void { if (!this.handlers[event]) { this.handlers[event] = []; } this.handlers[event]!.push(handler); } emit<K extends keyof Events>(event: K, payload: Events[K]): void { this.handlers[event]?.forEach((handler) => handler(payload)); } } const emitter = new TypedEventEmitter<EventMap>(); emitter.on("userLogin", (payload) => { console.log(payload.userId); // ✅ 자동완성 작동 console.log(payload.timestamp); // ✅ 타입 세이프 }); emitter.on("purchase", (payload) => { console.log(payload.amount); // ✅ number }); emitter.emit("pageView", { url: "/home", referrer: "google.com", }); // ✅ 페이로드 형태가 검증됨
이 패턴 쓰면 런타임 버그가 통째로 사라져요. 이벤트 이름이랑 페이로드 구조가 타입으로 묶여 있으니까요. 이벤트 이름을 고치거나 페이로드 구조를 바꾸면 TS가 빌드할 때 깨진 핸들러를 전부 찾아줘요.
제네릭 유틸리티 타입: TypeScript 내장 파워 툴
TypeScript에는 모든 개발자가 알아야 할 제네릭 유틸리티 타입이 내장돼 있어요. 지금 배우고 있는 제네릭 패턴으로 만들어진 거예요.
Partial<T> — 모든 속성을 선택적으로
interface User { name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>): void { // updates는 User 속성의 어떤 조합이든 가능 } updateUser("123", { name: "Alice" }); // ✅ updateUser("123", { email: "[email protected]" }); // ✅ updateUser("123", { invalid: true }); // ❌ 에러
Pick<T, K> — 특정 속성만 고르기
type UserPreview = Pick<User, "name" | "email">; // { name: string; email: string; }
Omit<T, K> — 특정 속성 제거하기
type CreateUserDto = Omit<User, "id" | "createdAt">; // User에서 id와 createdAt만 빼고 전부
Record<K, V> — 객체 타입 만들기
type StatusMap = Record<"active" | "inactive" | "banned", User[]>; // { active: User[]; inactive: User[]; banned: User[]; }
ReturnType<T> — 함수 리턴 타입 추출하기
function createUser() { return { id: "1", name: "Alice", role: "admin" as const }; } type NewUser = ReturnType<typeof createUser>; // { id: string; name: string; role: "admin" }
Partial의 내부 구현
실제 구현 코드:
type Partial<T> = { [P in keyof T]?: T[P]; };
이건 맵드 타입이에요. T의 모든 키 P를 순회하면서, 각각을 선택적(?)으로 만들고, 원래 값 타입(T[P])은 그대로 유지해요. 이걸 이해하면 자기만의 유틸리티 타입을 만들 수 있게 돼요.
조건부 타입: 타입 레벨의 로직
조건부 타입은 타입 시스템에서 if/else 로직을 쓸 수 있게 해줘요:
type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no"
좀 뜬구름 잡는 것 같죠? 근데 실무에서 진짜 쓸모 있어요.
실전 패턴: API 응답 언래퍼
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<Promise<number>>; // number type C = UnwrapPromise<string>; // string (Promise가 아니면 그대로)
infer 키워드는 패턴 안에서 타입을 캡처해요. 여기서는 Promise<> 안의 내부 타입을 뽑아내는 거죠.
실전 패턴: 깊은 프로퍼티 접근
type NestedValue<T, Path extends string> = Path extends `${infer Key}.${infer Rest}` ? Key extends keyof T ? NestedValue<T[Key], Rest> : never : Path extends keyof T ? T[Path] : never; interface Config { database: { host: string; port: number; credentials: { username: string; password: string; }; }; cache: { ttl: number; }; } type DbHost = NestedValue<Config, "database.host">; // string type DbUser = NestedValue<Config, "database.credentials.username">; // string type CacheTtl = NestedValue<Config, "cache.ttl">; // number
Lodash _.get()이나 tRPC 경로 기반 API가 내부적으로 이런 식으로 돌아가는 거예요. 타입 시스템이 객체 구조를 재귀적으로 따라가면서 리턴 타입을 정확하게 뽑아내죠.
맵드 타입: 타입을 프로그래밍적으로 변환
맵드 타입은 기존 타입을 변환해서 새 타입을 만들어요:
// 모든 속성을 readonly로 type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // 모든 속성을 nullable로 type Nullable<T> = { [P in keyof T]: T[P] | null; }; // 모든 속성을 필수로 (optional 제거) type Required<T> = { [P in keyof T]-?: T[P]; };
실전 패턴: 폼 상태 타입
interface UserForm { name: string; email: string; bio: string; } // 각 필드의 에러 상태 생성 type FormErrors<T> = { [K in keyof T]?: string; }; // 각 필드의 터치 상태 생성 type FormTouched<T> = { [K in keyof T]: boolean; }; // 사용법 const errors: FormErrors<UserForm> = { email: "이메일 형식이 올바르지 않습니다", }; const touched: FormTouched<UserForm> = { name: true, email: true, bio: false, };
폼 인터페이스 하나 만들어두면 에러 상태랑 터치 상태가 자동으로 따라와요. UserForm에 필드 하나 추가하면 FormTouched에도 빠짐없이 넣으라고 TS가 잡아줘요.
템플릿 리터럴 타입: 타입 레벨의 문자열 조작
TypeScript는 타입 시스템에서 문자열을 조작할 수 있어요:
type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type FocusEvent = EventName<"focus">; // "onFocus" type SubmitEvent = EventName<"submit">; // "onSubmit"
실전 패턴: 타입 세이프 라우트 빌더
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId" function buildUrl<T extends string>( template: T, params: Record<ExtractParams<T>, string> ): string { return Object.entries(params).reduce( (url, [key, value]) => url.replace(`:${key}`, value as string), template as string ); } buildUrl("/users/:userId/posts/:postId", { userId: "123", postId: "456", }); // ✅ buildUrl("/users/:userId/posts/:postId", { userId: "123", // ❌ 에러: 'postId' 누락 });
TS가 URL 템플릿을 파싱해서 파라미터 빠뜨리면 바로 에러를 던져요.
satisfies 연산자: 타입 확인하되 넓히지 않기
TypeScript 4.9에서 나온 satisfies는 값이 타입에 맞는지 체크하면서도 추론된 타입을 그대로 살려둬요:
type Color = "red" | "green" | "blue"; type ColorMap = Record<Color, string | number[]>; // satisfies 없이 — 타입이 넓어짐 const colors1: ColorMap = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", }; colors1.red.toUpperCase(); // ❌ 에러: number[]일 수도 있음 // satisfies로 — 리터럴 타입 유지 const colors2 = { red: "#ff0000", green: [0, 255, 0], blue: "#0000ff", } satisfies ColorMap; colors2.red.toUpperCase(); // ✅ string인 걸 앎 colors2.green.map((c) => c * 2); // ✅ number[]인 걸 앎
satisfies는 양쪽 다 먹을 수 있게 해줘요 — 타입 제약도 체크하고, 추론도 정밀하게 유지하고.
제네릭 베스트 프랙티스
1. 타입 파라미터에 설명적인 이름 쓰기
단일 문자 파라미터는 간단한 제네릭에는 괜찮지만, 복잡한 건 이름이 필요해요:
// ❌ 읽기 어려움 function merge<A, B, C>(source: A, override: B, defaults: C): A & B & C; // ✅ 훨씬 명확 function merge< TSource, TOverride, TDefaults, >(source: TSource, override: TOverride, defaults: TDefaults): TSource & TOverride & TDefaults;
2. 필요 없으면 제네릭 쓰지 않기
// ❌ 불필요한 제네릭 — T가 의미있게 안 쓰임 function greet<T extends string>(name: T): string { return `Hello, ${name}!`; } // ✅ 그냥 타입 직접 쓰기 function greet(name: string): string { return `Hello, ${name}!`; }
타입 파라미터가 시그니처에서 한 번만 나타나면 제네릭이 필요 없을 가능성이 높아요.
3. 제네릭 파라미터 기본값 쓰기
interface ApiResponse<T = unknown> { data: T; status: number; } // T 지정 없이 사용 가능 const response: ApiResponse = { data: null, status: 200 }; // 명시적으로 지정도 가능 const userResponse: ApiResponse<User> = { data: user, status: 200 };
4. assert 말고 constraint 쓰기
// ❌ 'as'로 타입 강제 function getProperty(obj: any, key: string) { return obj[key] as any; } // ✅ 제네릭 + 제약 조건 사용 function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Alice", age: 30 }; getProperty(user, "name"); // ✅ string 리턴 getProperty(user, "age"); // ✅ number 리턴 getProperty(user, "email"); // ❌ 에러: "email"은 keyof user에 없음
흔한 실수와 해결법
실수 1: 제네릭 과잉 엔지니어링
// ❌ 읽을 수 없고 불필요 type OverEngineered<T extends object, K extends keyof T, V extends T[K]> = { key: K; value: V; original: T; }; // ✅ 같은 일을 하는 더 간단한 버전 type PropertyEntry<T extends object> = { [K in keyof T]: { key: K; value: T[K]; original: T }; }[keyof T];
실수 2: 유니온에서 조건부 타입 분배 잊기
조건부 타입은 유니온에 대해 분배돼요:
type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // string[] | number[] ((string | number)[]이 아님!)
분배를 막으려면 양쪽을 튜플로 감싸면 돼요:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never; type Result = ToArrayNonDist<string | number>; // (string | number)[]
실수 3: const 타입 파라미터 안 쓰기
TypeScript 5.0+은 리터럴 타입 보존을 위한 const 타입 파라미터를 지원해요:
// const 없이 — 리터럴이 넓어짐 function createConfig<T>(config: T) { return config; } const c1 = createConfig({ mode: "production" }); // { mode: string } — string으로 넓어짐 // const로 — 리터럴 유지 function createConfig<const T>(config: T) { return config; } const c2 = createConfig({ mode: "production" }); // { mode: "production" } — 리터럴 타입 보존 ✅
전부 합치기: 타입 세이프 쿼리 빌더
실전으로 하나 만들어볼게요. 여러 제네릭 패턴을 조합한 타입 세이프 쿼리 빌더예요:
interface Schema { users: { id: string; name: string; email: string; age: number; role: "admin" | "user"; }; posts: { id: string; title: string; content: string; authorId: string; published: boolean; }; } type WhereClause<T> = { [K in keyof T]?: T[K] | { gt?: T[K]; lt?: T[K]; eq?: T[K] }; }; class QueryBuilder< TSchema extends Record<string, Record<string, any>>, TTable extends keyof TSchema = keyof TSchema, > { private table: TTable | null = null; private selectedFields: (keyof TSchema[TTable])[] = []; from<T extends keyof TSchema>(table: T): QueryBuilder<TSchema, T> { const qb = new QueryBuilder<TSchema, T>(); (qb as any).table = table; return qb; } select<K extends keyof TSchema[TTable]>( ...fields: K[] ): QueryBuilder<TSchema, TTable> { this.selectedFields = fields; return this; } where(clause: WhereClause<TSchema[TTable]>): QueryBuilder<TSchema, TTable> { return this; } build(): string { const fields = this.selectedFields.length > 0 ? this.selectedFields.join(", ") : "*"; return `SELECT ${fields} FROM ${String(this.table)}`; } } const db = new QueryBuilder<Schema>(); // 완벽한 타입 안전성 — 자동완성이 전부 동작 db.from("users") .select("name", "email") .where({ role: "admin", age: { gt: 18 } }); db.from("posts") .select("title", "published") .where({ published: true }); // ❌ 이것들은 전부 컴파일 타임 에러: // db.from("users").select("title"); // users에 'title' 없음 // db.from("posts").where({ role: "admin" }); // posts에 'role' 없음 // db.from("invalid"); // 스키마에 'invalid' 없음
이게 제네릭이에요. 클래스 하나로 스키마 전체 테이블을 커버하면서, 없는 컬럼 select 하면 에러, 엉뚱한 필드로 where 걸면 에러. 전부 빌드할 때 잡아주고, 런타임 비용은 제로.
언제 제네릭을 써야 할까
제네릭이 빛나는 경우:
- 범용 유틸을 만들 때 — 여러 가지 데이터 형태에서 돌려 쓸 함수, 클래스, 타입
- 입력이 출력을 결정할 때 — 넣는 타입에 따라 나오는 타입이 달라져야 할 때
- 라이브러리/API를 만들 때 — 쓰는 사람이 자기 타입을 넘기는 구조
any없애고 싶을 때 — 거의 항상 제네릭이 더 나은 선택
제네릭 안 써도 되는 경우:
- 구체 타입이면 충분할 때 —
User만 다루면 그냥User로 박으면 됨 - 타입이 흘러가지 않을 때 —
T가 파라미터에만 있고 리턴에 안 나오면 의미 없음 - 읽기 힘들어질 때 — 같이 일하는 사람이 이해 못하면 단순하게 바꾸기
마무리
제네릭은 코드가 복잡해질 때 갖다 붙이는 별도 기능이 아니에요. TypeScript 타입 시스템 자체를 굴리는 핵심 엔진이에요. Array<T>, Promise<T>, Record<K, V> — 싹 다 제네릭이거든요.
멘탈 모델은 단순해요: 제네릭은 타입의 함수. 타입 인풋을 받아서 제약이랑 조건 거쳐서 타입 아웃풋을 뱉어내는 거. 이게 몸에 배면 문법 외우는 단계를 넘어서 패턴이 눈에 들어오기 시작해요.
처음엔 간단하게 가요: 타입 파라미터 하나, 제약 하나. 조건부 타입이나 맵드 타입은 문제가 요구할 때 올라가면 돼요. 그리고 매번 스스로 물어봐요 — "여기 그냥 구체 타입 박으면 안 돼?" 된다면 제네릭은 패스. 최고의 타입 코드는 필요한 정보만 딱 살리는 가장 단순한 코드니까요.
TypeScript 타입 시스템은 그냥 린터가 아니에요. 언어 안의 언어예요. 제네릭은 거기서 프로그래밍하는 방법이고요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요