프런트엔드 개발을 하다 보면 "로직은 똑같은데 다루는 데이터 타입만 다른" 경우를 자주 만납니다. 예를 들어, API 응답 데이터를 받아서 리스트를 그려주는 컴포넌트가 있다고 합시다. 어떤 곳에서는 User []를 다루고, 어떤 곳에서는 Product []를 다룹니다.
이때 타입마다 별도의 컴포넌트를 만드는 것은 비효율적입니다. 그렇다고 any를 쓰자니 타입 안정성이 깨집니다. 이럴 때 필요한 것이 바로 제네릭(Generic)입니다. 제네릭은 타입을 마치 함수의 '인수(Argument)'처럼 취급하여, 사용하는 시점에 타입을 결정하게 해 줍니다.
1. 제네릭(Generic)이란 무엇인가?
제네릭은 한마디로 '타입의 변수화'입니다. 컴포넌트나 함수를 정의할 때는 타입을 비워두었다가, 실제로 사용할 때 구체적인 타입을 주입하는 방식입니다.
1.1 기본적인 제네릭 함수
function wrapInArray<T>(value: T): T[] {
return [value];
}
const stringArray = wrapInArray<string>("Hello"); // T가 string이 됨
const numberArray = wrapInArray<number>(123); // T가 number가 됨
위 코드에서 <T>는 관습적으로 사용하는 타입 변수명입니다. wrapInArray는 호출되는 순간 입력받은 값의 타입을 추론하여 반환 타입까지 완벽하게 맞물리게 합니다.
2. 실전! 제네릭을 활용한 리액트 컴포넌트
제네릭은 리액트 컴포넌트에서 데이터 목록(List)이나 테이블(Table)을 만들 때 가장 빛을 발합니다.
2.1 Generic List 컴포넌트 설계
다양한 형태의 데이터를 렌더링 할 수 있는 리스트 컴포넌트를 만들어 보겠습니다.
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
// 사용 예시
<List<User>
items={users}
renderItem={(user) => <span>{user.name}</span>}
/>
이렇게 하면 renderItem 내부의 user는 자동으로 User 타입이 됩니다. any 없이도 완벽한 자동 완성 기능을 누릴 수 있는 것이죠.
3. 고차 컴포넌트(HOC)에서의 제네릭 활용
고차 컴포넌트(HOC)는 컴포넌트를 인자로 받아 기능을 추가한 새 컴포넌트를 반환하는 패턴입니다. 여기에 제네릭을 입히면 어떤 컴포넌트가 들어와도 타입을 유지한 채로 기능을 덧붙일 수 있습니다.
3.1 로딩 상태를 주입하는 withLoading HOC
컴포넌트에 로딩 스피너 기능을 추가하는 제네릭 HOC 예시입니다.
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingComponent({ isLoading, ...props }: { isLoading: boolean } & P) {
if (isLoading) return <Spinner />;
return <WrappedComponent {...(props as P)} />;
};
}
여기서 P extends object는 "인자로 들어오는 Props 타입은 객체 형태여야 한다"는 제약을 거는 것입니다. 이를 통해 원본 컴포넌트가 가진 Props 타입을 그대로 보존하면서 isLoading이라는 새로운 Prop만 추가할 수 있습니다.
4. 제네릭 제약 조건 (Constraints)
제네릭을 무조건 자유롭게 두는 것보다, 특정 조건을 만족할 때만 사용 가능하도록 제한하는 것이 더 안전합니다. extends 키워드를 사용합니다.
interface HasId {
id: string;
}
// T는 반드시 id를 가진 객체여야 함
function logId<T extends HasId>(item: T) {
console.log(item.id);
}
이렇게 제약을 걸면 logId({ name: 'Yumina' })는 에러가 발생하지만, logId({ id: '1', name: 'Yumina' })는 통과됩니다. 실무에서 API 응답 형태를 보장할 때 자주 쓰는 패턴입니다.
'개발' 카테고리의 다른 글
| [TS] Utility Types 정복하기: Pick, Omit, Partial로 중복 없는 타입 정의 (0) | 2026.04.27 |
|---|---|
| [TS] any 대신 unknown을 써야 하는 이유와 타입 가드 활용법 (0) | 2026.04.27 |
| [Architecture] 프론트엔드 클린 아키텍처: 도메인 로직과 UI 컴포넌트 분리하기 (0) | 2026.04.27 |
| [React] 선언적 프로그래밍의 정수: Suspense와 ErrorBoundary로 우아한 UI 만들기 (0) | 2026.04.27 |
| [React] Props Drilling을 피하는 3가지 방법: Composition vs Context vs Zustand (0) | 2026.04.27 |