다형성 컴포넌트 구현에 문제가 있나요?

Develop
2024-11-24

현실에서의 코드

가끔 이런 코드를 짜다 보면 고민에 빠집니다.

<>
  {items.map((item) => (
    <button>
      <a href="#">{item.name}</a>
    </button>
  ))}
</>
 
// DOM 구조
// <>
//   <button>
//     <a href="#">1</a>
//   </button>
//   <button>
//     <a href="#">2</a>
//   </button>
//   <button>
//     <a href="#">3</a>
//   </button>
//   ...
//   <button>
//     <a href="#">n</a>
//   </button>
// </>
 
// 비효율적인 button > a 노드 생성을 피할 수는 없을까?🤔

이때, button 컴포넌트가 a 타입을 가질 수 있다면 예시 상황에서 효과적으로 사용할 수 있겠죠?
이런 컴포넌트를 다형성(polymorphism)을 가지는 컴포넌트라고 부릅니다.

'많은(poly)'과 '형태(morph)'라는 그리스어에서 유래한 이 용어는, 하나의 객체가 여러 가지 형태를 가질 수 있다는 개념을 나타냅니다.
객체 지향 프로그래밍에서 가장 중요한 개념 중 하나인데요, 특히 OCP(Open-Closed Principle)와 밀접한 연관이 있어요.

OCP, 즉 개방-폐쇄 원칙은 객체 지향의 다섯 가지 원칙(SOLID) 중 하나입니다.
여기서 말하는 개방은 확장에 대한 개방, 폐쇄는 수정에 대한 폐쇄를 의미합니다.

우리가 <Button as="a"> 처럼 사용한다면, 버튼에 새로운 기능(a 태그의 속성)을 추가할 때 기존 Button 컴포넌트의 코드를 수정하지 않아도 됩니다.
이는 OCP를 잘 준수한 예시라고 할 수 있죠.

다형성이 적용된 컴포넌트는 다양한 형태로 사용될 수 있으니, 재사용성, 유연성은 당연히 높아지겠죠?🤗
또한, 시맨틱한 태그를 사용할 수 있게 되니 접근성, 가독성도 좋아진다고 볼 수 있습니다.
그리고 리액트에서 다형성 컴포넌트를 구현하는 방식들은 구체적인 구현을 숨기고, 필요한 동작만을 외부에 노출하도록 추상화, 캡슐화가 적용되어 있습니다.

이제 다형성 컴포넌트를 어떻게 구현할 수 있는지 알아보겠습니다.


Render Delegation

먼저 렌더 위임(Render Delegation) 패턴을 살펴보겠습니다.
요즘 shadcn/ui를 사용하는 분들이 많은데, shadcn이 이 패턴을 사용하고 있어요.
정확히 말하자면 Radix UI가 이 패턴을 사용하고 있습니다.

<Button asChild>
  <Link href="/login">Login</Link>
</Button>
 
// 실제 DOM 구조
// <a href="/login">Login</a>

asChild prop을 사용하면, 컴포넌트의 렌더링 대상을 동적으로 변경할 수 있습니다.
그러니까, asChild가 true라면 컴포넌트가 자식 요소로 렌더링 되는 것이죠.

// shadcn의 Button 컴포넌트 코드 간략 ver
 
import { Slot } from "@radix-ui/react-slot";
 
interface ButtonProps {
  asChild?: boolean;
  children: React.ReactNode;
}
 
const Button = ({ asChild, ...props }: ButtonProps) => {
  // asChild가 true면 Slot을, 아니면 기본 button 엘리먼트를 사용
  const Comp = asChild ? Slot : "button";
  return <Comp {...props} />;
};

이런 방식으로 다형성을 가지는 컴포넌트를 구현할 수 있습니다.

끝! 이면 좋겠지만, 이 방식은 컴포넌트의 UI가 복잡하다면 사용하기 어렵습니다.
asChild로 모든 것이 해결되었다면 이 글을 쓰지 않았을 거예요...🥲

만약, 이런 식으로 복잡한 녀석이라면?

