본문 바로가기

개발/React

Polymorphic Component를 정의하는 여러가지 방법 해체분석

반응형

https://patterns-dev-kr.github.io/rendering-patterns/overview-of-reactjs/

 

서론

 

사내 프로젝트를 진행하는 과정에서 Polymorphic Component에 대한 니즈가 생겼고, 기존에 Polymorphic Component를 정의하기 위한 복잡한 타입을 설명해 주신 좋은 글이 있었으나 React, Typescript의 버전이 올라가면서 이에 대한 완벽한 호환이 어려워져 이에 대한 대안을 설명해 둔 글을 보게 되었고 해당 글의 내용이 좋지만 forwardRef를 호환하기 위한 복잡한 타입들이 엮여있어 이를 흡수하기 위해 해당 글에 있는 타입들을 분석해보려 한다.

 

원문 : https://www.tsteele.dev/posts/react-polymorphic-forwardref

 

 

 

본론

 

위에서 언급한 바와 같이 해당 글에서 나온 forwardRef까지 완벽하게 호환할 수 있는 Polymorphic Component를 위한 타입들을 하나씩 분석해 보겠다.

 

 

방법 1 (출처 : https://gist.github.com/kripod/4434e7cecfdecee160026aee49ea6ee8 )

 

 

 

해당 코드를 하나씩 뜯어보겠다.

 

먼저 IntrinsicAttributes는 제네릭으로 JSX.IntrinsicElements 나 JSXElementConstructor를 받는데 먼저 둘에 대해 알아보자

 

- JSX.IntrinsicElements : 일반적인 HTML 태그들의 이름을 key로 (div, p, a 등등..), 해당 태그가 가질 수 있는 props을 value로 가지고 있는 객체형태의 타입을 말한다.

- JSXElementConstructor : 만들어진 React 컴포넌트에 대한 타입으로 클래스형 컴포넌트, 함수 컴포넌트의 구현체를 가리킨다.

 

두 타입의 내부 구현은 아래와 같다.

 

 

- JSX.LibraryManagedAttributes : 라이브러리에서 관리하는 기본 태그의 속성으로 defaultProps와 같은 props 관리 기능을 가지고 있는 커스텀 컴포넌트 타입을 의미한다. defaultProps의 실제 동작까지 타입 시스템에 반영되어 있다는 점이 특징으로 해당 특징이 ComponentPropsWithRef와 같은 타입과 구분되는 이유이다. (ComponentPropsWithRef는 defaultProps 타입을 받을 수 있다는 타입 정의만 되어있을 뿐 동작까지 정의하지는 않는다.)

 

- ElementType : HTML Element와 컴포넌트에 대한 범용적인 타입이지만 HTML Element에 대해서는 단순히 태그명만 가진다.

 

 

정리해 보면 IntrinsicAttributes는 JSX.IntrinsicElements에 대한 key list이기 때문에 HTML 태그들에 대한 이름 리스트이기 때문에 HTML 태그들에 대한 이름 리스트나 커스텀 컴포넌트의 구현체를 제네릭으로 받는데 해당 제네릭을 베이스로 defaultProps를 가진 해당 컴포넌트의 props 타입을 반환해 주는 것이다.

 

다음으로는 BoxOwnProps인데 이는 단순히 ElementType을 제네릭으로 받아서 as prop에 들어올 수 있는 값을 해당 태그라고 추가해 주는 역할로 보면 된다.

 

그렇다면 실제 컴포넌트의 타입으로 들어가는 최종 구현체 타입인 BoxProps을 보자.

 

이는 받아온 ElementType명을 as로 넣을 수 있게 해 주며, 기본적으로는 해당 태그가 HTML 태그로서 사용될 때 가지는 props 전부와 React 컴포넌트로서 사용될 때 가지는 props (key, children 등등...)을 모두 가질 수 있도록 해주는 것이다.

 

하지만 이 방법은 ref에 대해 불안정한 타입을 보이며 사용 시에 반드시 단언을 해주어야 한다는 단점이 있다.

 

 

방법 2 : Radix-ui의 forwardRefWithAs

 

( 출처 : https://github.com/radix-ui/primitives/blob/7101e7d6efb2bff13cc6761023ab85aeec73539e/packages/react/polymorphic/src/forwardRefWithAs.ts )

 

 

 

 

이 방법은 현재 많이 사용되는 Radix-ui의 Type Override 방식으로 이것 역시 하나씩 파헤쳐보자.

 

먼저 아래에서 사용하기 위한 Utility Type인 Merge이다.

 

Typescript는 JS에서의 override를 통한 merge 방식과는 다르게 두 객체를 합치는 intersect를 하는 경우 동일한 key가 존재한다면 두 value 사이의 겹치는 타입이 있다면 둘 중 더 구체적인 타입을 선택(string과 string | number가 있다면 string이 된다.)하고, 두 값 사이에 겹치는 타입이 없다면 never를 가지게 된다. 따라서 두 개의 타입을 안전하게 merge하기 위해 한쪽에서 다른 한쪽의 key에 대한 value를 전부 없애고, 그다음에 intersect를 수행하는 것이다.

 

두번째 Utility Type인 MergeProps하나의 태그명을 받아 이에 대한 태그의 고유 props와 인자로 받아온 object props를 합쳐주는 역할을 한다. 

 

이제 위 타입의 메인 타입을 분석해 보겠다.

 

먼저 ForwardRefComponent 타입의 제네릭으로 받는 IntricsicElementString은 MergeProps에 들어가는 as, 즉 해당 타입이 나타내고자 하는 태그명이 된다. 또한 이는 ForwardRefExoticComponent, 즉 forwardRef로 감싸진 컴포넌트의 타입을 반환하는데, 해당 컴포넌트는 제네릭으로 받아온 태그 기반으로 하고 받아온 커스텀 props와 as prop을 합치는 구현체인 것이다.

 

 

또한 이 구현체 내에서 props는 종류에 따라 2가지 특성을 가질 수 있는데 이를 가리키는 코드는 위의 코드베이스 중에서 아래와 같다.

 

 

먼저 1번 케이스를 보면 as 자리에 기본 HTML 태그가 오는 경우이다. (JSX.IntrinsicElements의 key이기 때문)

 

이때는 props에 해당 HTML 태그의 기본 props와 현재 컴포넌트를 선언하면서 넣어주고자 하는 커스텀 props (OwnProps), 그리고 해당 컴포넌트를 나타내는 as prop을 merge 해준다.

 

 

2번째 케이스가 복잡한데 여기서 먼저 알아야 할 타입은 ElementType이다.

 

ElementType은 해당 컴포넌트의 Props타입을 제네릭으로 추론하는 데에 자주 사용하는 타입이며 실제로는 어떤 props를 가진 컴포넌트던지 받아올 수 있다는 타입으로 대략적인 구현체는 아래와 같다.

 

 

 

그렇다면 이에 따라 첫 번째 제네릭은 첫번째 케이스에서 조금 더 확장된 형태로 어떤 컴포넌트던지 올 수 있다는 것이고, 두 번째 제네릭을 보면 위에서 언급한 ElementType과 infer를 활용해서 props에 접근하는 것을 볼 수 있다.

 

ElementType의 제네릭으로는 기본적으로 해당 컴포넌트의 props가 오다 보니 infer를 하게 된다면 P는 대상 컴포넌트의 Props 타입이고, 컴포넌트의 props가 있다면 해당 props 타입을 제네릭으로 하는 ElementType을 반환해 준다.

 

여기서 ElementType으로 다시 감싸는 이유단순 props 타입이 아니라 당 props를 받는 컴포넌트 타입을 유지하기 위함이라고 보면 된다.

 

 

위와 같은 타입추론을 마치고 나면 이제는 아래 형태처럼 위의 예시와 같이 유사하게 처리가 가능하다

 



 

 

여기서 첫번째 방식보다 두번째 Radix ui 방식이 더 나은 점은 첫번째 방식은 ref 타입이 제대로 처리되지 않을 수 있고, 다형성을 표현하기에는 부족했기 때문이다.

 

이 방식은 타입적으로는 완전하나 이 역시도 컴포넌트에 타입 단언을 해줘야한다는 점이 아쉬운 점이다.

 

 

방법 3 ( 출처 : https://dev.to/nasheomirro/creating-fast-type-safe-polymorphic-components-3f6p )

 

지금까지의 한계를 모두 보완한 세번째 방식을 보자.

 

 

 

세번째 방식은 시작부터 복잡하기때문에 하나씩 살펴보자.

 

 

먼저 처음으로 있는 Utility Type인 DistributiveOmit을 보면 생소한 코드가 보인다. 그것은 두번째 제네릭을 제한하는 keyof any 인데 이것의 의미는 key로 올 수 있는 모든 타입의 유니온을 가리키기때문에 string | number | symbol 과 같다. 이는 PropertyKey와 같기때문에 대체도 가능하다.

 

이어서 해당 타입을 마저 분석해보면 이는 Typescript의 Distributive Conditional Types를 활용한 방식임을 알 수 있는데, 일반적으로 Omit만을 사용하는 경우에는 대상 타입이 유니온인 경우 각 케이스별로 omit 해주지 않는데, 위와 같이 조건부 타입을 정해주면 유니온의 각 타입마다 조건문을 수행해준다.

 

 

 

따라서 해당 Utility Type은 각 타입에서 특정 key를 Omit 해주는 타입이다.

 

DistributiveMerge는 첫번째 제네릭에서 두번째를 제거한 후 다시 머지해주는데 유니온타입까지 호환한다는 점만 Merge와 다르다고 보면 된다.

 

지금부터는 각 타입을 잘라서 보겠다.

 

 

 

먼저 AsProps 타입을 보면 대상 컴포넌트의 타입과 유지하려고하는 Props인 PermanentProps, 그리고 이전 위의 예시들과 동일하게 as prop을 통해 대상 컴포넌트의 정보를 받아와야하기때문에 as 까지 Merge한 Type을 반환한다.

 

 

 

 

PolymorphicWithRef는 기본적인 틀은 기본 컴포넌트 타입에 추가하고자하는 커스텀 Props를 추가해주는 타입인데 여기서 OnlyAs라는 제네릭을 활용해서 해당 태그에 올 수 있는 태그를 제한할 수 있는 타입이다.

 

 

 

PolyForwardComponent는 forwardRef의 반환체 역할을 하는 ForwardRefExoticComponent 타입에 현재 컴포넌트의 타입, 커스텀 Props, as prop을 가진 형태로 만들고, 이에 다형성을 가지도록 하는 타입인 PolymorphicWithRef 를 위와 동일한 조건으로 만들어 병합해주는 타입이다.

 

 

 

 

마지막인 PolyRefFunction은 사실상 이전까지 만들어둔 타입을 그저 forwardRef에 적용하여 forwardRef의 인자로 들어오는 Component에 다형성을 가진 타입을 적용해주는 역할만 한다.

 

 

이 방식은 불완전한 첫번째 방식을 넘어서 Radix에서 사용하는 방식 역시도 가지고있는 대상 컴포넌트에 직접적으로 단언해주는 타입 단언의 형태를 사용하지 않고, 타입 선언을 통해서 적용할 수 있다는 점에서 더욱 유연하다.

 

 

결론

 

사내 프로젝트를 진행하면서 Polymorphic한 컴포넌트에 대한 니즈도 있었고 유용함을 알고있었지만 이를 완벽하게 구현하기가 너무 까다로워 쉽게 적용하지 못하고 있던 찰나에 위와 같은 글을 팀원분이 찾아내서 공유해주셨다. 공유해주셨던 당시에는 우선순위가 높은 다른 작업들로 인해 완전하게 흡수하기 어려워 미뤄두었지만 여러 복잡한 타입이 얽혀있는 Polymorphic Component을 이해한다면 컴포넌트에 대한 타입시스템을 전반적으로 이해하기에는 유용하다고 생각해 해당 글에 있는 타입들이 어떤 문제를 해결하려고 했으며 결론적으로 어떤 형태까지 이르게 되었는지 분석하게 되었다. 분석하고나니 실제로 여러 형태를 띌 수 있는 컴포넌트의 타입을 만들기 위해 고려해야할 사항들이 상당히 많다는걸 다시 느끼게 되었고 다양한 컴포넌트 타입에 따른 올바른 ref 타입 처리와 ForwardRefExoticComponent를 통한 ref 관련 기능 보존하는 방법이나 여러가지 유틸리티 타입을 알게되어 앞으로 타입을 작성함에 있어서 이번 글을 통해 배운 점을 다양하게 적용해볼 수 있을 것 같았다.

 

반응형