이번 글부터 많은 분들이 주신 피드백을 반영하여 글의 스타일을 변경해 보았습니다. 앞으로도 더 나은 방식으로 도움이 되는 내용을 전달하기 위해 노력하겠습니다. 감사합니다.

안녕하세요. 최근에 헤드리스 컴포넌트를 개발하면서, 비즈니스 도메인과 상관없이 재사용 가능하고 유연하게 확장할 수 있는 UI 컴포넌트에 대해 많은 고민을 하고 있는데요. Polymorphic Components(다형성 구성요소)는 호출하는 쪽에서 해당 컴포넌트의 시맨틱 요소를 결정할 수 있게 하는 React 패턴으로, UI 컴포넌트의 재사용성을 높이는 데 매우 유용한 기술이기 때문에 소개해드리고자 합니다.

 

특히 타입스크립트와 함께 사용하면 안전하고 쉽게 Polymorphic Components를 구축할 수 있어서, 재사용과 확장성이 용이한 UI 컴포넌트를 개발하고 싶으신 분들은 참고하시면 많은 도움이 될 것 같습니다.

 

 

Polymorphic Components

먼저 Polymorphic Components를 언제, 왜 사용하는지 알아보겠습니다. 가장 쉽게 생각할 수 있는 예는 링크 버튼을 만드는 경우입니다. 회사 스타일을 반영한 버튼 컴포넌트에 링크 기능을 추가해야 한다고 가정해 봅시다. 링크 기능이 추가되더라도 버튼의 스타일을 유지하고 싶기 때문에, 일반적으로 아래와 같이 <a> 태그로 감싼 LinkButton 컴포넌트를 만들게 될 것입니다.

import { Button } from './Button';

export const LinkButton = ({ href, ...props }) => {
  return (
    <a href={href}>
      <Button {...props} />
    </a>
  );
}

매우 나쁜 설계는 아니지만, 몇 가지 문제가 있습니다. 첫 번째로, 링크 버튼의 <a> 태그를 확장할 수 없다는 점이 눈에 띕니다. 또 다른 문제는, 이 방식으로는 기능을 확장할 때마다 새로운 컴포넌트를 만들어야 한다는 점입니다. 이는 개발과 관리 측면에서 어려움을 초래할 수 있습니다. 그렇다면 버튼이 Polymorphic Components였다면 어떤 차이가 있었을까요?

export default function App2() {
  return <Button as="a" href="..." />;
}

보시는 것처럼, 호출하는 쪽에서 as 프로퍼티를 사용해 원하는 ElementType을 지정하고, 해당 프로퍼티를 넘겨주기만 하면 구현이 완료됩니다. 이처럼 Polymorphic Components는 기존 컴포넌트를 재사용하면서도 확장이 가능하므로 매우 유용합니다.

 

참고로, 위 예제는 많은 분들이 쉽게 공감할 수 있는 사례로, Iskander Samatov의 "React Polymorphic Components with TypeScript"를 참고했습니다.

 

 

Polymorphic Components 구현하기

이제 왜 Polymorphic Components를 사용해야 하는지 알아보았으니, 직접 구현해 보겠습니다. Polymorphic Components를 사용할 때는 반드시 TypeScript와 함께 사용하는 것을 권장합니다. TypeScript는 컴포넌트의 Semantic 요소에 따라 타입 검사와 자동완성 기능을 제공하므로, Polymorphic Components를 안전하게 사용할 수 있기 때문입니다. 예를 들어, TypeScript가 없다면 Button에 as를 a로 지정하지 않고 href 속성을 정의하더라도 컴파일 오류가 발생하지 않아, 에러를 추적하는 데 어려움을 겪을 수 있습니다.

 

그러면 진짜로 타입스크립트를 사용해서 Polymorphic한 버튼 컴포넌트를 구현해 봅시다.

type ButtonProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;

function Button<T extends React.ElementType = "button">({
  as,
  ...props
}: ButtonProps<T>) {
  const Element = as || "button";

  return <Element {...props} />;
}

