본문 바로가기
개발

[TS] 제네릭(Generic)을 활용한 재사용 가능한 고차 컴포넌트(HOC) 설계

by 돌미나리는야생미나리 2026. 4. 27.

프런트엔드 개발을 하다 보면 "로직은 똑같은데 다루는 데이터 타입만 다른" 경우를 자주 만납니다. 예를 들어, 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 응답 형태를 보장할 때 자주 쓰는 패턴입니다.