TypeScriptMedium#89
타입스크립트의 제네릭(Generic)이란 무엇이며, 어떻게 활용하나요?
#TypeScript#제네릭#타입시스템
힌트
타입을 파라미터처럼 사용하는 개념을 생각해보세요.
정답 및 해설
타입스크립트의 제네릭(Generic)이란 무엇이며, 어떻게 활용하나요?
제네릭(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[] | null
Redux 스타일 상태 관리
// 제네릭 리듀서 패턴
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와 달리 타입 정보가 유지되므로, 재사용성과 타입 안정성을 동시에 달성할 수 있습니다.