// UI 요구사항을 만족하기 위해 이렇게 중첩된 구조를 갖게 됐다면?
<button>
  <div>
    <div>{leftIcon}</div>
    <div>
      <div>{children}</div>
      <div>{bottomText}</div>
    </div>
    <div>{rightIcon}</div>
  </div>
</button>

음... 아까 Slot을 여기도 적용해 볼까요...?
(스타일은 생략)

const Button = ({ asChild, ...props }: ButtonProps) => {
  const Comp = asChild ? Slot : "button";
 
  return (
    <Comp {...props}>
      <div>
        <div>{leftIcon}</div>
        <div>
          <div>{children}</div>
          <div>{bottomText}</div>
        </div>
        <div>{rightIcon}</div>
      </div>
    </Comp>
  );
};

이렇게 만들면 원하는 태그를 사용할 수 있겠죠?
Link로 나와라!

<Button asChild>
  <Link href="/login">Login</Link>
</Button>
 
// 실제 DOM 구조
// <div>
//   <div>{leftIcon}</div>
//   <div>
//     <div>
//       <a href="/login">
//         Login
//       </a>
//     </div>
//     <div>{bottomText}</div>
//   </div>
//   <div>{rightIcon}</div>
// </div>

넵. 안됩니다.
Slot은 직계 자식에만 적용되기 때문에 이런 식으로 사용할 수 없습니다.
렌더링된 실제 DOM 구조를 보면 Slot 자리에 Link가 아닌 div가 들어가 있습니다.
해당 구조에서 Slot의 직계 자식은 div이기 때문에 Buttonchildren으로 뭘 넣든 div로 렌더링되는 것이죠.
Slottable을 사용하면 여러 직계 자식 중에서 어떤 것으로 렌더링할지 선택할 수는 있습니다만, 이렇게 중첩된 구조에서는 활용하기 어렵습니다.

저는 회사에서 shadcn을 사용해서 디자인 시스템을 구현하고 있는데, 이 문제를 가능한 한 간단하게 해결하고 싶었어요.
분명히 저와 비슷한 고민을 한 사람이 있을 거로 생각해서 이슈를 좀 찾아봤는데, 이미 논의가 이루어졌고, 해결하는 방법🔗도 있었습니다.

const NewSlottable = ({
  asChild,    // Slot 컴포넌트처럼 동작할지 여부
  child,      // 원본 children (예: <a>Link</a>)
  children,   // 렌더 함수 (예: (child) => <div>{child}</div>)
}: {
  asChild: boolean;
  child: React.ReactNode;
  children: (child: React.ReactNode) => React.ReactNode;
}) => {
  return (
    <>
      {asChild
        ? React.isValidElement(child)  // child가 유효한 React 엘리먼트인지 확인
          ? React.cloneElement(
              child,                           // 복제할 대상 엘리먼트 (예: <a>Link</a>)
              undefined,                       // 새로운 props는 전달하지 않음
              children(child.props.children)   // 원본의 children을 렌더 함수로 처리
            )
          : null
        : children(child)}  // asChild가 false면 그냥 렌더 함수 실행
    </>
  );
};
 
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild = false, children, ...props }) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp {...props}>
        <NewSlottable asChild={asChild} child={children}>
          {(child) => (
            <div>
              <div>leftIcon</div>
              <div>
                <div>{child}</div>
                <div>bottomText</div>
              </div>
              <div>rightIcon</div>
            </div>
          )}
        </NewSlottable>
      </Comp>
    );
  }
);

이렇게 cloneElement를 사용해서 원하는 태그를 사용할 수 있습니다.
다만, 이 방법은 NewSlottable의 복잡도가 높아 유지보수가 어렵고, 성능 문제도 있습니다.
isValidElement, cloneElement를 사용해야 하고, 렌더링마다 새로운 함수를 생성하기 때문이죠.

그럼 더 나은 방법은 없을까요?


Polymorphic Components

as prop을 사용하는 방법이 있습니다.

