프런트엔드 개발을 하다 보면 "로직은 똑같은데 다루는 데이터 타입만 다른" 경우를 자주 만납니다. 예를 들어, API 응답 데이터를 받아서 리스트를 그려주는 컴포넌트가 있다고 합시다. 어떤 곳에서는 User []를 다루고, 어떤 곳에서는 Product []를 다룹니다.

이때 타입마다 별도의 컴포넌트를 만드는 것은 비효율적입니다. 그렇다고 any를 쓰자니 타입 안정성이 깨집니다. 이럴 때 필요한 것이 바로 제네릭(Generic)입니다. 제네릭은 타입을 마치 함수의 '인수(Argument)'처럼 취급하여, 사용하는 시점에 타입을 결정하게 해 줍니다.


1. 제네릭(Generic)이란 무엇인가?

제네릭은 한마디로 '타입의 변수화'입니다. 컴포넌트나 함수를 정의할 때는 타입을 비워두었다가, 실제로 사용할 때 구체적인 타입을 주입하는 방식입니다.

1.1 기본적인 제네릭 함수

TypeScript
 
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 컴포넌트 설계

다양한 형태의 데이터를 렌더링 할 수 있는 리스트 컴포넌트를 만들어 보겠습니다.

TypeScript
 
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 예시입니다.

TypeScript
 
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 키워드를 사용합니다.

TypeScript
 
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 응답 형태를 보장할 때 자주 쓰는 패턴입니다.

+ Recent posts