타입스크립트의 제네릭과 React에서 제공하는 타입을 활용하여 간단하게 Polymorphic Components를 구현할 수 있습니다. 이제 호출하는 쪽에서 as로 ElementType을 지정해 주면, TypeScript가 이를 인식하여 해당 Element에 맞는 타입을 검사해 줍니다. 실제로 vscode에서 확인해 보니, 타입 체크가 잘 작동하는 것을 볼 수 있습니다.

타입 체크가 제대로 이뤄지고 있다.

 

ref 받아오기

기본적인 Polymorphic Components를 구현하는 것은 위 예제처럼 매우 간단합니다. 그러나 ref를 사용하려면 조금 더 복잡해집니다. 다들 아시겠지만 React에서 컴포넌트를 만들 때, 기본적으로 ref를 prop로 받을 수 없습니다. 이를 위해서는 forwardRef라는 특수한 함수를 사용해야 합니다.

 

참고로, 컴포넌트의 props로 ref를 전달할 수 없도록 한 것은 React의 의도적인 결정입니다. React 입장에서는 ref가 사이드 이펙트를 발생시킬 위험이 있기 때문에, 다른 컴포넌트의 DOM 노드에 접근하는 것을 기본적으로 허용하지 않는 것입니다. 하지만 UI 컴포넌트의 경우, 호출자가 마치 native DOM 요소처럼 사용하기를 원하기 때문에 forwardRef를 사용하여 props로 ref를 전달할 수 있도록 해야 합니다. 참고로, 특정 상황에서는 기능 노출을 제한하고 싶을 때가 있습니다. 이럴 때는 useImperativeHandle 훅을 사용하면 원하는 명령만을 정의하여 사용할 수 있습니다.

 

본격적으로 위 버튼 컴포넌트를 forwardRef를 사용해서 ref를 받아올 수 있도록 확장해 봅시다.

const Button = forwardRef(function Button<
  T extends React.ElementType = "button",
>(
  { as, ...props }: ButtonProps<T>,
  ref: React.ComponentPropsWithRef<T>["ref"]
) {
  const Element = as || "button";

  return <Element {...props} />;
});

이처럼 쉽게 구현할 수 있으면 좋겠지만, 실제로 vscode에서 확인해 보면 타입 체크가 제대로 이루어지지 않는 것을 알 수 있습니다.

타입 체크가 제대로 이뤄지지 않고 있다

이는 제네릭이 forwardRef 내부 함수에만 적용되기 때문에, 버튼 컴포넌트 자체에는 타입이 제대로 정의되지 않기 때문입니다. 따라서, Button 컴포넌트 자체에 제네릭 타입을 적용해야 합니다. Button 컴포넌트 타입을 정의하기 위해서는 forwardRef() 함수의 반환 타입을 살펴봐야 합니다. 타입을 타고 타고 들어가 보니 ExoticComponent 타입을 보니 단순히 (props: P): ReactNode를 이루는 함수형 컴포넌트 타입임을 알 수 있었습니다

 

이는 제네릭이 forwardRef 내부 함수에만 적용되므로, 버튼 컴포넌트 자체에는 올바르게 타입이 정의되지 않기 때문입니다. 따라서, Button 컴포넌트 자체에 제네릭 타입을 적용해야 합니다. 이를 위해 forwardRef() 함수의 반환 타입을 살펴보면, ExoticComponent 타입이 단순히 (props: P): ReactNode 타입을 가지는 함수형 컴포넌트 타입임을 알 수 있습니다.

