5.1 제네릭 기초
제네릭이란 무엇인가
프로그래밍을 하다 보면 "이 함수는 숫자 배열도 처리하고 싶고, 문자열 배열도 처리하고 싶다"는 상황을 자주 만납니다. 이때 선택지는 세 가지입니다. 첫째, 각 타입마다 별도 함수를 만든다. 둘째, any를 사용한다. 셋째, 제네릭을 사용한다.
제네릭(Generics)은 타입을 매개변수로 받는 기능입니다. 함수가 값을 매개변수로 받듯이, 제네릭 함수는 타입을 매개변수로 받습니다. 덕분에 하나의 함수 정의로 다양한 타입을 안전하게 처리할 수 있습니다.
일반 함수: function add(a: number, b: number): number
제네릭 함수: function identity<T>(value: T): T
T는 타입 매개변수(Type Parameter)입니다. 함수를 호출할 때 실제 타입이 결정됩니다.
any와 제네릭의 차이
any를 사용하면 모든 타입 정보가 사라집니다.
// any 사용: 타입 안전성 없음
function identityAny(value: any): any {
return value;
}
const result1 = identityAny(42);
// result1의 타입은 any — 자동완성도, 타입 검사도 없음
result1.toUpperCase(); // 런타임에서야 오류 발견!
// 제네릭 사용: 타입 안전성 유지
function identity<T>(value: T): T {
return value;
}
const result2 = identity(42);
// result2의 타입은 number — TypeScript가 추론
result2.toUpperCase(); // 컴파일 타임에 오류 감지!
// Error: Property 'toUpperCase' does not exist on type 'number'
| 구분 | any | 제네릭 |
|---|---|---|
| 타입 안전성 | 없음 | 있음 |
| 자동완성 | 없음 | 있음 |
| 타입 추론 | 없음 | 있음 |
| 재사용성 | 있음 | 있음 |
제네릭 함수 문법
기본 문법
꺾쇠(<>) 안에 타입 파라미터를 선언합니다.
// 기본 제네릭 함수
function identity<T>(value: T): T {
return value;
}
// 화살표 함수 제네릭
const identityArrow = <T>(value: T): T => value;
// .tsx 파일에서는 JSX와 혼동 방지를 위해 trailing comma 추가
const identityTsx = <T,>(value: T): T => value;
타입 인수 명시 vs 타입 추론
TypeScript는 대부분의 경우 타입 인수를 자동으로 추론합니다.
function identity<T>(value: T): T {
return value;
}
// 타입 명시 (Type Argument Explicitly Provided)
const a = identity<string>("hello"); // T = string
const b = identity<number>(42); // T = number
// 타입 추론 (Type Argument Inferred)
const c = identity("hello"); // T = string (자동 추론)
const d = identity(42); // T = number (자동 추론)
// 추론이 불가능한 경우 — 명시 필요
function createArray<T>(length: number, defaultValue: T): T[] {
return Array.from({ length }, () => defaultValue);
}
const arr1 = createArray<string>(3, ""); // 명시
const arr2 = createArray(3, ""); // 추론 (""에서 string 추론)
const arr3 = createArray<number[]>(3, []); // 명시 ([]만으로는 추론 불확실)
여러 곳에서 같은 타입 파라미터 사용
// 입력과 출력 타입이 연결됨
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const first1 = firstElement([1, 2, 3]); // number | undefined
const first2 = firstElement(["a", "b", "c"]); // string | undefined
const first3 = firstElement([]); // unknown (빈 배열은 T를 알 수 없음)
제네릭 인터페이스와 타입 별칭
제네릭 인터페이스
// 제네릭 인터페이스 정의
interface Box<T> {
value: T;
label: string;
}
// 사용
const numberBox: Box<number> = { value: 42, label: "숫자 상자" };
const stringBox: Box<string> = { value: "hello", label: "문자열 상자" };
// 제네릭 인터페이스 — 메서드 포함
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
size(): number;
isEmpty(): boolean;
}
// 구현
class ArrayStack<T> implements Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
size(): number {
return this.items.length;
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new ArrayStack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.peek()); // 2
console.log(numberStack.pop()); // 2
console.log(numberStack.size()); // 1
제네릭 타입 별칭
// 타입 별칭에 제네릭 적용
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;
// 함수 타입에도 적용
type Transformer<T, U> = (input: T) => U;
type Predicate<T> = (value: T) => boolean;
type Comparator<T> = (a: T, b: T) => number;
// 사용 예시
const toNumber: Transformer<string, number> = (s) => Number(s);
const isPositive: Predicate<number> = (n) => n > 0;
const compareNumbers: Comparator<number> = (a, b) => a - b;
// 재귀 타입 별칭 (TypeScript 3.7+)
type Tree<T> = {
value: T;
left?: Tree<T>;
right?: Tree<T>;
};
const numberTree: Tree<number> = {
value: 1,
left: {
value: 2,
left: { value: 4 },
right: { value: 5 },
},
right: {
value: 3,
},
};
다중 타입 파라미터
기본 다중 파라미터
// 두 타입을 받는 제네릭 함수
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair("hello", 42); // [string, number]
const p2 = pair(true, ["a", "b"]); // [boolean, string[]]
const p3 = pair<string, number>("x", 1); // 명시
// 타입 관계 표현
function zipWith<T, U, R>(
arr1: T[],
arr2: U[],
fn: (a: T, b: U) => R
): R[] {
const length = Math.min(arr1.length, arr2.length);
const result: R[] = [];
for (let i = 0; i < length; i++) {
result.push(fn(arr1[i], arr2[i]));
}
return result;
}
const zipped = zipWith([1, 2, 3], ["a", "b", "c"], (n, s) => `${n}${s}`);
// ["1a", "2b", "3c"]
swap 함수
function swap<T, U>(pair: [T, U]): [U, T] {
return [pair[1], pair[0]];
}
const original: [string, number] = ["hello", 42];
const swapped = swap(original); // [number, string] = [42, "hello"]
console.log(original); // ["hello", 42]
console.log(swapped); // [42, "hello"]
타입 파라미터 간 관계 제약
// T와 U의 관계를 제약으로 표현
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: "Alice" }, { age: 30 });
// { name: string; age: number }
console.log(merged.name); // "Alice"
console.log(merged.age); // 30
기본 타입 인수 (Default Type Parameters)
TypeScript 2.3부터 타입 파라미터에 기본값을 지정할 수 있습니다.
// 기본 타입 파라미터
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// T를 명시하지 않으면 unknown 사용
const rawResponse: ApiResponse = {
data: "raw",
status: 200,
message: "OK",
};
// T를 명시하면 해당 타입 사용
const typedResponse: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: "Alice" },
status: 200,
message: "OK",
};
// 기본 타입이 있어도 명시 가능
const stringResponse: ApiResponse<string> = {
data: "Hello",
status: 200,
message: "OK",
};
기본 타입 파라미터의 제약
// 기본 타입 파라미터는 기존 타입 파라미터를 참조할 수 있음
interface Pair<T, U = T> {
first: T;
second: U;
}
const samePair: Pair<number> = { first: 1, second: 2 }; // U = number
const diffPair: Pair<number, string> = { first: 1, second: "a" }; // U = string
// 제약과 기본값 함께 사용
interface Container<T extends object = Record<string, unknown>> {
content: T;
metadata: { createdAt: Date };
}
const defaultContainer: Container = {
content: { key: "value" },
metadata: { createdAt: new Date() },
};
제네릭 함수 vs 오버로드
오버로드가 필요한 경우
// 오버로드: 입력 타입에 따라 출력 타입이 명확히 다를 때
function processInput(input: string): string;
function processInput(input: number): number;
function processInput(input: string | number): string | number {
if (typeof input === "string") {
return input.toUpperCase();
}
return input * 2;
}
const s = processInput("hello"); // string
const n = processInput(42); // number
제네릭이 적합한 경우
// 제네릭: 타입과 무관하게 동일한 로직이 적용될 때
function identity<T>(value: T): T {
return value;
}
// 잘못된 제네릭 사용 (오버로드가 더 적합한 상황)
// 이렇게 하면 T가 string | number로 추론되어 정확도 낮아짐
function processWrong<T extends string | number>(input: T): T {
if (typeof input === "string") {
return input.toUpperCase() as T; // as T 강제 캐스팅 필요 — 위험 신호
}
return (input as number * 2) as T; // 마찬가지
}
선택 기준
// 입력과 출력의 관계가 일정하면 제네릭
function wrap<T>(value: T): { value: T } {
return { value };
}
// 입력 타입에 따라 출력 타입이 달라지면 오버로드
function toArray(value: string): string[];
function toArray(value: number): number[];
function toArray(value: string | number): string[] | number[] {
return [value as any];
}
실전 예제
제네릭 ApiResponse 래퍼
// API 응답 래퍼 타입
interface ApiResponse<T> {
data: T | null;
error: string | null;
status: number;
loading: boolean;
}
// 성공/실패 헬퍼 함수
function createSuccessResponse<T>(data: T, status = 200): ApiResponse<T> {
return {
data,
error: null,
status,
loading: false,
};
}
function createErrorResponse<T>(
error: string,
status = 500
): ApiResponse<T> {
return {
data: null,
error,
status,
loading: false,
};
}
function createLoadingResponse<T>(): ApiResponse<T> {
return {
data: null,
error: null,
status: 0,
loading: true,
};
}
// 사용
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
authorId: number;
}
const userResponse = createSuccessResponse<User>({
id: 1,
name: "Alice",
email: "alice@example.com",
});
const postResponse = createSuccessResponse<Post[]>([
{ id: 1, title: "첫 번째 포스트", content: "내용", authorId: 1 },
{ id: 2, title: "두 번째 포스트", content: "내용2", authorId: 1 },
]);
const errorResponse = createErrorResponse<User>("사용자를 찾을 수 없습니다.", 404);
// 타입 안전 처리
function handleResponse<T>(
response: ApiResponse<T>,
onSuccess: (data: T) => void,
onError: (error: string) => void
): void {
if (response.loading) {
console.log("로딩 중...");
return;
}
if (response.error) {
onError(response.error);
return;
}
if (response.data !== null) {
onSuccess(response.data);
}
}
handleResponse(
userResponse,
(user) => console.log(`환영합니다, ${user.name}!`),
(err) => console.error(`오류: ${err}`)
);
제네릭 캐시 구현
class Cache<K, V> {
private store = new Map<K, { value: V; expiresAt: number }>();
private ttl: number;
constructor(ttlMs = 5 * 60 * 1000) { // 기본 5분
this.ttl = ttlMs;
}
set(key: K, value: V): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttl,
});
}
get(key: K): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
has(key: K): boolean {
return this.get(key) !== undefined;
}
delete(key: K): boolean {
return this.store.delete(key);
}
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
}
// 사용
const userCache = new Cache<number, User>(10 * 60 * 1000); // 10분 TTL
userCache.set(1, { id: 1, name: "Alice", email: "alice@example.com" });
const cachedUser = userCache.get(1);
if (cachedUser) {
console.log(`캐시에서 가져옴: ${cachedUser.name}`);
}
제네릭 파이프라인
// 값을 변환하는 파이프라인
function pipe<T>(value: T): PipeBuilder<T> {
return new PipeBuilder(value);
}
class PipeBuilder<T> {
constructor(private value: T) {}
map<U>(fn: (value: T) => U): PipeBuilder<U> {
return new PipeBuilder(fn(this.value));
}
filter(predicate: (value: T) => boolean): PipeBuilder<T | undefined> {
if (predicate(this.value)) {
return new PipeBuilder<T | undefined>(this.value);
}
return new PipeBuilder<T | undefined>(undefined);
}
result(): T {
return this.value;
}
}
const result = pipe(42)
.map((n) => n * 2) // PipeBuilder<number>
.map((n) => n.toString()) // PipeBuilder<string>
.map((s) => s + "!") // PipeBuilder<string>
.result();
console.log(result); // "84!"
고수 팁
타입 파라미터 네이밍 컨벤션
TypeScript 커뮤니티에서 통용되는 타입 파라미터 명명 규칙입니다.
// T — 일반적인 타입 (Type)
function identity<T>(value: T): T { return value; }
// K, V — 키(Key)와 값(Value)
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// E — 요소(Element), 주로 컬렉션 요소
interface Collection<E> {
add(element: E): void;
remove(element: E): boolean;
contains(element: E): boolean;
toArray(): E[];
}
// R — 반환 타입(Return)
type Mapper<T, R> = (input: T) => R;
// P — Props (React 컴포넌트에서 자주 사용)
function withLogger<P extends object>(Component: React.FC<P>): React.FC<P> {
return (props: P) => {
console.log("Props:", props);
return Component(props);
};
}
// TError, TData — 접두사로 T를 붙여 명확성 강조
interface Result<TData, TError = Error> {
data: TData | null;
error: TError | null;
}
// 이름이 긴 것을 줄일 때는 의미 있는 약어 사용
// Item, Node, Value 등도 흔히 사용
type TreeNode<Item> = {
item: Item;
children: TreeNode<Item>[];
};
불필요한 제네릭 남용 주의
// 나쁜 예 — 제네릭이 전혀 필요 없음
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
// 이렇게 하면 충분
function getLength(value: { length: number }): number {
return value.length;
}
// 나쁜 예 — T가 한 군데서만 쓰임 (= any와 동일한 효과)
function log<T>(value: T): void {
console.log(value);
}
// 이렇게 하면 충분
function log(value: unknown): void {
console.log(value);
}
// 좋은 예 — T가 입력과 출력을 연결하는 의미 있는 역할
function identity<T>(value: T): T {
return value;
}
// 좋은 예 — T가 여러 위치에서 타입 일관성을 보장
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
타입 추론 개선 팁
// 튜플 타입 추론 개선
function tuple<T extends unknown[]>(...args: T): T {
return args;
}
const t = tuple(1, "hello", true); // [number, string, boolean]
// vs
const arr = [1, "hello", true]; // (number | string | boolean)[]
// const 어서션과 제네릭 결합
function createConfig<T extends Record<string, unknown>>(config: T): Readonly<T> {
return Object.freeze(config);
}
const config = createConfig({
host: "localhost",
port: 3000,
debug: true,
});
// config.host는 string (readonly)
정리 표
| 개념 | 문법 | 설명 |
|---|---|---|
| 제네릭 함수 | function f<T>(x: T): T | 타입을 매개변수로 받는 함수 |
| 타입 인수 명시 | f<string>("hello") | 호출 시 타입 직접 지정 |
| 타입 추론 | f("hello") | 인수에서 타입 자동 추론 |
| 제네릭 인터페이스 | interface Box<T> | 타입 파라미터를 가진 인터페이스 |
| 제네릭 타입 별칭 | type Pair<T, U> | 타입 파라미터를 가진 타입 별칭 |
| 다중 파라미터 | <T, U> | 여러 타입 파라미터 사용 |
| 기본 타입 파라미터 | <T = string> | 타입 인수 생략 시 기본값 |
| 네이밍 컨벤션 | T, K, V, E, R | 역할에 따른 단일 대문자 |
다음 장에서는...
5.2절에서는 **제네릭 제약(Generic Constraints)**을 다룹니다. extends 키워드로 타입 파라미터가 특정 조건을 만족하도록 제한하는 방법, keyof와 결합해 객체 프로퍼티에 안전하게 접근하는 패턴, 그리고 제약이 너무 엄격하면 재사용성이 떨어지는 트레이드오프를 살펴봅니다.