들어가기에 앞서 Polymorphism은 한국어로 다형성이라고 부르는데, 여러 개의 형태를 가진다라는 의미를 가진 그리스어에서 유래된 단어다. 그럼 이 글의 제목에 포함된 Polymorphic다형의 혹은 다양한 형태의 등으로 표현할 수 있을 것이다. 컴퓨터 과학에서 다형성은 프로그래밍적인 요소가 여러 형태로 표현 될 수 있는 것을 의미하는데 보통은 객체가 여러 자료형으로 나타날 수 있음을 표현할 때 사용한다.

그럼 Polymorphic한 UI 컴포넌트는 다양한 형태의 UI 컴포넌트라고 바꿔 말할 수 있을 것이다. 필자는 이 말이 다음과 같은 내용을 담고 있다고 생각한다.

  • 다양한 Semantic을 표현할 수 있는 UI 컴포넌트
  • 다양한 속성을 가질 수 있는 UI 컴포넌트
  • 다양한 스타일을 가질 수 있는 UI 컴포넌트

좀 더 풀어서 설명하자면 웹 프론트엔드에서의 Polymorphic 컴포넌트는 코드에 따라 어떠한 요소(Element)도 될 수 있고 그에 따른 속성(Attribute)도 사용할 수 있다. 즉, 상황에 맞는 Semantic을 사용할 수 있고 앵커 태그처럼 특수한 용도로 사용되는 컴포넌트가 될 수도 있다. 결국 無의 형태에서 무엇이든지 될 수 있는 컴포넌트가 되는 것이 Polymorphic 컴포넌트고 가장 추상화된 형태의 컴포넌트라고 볼 수 있다.

최종적으로 사용자에게 보이는 컴포넌트는 이러한 구현 과정을 거친다

그래서 Polymorphic 컴포넌트는 React와 관련된 UIKit을 뜯어보면 높은 확률로 사용되고 있는 패턴이다. 예를 들면, MUI의 Box 컴포넌트나 Mantine의 Box 컴포넌트를 예시로 들 수 있다. 두 UI 라이브러리는 Box라는 Polymorphic한 컴포넌트를 이용하여 재사용성을 높이고 다양한 컴포넌트를 확장성 있게 구현하고 있다. 굉장히 유용한 컴포넌트기 때문에 필자가 재직 중인 회사에서 만들고 사용하는 디자인 시스템에도 View 컴포넌트를 구현하여 비슷하게 사용하고 있다.

아쉽게도 Polymorphic 컴포넌트와 관련된 자료는 한국어로 작성된 자료가 거의 없고 영문으로도 구체적인 설명한 자료는 찾기 힘들어 이번 기회에 관련된 내용을 포스팅 해보기로 했다.

문제 인식하기

실제 사례를 보지 않는다면 이 컴포넌트가 왜 필요한지 모를 수 있다. 다음 코드를 살펴보자.

/**
 * Button.jsx
 */
