React에서 Polymorphic Component 만들기

2025년 02월 20일

React에서 UI 컴포넌트를 개발할 때, 특정 HTML 태그에 종속되지 않으면서도 재사용성과 확장성을 유지할 수 있는 컴포넌트를 자주 필요로 합니다. Button 컴포넌트를 만들었는데, 경우에 따라서 <button> 이나 <a> 로 렌더링하고 싶다면 어떻게 해야할까요?

이 글에서는 Polymorphic Component란 무엇인지, 그리고 TypeScript와 forwardRef를 활용해 구현하는 방법을 정리하려고 합니다.


Polymorphic Component란?

Polymorphism(다형성)은 하나의 요소가 여러 형태를 가질 수 있다는 의미입니다.

프로그래밍에서 다형성이란 같은 인터페이스나 메서드를 사용하지만, 다양한 자료형이나 동작을 가질 수 있도록 하는 개념을 뜻합니다.

React 컴포넌트에서 Polymorphism을 적용한다는 건, 하나의 컴포넌트가 상황에 따라 다양한 HTML 태그나 다른 React 컴포넌트로 유연하게 렌더링될 수 있다는 뜻입니다.

예를 들어, Button 컴포넌트를 Polymorphic Component로 만들면 <button>, <a>, <div> 등 필요에 따라 원하는 태그로 쉽게 바꿀 수 있습니다.

단순히 태그가 바뀌는 것뿐 아니라, 각 태그에 맞는 속성도 자동으로 안전하게 처리되기 때문에, 코드의 재사용성과 유연성이 크게 향상됩니다. 이런 구조 덕분에 다양한 UI 요구사항을 하나의 컴포넌트로 깔끔하게 해결할 수 있습니다.

아래는 일반적인 Button 컴포넌트입니다.

const Button = ({ children, onClick }) => {
  return <button onClick={onClick}>{children}</button>;
};
 
<Button onClick={() => alert("Clicked!")}>Click Me</Button>;

기존 Button 컴포넌트는 기본적으로 <button> 태그로 렌더링되기 때문에 페이지 이동을 위한 링크(<a> 태그)로는 사용할 수 없습니다. 만약 페이지 이동 기능이 필요한 버튼을 만들고 싶다면, 새로운 LinkButton 컴포넌트를 추가로 만들어야 합니다.

문제는 이렇게 하면 버튼 역할을 하는 컴포넌트가 계속 늘어날 수 있다는 점입니다. 페이지 이동을 위한 LinkButton, 제출을 위한 SubmitButton 등 상황에 맞춰 컴포넌트를 계속 추가하게 됩니다. 결국 버튼 컴포넌트가 많아질수록 관리하기도 복잡해집니다.

이런 문제를 해결하기 위해 Polymorphic 컴포넌트를 사용하는 게 좋습니다. Polymorphic Component는 컴포넌트가 지정된 태그 (as prop)로 동적으로 렌더링될 수 있으며, 그에 따른 속성도 자동으로 타입이 지정되는 React 컴포넌트입니다. 하나의 Button 컴포넌트에서 as="a" 속성을 전달하면 링크로 동작할 수 있고, as="button"으로 전달하면 기본 버튼으로 동작할 수 있습니다.

결과적으로 하나의 컴포넌트로 다양한 버튼을 처리할 수 있고, 스타일이나 동작도 한 곳에서 관리할 수 있어 코드가 훨씬 깔끔해집니다.

<Button as="a" href="/home">홈 이동</Button>
<Button as="button" onClick={handleClick}>Click Me</Button>

이 방식은 MUI, Mantine, Radix 등 여러 UI 라이브러리에서 사용되는 패턴으로 재사용하기 쉽고, 유연한 컴포넌트를 설계할 수 있습니다.


버튼 컴포넌트 만들기

Polymorphic Component를 적용할 Button 컴포넌트를 만들기 전, 요구사항을 정리해보겠습니다.

  1. as prop을 통해 다양한 태그(button, a, div)로 렌더링할 수 있어야 한다.
  2. ref를 지원하여 외부에서 DOM 요소에 직접 접근할 수 있어야 한다.
  3. as prop으로 지정된 태그에 맞는 속성을 자동으로 지원해야 한다.
  4. TypeScript를 활용해 올바른 타입을 추론할 수 있어야 한다.

이제 이러한 요구사항을 만족하는 Button 컴포넌트를 만들어보겠습니다.

기본적인 Polymorphic Component 만들기

가장 기본적인 형태로 as props로 받아 동적으로 태그를 변경하는 컴포넌트를 만들어보겠습니다.

import React from "react";
 
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} />;
}

위의 코드는 as props로 통해 원하는 태그를 설정할 수 있습니다.
하지만 React에서 ref는 props로 직접 전달할 수 없기 때문에 forwardRef를 적용해야 합니다.
React에서는 ref를 특별한 props로 취급하여 일반적인 컴포넌트에서는 전달되지 않으며,
이를 해결하기 위해 forwardRef를 사용하면 부모 컴포넌트에서 ref를 전달하여 하위 요소에 접근할 수 있습니다.