아마 이 방식이 더 유명할 것 같아요.
개인적으로 사용법이 직관적이고 쉽다고 생각해요.

<Button as="a" href="/login">Login</Button>
 
// 실제 DOM 구조
// <a href="/login">Login</a>

as prop을 구현하는 방법은 이미 많은 아티클에서 다루고 있기 때문에 자세히 다루지 않겠습니다.
대신 구현 방법을 잘 정리해 둔 아티클 몇 개를 추천하고 넘어갈게요.

(한글) Polymorphic한 React 컴포넌트 만들기🔗
(한글) Type-Safe하게 다형성 지원하기🔗
(영어) How to Build Strongly Typed Polymorphic Components🔗

타입 에러 발생

위에서 추천한 아티클은 모두 비슷한(거의 동일한) 방식으로 as prop을 구현하고 있습니다.
잘 정리된 글을 따라 구현해보면... 타입 에러가 발생합니다 😇
왜 타입 에러가 발생하는지, 그리고 해결 방법에 대해 알아보겠습니다.

원인 분석

아티클을 따라 완성한 코드는 이렇습니다.

// polymorphic.ts
type AsProp<C extends ElementType> = {
  as?: C;
};
 
type KeyWithAs<C extends ElementType, Props> = keyof (AsProp<C> & Props);
 
export type PolymorphicRef<C extends ElementType> =
  ComponentPropsWithRef<C>["ref"];
 
export type PolymorphicComponentProps<
  C extends ElementType,
  Props = object
> = (Props & AsProp<C>) &
  Omit<ComponentPropsWithoutRef<C>, KeyWithAs<C, Props>>;
 
export type PolymorphicComponentPropsWithRef<
  C extends ElementType,
  Props = object
> = Props & { ref?: PolymorphicRef<C> };
 
// Button.tsx
type Size = "large" | "medium" | "small";
 
type Props<C extends ElementType> = PolymorphicComponentProps<
  C,
  {
    size?: Size;
  }
>;
 
const Button = forwardRef(
  <C extends ElementType = "button">( // 💥
    { as, children, ...restProps }: Props<C>, // 💥
    ref?: PolymorphicRef<C> // 💥
  ) => {  // 💥
// Argument of type '<C extends ElementType = "button">({ as, children, ...restProps }: Props<C>, ref?: PolymorphicRef<C>) => JSX.Element' is not assignable to parameter of type 'ForwardRefRenderFunction<unknown, Omit<Props<ElementType>, "ref">>'.
//  Types of parameters 'ref' and 'ref' are incompatible.
//    Type 'ForwardedRef<unknown>' is not assignable to type '((instance: HTMLButtonElement | null) => void) | RefObject<HTMLButtonElement> | null | undefined'.ts(2345)
 
    const Comp = as || "button";
 
    return (
      <Comp as={as} ref={ref} {...restProps}>
        {children}
      </Comp>
    );
  }
);

에러 메시지에서 확인할 수 있듯이 타입 에러는 ref의 타입 불일치로 인해 발생합니다.
근데, 하나도 아니고 무슨 아티클을 참고하든 동일한 에러가 발생합니다.
사람들은 왜 타입 에러가 발생하는 코드를 아티클로 작성한 걸까요?

에러가 발생하는 이유는 @types/react 18.3.5 버전부터 forwardRef의 타입이 변경되었기 때문이에요.
실제로 18.3.4 버전을 사용하면 에러 없이 잘 동작하는데, 18.3.5 이상 버전에서는 타입 에러가 발생하는 것을 확인할 수 있어요.
아티클은 18.3.5 버전 이전에 작성된 것 같습니다.

그럼 forwardRef의 타입은 어떻게 변경되었을까요?

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

ForwardRefRenderFunction의 두 번째 타입 파라미터가 P에서 PropsWithoutRef<P>로 변경되면서 문제가 발생했습니다.
PropsWithoutRef는 뭐 하는 녀석이길래 에러를 발생시키는 걸까요?