function forwardRef<T, P = {}>(
  render: ForwardRefRenderFunction<T, P>
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

interface ForwardRefExoticComponent<P> extends NamedExoticComponent<P> {
  defaultProps?: Partial<P> | undefined;
  propTypes?: WeakValidationMap<P> | undefined;
}

interface ForwardRefExoticComponent<P> extends NamedExoticComponent<P> {
  defaultProps?: Partial<P> | undefined;
  propTypes?: WeakValidationMap<P> | undefined;
}

interface NamedExoticComponent<P = {}> extends ExoticComponent<P> {
  displayName?: string | undefined;
}

// 타고타고 들어가다보면 함수형 컴포넌트임을 알 수 있다.
interface ExoticComponent<P = {}> {
  (props: P): ReactNode;
  readonly $$typeof: symbol;
}

따라서 버튼 컴포넌트 타입을 아래와 같이 작성해 볼 수 있습니다.

type ButtonComponent = <T extends React.ElementType = "div">(
  props: ButtonProps<T> & {
    ref?: React.ComponentPropsWithRef<T>["ref"];
  }
) => React.ReactNode;

const Button: ButtonComponent = forwardRef(function Button<
  T extends React.ElementType = "button",
>(
  { as, ...props }: ButtonProps<T>,
  ref: React.ComponentPropsWithRef<T>["ref"]
) {
  const Element = as || "button";

  return <Element {...props} />;
});

에디터를 확인해 보면 타입 체크가 제대로 이루어지는 것을 확인할 수 있습니다.

다시 타입 체크가 잘 이뤄지고 있다

 

타입 재사용 가능하도록 만들기

이제 위에서 만든 타입을 버튼 컴포넌트뿐만 아니라 다른 Polymorphic Component를 만들 때도 재사용할 수 있도록 해봅시다.

export type AsProp<T extends React.ElementType> = {
  as?: T;
};

export type PolymorphicRef<T extends React.ElementType> =
  React.ComponentPropsWithRef<T>["ref"];

export type PolymorphicComponentPropsWithoutRef<
  T extends React.ElementType,
  Props = {},
> = AsProp<T> & Props & Omit<React.ComponentPropsWithoutRef<T>, keyof Props>;

export type PolymorphicComponentPropsWithRef<
  T extends React.ElementType,
  Props = {},
> = PolymorphicComponentPropsWithoutRef<T, Props> & {
  ref?: PolymorphicRef<T>;
};

PolymorphicComponentPropsWithoutRef 타입을 만들 때는, Props에서 수동으로 넘겨주는 타입과 이름이 충돌하지 않도록 Omit 유틸리티 타입을 사용했습니다. 이름 충돌은 예상치 못한 에러를 발생시킬 수 있으므로 주의해야 합니다.

이제 위에서 만든 타입을 활용하여 버튼 컴포넌트를 리팩터링해 봅시다.

type _ButtonProps = {
  children: React.ReactNode;
};

type ButtonProps<T extends React.ElementType> =
  PolymorphicComponentPropsWithRef<T, _ButtonProps>;

type ButtonComponent = <T extends React.ElementType = "button">(
  props: ButtonProps<T>
) => React.ReactNode;

const Button: ButtonComponent = forwardRef(function Button<
  T extends React.ElementType = "button",
>({ as, ...props }: ButtonProps<T>, ref: PolymorphicRef<T>) {
  const Element = as || "button";

  return <Element {...props} />;
});

마무리하며

이번 포스팅에서는 TypeScript를 사용하여 Polymorphic Component를 구현해 보았습니다. 다형성 컴포넌트는 UI 컴포넌트와 같은 특정 상황에서 재사용 가능하고 확장 가능한 컴포넌트를 구현하는 데 매우 유용합니다. 물론, Polymorphic Component를 개발하다 보면 다형성으로 인해 타입 작성에 더 신경 써야 하기 때문에 개발이 어려워질 수 있습니다. 그러나 호출하는 쪽에서 Semantic 요소를 결정할 수 있다는 점은 큰 장점입니다.

 

이렇게 사용하는 쪽에서 제어권을 가지는 방식을 IoC(Inversion of Control, 제어의 역전)라고 하는데요. 다음 포스팅에서는 제어의 역전을 활용한 리액트 패턴 5가지를 소개하도록 하겠습니다. 이러한 리액트 패턴과 다형성 컴포넌트를 함께 사용하면 매우 유연한 UI 컴포넌트를 만들 수 있습니다.