일상 코드에 필요한 TypeScript 제네릭: 실용 가이드
여러분은 모든 타입에서 작동하는 함수를 작성하고 있지만, TypeScript는 계속 any에 대해 불평합니다. 아니면 서로 다른 타입을 처리하기 위해 같은 함수를 세 번 복사했을 수도 있습니다. 제네릭이 이 문제를 해결합니다. 제네릭을 사용하면 타입 안전성을 유지하면서 여러 타입에서 작동하는 코드를 작성할 수 있습니다. 하지만 많은 개발자가 제네릭을 피하거나 과도하게 사용합니다. 이 가이드는 골치 아픈 일 없이 일상 코드에서 제네릭을 사용하는 방법을 보여줍니다.
제네릭이란 무엇이며 왜 신경 써야 할까요?
제네릭은 타입을 매개변수화하는 방법입니다. string이나 number와 같은 타입을 하드코딩하는 대신, 타입 변수(종종 T)를 사용하여 함수나 클래스가 사용될 때 구체적인 타입으로 대체됩니다.
왜 중요할까요? 재사용 가능한 코드를 타입 안전하게 유지할 수 있기 때문입니다. 제네릭이 없다면 코드를 중복하거나 any를 사용해야 하는데, 이는 TypeScript의 목적을 무색하게 만듭니다.
제네릭 함수: 기본 구성 요소
간단한 예제부터 시작해 보겠습니다. 배열의 첫 번째 요소를 반환하는 함수를 원한다고 가정해 봅시다.
function firstElement(arr: any[]): any {
return arr[0];
}
이것은 작동하지만 타입 정보를 잃게 됩니다. 문자열 배열을 전달하면 반환 타입은 string이 아니라 any입니다. 이제 제네릭을 사용해 보겠습니다:
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
이제 TypeScript는 string[]을 전달하면 string | undefined를 반환한다는 것을 알 수 있습니다. 타입이 유지됩니다.
여러 타입 매개변수를 사용할 수도 있습니다:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
제네릭 인터페이스와 타입 별칭
제네릭은 함수에만 국한되지 않습니다. 유연한 데이터 구조를 설명하기 위해 제네릭 인터페이스와 타입 별칭을 만들 수 있습니다.
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };
이것은 API 응답에 특히 유용합니다. 모든 엔드포인트에 대해 새 인터페이스를 정의하는 대신, 제네릭 ApiResponse<T>를 정의할 수 있습니다:
interface ApiResponse<T> {
data: T;
status: number;
}
type User = { id: number; name: string };
const response: ApiResponse<User> = {
data: { id: 1, name: "Alice" },
status: 200,
};
제네릭 제약: 규칙 추가하기
때로는 제네릭과 함께 사용할 수 있는 타입을 제한하고 싶을 수 있습니다. 여기서 제약이 필요합니다. extends 키워드를 사용하여 타입이 특정 속성을 가져야 함을 요구하세요.
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // OK
getLength([1, 2, 3]); // OK
getLength(42); // Error: number doesn't have length
제약은 제네릭을 더 예측 가능하게 만들고 런타임 오류를 방지합니다.
제네릭 클래스
클래스도 제네릭이 될 수 있습니다. 이는 스택, 큐 또는 리포지토리와 같은 데이터 구조에 유용합니다.
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.pop(); // number | undefined
제네릭을 사용해야 할 때 (그리고 사용하지 말아야 할 때)
제네릭은 강력하지만 과도하게 사용하면 코드를 읽기 어렵게 만들 수 있습니다. 다음은 빠른 가이드입니다:
| 제네릭을 사용해야 할 때 | 제네릭을 피해야 할 때 |
|---|---|
| 타입 관계를 유지해야 할 때 | 단순한 유니온 타입이나 any로 충분할 때 |
| 재사용 가능한 유틸리티를 작성할 때 | 타입이 항상 동일할 때 |
| 코드 중복을 피하고 싶을 때 | 코드를 이해하기 어렵게 만들 때 |
좋은 경험 법칙: 여러 타입에 대해 동일한 로직을 작성하고 있다면 제네릭을 고려하세요. 세 번째 타입 매개변수를 추가하고 아무도 이해하지 못한다면 한 걸음 물러서세요.
일반적인 패턴과 모범 사례
- 타입 매개변수에 설명적인 이름을 사용하세요.
T는 간단한 경우에 괜찮지만,TData나TResponse가 더 명확할 수 있습니다. any보다 제약을 선호하세요. 유연성이 필요하다면extends를 사용하여 최소 형태를 강제하세요.- 기본 타입 매개변수를 활용하세요. 기본값을 제공할 수 있습니다:
<T = string>. - 유틸리티 타입과 함께 제네릭을 사용하세요. TypeScript의 내장
Partial<T>,Readonly<T>,Record<K, V>는 모두 제네릭입니다.
실제 예제: 제네릭 API 클라이언트
실용적인 예제로 마무리해 보겠습니다: 데이터를 가져와 타입이 지정된 응답을 반환하는 간단한 API 클라이언트입니다.
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
}
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
async function main() {
const post = await fetchData<Post>("https://jsonplaceholder.typicode.com/posts/1");
console.log(post.title); // string
}
이 패턴은 프론트엔드 코드에서 흔히 사용됩니다. 모든 엔드포인트에 대해 반복하지 않고도 타입 안전성을 얻을 수 있습니다.
FAQ
TypeScript에서 <T> 구문은 무엇을 의미하나요?
<T>는 타입 매개변수를 선언합니다. 함수, 인터페이스 또는 클래스가 사용될 때 지정될 타입의 자리 표시자입니다. 어떤 이름으로든 지정할 수 있지만, 단일 타입에는 T가 관례입니다.
여러 제네릭 타입 매개변수를 사용할 수 있나요?
네. 필요한 만큼 쉼표로 구분하여 정의할 수 있습니다: <T, U, V>. 코드를 읽기 쉽게 유지하려면 설명적인 이름을 사용하세요.
제네릭이 객체만 허용하도록 제한하려면 어떻게 해야 하나요?
T extends object를 사용하세요. 이렇게 하면 T가 string이나 number와 같은 원시 타입이 아닌 객체 타입임을 보장합니다. T extends { id: number }와 같이 특정 형태로 제한할 수도 있습니다.
제네릭은 유지 관리 가능한 TypeScript를 작성하는 핵심 부분입니다. JSON 데이터로 작업하고 빠르게 유효성을 검사하거나 형식을 지정하고 싶다면, JSON Formatter를 사용하여 API 응답을 예쁘게 출력하고 검사해 보세요.