본문 바로가기

Web.d

[React][TypeScript] Generic Props on Functional Component

반응형

 

 

타입스크립트와 리액트에서, 제네릭 props를 가진 컴포넌트에 대하여 설명합니다.

타입스크립트에 대해 기초적인 개념이 있다면 글을 읽기에 좀 더 수월합니다 :)

 

목차
1️⃣ 서론, Generic Props?
2️⃣ Table Component without generic
3️⃣ Table Component with generic
4️⃣ Cons of Generic Props on Functional Component
5️⃣ 글을 줄이며

 


1️⃣ What is Generic Props?

React에서 컴포넌트 설계는 가장 중요한 개념 중 하나입니다.

컴포넌트는 코드의 재사용성을 극대화하고, 핵심 로직은 유지하며 다양한 유형에 대응할 수 있도록 합니다.

컴포넌트를 사용할 때에는 props라는 인터페이스를 이용합니다. 함수형 컴포넌트에서는 함수의 인자에 해당하는 정보입니다.

 

이후 TypeScript를 사용하며 컴포넌트 Props의 타입을 설계할 수 있게 되었습니다.

이때 중요한 것은, 재사용 가능한 컴포넌트를 만드는 동시에 타입의 안전성을 유지해야 한다는 것입니다.

하지만 타입의 안전성을 지키는 것은 실질적으로 꽤나 어려운 일입니다. 본래 프로퍼티가 가질 본연의 타입보다 상위 개념의 타입을 선언함으로써 유효하지 않는 Props Interface를 구성했던 경험이 이전에 한 번씩 있을 것입니다. (특정 문자열을 string으로 선언한다거나, 복잡한 구조의 데이터를 any로써 사용한다거나 하는 등 말이죠)

이처럼 유효하지 않는 타입이 늘어날수록, 코드의 타입 안전성은 떨어질 것이고 TypeScript 사용의 의미가 퇴색되기 쉽습니다. (FYI, 🔗 유효한 타입에 대해 생각해보기)

이때, 컴포넌트와 TpeScript의 장점을 극대화하기 위해 🔗 Generic이 큰 힘을 발휘합니다.

 

Generic(이하 제네릭)을 사용하면 단일 정적 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 작성할 수 있습니다. 사용자는 제네릭을 통해 여러 타입의 컴포넌트나 자신만의 타입을 사용할 수 있게 되는 것이죠. 이를 통해 컴포넌트를 더 깔끔하게 만들고, 확장을 위해 열어두되 수정을 위해 닫아둘 수 있습니다. (OCP; Open-Closed Principle)

 

제네릭 props에 대해 더 살펴보고자 합니다.

가장 대표적인 예시로, Table Component가 있습니다.

 

2️⃣ Table Component without generic

id name
1 Steven
2 Gerrard

 

위와 같은 테이블을 만들고 싶습니다.

table UI를 설계하여 렌더링 해주는 컴포넌트를 아래와 같이 구현한다고 가정합니다.

table header에 대한 렌더링 함수와, table row에 대한 렌더링 함수를 바깥에서 제어함으로써 데이터에 종속성을 분리합니다. (스타일 관련 내용은 전부 생략합니다)

 

const MyTable = ({
  data,
  renderHeaderItem,
  renderItem,
}: MyTableProps) => (
  <table>
    {
      renderHeadingItem
      && (
        <th>
          {
            Object
            .keys(data)
            .map(renderHeadingItem => (<td>{renderHeadingItem}</td>))
          }
        </th>
      )
    }
    <tr>
      {
        data
        .map(renderItem => (<td>{renderItem}</td>))
      }
    </tr>
  </table>
);

 

이제 Props Type을 구현해 봅시다.

저희가 구현하려 하는 UI는 id와 name의 값을 받아 처리하는 Table입니다.

GraphQL을 사용하지 않는 이상, 가장 먼저 아래의 코드처럼 생각이 들 것 같아요.

 

type Data = { id: number; name: string; }

interface MyTableProps {
  data: Data[];
  renderHeadingItem?: (headingItem: keyof Data) => React.ReactNode;
  renderItem: (item: Data) => React.ReactNode;
}

 


 

