타입스크립트의 제네릭(Generic)이란 무엇이며, 어떻게 활용하나요?
답변 포인트
타입을 파라미터처럼 사용하는 개념을 생각해보세요.
정답 및 해설
빠른 요약
제네릭은 타입을 매개변수처럼 사용하여 재사용 가능한 컴포넌트/함수를 만드는 기능입니다. 기본 예시: function identity<T>(arg: T): T { return arg; } 활용: - 배열/컬렉션 유틸리티 함수 (map, filter 등) - API 응답 타입 제네릭 래퍼 - 상태 관리 (useState<T>) 제약 조건(constraint): function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] 제네릭을 사용하면 any 타입 없이 타입 안전성을 유지하면서 유연한 코드를 작성할 수 있습니다.
제네릭(Generic)은 타입을 매개변수처럼 사용하여 다양한 타입에 대해 동작하는 재사용 가능한 컴포넌트를 만드는 TypeScript의 핵심 기능입니다. 제네릭을 사용하면 타입 안정성을 유지하면서도 코드 중복을 줄이고 유연한 API를 설계할 수 있습니다. C++의 템플릿, Java의 제네릭과 유사한 개념입니다.
제네릭이 필요한 이유
문제: any를 사용하면 타입 안정성 손실
// any 사용 - 타입 체크가 전혀 되지 않음
function identity(arg: any): any {
return arg;
}
const result = identity("hello");
result.toUpperCase(); // OK
result.toFixed(2); // 런타임 에러! 하지만 TypeScript는 모름해결: 제네릭으로 타입 안정성 확보
// 제네릭 사용 - 타입 안정성 보장
function identity<T>(arg: T): T {
return arg;
}
const strResult = identity<string>("hello"); // T = string
strResult.toUpperCase(); // OK
strResult.toFixed(2); // TS 에러! string에는 toFixed가 없음
const numResult = identity<number>(42); // T = number
numResult.toFixed(2); // OK
// 타입 추론으로 생략 가능
const inferred = identity("hello"); // T는 string으로 추론기본 문법
// 제네릭 타입 매개변수는 관례적으로 대문자 사용
// T, U, V ... 또는 의미있는 이름 (Item, Key, Value, Element 등)
// 함수
function first<T>(arr: T[]): T {
return arr[0];
}
// 화살표 함수 (tsx 파일에서는 <T,> 또는 <T extends unknown> 사용)
const first = <T>(arr: T[]): T => arr[0];
const first = <T,>(arr: T[]): T => arr[0]; // tsx 파일
// 여러 타입 매개변수
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const result = pair<string, number>("age", 25); // [string, number]제네릭 함수
// 배열에서 특정 조건의 요소 찾기
function findItem<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
return arr.find(predicate);
}
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const found = findItem(users, user => user.id === 1);
// found의 타입: { id: number, name: string } | undefined
// 배열 변환
function map<T, U>(arr: T[], transform: (item: T) => U): U[] {
return arr.map(transform);
}
const names = map(users, user => user.name); // string[]
const ids = map(users, user => user.id); // number[]제네릭 인터페이스
// API 응답 래퍼 타입
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// 사용 예시
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
}
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
async function fetchPosts(): Promise<ApiResponse<Post[]>> {
const response = await fetch('/api/posts');
return response.json();
}
// 타입 안전하게 사용
const userResponse = await fetchUser(1);
console.log(userResponse.data.name); // string - 타입 확인됨
console.log(userResponse.data.title); // TS 에러! User에는 title 없음제네릭 클래스
// 타입 안전한 스택(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];
}
get size(): number {
return this.items.length;
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push("hello"); // TS 에러! string은 number 스택에 추가 불가
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");제네릭 타입 별칭
// 공통 페이지네이션 타입
type PaginatedResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
};
// 상태 관리용 타입
type AsyncState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
// 사용 예시
const userState: AsyncState<User> = {
data: null,
loading: true,
error: null,
};
// 키-값 맵 타입
type Dictionary<T> = {
[key: string]: T;
};
const errorMessages: Dictionary<string> = {
NOT_FOUND: "리소스를 찾을 수 없습니다",
UNAUTHORIZED: "인증이 필요합니다",
};제네릭 제약(Constraints)
타입 매개변수에 제약을 추가하여 특정 속성을 가진 타입만 허용합니다.
// T는 length 속성을 가져야 함
function getLength<T extends { length: number }>(arg: T): number {
return arg.length;
}
getLength("hello"); // OK (string은 length 있음)
getLength([1, 2, 3]); // OK (array는 length 있음)
getLength(42); // TS 에러! 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", age: 30 };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // number
const xyz = getProperty(user, "xyz"); // TS 에러! 존재하지 않는 키
// 인터페이스 확장을 통한 제약
interface Identifiable {
id: number;
}
function findById<T extends Identifiable>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
const found = findById(users, 1); // { id: number, name: string } | undefined제네릭 기본값
// 기본 타입 지정
interface Container<T = string> {
value: T;
}
const strContainer: Container = { value: "hello" }; // T = string (기본값)
const numContainer: Container<number> = { value: 42 }; // T = number
// 복잡한 기본값 예시
type EventHandler<T = Event> = (event: T) => void;
const clickHandler: EventHandler = (e) => { /* e는 Event */ };
const customHandler: EventHandler<MouseEvent> = (e) => { /* e는 MouseEvent */ };실무 활용 패턴
React useState와 제네릭
import { useState } from 'react';
// useState는 내부적으로 제네릭 사용
// function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
// 타입 추론으로 생략 가능
const [count, setCount] = useState(0); // number로 추론커스텀 훅에서 제네릭 활용
import { useState, useEffect } from 'react';
// 재사용 가능한 API 페치 훅
function useFetch<T>(url: string): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
fetch(url)
.then(res => res.json())
.then((data: T) => setState({ data, loading: false, error: null }))
.catch(error => setState({ data: null, loading: false, error: error.message }));
}, [url]);
return state;
}
// 사용
const { data: user, loading } = useFetch<User>('/api/users/1');
const { data: posts } = useFetch<Post[]>('/api/posts');
// user의 타입은 User | null, posts의 타입은 Post[] | nullRedux 스타일 상태 관리
// 제네릭 리듀서 패턴
type Action<T extends string, P = void> = P extends void
? { type: T }
: { type: T; payload: P };
type UserAction =
| Action<'SET_USER', User>
| Action<'CLEAR_USER'>
| Action<'SET_LOADING', boolean>;
function userReducer(state: AsyncState<User>, action: UserAction): AsyncState<User> {
switch (action.type) {
case 'SET_USER':
return { ...state, data: action.payload, loading: false }; // payload는 User 타입
case 'CLEAR_USER':
return { data: null, loading: false, error: null };
case 'SET_LOADING':
return { ...state, loading: action.payload }; // payload는 boolean 타입
}
}유틸리티 함수들
// 안전한 배열 청크 분할
function chunk<T>(arr: T[], size: number): T[][] {
return arr.reduce((result, _, index) => {
if (index % size === 0) result.push(arr.slice(index, index + size));
return result;
}, [] as T[][]);
}
const chunks = chunk([1, 2, 3, 4, 5], 2); // number[][]
// [[1, 2], [3, 4], [5]]
// 객체 배열에서 특정 키로 그룹화
function groupBy<T, K extends keyof T>(arr: T[], key: K): Map<T[K], T[]> {
return arr.reduce((map, item) => {
const groupKey = item[key];
const group = map.get(groupKey) ?? [];
return map.set(groupKey, [...group, item]);
}, new Map<T[K], T[]>());
}
const grouped = groupBy(users, 'role'); // Map<string, User[]>정리
| 구분 | 문법 | 설명 |
|---|---|---|
| 기본 함수 제네릭 | function fn<T>(arg: T): T | 입출력 타입을 동일하게 유지 |
| 인터페이스 제네릭 | interface Api<T> { data: T } | API 응답 등 컨테이너 타입 |
| 클래스 제네릭 | class Stack<T> { ... } | 타입 안전한 자료구조 |
| 타입 별칭 제네릭 | type State<T> = { data: T } | 유연한 타입 조합 |
| 제약 (Constraint) | <T extends { id: number }> | 특정 속성 보유 타입으로 제한 |
| keyof 제약 | <K extends keyof T> | 객체의 키 타입으로 제한 |
| 기본값 | <T = string> | 지정 없을 때 기본 타입 사용 |
| 여러 매개변수 | <T, U, V> | 복수의 독립적인 타입 매개변수 |
핵심: 제네릭은 "타입을 변수처럼 다루는 것"입니다. any와 달리 타입 정보가 유지되므로, 재사용성과 타입 안정성을 동시에 달성할 수 있습니다.