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와 결합해 객체 프로퍼티에 안전하게 접근하는 패턴, 그리고 제약이 너무 엄격하면 재사용성이 떨어지는 트레이드오프를 살펴봅니다.