export const Button = ({ ...props }) => {
  return (
    <button 
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

/**
 * App.jsx
 */
import { Button } from './Button';

const App = () => {
  return (
    <div>
      <Button onClick={() => alert('Good!')}>Click Me!</Button>
    </div>
  );
}

설명이 필요 없을 정도로 간단한 코드다. 단순하게 표현했지만 위 코드처럼 스타일만 적용한 컴포넌트는 실제로도 많이 사용된다. Button 컴포넌트는 prop으로 넘기는 값을 button 태그의 속성으로 전부 넘기기 때문에 꽤 확장성 있게 사용할 수 있는 컴포넌트라고 생각할 수 있다. 그런데 만약 버튼에 페이지 링크를 추가하고 싶다면 어떻게 해야 할까?

import { Button } from './Button';

const App = () => {
  return (
    <div>
      <a href="https://kciter.so">
        <Button>Click Me!</Button>
      </a>
    </div>
  );
}

위 처럼 작성할 수도 있겠지만 재사용성 측면에선 그다지 좋은 방법은 아니다. 추후 재사용을 고려하여 새로운 컴포넌트를 만들 수도 있다.

/**
 * Button.jsx
 */
export const Button = ({ ...props }) => {
  return (
    <button 
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

/**
 * LinkButton.jsx
 */
import { Button } from './Button';

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

/**
 * App.jsx
 */
import { LinkButton } from './LinkButton';

const App = () => {
  return (
    <div>
      <LinkButton href="https://kciter.so">Click Me!</LinkButton>
    </div>
  );
}

위와 같이 작성할 수도 있지만 이 경우 a 태그가 확장되지 않는다는 문제점이 있고 컴포넌트의 의존 관계가 새롭게 추가된다는 문제점이 있다. 그리고 만약 react-router나 Next.js를 사용하여 SPA를 위한 Link 컴포넌트를 사용한다면 또 새로운 컴포넌트를 만들어줄 수 밖에 없다. 이 문제의 해결법으로 Polymorphic 컴포넌트를 사용할 수 있다.

JavaScript에서 구현하기

사실 JavaScript에선 Type-safe에 자유롭기 때문에 Polymorphic 컴포넌트를 구현하는 것이 어렵지 않다. 이런 부분은 JavaScript 약점이지만 한편으로는 구현의 편리함으로서 강점이 될 수도 있다. 다음과 같이 아주 간단하게 Polymorphic한 컴포넌트를 만들 수 있다.

export const View = forwardRef(({ as, ...props }, ref) => {
  const Element = as || "div";
  return <Element ref={ref} {...props} />;
});

여기서 구현한 View 컴포넌트는 React에서 가장 추상적인 컴포넌트다. as를 통해 기본 내장된 컴포넌트를 포함하여 어떠한 컴포넌트로도 될 수 있다. 만약 생략한다면 기본적으로 div를 사용하게 된다. 이때, 필요한 속성이 있다면 자유롭게 넘길 수 있도록 컴포넌트를 작성하고 forwardRef를 통해 부모 컴포넌트에서 요소에 접근할 수 있도록 만들었다. 이 컴포넌트는 다음과 같이 사용할 수 있다.

import { View } from './View';

const App = () => {
  return (
    <div>
      <View as="a" href="https://kciter.so">Click Me!</View>
    </div>
  );
}

코드를 살펴보면 as를 통해 View 컴포넌트에 사용되는 요소를 a 태그로 변경하고 href 속성을 사용한 것을 볼 수 있다. 그럼 이 코드를 실행하면 Click Me!라는 링크가 보이게 된다. 사실 이렇게만 사용하면 왜 사용하는지 이해가 안가는 것이 당연하다. 그냥 바로 a 태그를 쓰면 되니 번거롭게 컴포넌트를 만들 필요가 없기 때문이다. 그렇지만 위 코드를 응용하여 다음과 같이 사용하는 것도 가능하다.

/**
 * Button.jsx
 */
import View from './View';

export const Button = ({ as, ...props }) => {
  return (
    // 위에서 만들어둔 View 컴포넌트를 이용했다.
    <View as={as || 'button'}
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

// 혹은 다음과 같이 작성할 수 있다.
export const Button = ({ as, ...props }) => {
  const Element = as || 'button';
  return (
    <Element
      style={{ backgroundColor: 'black', color: 'white' }} 
      {...props} 
    />
  );
}

/**
 * App.jsx
 */
import { Button } from './Button';

const App = () => {
  return (
    <div>
      // 마치 앵커 태그처럼 사용할 수 있다.
      <Button as="a" href="https://kciter.so">Click Me!</Button>
    </div>
  );
}

다시 문제 인식하기 부분을 살펴보면 이때는 LinkButton이라는 컴포넌트를 만드는 것으로 요구사항을 충족했었다. 만약 위 코드처럼 Polymorphic 하도록 컴포넌트를 작성한다면 중복 코드를 제거하고 조금 더 유연하게 컴포넌트를 사용할 수 있게 된다. 생각보다 이런 사례가 많고 구현이 간단하기 때문에 좋은 컴포넌트 설계라고 볼 수 있다.

TypeScript에서 구현하기

JavaScript를 쓸 때 아쉬운 점은 IntelliSense1를 사용할 수 없다는 점이다. 어느 정도 자동 완성을 해주긴 하지만 TypeScript의 강력함에 비하면 좀 아쉽다. 위 코드도 as를 통해 다른 요소를 사용하도록 변경했지만 어떤 속성을 넘길 수 있을지는 개발자가 잘 판단하여야 한다. 혹은 개발자가 오타를 내어 잘못된 값을 as로 전달할 수도 있다. 이런 문제점은 TypeScript를 통해 Type-safe한 Polymorphic 컴포넌트를 구현하면 해결할 수 있다.

요소와 속성 표현하기

JavaScript에서 사용한 코드와 똑같이 사용할 수 있도록 만들면서 자동 완성 기능까지 사용하려면 타입 정의가 필요하다. 우선 다음 코드를 확인해보자.

/**
 * View.tsx
 */
interface ViewProps<T extends React.ElementType> {
  as?: T;
}

export const View = <T extends React.ElementType = "div">({
  as,
  ...props
}: ViewProps<T>) => {
  const Element = as || "div";
  return <Element {...props} />;
};

/**
 * App.tsx
 */
import { View } from "./components/View";

const App = () => {
  return (
    // 컴포넌트 부분에 에러가 발생한다.
    <View as="a" href="https://kciter.so">
      Link
    </View>
  );
}

export default App;

React.ElementType은 JSX 내장 컴포넌트 또는 사용자 정의 컴포넌트를 둘 다 받을 수 있는 타입으로 string | React.ComponentType<any>로 정의되어있다. 이 타입과 제네릭을 사용하면 위 JavaScript 코드에서 했던 것처럼 as를 통해 사용하려는 요소를 바꿀 수 있게 된다.

하지만 위와 같이 View 컴포넌트를 작성하면 as를 통해 사용하려한 요소가 어떤 것인지 알 수가 없다. 따라서 다음과 같은 에러가 발생하게 된다.

에러 메시지를 살펴보면 prop으로 넘긴 값들이 타입에 맞지 않는다는 것을 알 수 있다. 이를 위해 다음과 같이 View 컴포넌트를 수정할 수 있다.

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

export const View = <T extends React.ElementType = "div">({
  as,
  ...props
}: ViewProps<T>) => {
  const Element = as || "div";
  return <Element {...props} />;
};

React.ComponentPropsWithoutRefref를 제외한 나머지 속성을 정의할 수 있게 해주는 타입이다. 이 타입을 이용하면 제네릭을 통해 나머지 속성에 대한 것을 알 수 있게 된다. 하지만 아직 ref는 받아올 수 없다.

ref 받아오기

여기까지는 이해하는 것이 어렵지는 않았을 것이다. 코드 양이 많은 것도 아니기 때문에 생각보다 쉽게 구현할 수 있다. 그렇지만 ref까지 사용하게 된다면 조금 복잡해진다. 일단 다음 코드를 살펴보자.

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

export const View = forwardRef(
  <T extends React.ElementType = "div">(
    { as, ...props }: ViewProps<T>,
    ref: React.ComponentPropsWithRef<T>["ref"] // ref만 받아오도록
  ) => {
    const Element = as || "div";
    return <Element ref={ref} {...props} />;
  }
);

만약 위에와 같이 이미 제공되는 React.ComponentPropsWithRef 타입을 사용하면 쉽게 해결됐다고 생각할 수 있겠지만 다음과 같이 unknown으로 타입을 알 수 없다는 것을 알 수 있다. 이러면 제대로된 타입이 아니어도 에러가 발생하지 않는다.

잘못된 타입인 ref가 들어갔음에도 에러가 발생하지 않는다

이런 일이 발생한 이유는 아직 forwardRef에 대한 타입이 모호하기 때문이다. 제대로 정의된 것 처럼 보이지만 제네릭은 함수 파라메터에만 적용되었을 뿐 함수 자체엔 적용되지 않았다. 따라서 forwardRef에 대한 제네릭 타입 정의가 필요하다. 타입 정의를 위해 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): (ReactElement|null);
  readonly $$typeof: symbol;
}

여기서 forwardRef 함수의 반환 타입을 살펴보면 ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>으로 되어있다. ForwardRefExoticComponent는 최종적으로 ExoticComponent 인터페이스를 상속받는데 내용을 살펴보면 결국 함수 컴포넌트의 형태가 되는 것을 알 수 있다.

따라서 PropsWithoutRef<P> & RefAttributes<T>를 View 컴포넌트의 타입으로 만들어주면 된다. RefAttributes는 다음과 같이 정의되어 있다.

interface RefAttributes<T> extends Attributes {
  ref?: Ref<T> | undefined;
}

ComponentPropsWithRef에는 이미 RefAttributes이 결합되어 있기 때문에 다음과 같이 선언하여 View 컴포넌트를 완성할 수 있다.

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

type ViewComponent = <C extends React.ElementType = "div">(
  props: ViewProps<C> & {
    ref?: React.ComponentPropsWithRef<C>["ref"];
  }
) => React.ReactElement | null;

export const View: ViewComponent = forwardRef(
  <T extends React.ElementType = "div">(
    { as, ...props }: ViewProps<T>,
    ref: React.ComponentPropsWithRef<T>["ref"]
  ) => {
    const Element = as || "div";
    return <Element ref={ref} {...props} />;
  }
);

위 코드를 반영하고나서 다시 App 컴포넌트를 확인하면 ref의 타입을 HTMLDivElement로 사용하여 컴포넌트 타입과 일치하지 않기 때문에 에러가 발생하는 것을 확인할 수 있다.

잘못된 ref가 들어가 에러가 발생한다

이제 useRef의 제네릭 타입을 HTMLAnchorElement로 변경해주면 정상적으로 실행되는 것을 볼 수 있다.

범용성 있게 사용하기

여기까지 왔으면 대체로 복잡한 내용은 끝난 셈이다. 지금까지는 View 컴포넌트만을 위하여 타입을 정의했는데 타입을 한 단계 더 추상화하여 조금 더 범용적으로 쓸 수 있게 만들어보자.

// 기존 작성한 ViewProps에서 as를 분리한다.
type AsProp<T extends React.ElementType> = {
  as?: T;
};

// 직관적인 이름을 붙여서 타입으로 만들어준다.
export type PolymorphicRef<T extends React.ElementType> =
  React.ComponentPropsWithRef<T>["ref"];

// 결합 타입을 만든다.
export type PolymorphicComponentProps<
  T extends React.ElementType,
  Props = {}
> = AsProp<T> & React.ComponentPropsWithoutRef<T> & Props & {
  ref?: PolymorphicRef<T>;
};

기존 ViewProps 타입을 분해하고 PolymorphicComponentProps라는 제네릭 타입을 만들어서 필요한 속성을 추가할 수 있도록 만들었다. 이렇게 만든 타입을 통해 새로운 컴포넌트를 만들어보자.

type _TextProps = {
  size: number;
  color: string;
};

export type TextProps<T extends React.ElementType> = 
  PolymorphicComponentProps<T, _TextProps>;

type TextComponent = <T extends React.ElementType = "span">(
  props: TextProps<T>
) => React.ReactElement | null;

export const Text: TextComponent = forwardRef(
  <T extends React.ElementType = "span">(
    { as, size, color, ...props }: TextProps<T>,
    ref: PolymorphicRef<T>["ref"]
  ) => {
    const Element = as || "span";
    // size와 color를 style로 적용
    return <Element ref={ref} {...props} style={{ fontSize: size, color }} />;
  }
);

PolymorphicComponentProps를 통해 속성 확장 가능한 Polymorphic 컴포넌트를 아주 쉽게 만들어냈다. 여기서는 sizecolor를 새롭게 추가했다. 다음과 같이 사용할 수 있다.

const App = () => {
  return (
    <View>
      <View as="a" href="https://kciter.so">
        Link
      </View>
      <Text as="div" color="red" size={50}>
        Text
      </Text>
    </View>
  );
};

결과 화면을 살펴보면 다음과 같이 제대로 적용된 것을 확인할 수 있다.

업데이트가 거슬리는 분께는 심심한 사과를 드린다

마치며

이렇게 꽤 다양한 곳에서 활용 가능한 Polymorphic 컴포넌트를 구현할 수 있다. 이런 컴포넌트를 만드는 패턴은 요즘 유행하는 UI 라이브러리에선 대부분 사용하고 있기 때문에 알아두면 도움이 될 것이다. 이 포스팅을 통해 만들어진 최종적인 코드는 GitHub 저장소에서 확인할 수 있다.

아직 글 서두에서 소개한 MUI의 Box 컴포넌트나 Mantine의 Box 컴포넌트처럼 스타일 확장 가능한 컴포넌트까지는 다루지 못했다. 원래 이 부분까지 다루는 것이 필자의 목표였으나 너무 길어져서 완성하는데 오래걸릴 것 같아 쓰지 못했지만 빠른 시일 내에 추가로 글을 작성할 예정이다.


  1. Visual Studio 계열 IDE에서 제공하는 자동 완성 기능