📋 목차
📘 TypeScript 제네릭 개요와 기본 개념
TypeScript 제네릭은 코드의 재사용성과 타입 안정성을 동시에 확보할 수 있는 강력한 기능입니다.
TypeScript의 제네릭(Generics)은 타입을 마치 함수의 매개변수처럼 사용할 수 있게 해주는 기능입니다. 이를 통해 다양한 타입에 대해 동작하는 컴포넌트를 만들 수 있으면서도, 컴파일 타임에 타입 안정성을 보장받을 수 있습니다.
제네릭을 사용하지 않으면 같은 로직을 다른 타입마다 반복해서 작성해야 하거나, `any` 타입을 사용해서 타입 안정성을 포기해야 합니다. 제네릭은 이 두 가지 문제를 모두 해결해줍니다.
// 제네릭 없이 - 타입별로 함수를 만들어야 함
function identityString(arg: string): string {
return arg;
}
function identityNumber(arg: number): number {
return arg;
}
// 제네릭 사용 - 하나의 함수로 모든 타입 처리
function identity(arg: T): T {
return arg;
}
// 사용 예시
const stringResult = identity("hello"); // 타입: string
const numberResult = identity(42); // 타입: number
const boolResult = identity(true); // 타입 추론: boolean
💡 핵심 포인트
제네릭을 사용하면 코드의 재사용성을 높이면서도 타입 안정성을 유지할 수 있습니다. 타입 매개변수 `T`는 관례적으로 사용되는 이름이며, 의미가 명확한 경우 `TData`, `TResponse` 등으로 명명할 수도 있습니다.
⚙️ 기본 제네릭 문법과 타입 매개변수
제네릭의 기본 문법을 익히고 타입 매개변수를 효과적으로 활용하는 방법을 알아보겠습니다.
제네릭은 꺾쇠괄호(`<>`) 안에 타입 매개변수를 정의하여 사용합니다. 하나의 함수나 클래스에 여러 개의 타입 매개변수를 사용할 수도 있으며, 각각은 서로 다른 타입을 나타낼 수 있습니다.
// 단일 타입 매개변수
function getFirst(array: T[]): T | undefined {
return array[0];
}
// 다중 타입 매개변수
function pair(first: T, second: U): [T, U] {
return [first, second];
}
// 기본 타입 매개변수 (Default Type Parameters)
function createArray(length: number, value: T): T[] {
return Array(length).fill(value);
}
// 사용 예시
const numbers = getFirst([1, 2, 3]); // 타입: number | undefined
const mixed = pair("hello", 42); // 타입: [string, number]
const strings = createArray(3, "default"); // 타입: string[]
const booleans = createArray(2, true); // 타입: boolean[]
🎯 실무 예시
API 응답 처리에서 제네릭을 활용하면 다양한 데이터 타입에 대해 동일한 응답 구조를 유지하면서도 타입 안정성을 보장할 수 있습니다.
// API 응답 타입 정의
interface ApiResponse {
success: boolean;
data: T;
message: string;
}
// 사용자 데이터 타입
interface User {
id: number;
name: string;
email: string;
}
// 제네릭을 활용한 API 함수
async function fetchData(url: string): Promise> {
const response = await fetch(url);
return response.json();
}
// 타입 안전한 사용
const userResponse = await fetchData('/api/users');
// userResponse.data는 User[] 타입으로 추론됨
🔧 제네릭 함수와 인터페이스 활용
제네릭을 함수와 인터페이스에 적용하여 더욱 유연하고 타입 안전한 코드를 작성하는 방법을 살펴보겠습니다.
제네릭 인터페이스는 다양한 타입에 대해 동일한 구조를 제공하면서도 각 타입에 맞는 구체적인 타입 정보를 유지할 수 있게 해줍니다. 이는 특히 데이터 컨테이너나 유틸리티 타입을 만들 때 매우 유용합니다.
// 제네릭 인터페이스 정의
interface Container {
value: T;
getValue(): T;
setValue(value: T): void;
}
// 제네릭 인터페이스 구현
class Box implements Container {
constructor(private _value: T) {}
getValue(): T {
return this._value;
}
setValue(value: T): void {
this._value = value;
}
get value(): T {
return this._value;
}
}
// 사용 예시
const stringBox = new Box("Hello TypeScript");
const numberBox = new Box(42);
console.log(stringBox.getValue()); // "Hello TypeScript"
console.log(numberBox.getValue()); // 42
💡 핵심 포인트
제네릭 인터페이스를 사용할 때는 타입 매개변수의 의미를 명확히 하고, 필요한 경우 제약 조건을 추가하여 더욱 안전한 코드를 작성할 수 있습니다.
// 고급 제네릭 인터페이스 패턴
interface Repository {
findById(id: string): Promise;
findAll(): Promise;
create(entity: Omit): Promise;
update(id: string, updates: Partial): Promise;
delete(id: string): Promise;
}
// 사용자 엔티티
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// 타입 안전한 Repository 구현
class UserRepository implements Repository {
async findById(id: string): Promise {
// 구현 로직
return null;
}
async findAll(): Promise {
// 구현 로직
return [];
}
async create(userData: Omit): Promise {
// userData는 name과 email만 필요
const user: User = {
id: generateId(),
createdAt: new Date(),
...userData
};
return user;
}
// 기타 메서드 구현...
}
🚀 고급 제네릭 패턴과 제약 조건
제네릭 제약 조건과 조건부 타입을 활용하여 더욱 정교하고 안전한 타입 시스템을 구축하는 방법을 알아보겠습니다.
제네릭 제약 조건(Generic Constraints)을 사용하면 타입 매개변수가 특정 조건을 만족하도록 제한할 수 있습니다. 이를 통해 더 안전하고 예측 가능한 코드를 작성할 수 있습니다.
// 기본 제약 조건 - extends 키워드 사용
interface Lengthwise {
length: number;
}
function logAndReturn(arg: T): T {
console.log(arg.length); // length 속성에 안전하게 접근 가능
return arg;
}
// 사용 예시
logAndReturn("hello"); // ✅ string은 length 속성을 가짐
logAndReturn([1, 2, 3]); // ✅ Array도 length 속성을 가짐
logAndReturn({length: 10, value: "test"}); // ✅ length 속성을 가진 객체
// logAndReturn(123); // ❌ number는 length 속성이 없음
// keyof를 사용한 제약 조건
function getProperty(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "John", age: 30, email: "john@example.com" };
const name = getProperty(person, "name"); // 타입: string
const age = getProperty(person, "age"); // 타입: number
// const invalid = getProperty(person, "height"); // ❌ 컴파일 에러
🎯 실무 예시
조건부 타입을 활용하면 입력 타입에 따라 다른 반환 타입을 가지는 함수를 안전하게 작성할 수 있습니다.
// 조건부 타입 활용
type ApiResult = T extends string
? { message: T; status: "success" }
: T extends Error
? { error: T; status: "error" }
: { data: T; status: "success" };
function processApiResponse(input: T): ApiResult {
if (typeof input === 'string') {
return { message: input, status: "success" } as ApiResult;
}
if (input instanceof Error) {
return { error: input, status: "error" } as ApiResult;
}
return { data: input, status: "success" } as ApiResult;
}
// 사용 예시 - 타입이 자동으로 추론됨
const stringResult = processApiResponse("Operation completed");
// 타입: { message: string; status: "success" }
const errorResult = processApiResponse(new Error("Something went wrong"));
// 타입: { error: Error; status: "error" }
const dataResult = processApiResponse({ id: 1, name: "John" });
// 타입: { data: { id: number; name: string }; status: "success" }
💼 실무 프로젝트에서의 제네릭 활용법
실제 프로젝트에서 제네릭을 어떻게 효과적으로 활용할 수 있는지 구체적인 예시와 함께 살펴보겠습니다.
실무에서는 제네릭을 사용하여 API 클라이언트, 상태 관리, 폼 처리 등 다양한 영역에서 타입 안전성을 확보하면서도 코드의 재사용성을 높일 수 있습니다.
// HTTP 클라이언트에 제네릭 적용
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get(endpoint: string): Promise {
const response = await fetch(`${this.baseUrl}${endpoint}`);
return response.json();
}
async post(
endpoint: string,
data: TRequest
): Promise {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
}
// 사용 예시
interface CreateUserRequest {
name: string;
email: string;
}
interface CreateUserResponse {
id: string;
name: string;
email: string;
createdAt: string;
}
const client = new ApiClient('https://api.example.com');
// 타입 안전한 API 호출
const users = await client.get('/users');
const newUser = await client.post(
'/users',
{ name: 'John', email: 'john@example.com' }
);
⚠️ 주의사항
제네릭을 과도하게 사용하면 코드의 복잡성이 증가할 수 있습니다. 간단한 경우에는 명시적인 타입을 사용하는 것이 더 나을 수 있습니다.
// React Hook에 제네릭 적용
function useApi(url: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
const result: T = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 오류');
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
// 사용 예시 - 컴포넌트에서
function UserList() {
const { data: users, loading, error } = useApi('/api/users');
if (loading) return 로딩 중...;
if (error) return 오류: {error};
return (
{users?.map(user => (
- {user.name}
))}
);
}
⭐ 2025년 최신 제네릭 패턴과 Best Practices
TypeScript의 최신 기능들과 함께 사용하는 현대적인 제네릭 패턴과 모범 사례들을 알아보겠습니다.
TypeScript 5.0 이후 추가된 새로운 기능들과 함께 사용할 수 있는 최신 제네릭 패턴들을 소개하고, 실무에서 따라야 할 모범 사례들을 정리해보겠습니다.
// Template Literal Types과 제네릭 조합
type EventName = `on${Capitalize}`;
type EventHandler = (event: T) => void;
interface EventEmitter> {
on(
eventName: EventName,
handler: EventHandler
): void;
emit(
eventName: EventName,
event: TEvents[K]
): void;
}
// 사용 예시
interface MyEvents {
click: MouseEvent;
hover: { x: number; y: number };
keypress: KeyboardEvent;
}
const emitter: EventEmitter = new EventEmitterImpl();
// 타입 안전한 이벤트 처리
emitter.on('onClick', (event) => {
// event는 MouseEvent 타입으로 추론
console.log(event.clientX, event.clientY);
});
emitter.emit('onHover', { x: 100, y: 200 });
💡 핵심 포인트
최신 TypeScript에서는 Template Literal Types, Mapped Types, Conditional Types 등을 제네릭과 조합하여 더욱 정교한 타입 시스템을 구축할 수 있습니다.
// 고급 유틸리티 타입 패턴
type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};
type RequiredFields = T & Required>;
type OptionalFields = Omit & Partial>;
// 실무 활용 예시
interface UserProfile {
id: string;
name: string;
email: string;
avatar?: string;
preferences: {
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
};
};
}
// 필수 필드가 있는 업데이트 타입
type UserUpdateRequest = OptionalFields & {
id: string; // id는 필수
};
// 깊은 부분 업데이트 타입
type UserPreferencesUpdate = DeepPartial;
// 사용 예시
function updateUser(update: UserUpdateRequest): Promise {
// update.id는 항상 존재
// 다른 필드들은 선택적
return Promise.resolve({} as UserProfile);
}
function updatePreferences(
userId: string,
preferences: UserPreferencesUpdate
): Promise {
// 중첩된 객체의 일부만 업데이트 가능
// preferences.notifications?.email 같은 선택적 접근 가능
return Promise.resolve();
}
🎯 실무 예시
Form 라이브러리나 Validation 라이브러리를 만들 때 이런 고급 제네릭 패턴을 활용하면 사용자에게 뛰어난 타입 안전성과 개발자 경험을 제공할 수 있습니다.
🎯 결론
TypeScript 제네릭은 타입 안전성과 코드 재사용성을 동시에 확보할 수 있는 강력한 도구입니다. 기본 개념부터 고급 패턴까지 단계적으로 학습하면서 실무에 적용해보시기 바랍니다.
제네릭을 마스터하면 더욱 안전하고 유지보수 가능한 TypeScript 코드를 작성할 수 있게 되며, 대규모 프로젝트에서도 확신을 가지고 개발할 수 있습니다. 2025년 현재 TypeScript의 최신 기능들과 함께 사용하면 더욱 강력한 타입 시스템을 구축할 수 있습니다.
계속해서 새로운 패턴들을 학습하고 실무에 적용하면서 TypeScript 제네릭의 진정한 힘을 경험해보세요!
이 가이드가 TypeScript 제네릭 마스터에 도움이 되었다면 좋아요와 공유 부탁드립니다!