들어가며
TypeScript를 처음 접하는 개발자들이 가장 어려워하는 개념 중 하나가 바로 제네릭(Generic)입니다. 처음 보는 <T>
같은 문법에 당황하고, "왜 이런 복잡한 걸 써야 하지?"라고 생각하기 쉽습니다. 하지만 제네릭을 이해하고 나면, 코드의 재사용성과 타입 안전성을 동시에 확보할 수 있는 강력한 도구라는 것을 알게 됩니다.
이 글에서는 제네릭의 기본 개념부터 실무에서 자주 사용하는 패턴까지, 실전 예제와 함께 차근차근 알아보겠습니다.
제네릭이란 무엇인가?
일상생활 비유로 이해하기
제네릭을 이해하기 위해 간단한 비유를 들어보겠습니다.
상자를 만드는 공장이 있다고 생각해보세요. 이 공장에서는 다양한 크기의 물건을 담을 수 있는 범용 상자를 만듭니다. 상자 자체는 하나의 설계도로 만들어지지만, 실제로 사용할 때는 책을 담는 상자, 신발을 담는 상자, 옷을 담는 상자로 각각 다르게 활용됩니다.
TypeScript의 제네릭도 이와 같습니다. 하나의 함수나 클래스를 작성하되, 실제 사용할 때 타입을 지정하여 다양한 타입에 대해 동작하도록 만드는 것입니다.
제네릭의 핵심 개념
// 제네릭 없이 작성한 함수 (문제가 있는 코드)
function getFirstElement(array: any[]): any {
return array[0];
}
// 제네릭을 사용한 함수 (개선된 코드)
function getFirstElement<T>(array: T[]): T {
return array[0];
}
위 예제에서 <T>
는 타입 매개변수입니다. 함수를 호출할 때 실제 타입을 전달하면, T가 그 타입으로 대체됩니다.
왜 제네릭을 사용해야 할까?
1. 타입 안전성 확보
// 제네릭 없이
const numbers = [1, 2, 3, 4, 5];
const firstNumber = getFirstElement(numbers); // any 타입 반환
console.log(firstNumber.toFixed(2)); // 런타임에 오류 가능성
// 제네릭 사용
const firstNumberTyped = getFirstElement<number>(numbers); // number 타입 반환
console.log(firstNumberTyped.toFixed(2)); // 컴파일 타임에 타입 검증
2. 코드 재사용성 증대
// 각 타입별로 함수를 만들 필요가 없음
const stringArray = ["hello", "world"];
const numberArray = [1, 2, 3];
const booleanArray = [true, false];
const firstString = getFirstElement<string>(stringArray); // string
const firstNumber = getFirstElement<number>(numberArray); // number
const firstBoolean = getFirstElement<boolean>(booleanArray); // boolean
3. 타입 추론 활용
// 타입을 명시하지 않아도 TypeScript가 자동으로 추론
const firstString = getFirstElement(["hello", "world"]); // string으로 추론
const firstNumber = getFirstElement([1, 2, 3]); // number로 추론
실무에서 자주 사용하는 제네릭 패턴
1. API 응답 데이터 처리
// API 응답 타입 정의
interface ApiResponse<T> {
data: T;
message: string;
status: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// 사용 예제
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "김개발", email: "dev@example.com" },
message: "성공",
status: 200
};
const productResponse: ApiResponse<Product[]> = {
data: [
{ id: 1, title: "노트북", price: 1500000 },
{ id: 2, title: "마우스", price: 50000 }
],
message: "성공",
status: 200
};
2. 재시도 로직이 포함된 API 호출 함수
실무에서 API 호출 시 네트워크 오류나 서버 오류로 인한 실패를 대비해 재시도 로직을 구현하는 경우가 많습니다. 제네릭을 활용하면 다양한 API 호출에 대해 재사용 가능한 유틸리티 함수를 만들 수 있습니다.
// 재시도 로직을 위한 유틸리티 함수
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
// 500 에러가 아니거나 마지막 시도면 바로 throw
if (error.response?.status !== 500 || attempt === maxRetries) {
throw error;
}
// 지연 후 재시도
await new Promise(resolve => setTimeout(resolve, delay * attempt));
}
}
throw lastError!;
}
// 사용 예제
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
async function fetchProducts(): Promise<Product[]> {
const response = await fetch('/api/products');
return response.json();
}
// 재시도 로직과 함께 API 호출
const user = await withRetry(() => fetchUser(1));
const products = await withRetry(() => fetchProducts(), 5, 2000);
코드 설명
위 withRetry
함수의 핵심 포인트들을 살펴보겠습니다:
제네릭 타입 매개변수
<T>
: 함수가 반환하는 데이터의 타입을 나타냅니다. 이를 통해 어떤 타입의 API 호출이든 재사용할 수 있습니다.함수 매개변수
fn: () => Promise<T>
: 실제 API 호출 함수를 받습니다. 이 함수는 Promise를 반환해야 하며, 그 Promise의 타입이 T입니다.재시도 로직: 500 에러(서버 오류)인 경우에만 재시도하며, 각 시도마다 지연 시간을 증가시켜 서버 부하를 줄입니다.
타입 안전성: 반환 타입이
Promise<T>
로 명확히 정의되어, 호출하는 쪽에서 정확한 타입을 받을 수 있습니다.
제네릭 제약 조건 활용하기
extends 키워드 사용
// 특정 속성을 가진 객체만 받도록 제약
interface Identifiable {
id: number;
}
function updateEntity<T extends Identifiable>(entity: T, updates: Partial<T>): T {
return { ...entity, ...updates };
}
// 사용 예제
const user = { id: 1, name: "김개발", email: "dev@example.com" };
const updatedUser = updateEntity(user, { name: "이개발" }); // ✅ 정상
const invalidEntity = { name: "무명" }; // id가 없음
// const updated = updateEntity(invalidEntity, { name: "변경" }); // ❌ 컴파일 오류
keyof 연산자와 함께 사용
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "김개발", age: 25, job: "개발자" };
const name = getProperty(person, "name"); // string 타입
const age = getProperty(person, "age"); // number 타입
// const invalid = getProperty(person, "salary"); // ❌ 컴파일 오류
실전 활용 팁
1. 유틸리티 타입과 조합하기
// Partial, Pick, Omit과 함께 사용
interface CreateUserDto<T> {
userData: Omit<T, 'id' | 'createdAt'>;
metadata?: Record<string, any>;
}
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
const createUser: CreateUserDto<User> = {
userData: {
name: "신규유저",
email: "newuser@example.com"
// id와 createdAt는 제외됨
}
};
2. 조건부 타입 활용
type ApiResult<T> = T extends string
? { message: T }
: { data: T };
function processApiResponse<T>(input: T): ApiResult<T> {
if (typeof input === 'string') {
return { message: input } as ApiResult<T>;
}
return { data: input } as ApiResult<T>;
}
const stringResult = processApiResponse("에러 메시지"); // { message: string }
const dataResult = processApiResponse({ id: 1 }); // { data: { id: number } }
주의사항과 베스트 프랙티스
1. 과도한 제네릭 사용 피하기
// ❌ 너무 복잡함
function complexFunction<T, U, V, W>(
a: T,
b: U,
c: V,
d: W
): SomeComplexType<T, U, V, W> {
// ...
}
// ✅ 필요한 만큼만 사용
function simpleFunction<T>(data: T[]): T | undefined {
return data[0];
}
2. 의미있는 타입 매개변수 이름 사용
// ❌ 의미가 불분명
function process<T, U, V>(input: T): U {
// ...
}
// ✅ 의미가 명확
function transform<TInput, TOutput>(input: TInput): TOutput {
// ...
}
function createRepository<TEntity>(entityClass: new() => TEntity) {
// ...
}
3. 기본 타입 제공
// 기본 타입을 제공하여 사용성 향상
interface PaginatedResponse<T = any> {
data: T[];
total: number;
page: number;
limit: number;
}
// 타입을 명시하지 않으면 any 사용
const response1: PaginatedResponse = { /* ... */ };
// 필요시 구체적인 타입 지정
const response2: PaginatedResponse<User> = { /* ... */ };
결론
TypeScript의 제네릭은 처음에는 어렵게 느껴질 수 있지만, 한번 익숙해지면 코드의 품질을 크게 향상시킬 수 있는 강력한 도구입니다.
핵심 포인트 요약:
- 타입 안전성: 컴파일 타임에 타입 오류를 미리 발견
- 재사용성: 하나의 코드로 여러 타입에 대응
- 가독성: 의도가 명확한 코드 작성 가능
- 유지보수성: 타입 변경 시 관련된 모든 부분을 쉽게 추적
실무에서는 API 호출, 데이터 변환, 유틸리티 함수 등 다양한 상황에서 제네릭을 활용할 수 있습니다. 처음에는 간단한 예제부터 시작해서 점진적으로 복잡한 패턴을 익혀나가는 것을 추천합니다.
제네릭을 적절히 활용하면 더 안전하고 재사용 가능한 TypeScript 코드를 작성할 수 있을 것입니다.
참고 자료
'Frontend Development' 카테고리의 다른 글
TypeScript any vs 제네릭 T: 실행 결과로 보는 확실한 차이점 (0) | 2025.06.09 |
---|---|
TypeScript 제네릭 완전 정복: 실행 결과로 배우는 실전 가이드 (0) | 2025.06.09 |
Redux vs Zustand 상태 관리 비교: 쇼핑몰 & 대시보드 실무 사례 (6) | 2025.06.01 |
Streaming SSR: 서버 사이드 렌더링의 새로운 패러다임 (0) | 2025.05.30 |
React 컴포넌트 설계의 핵심: 재사용성과 유지보수성을 높이는 방법 (0) | 2025.05.29 |