본문으로 건너뛰기

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