이때, 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가 이 패턴을 사용하고 있습니다.
asChild prop을 사용하면, 컴포넌트의 렌더링 대상을 동적으로 변경할 수 있습니다.
그러니까, asChild가 true라면 컴포넌트가 자식 요소로 렌더링 되는 것이죠.
이런 방식으로 다형성을 가지는 컴포넌트를 구현할 수 있습니다.
끝! 이면 좋겠지만, 이 방식은 컴포넌트의 UI가 복잡하다면 사용하기 어렵습니다. asChild로 모든 것이 해결되었다면 이 글을 쓰지 않았을 거예요...🥲
만약, 이런 식으로 복잡한 녀석이라면?
음... 아까 Slot을 여기도 적용해 볼까요...?
(스타일은 생략)
이렇게 만들면 원하는 태그를 사용할 수 있겠죠? Link로 나와라!
넵. 안됩니다. Slot은 직계 자식에만 적용되기 때문에 이런 식으로 사용할 수 없습니다.
렌더링된 실제 DOM 구조를 보면 Slot 자리에 Link가 아닌 div가 들어가 있습니다.
해당 구조에서 Slot의 직계 자식은 div이기 때문에 Button의 children으로 뭘 넣든 div로 렌더링되는 것이죠. Slottable을 사용하면 여러 직계 자식 중에서 어떤 것으로 렌더링할지 선택할 수는 있습니다만, 이렇게 중첩된 구조에서는 활용하기 어렵습니다.
저는 회사에서 shadcn을 사용해서 디자인 시스템을 구현하고 있는데, 이 문제를 가능한 한 간단하게 해결하고 싶었어요.
분명히 저와 비슷한 고민을 한 사람이 있을 거로 생각해서 이슈를 좀 찾아봤는데, 이미 논의가 이루어졌고, 해결하는 방법🔗도 있었습니다.
이렇게 cloneElement를 사용해서 원하는 태그를 사용할 수 있습니다.
다만, 이 방법은 NewSlottable의 복잡도가 높아 유지보수가 어렵고, 성능 문제도 있습니다. isValidElement, cloneElement를 사용해야 하고, 렌더링마다 새로운 함수를 생성하기 때문이죠.
그럼 더 나은 방법은 없을까요?
Polymorphic Components
as prop을 사용하는 방법이 있습니다.
아마 이 방식이 더 유명할 것 같아요.
개인적으로 사용법이 직관적이고 쉽다고 생각해요.
as prop을 구현하는 방법은 이미 많은 아티클에서 다루고 있기 때문에 자세히 다루지 않겠습니다.
대신 구현 방법을 잘 정리해 둔 아티클 몇 개를 추천하고 넘어갈게요.
위에서 추천한 아티클은 모두 비슷한(거의 동일한) 방식으로 as prop을 구현하고 있습니다.
잘 정리된 글을 따라 구현해보면... 타입 에러가 발생합니다 😇
왜 타입 에러가 발생하는지, 그리고 해결 방법에 대해 알아보겠습니다.
원인 분석
아티클을 따라 완성한 코드는 이렇습니다.
에러 메시지에서 확인할 수 있듯이 타입 에러는 ref의 타입 불일치로 인해 발생합니다.
근데, 하나도 아니고 무슨 아티클을 참고하든 동일한 에러가 발생합니다.
사람들은 왜 타입 에러가 발생하는 코드를 아티클로 작성한 걸까요?
에러가 발생하는 이유는 @types/react 18.3.5 버전부터 forwardRef의 타입이 변경되었기 때문이에요.
실제로 18.3.4 버전을 사용하면 에러 없이 잘 동작하는데, 18.3.5 이상 버전에서는 타입 에러가 발생하는 것을 확인할 수 있어요.
아티클은 18.3.5 버전 이전에 작성된 것 같습니다.
그럼 forwardRef의 타입은 어떻게 변경되었을까요?
ForwardRefRenderFunction의 두 번째 타입 파라미터가 P에서 PropsWithoutRef<P>로 변경되면서 문제가 발생했습니다. PropsWithoutRef는 뭐 하는 녀석이길래 에러를 발생시키는 걸까요?
타입 파라미터 P에 ref 프로퍼티가 있으면 제거하고, 없으면 그대로 유지하는 타입입니다. ref 프로퍼티를 props에서 명시적으로 제거하여 타입 안전성을 높이고자 한 것이죠.
그리고 이런 타입을 Distributive Conditional Type🔗, 조건부 타입의 분배 법칙이라고 합니다.
조건부 타입의 분배 법칙은 수학에서 배운 분배 법칙과 비슷하다고 생각할 수 있어요.
즉, 2 * (3 + 4) = (2 * 3) + (2 * 4) 요런 것으로 이해하면 됩니다.
타입에서 예시를 들어볼게요.
아래와 같은 조건부 타입이 있다고 가정해 봅시다.
이제 이걸 유니온 타입에 사용하면?
타입스크립트는 string | number를 각각 나눠서 처리하고, 이렇게 분배됩니다.
그리고 각각 계산됩니다.
요리할 때 소스 같은 걸 만들다 보면 이런 분배 법칙이 적용되는 경우가 있어요.
바질 갈고, 마늘 갈고, 후추 갈고, ... 각각 따로 갈아낸 후(분배) 갈았던 것들을 하나로 합쳐서(유니온) 완성하는 소스처럼 말이죠.
다시 위 컴포넌트 코드로 돌아와서 생각해 봅시다.
ElementType은 다음과 같습니다.
이게 평가되면 모~든 HTML 태그를 포함한 유니온 타입이 됩니다.
이걸 PropsWithoutRef에 넣으면 분배 법칙이 작동합니다.
근데 우리의 Props는 다음과 같습니다.
보다시피 우리의 Props는 C와 ref가 연결되어 있습니다.
하지만 Result는 C와 연결이 깨진 별개의 타입이 되었으므로 우리의 ref 타입과 호환되지 않습니다.
따라서 ref 타입 불일치 에러가 발생하는 것이죠.
해결, 그리고 또 다른 문제
범인을 찾았으니 이제 해결해 봅시다.
그냥 forwardRef의 타입 정의를 18.3.4 때로 오버로딩해버리면 타입 에러가 사라집니다 🤗
이게 뭐가 해결이냐고요?
어쨌든 잘 되잖아~ 한잔해~
이렇게 타입 오버로딩으로 해결할 수 있다면 좋겠지만...
한잔하고 나서 확인해 보면 타입 에러는 사라졌지만, 또 다른 문제가 발생합니다.
아니 왜 또...?
다시 원인을 분석해 봅시다.
우리가 구현한 컴포넌트는 간단하게 표현하자면 이런 형태입니다.
자, 위에서 신나게 살펴봤듯이 forwardRef는 제네릭 함수입니다.
제네릭이란 뭐죠?
바로 호출 시점에 파라미터의 타입을 결정하는 기능입니다.
따라서 우리가 만든 컴포넌트는 이렇게 추론됩니다.
forwardRef가 반환하는 결과인 Button은 더 이상 제네릭이 아니게 되는 거죠.
직접 지정하지 않아도 호출 시점에 인자 값을 보고 추론해서 어떤 특정 타입으로 결정해 버립니다.
따라서 우리가 만든 컴포넌트는 제네릭 타입이 아니었던 거죠.
해결 방법
방법은 몇 가지가 있습니다.
forwardRef를 사용하지 않는 방법
타입 어노테이션을 통해 제네릭으로 만들어주는 방법
Higher order type inference from generic functions
하나씩 살펴보겠습니다.
1. forwardRef를 사용하지 않는 방법
forwardRef를 사용하지 않으면 타입 에러가 발생하지 않습니다.
위에서 말했던 것처럼, 이 문제는 forwardRef를 사용해서 제네릭이 아니라 특정 타입으로 결정되는 것이 원인이기 때문이에요.
따라서 forwardRef를 사용하지 않으면 해결입니다.
해결이라고 볼 수 있을까요?😇
2. 타입 어노테이션을 통해 제네릭으로 만들어주는 방법
제네릭이 아니라 특정 타입으로 결정되는 것이 원인이기 때문에, 타입 어노테이션을 통해 다시 제네릭으로 만들어주면 해결됩니다.
간단하게 해결됩니다!👍
3. Higher order type inference from generic functions
인자 함수인 ForwardRefRenderFunction, 그리고 반환 함수인 ForwardRefExoticComponent 모두 순수한 함수 타입이 아닌데요,
둘 다 displayName, defaultProps, propTypes 속성을 갖고 있기 때문입니다.
아까 Higher order type inference from generic functions 기능을 사용하기 위해서는 인자 함수와 반환 함수가 모두 순수한 함수 타입이어야 한다고 했습니다.
그럼 그냥 이 둘을 순수한 함수 타입으로 만들어주면 되겠죠?
forwardRef 함수 타입을 오버로딩해서 인자 함수와 반환 함수에서 해당 필드들을 모두 빼고, 함수 시그니처만 남기겠습니다.
이제 Higher order type inference from generic functions 기능이 잘 동작해서 타입 추론이 정상적으로 이루어지는 것을 확인할 수 있습니다 👍
어떤 방법이 좋을까?
저는 회사에서 3번, forwardRef를 오버로딩해서 Higher order type inference from generic functions 기능을 사용하는 방식으로 해결했습니다.
왜냐하면, 1번은 일단 논외고... 2번은 타입이 고정되는 것이 다소 걱정스러웠어요.
아마 읽는 분들은 3번이 가장 별로라고 생각할 수도 있을 것 같은데요,
리액트의 타입을 마구 오버로딩하는 것이 좋은 방법은 아니라는 것에 동의합니다.
하지만! 이제 곧 forwardRef를 사용할 필요가 없어지기 때문에!
당분간 오버로딩으로 해결해두고 리액트 19가 나오면 forwardRef를 걷어내면 될 것 같다고 판단했어요.
선택은 여러분에게 달렸습니다 🤗
마치며
다형성 컴포넌트는 재사용성, 유연성, 가독성, 접근성 등 다양한 장점이 있습니다.
특히 디자인 시스템을 구축할 때 아주 유용하게 사용되는데요, 대신 구현할 때 제대로 공부해 두면 좋을 것 같습니다.
다형성 컴포넌트는 강력한 도구이지만, 단점도 존재하는 것이 사실입니다. 일단 구현 자체의 난이도가 있고, 알아본 것과 같이 타입 추론 문제도 있습니다.
저는 항상 최신 버전의 리액트 환경이 아닌 환경에서 개발했기 때문에, 항상 잘 되던 코드가 갑자기 안돼서 꽤 당황했습니다.
원인을 자세히 찾는 게 상당히 어려웠고, 과정에서 팀원들에게 많은 도움을 받았습니다.
(타입스크립트를 좀 더 공부해야 할 필요성을 느꼈습니다... 🥲)
이 트러블 슈팅 경험이 다형성 컴포넌트를 구현하려는 누군가에게 도움이 되었으면 좋겠습니다!😊