꽤나 로직 분리도 잘한 것 같다는 생각이 들던 찰나,

피그마에 익명의 유저를 위한 테이블 UI가 추가됩니다.

 

id nickname
1 snupi
2 joam

 

Data의 타입을 수정해야 할 듯합니다.

수월한 타입추론을 위해 아래와 같이 정적으로 수정하려 합니다.

 

type Data =
  { id: number; name: string; }
  | { id: number; nickname: string; };

interface MyTableProps {
  data: Data[];
  renderHeadingItem?: (headingItem: keyof Data) => React.ReactNode;
  renderItem: (item: Data) => React.ReactNode;
}

// ---
// ❗️; I hope you don't think of Data type like below..
type Data = { id: number; name?: string; nickname?: string; }

 

이후 테이블 컴포넌트의 확장성을 상상해 봤을 때, 타이핑이 완전하지 않다는 생각이 들기 시작합니다.

단일 타입이 아닌 다양한 타입에서 작동하는 컴포넌트를 작성하길 원합니다.

이때, 재사용 가능한 컴포넌트를 생각하니 제네릭이 떠오릅니다. 한 번 사용해 보겠습니다.

 


3️⃣ Table Component with generic

제네릭에 대한 개념적인 설명이나, 사용 방법은 🔗 공식 문서 링크를 첨부하여 대체합니다.

 

다양한 데이터 구조 타입에 대응하고 싶습니다. Data 타입을 정적으로 static하게 선언하지 않고, 아래와 같이 선언하여 사용합니다.

 

type BaseData = { id: number };
interface MyTableProps<TData extends BaseData> {
  data: TData[];
  renderHeadingItem?: (headingItem: keyof TData) => React.ReactNode;
  renderItem: (item: TData) => React.ReactNode;
}

 

어느 데이터의 테이블이라고 하더라도, 테이블의 구조상 각각의 PK는 essential 합니다. BaseData 타입을 정의하여, extends 키워드로 “제약사항”을 명시합니다.

TData로 정의된 제네릭 타입은 BaseData에 대해 구조적으로 만족되는 타입을 가리킵니다. 이 중 어느 형태의 타입이 들어오든지, 그 다형성을 보장합니다.

Props 타입을 위와 같이 구성한다면, 컴포넌트에서도 제네릭에 해당하는 타입을 선언해주어야 합니다.

 

const MyTable = <TData extends BaseData>({
  data,
  renderHeadingItem,
  renderItem,
}: MyTableProps<TData>) => (
  <table>
    {
      renderHeadingItem
      && (
        <th>
          {
            (Object.keys(data) as Array<keyof TData>)
            .map(renderHeadingItem)
          }
        </th>
      )
    }
    <tr>
      {data.map(renderItem)}
    </tr>
  </table>
);

 

위와 같이 컴포넌트와 Prop type을 구성한다면,

prop에 따라 TData 타입이 특정되어 prop 간의 타입 정합성도 만족할 수 있습니다.

 

const Component = () => (
  <MyTable
    data={[
      { id: 1, name: 's n u p i' },
      { id: 2, name: 'j o a m' },
    ]}
    renderItem={
      ({ 
        id, 
        name, 
        nickname // ❗️ Property 'nickname' does not exist on type ~.
      }) => (<td key={id} />)
    }
  />
);

 

더 나아가서,

도메인의 성숙도가 높아져 시스템이 제시되는 상황을 가정해 보겠습니다.

테이블 UI에 대한 도메인이 확립되고, 특정한 “테이블 타입”에 대해 데이터 구조가 정립될 수 있습니다.

이와 같은 상황에서는 MyTable 컴포넌트 타입을 좀 더 다른 시각으로 리팩토링 할 수 있습니다.

 

type TableType = 'known' | 'anonymous';
type Data<TTableType extends TableType> =
  TTableType extends 'known'
  ? { id: number; name: string; }
  : TTableType extends 'anonymous'
  ? { id: number; nickname: string; }
  : never;