type PropsWithoutRef<P> =
// Omit would not be sufficient for this. We'd like to avoid unnecessary mapping and need a distributive conditional to support unions.
// see: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
// https://github.com/Microsoft/TypeScript/issues/28339
P extends any ? ("ref" extends keyof P ? Omit<P, "ref"> : P) : P;

타입 파라미터 Pref 프로퍼티가 있으면 제거하고, 없으면 그대로 유지하는 타입입니다.
ref 프로퍼티를 props에서 명시적으로 제거하여 타입 안전성을 높이고자 한 것이죠.
그리고 이런 타입을 Distributive Conditional Type🔗, 조건부 타입의 분배 법칙이라고 합니다.

조건부 타입의 분배 법칙은 수학에서 배운 분배 법칙과 비슷하다고 생각할 수 있어요.
즉, 2 * (3 + 4) = (2 * 3) + (2 * 4) 요런 것으로 이해하면 됩니다.

타입에서 예시를 들어볼게요.
아래와 같은 조건부 타입이 있다고 가정해 봅시다.

type IsString<T> = T extends string ? "네!" : "아니요!";

이제 이걸 유니온 타입에 사용하면?

type Result = IsString<string | number>;

타입스크립트는 string | number를 각각 나눠서 처리하고, 이렇게 분배됩니다.

type Result = IsString<string> | IsString<number>

그리고 각각 계산됩니다.

type Result = "네!" | "아니요!"

요리할 때 소스 같은 걸 만들다 보면 이런 분배 법칙이 적용되는 경우가 있어요.
바질 갈고, 마늘 갈고, 후추 갈고, ... 각각 따로 갈아낸 후(분배) 갈았던 것들을 하나로 합쳐서(유니온) 완성하는 소스처럼 말이죠.

다시 위 컴포넌트 코드로 돌아와서 생각해 봅시다.

type PropsWithoutRef<P> =
P extends any ? ("ref" extends keyof P ? Omit<P, "ref"> : P) : P;

ElementType은 다음과 같습니다.

type ElementType<P = any, Tag extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements> =
| { [K in Tag]: P extends JSX.IntrinsicElements[K] ? K : never }[Tag]
| ComponentType<P>;

이게 평가되면 모~든 HTML 태그를 포함한 유니온 타입이 됩니다.

type ElementType =
| "div"
| "span"
| "button"
| "input"
// ... 기타 모든 HTML 태그들
| React.ComponentType<P>  // React 컴포넌트

이걸 PropsWithoutRef에 넣으면 분배 법칙이 작동합니다.

type Result =
   | PropsWithoutRef<Props<"div">>
   | PropsWithoutRef<Props<"span">>
   | PropsWithoutRef<Props<"button">> ...

근데 우리의 Props는 다음과 같습니다.

type Props<C extends ElementType> = {
    as?: C;
    ref?: PolymorphicRef<C>;
    // 기타 props...
};

보다시피 우리의 PropsCref가 연결되어 있습니다.
하지만 ResultC와 연결이 깨진 별개의 타입이 되었으므로 우리의 ref 타입과 호환되지 않습니다.
따라서 ref 타입 불일치 에러가 발생하는 것이죠.

해결, 그리고 또 다른 문제

범인을 찾았으니 이제 해결해 봅시다.
그냥 forwardRef의 타입 정의를 18.3.4 때로 오버로딩해버리면 타입 에러가 사라집니다 🤗