forwardRef를 적용

ref를 사용할 수 있도록 forwardRef를 적용해보겠습니다.

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

그러나 이렇게하면 문제가 발생합니다.

const linkRef = React.useRef<HTMLAnchorElement>(null);
<Button as="a" ref={linkRef} href="/home">Go Home</Button>;

위 코드에서 Button에 as="a" 사용했지만, button의 ref타입 HTMLButtonElement로 인식되어 타입 에러가 발생합니다.
왜 그럴까요??


forwardRef 반환 타입

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 NamedExoticComponent<P = {}> extends ExoticComponent<P> {
  displayName?: string | undefined;
}
 
interface ExoticComponent<P = {}> {
  (props: P): ReactNode;
  readonly $$typeof: symbol;
}

React의 forwardRef자체가 반환하는 타입이 ForwardRefExoticComponent이고, forwardRef 의 최종 반환 타입은 React 컴포넌트 함수형 타입 (props: P) => ReactNode입니다.

Button 컴포넌트 자체에 제네릭을 적용하지 않으면 forwardRef 내부에서만 제네릭이 작동하고, 반환 타입이 제대로 추론되지 않습니다.

forwardRef의 반환 타입 ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> 여기서 제네릭 T는 RefAttributes<T> 내부에서만 사용되기 때문에, 컴포넌트를 사용할 때 제네릭 T를 직접 지정할 수 없습니다.

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

type ButtonComponent = <T extends React.ElementType = "button">(
  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} />;
});

제네릭을 사용할 수 있는 함수형 컴포넌트 타입 ButtonComponent를 먼저 정의합니다. 이 타입은 <T> 형태의 제네릭을 사용해 (props) => ReactNode 형태로 구성되어, props 안에 있는 as="a" 값을 기반으로 T = "a"로 자동 추론됩니다.


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

import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ElementType } from 'react';
 
export interface AsProp<T extends ElementType> {
  as?: T;
}
 
export type PolymorphicRef<T extends ElementType> = ComponentPropsWithRef<T>['ref'];
 
export type PolymorphicComponentPropsWithoutRef<T extends ElementType, Props = {}> = AsProp<T> &
  Props &
  Omit<ComponentPropsWithoutRef<T>, keyof Props>;
 
export type PolymorphicComponentPropsWithRef<
  T extends ElementType,
  Props = {},
> = PolymorphicComponentPropsWithoutRef<T, Props> & {
  ref?: PolymorphicRef<T>;
};
 
  • PolymorphicRef
    • T에 따라 ref의 타입이 자동으로 변경되도록 만듭니다.
    • 예를 들어, T가 "button"이면 ref 타입은 HTMLButtonElement, T가 "a"이면 ref 타입은 HTMLAnchorElement가 됩니다.
  • PolymorphicComponentPropsWithoutRef
    • ref를 제외한 모든 props 타입을 구성

최종 Button 컴포넌트

type PolymorphicButtonProps<C extends ElementType> = PolymorphicComponentPropsWithRef<
  C,
  ButtonProps
>;
 
type ButtonComponent = <C extends ElementType = 'button'>(
  props: PolymorphicButtonProps<C>,
) => ReactNode;
 
const Button: ButtonComponent & { displayName?: string } = forwardRef(
  <C extends ElementType = 'button'>(
    {
      as,
      children,
      subText,
      disabled = false,
      size = 'lg',
      variant = 'solid',
      icon,
      ...props
    }: PolymorphicButtonProps<C>,
    ref?: PolymorphicRef<C>,
  ) => {
    const Component = as || 'button';
 
    return (
      <Component
        aria-disabled={disabled}
        disabled={disabled}
        ref={ref}
        className={ButtonStyle({
          size: variant === 'sub' ? 'sm' : size,
          variant,
        })}
        {...props}
      >
        <styled.span className={ContentStyle({ size })}>
          {icon}
          {children}
        </styled.span>
        {subText && <styled.span textStyle="label3">{subText}</styled.span>}
      </Component>
    );
  },
);

Polymorphic Component는 UI 컴포넌트를 더 유연하고 재사용 가능하게 만들어줍니다. 버튼이나 링크처럼 다양한 태그로 렌더링할 수 있고, 각 태그에 맞는 속성을 안전하게 사용할 수 있어서 디자인 시스템이나 컴포넌트 라이브러리에서 자주 쓰입니다.

이번에는 TypeScript로 Polymorphic Component를 적용하면서, forwardRef와 제네릭을 어떻게 사용할 수 있는지 정리해봤습니다. 제네릭을 사용해 다양한 태그에 맞는 속성을 안전하게 처리할 수 있었고, 컴포넌트를 더 유연하게 관리할 수 있었습니다.