Typography를 polymorphic 하게 리팩토링하기(+ React.ElementType제한하기)

ujinsim·2025년 5월 18일
post-thumbnail

기존 Typography 컴포넌트

  • Typography 컴포넌트
function Typography({ children, variant = 'Body1', className = '', ...props }: Props) {
  return (
    <p className={cn(variantClasses({ type: variant }), className)} {...props}>
      {children}
    </p>
  );
}
  • 기본적으로 p 태그를 사용하며, variant에 따라 스타일이 바뀝니다.

여기서 아쉬운 점

처음엔 p태그가 고정인 것을 인지하지 못하고 있었는데 다른 컴포넌트를 만들고계신 팀원분께서 타이포그라피가 p태그로 고정된것이 아쉽다고 말씀해주셨습니다.

저도 이 부분에 대해서 생각해보았는데 검색 엔진 최적화(SEO)나 접근성 측면에서도 마찬가지로도 아쉽다고 생각이 됐습니다. 또 버튼 안에 들어가는 텍스트는 span이나 label으로 감싸줘야 더 적절한데, Typography가 무조건 p태그니까 이걸로 해결하는 방법은 또 다른 컴포넌트를 만들어야하는 수밖에 없다고 느꼈습니다.

mui 디자인에서는 어떻게 하나 찾아봤는데 대부분은 태그명을 prop을 주는 것을 확인했습니다
mui-typography


Typography 컴포넌트 리팩토링 방향

다른 Typography컴포넌트처럼 태그를 prop으로 받도록 하는 것으로 하였습니다
이런 컴포넌트를 다형성 컴포넌트라고 부릅니다.

다형성 컴포넌트란?

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

Polymorphic한 React 컴포넌트 만들기

Polymorphic 컴포넌트는 리액트(React)에서 하나의 컴포넌트가 여러 다른 HTML 태그 또는 컴포넌트를 유연하게 렌더링할 수 있도록 하는 패턴입니다.


버튼컴포넌트로 예를 들어 설명하자면,

type ButtonProps = {
  as?: React.ElementType;
  children: React.ReactNode;
};

export function Button({ as: Component = 'button', children, ...props }: ButtonProps) {
  return <Component {...props}>{children}</Component>;
}
<Button>기본 버튼</Button>             // <button>기본 버튼</button>
<Button as="a" href="/about">링크</Button> // <a href="/about">링크</a>
<Button as="div">디브</Button>          // <div>디브</div>

as라는 propa, div와 같은 태그명을 넣어서 다른 태그로서 사용할 수 있게 합니다.

이를 Typography 컴포넌트에 적용해보자!!

1. TypographyProps로 타입 변경

type TypographyProps = {
  variant: TypographyVariant;
  className?: string;
  as?: React.ElementType;
  children: React.ReactNode;
} & ComponentPropsWithoutRef<T>;
  • as는 "어떤 HTML 태그나 컴포넌트를 사용할 것인지"를 외부에서 지정할 수 있도록 하는 prop
  • ComponentPropsWithoutRef T(예: 'button', 'a', 'label', ...)가 기본적으로 받을 수 있는 모든 props를 가져오는 타입.(단, ref는 빼고)

2. 기존 Typography 함수에 as 적용

function Typography({
  variant = 'Body1',
  as: Component = 'p',
  ...
}: TypographyProps) {
  return (
    <Component className={cn(variantClasses({ type: variant }), className)} {...props}>
      {children}
    </Component>
  );
}

결과물

이렇게 간단하게 as속성을 받아서 사용할 수 있게되었어요 ㅎㅎ


하지만

link나 input같은 태그는 단일태그이기 때문에 Typography태그의 prop으로 받기에는 적절하지 않습니다. 그래서 아까 받은 as?: React.ElementType; 를 좁혀야 더 안전하게 사용할 수 있습니다.

그래서 자주사용하는
'p' , 'div' , 'label' , 'span' 요 4개의 태그로 추려서 받아보기로 정했습니다.

그냥 이렇게하면 안되나요?

type TypographyProps = {
  as?: 'span' | 'div' | 'label' | 'p';
  // ...
} & ComponentPropsWithoutRef<'div' | 'span' | 'label' | 'p'>;

이렇게 되면 참 좋겠지만....

  • label은 htmlFor 속성을 가진다.
  • div는 htmlFor을 안 가진다.
  • div는 onCopy 이벤트를 가지지만, label은 다를 수 있다.

이 두 태그가 가진 props가 서로 다르고, 일부는 충돌하거나 존재하지 않기 때문에
타입스크립트가 "이거 뭐가 뭔지 모르겠는데?" 하고 타입 충돌을 내는 상황이 발생합니다.

실제 문제 상황:
Apply to Typography.t...
<Component {...props}>
Component가 label일 때는 label에 맞는 props만 받아야 하는데
div의 props도 함께 전달되려고 해서 충돌이 발생
특히 이벤트 핸들러(onCopy 등)가 서로 다른 타입을 가져서 문제가 됨

이럴땐 제네릭을 사용하자

제네릭이란?

함수나 타입을 작성할 때, 구체적인 타입을 나중에 넘겨서 결정할 수 있게 하는 도구

type TypographyProps<T extends ElementType> = {
  as?: T;
} & ComponentPropsWithoutRef<T>;

여기서 T는 'div'일 수도 있고 'label'일 수도 있습니다. 그리고 그 선택에 따라 타입스크립트가 정확한 HTML props를 추론해줍니다.

  • TypographyProps 제네릭 타입 정의

type AllowedTag = 'p' | 'div' | 'label' | 'span';

type TypographyProps<T extends AllowedTag> = {
  as?: T;
  className?: string;
  children?: ReactNode;
  variant?: TypographyVariant;
} & Omit<ComponentPropsWithoutRef<T>, 'as' | 'className' | 'children'>;

제네릭은 사용자가 넘긴 태그 (as="label")의 타입을 나타냅니다.
T'p' | 'div' | 'label' | 'span' 중 하나만 될 수 있게되고, 이 T는 사용자가 as로 넘긴 태그에 따라 자동으로 추론됩니다

ComponentPropsWithoutRef를 통해 T에 해당하는 기본 props를 자동 추론해줍니다
다음과 같이 사용할 수 있습니다.

<Typography as="label" htmlFor="name">이름</Typography>

className, children, as는 우리가 수동으로 정의했기 때문에 충돌을 막기 위해 Omit으로 제외합니다.

  • Typography 함수형 컴포넌트

    export function Typography<T extends AllowedTag = 'p'>({
     as,
     className,
     children,
     variant = 'Body1',
     ...props
    }: TypographyProps<T>) {
     const Component = as || 'p';
    // as가 주어지면 그걸 컴포넌트로, 아니면 기본값으로 'p' 사용
     
  • CreateTypography 함수


  const createTypography = (variant: TypographyVariant) => {
  function Component<T extends AllowedTag = 'p'>(props: Omit<TypographyProps<T>, 'variant'>) {
    return <Typography variant={variant} {...props} />;
  }
  return Component;
};

결과물

짜쨘~ ✨ 이렇게 자동완성이 잘 뜨는 다형성 컴포넌트가 완성되었습니다

profile
프론트엔드 공부 중.. 💻👩‍🎤

0개의 댓글