// 18.3.4 타입을 사용
function forwardRef<T, P = {}>(
  render: ForwardRefRenderFunction<T, P>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

이게 뭐가 해결이냐고요?
어쨌든 잘 되잖아~ 한잔해~

이렇게 타입 오버로딩으로 해결할 수 있다면 좋겠지만...
한잔하고 나서 확인해 보면 타입 에러는 사라졌지만, 또 다른 문제가 발생합니다.

<Button as...? onClick...?> // props 타입 추론 불가 🥲
  Login
</Button>

아니 왜 또...?
다시 원인을 분석해 봅시다.

우리가 구현한 컴포넌트는 간단하게 표현하자면 이런 형태입니다.

const Button = forwardRef(Component)

자, 위에서 신나게 살펴봤듯이 forwardRef는 제네릭 함수입니다.

제네릭이란 뭐죠?
바로 호출 시점에 파라미터의 타입을 결정하는 기능입니다.
따라서 우리가 만든 컴포넌트는 이렇게 추론됩니다.

const Button = forwardRef<SomeRealType, SomeRealProps>(Component)

forwardRef가 반환하는 결과인 Button은 더 이상 제네릭이 아니게 되는 거죠.
직접 지정하지 않아도 호출 시점에 인자 값을 보고 추론해서 어떤 특정 타입으로 결정해 버립니다.
따라서 우리가 만든 컴포넌트는 제네릭 타입이 아니었던 거죠.

// 버튼 컴포넌트의 타입이 어떻게 추론되고 있냐면...  
const Button: React.ForwardRefExoticComponent<Omit<Props<React.ElementType>,
"ref"> & React.RefAttributes<unknown>>
 
// 우린 이걸 원했는데...
const Button: <C extends React.ElementType = "button">(props: 
React.PropsWithoutRef<Props<C>> & React.RefAttributes<unknown>) => React.ReactNode

해결 방법

방법은 몇 가지가 있습니다.

  1. forwardRef를 사용하지 않는 방법
  2. 타입 어노테이션을 통해 제네릭으로 만들어주는 방법
  3. Higher order type inference from generic functions

하나씩 살펴보겠습니다.

1. forwardRef를 사용하지 않는 방법

forwardRef를 사용하지 않으면 타입 에러가 발생하지 않습니다.
위에서 말했던 것처럼, 이 문제는 forwardRef를 사용해서 제네릭이 아니라 특정 타입으로 결정되는 것이 원인이기 때문이에요.
따라서 forwardRef를 사용하지 않으면 해결입니다.

해결이라고 볼 수 있을까요?😇

2. 타입 어노테이션을 통해 제네릭으로 만들어주는 방법

제네릭이 아니라 특정 타입으로 결정되는 것이 원인이기 때문에, 타입 어노테이션을 통해 다시 제네릭으로 만들어주면 해결됩니다.

type Size = "large" | "medium" | "small";
 
type Props<C extends ElementType> = PolymorphicComponentProps<
  C,
  {
    size?: Size;
  }
>;
 
// 다시 제네릭으로 만들어주기
type ButtonType = <C extends ElementType = "button">(
  props: PolymorphicComponentPropsWithRef<C, Props<C>>
) => ReactNode;
 
// 타입 어노테이션
const Button: ButtonType = forwardRef(
  <C extends ElementType = "button">(
    { as, children, ...restProps }: Props<C>,
    ref?: PolymorphicRef<C>
  ) => {
    const Comp = as || "button";
 
    return (
      <Comp as={as} ref={ref} {...restProps}>
        {children}
      </Comp>
    );
  }
);

간단하게 해결됩니다!👍

3. Higher order type inference from generic functions

타입스크립트는 3.4 버전부터 Higher order type inference from generic functions🔗라는 기능을 지원합니다.

요것은 고차함수의 인자가 제네릭 함수일 때, 반환하는 함수도 제네릭 함수가 되게 하는 기능입니다.
그럼 forwardRef도 고차함수니까 이 기능이 적용되어서 반환하는 Button도 제네릭 함수가 되어야 하지 않냐고요?
넵, 그래야 하는데... 왜 안 될까요?

이 기능을 사용하기 위해서는 인자 함수와 반환 함수가 모두 '순수한 함수 타입'이어야 하기 때문입니다.
순수한 함수 타입이란, **단일 호출 시그니처가 있고 다른 멤버가 없는 유형(call signature만 가지고, 다른 필드를 가지지 않는 함수 타입)**🔗입니다.
forwardRef는 인자 함수와 반환 함수가 모두 순수한 함수 타입이 아니라서 안되는 것입니다.

function forwardRef<T, P = {}>(
  render: ForwardRefRenderFunction<T, P>, // 🤨 ForwardRefRenderFunction은 순수한 함수 타입이 아님
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
// 🤨 ForwardRefExoticComponent는 순수한 함수 타입이 아님

인자 함수인 ForwardRefRenderFunction, 그리고 반환 함수인 ForwardRefExoticComponent 모두 순수한 함수 타입이 아닌데요,
둘 다 displayName, defaultProps, propTypes 속성을 갖고 있기 때문입니다.

// 인자 함수 ForwardRefRenderFunction
interface ForwardRefRenderFunction<T, P = {}> {
  (props: P, ref: ForwardedRef<T>): ReactNode;
  displayName?: string | undefined;
  defaultProps?: never | undefined;
  propTypes?: never | undefined;
}
 
 
// 반환 함수 ForwardRefExoticComponent
interface ExoticComponent<P = {}> {
  (props: P): ReactNode;
  readonly $$typeof: symbol;
}
 
interface NamedExoticComponent<P = {}> extends ExoticComponent<P> {
  displayName?: string | undefined;
}
 
interface ForwardRefExoticComponent<P> extends NamedExoticComponent<P> {
  defaultProps?: Partial<P> | undefined;
  propTypes?: WeakValidationMap<P> | undefined;
}

아까 Higher order type inference from generic functions 기능을 사용하기 위해서는 인자 함수와 반환 함수가 모두 순수한 함수 타입이어야 한다고 했습니다.
그럼 그냥 이 둘을 순수한 함수 타입으로 만들어주면 되겠죠?

forwardRef 함수 타입을 오버로딩해서 인자 함수와 반환 함수에서 해당 필드들을 모두 빼고, 함수 시그니처만 남기겠습니다.

function forwardRef<T, P = {}>(
  render: (props: P, ref: React.ForwardedRef<T>) => React.ReactNode,
): (
  props: React.PropsWithoutRef<P> & React.RefAttributes<T>,
) => React.ReactNode

이제 Higher order type inference from generic functions 기능이 잘 동작해서 타입 추론이 정상적으로 이루어지는 것을 확인할 수 있습니다 👍

어떤 방법이 좋을까?

저는 회사에서 3번, forwardRef를 오버로딩해서 Higher order type inference from generic functions 기능을 사용하는 방식으로 해결했습니다.
왜냐하면, 1번은 일단 논외고... 2번은 타입이 고정되는 것이 다소 걱정스러웠어요.

아마 읽는 분들은 3번이 가장 별로라고 생각할 수도 있을 것 같은데요,
리액트의 타입을 마구 오버로딩하는 것이 좋은 방법은 아니라는 것에 동의합니다.

하지만! 이제 곧 forwardRef를 사용할 필요가 없어지기 때문에!
당분간 오버로딩으로 해결해두고 리액트 19가 나오면 forwardRef를 걷어내면 될 것 같다고 판단했어요.

선택은 여러분에게 달렸습니다 🤗


마치며

다형성 컴포넌트는 재사용성, 유연성, 가독성, 접근성 등 다양한 장점이 있습니다.
특히 디자인 시스템을 구축할 때 아주 유용하게 사용되는데요, 대신 구현할 때 제대로 공부해 두면 좋을 것 같습니다.
다형성 컴포넌트는 강력한 도구이지만, 단점도 존재하는 것이 사실입니다. 일단 구현 자체의 난이도가 있고, 알아본 것과 같이 타입 추론 문제도 있습니다.

저는 항상 최신 버전의 리액트 환경이 아닌 환경에서 개발했기 때문에, 항상 잘 되던 코드가 갑자기 안돼서 꽤 당황했습니다.
원인을 자세히 찾는 게 상당히 어려웠고, 과정에서 팀원들에게 많은 도움을 받았습니다.
(타입스크립트를 좀 더 공부해야 할 필요성을 느꼈습니다... 🥲)

이 트러블 슈팅 경험이 다형성 컴포넌트를 구현하려는 누군가에게 도움이 되었으면 좋겠습니다!😊