TypeScriptMedium#10
제네릭(Generics)이란 무엇이며 어떤 상황에서 유용한가요?
#TS#제네릭#타입안전성
힌트
타입의 재사용성과 타입 안전성을 동시에 확보하는 방법을 생각해보세요.
정답 및 해설
제네릭(Generics)이란 무엇이며 어떤 상황에서 유용한가요?
제네릭(Generics)은 타입을 매개변수처럼 사용하여, 다양한 타입에서 재사용 가능한 컴포넌트(함수, 클래스, 인터페이스)를 만드는 TypeScript의 핵심 기능입니다. 타입 안전성을 유지하면서 유연한 코드를 작성할 수 있게 해줍니다.
제네릭이 필요한 이유
any를 사용할 때의 문제
// any 사용 — 타입 안전성 완전히 포기
function identity(value: any): any {
return value;
}
const result = identity('hello');
result.toUpperCase(); // 동작하지만...
result.nonExistentMethod(); // 에러가 있어도 컴파일 통과 (위험!)
오버로딩으로 해결하려는 경우 — 코드 중복
function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
function identityBoolean(value: boolean): boolean { return value; }
// 타입마다 별도 함수 — 중복, 비효율적
제네릭으로 해결
// T는 타입 매개변수 (Type Parameter)
function identity<T>(value: T): T {
return value;
}
// 타입 추론 — TypeScript가 자동으로 T를 추론
const str = identity('hello'); // T = string, str: string
const num = identity(42); // T = number, num: number
// 명시적 타입 지정
const arr = identity<number[]>([1, 2, 3]);
제네릭 함수
기본 예제
// 배열의 첫 번째 요소 반환
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNum = first([1, 2, 3]); // number | undefined
const firstStr = first(['a', 'b']); // string | undefined
// 두 값을 쌍으로 반환
function pair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
const p = pair('hello', 42); // [string, number]
실용적인 예제
// 배열 요소를 키로 그룹화
function groupBy<T, K extends string | number | symbol>(
arr: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return arr.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
const users = [
{ name: 'Alice', role: 'admin' },
{ name: 'Bob', role: 'user' },
{ name: 'Charlie', role: 'admin' },
];
const byRole = groupBy(users, (u) => u.role);
// { admin: [{...}, {...}], user: [{...}] }
제네릭 인터페이스
// API 응답 래퍼
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
interface User {
id: number;
name: string;
email: string;
}
// 사용
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
const result = await fetchUser(1);
console.log(result.data.name); // User 타입으로 자동완성 지원
// 페이지네이션 응답
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
interface Post {
id: number;
title: string;
content: string;
}
async function fetchPosts(): Promise<PaginatedResponse<Post>> {
const response = await fetch('/api/posts');
return response.json();
}
제네릭 클래스
// 타입 안전한 스택(Stack) 자료구조
class 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];
}
isEmpty(): boolean {
return this.items.length === 0;
}
get size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.peek(); // number | undefined
const stringStack = new Stack<string>();
stringStack.push('hello');
// stringStack.push(42); // 에러: number는 string에 할당 불가
제약 조건 (Constraints)
extends 키워드로 타입 매개변수에 제약을 걸 수 있습니다.
// T는 length 프로퍼티를 가져야 함
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength('hello'); // 5 (string은 length 있음)
getLength([1, 2, 3]); // 3 (array는 length 있음)
// getLength(42); // 에러: number에는 length가 없음
// keyof 제약
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const name = getProperty(user, 'name'); // string
const id = getProperty(user, 'id'); // number
// getProperty(user, 'age'); // 에러: 'age'는 User의 키가 아님
기본값과 조건부 제네릭
// 기본 타입 매개변수
interface Container<T = string> {
value: T;
label: string;
}
const defaultContainer: Container = { value: 'hello', label: '기본' }; // T = string
const numContainer: Container<number> = { value: 42, label: '숫자' };
실무 활용 패턴
useFetch 훅 (React)
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
fetch(url)
.then((res) => res.json() as Promise<T>)
.then((data) => setState({ data, loading: false, error: null }))
.catch((error) => setState({ data: null, loading: false, error }));
}, [url]);
return state;
}
// 사용
const { data: user, loading } = useFetch<User>('/api/users/1');
// user는 User | null 타입
유틸리티 함수
// null/undefined를 제거하고 타입 좁히기
function filterDefined<T>(arr: (T | null | undefined)[]): T[] {
return arr.filter((item): item is T => item != null);
}
const maybeUsers: (User | null)[] = [
{ id: 1, name: 'Alice' },
null,
{ id: 2, name: 'Bob' },
];
const users = filterDefined(maybeUsers); // User[]
정리
| 사용 위치 | 예제 | 주요 용도 |
|---|---|---|
| 함수 | function fn<T>(arg: T): T | 입출력 타입 연결 |
| 인터페이스 | interface Resp<T> { data: T } | API 응답, 컨테이너 타입 |
| 클래스 | class Stack<T> | 자료구조, 서비스 클래스 |
| 제약 조건 | <T extends object> | 허용 타입 범위 제한 |
제네릭은 any를 사용하지 않고도 유연한 코드를 작성할 수 있게 해주는 핵심 도구입니다. 처음에는 복잡해 보일 수 있지만, 재사용성과 타입 안전성을 동시에 얻을 수 있어 TypeScript 코드 품질을 크게 향상시킵니다.