interface MyTableProps<TTableType extends TableType> {
  data: Data<TTableType>[];
  renderHeadingItem?: (headingItem: keyof Data<TTableType>) => React.ReactNode;
  renderItem: (item: Data<TTableType>) => React.ReactNode;
}

 

해당 Props type을 사용하는 MyTable 컴포넌트는

특정 테이블 타입만 사용할 때에 명시해 준다면, data prop과 렌더링 함수 prop의 인자 타입을 정확하게 파악할 수 있습니다.

 

const Component = () => (
  <MyTable<'anonymous'>
    data={[
      { id: 1, name: 'nupi' }, // ❗️ Property 'name' does not exist on type ~.
      { id: 2, name: 'joam' },
      { id: 3, name: 'haem' },
    ]}
    renderItem={
      ({ 
        id, 
        name, // ❗️
      }) => <td key={id} />
    }
  />
);

 


4️⃣ Cons of Generic Props on Functional Component

완전한 방법처럼 보입니다. 하지만 재사용성을 극대화하는 범용성 넓은 타입인 만큼, 주의할 점도 분명합니다.

 

  1. 가독성
    ‘이런 타입이 올거야’를 가정한 채로 타입을 선언하다보니, 코드를 읽기가 편안하지 않을 수 있습니다.
    위 예시보다 더 복잡한 타입들로 제네릭이 구성될 수 있습니다. 조금이라도 타입명과 선언 구조가 명확하지 않으면, next engineer들 입장에서 과도한 코드가 되기 쉽습니다.
    이를 방지하기 위해서는 컴포넌트에 명확한 역할만 주어야 합니다.
    그렇지 않고 컴포넌트를 과도하게 재사용하려 할 경우, 타이핑에 제네릭이 무분별하게 사용될 수 있고, 이는 유지보수하기 어려운 코드를 초래합니다.

  2. forwardRef 사용
    타입 캐스팅, 혹은 Wrapper Component를 따로 구성하지 않고서는 문법상의 제약으로 forwardRef의 사용이 어렵다는 문제가 있습니다.
    이에 대해서는 🔗 레퍼런트 자료를 첨부하여 설명을 대체합니다.

  3. Type Narrowing
    별도의 JavaScript 정보 없이 타입 내로잉이 수월하지 않습니다. 이는 TypeScript의 런타임 동작과 연관이 있습니다.
    위 테이블 예시의 TTableType의 경우, 컴포넌트 내부에서 TTableType을 분기하여 로직을 구성할 수는 없기 때문에, tableType: TTableType 과 같은 별도의 prop 정보가 필요할 수 있습니다.

 

5️⃣ 글을 줄이며

컴포넌트를 제네릭으로 타입 정의하여, 다형성과 OCP를 보장하는 테이블 컴포넌트를 살펴보았습니다.

하지만 장점과 함께 단점들도 존재하기에, 상황에 따라 사용함이 필요함을 알 수 있습니다.

다양한 UI를 가지는 B2C 서비스에서는 UI와 정체성에 대한 관계를 guarantee 할 수 없습니다. 이 경우에는 되려 컴포넌트를 최대한 분리하며 도메인을 나누어 정체성을 가져가는 것이 적절할 수 있습니다.

이와 반대로 백오피스와 같은 서비스는 UI-정체성에 대한 시스템이 정의될 수 있습니다. 각 도메인에 대해 재사용성을 증가시키는 장점을 가져갈 수 있다면, 재사용을 보장하는 제네릭 props를 가진 컴포넌트를 적극적으로 고려해볼 수 있습니다.

 

긴 글 읽어주셔서 감사합니다🙏 틀린 부분이나 다양한 인사이트들 피드백 주시면 감사하겠습니다 :)

건강하고 따듯한 연말 보내시길 바랍니다

 


References
https://wanago.io/2020/03/09/functional-react-components-with-generic-props-in-typescript/

https://www.totaltypescript.com/tips/use-generics-in-react-to-make-dynamic-and-flexible-components

https://www.carlrippon.com/React-generic-props/

https://ui.toast.com/posts/ko_20210505

https://www.freecodecamp.org/news/typescript-generics-with-functional-react-components/

반응형