최근에 반복적으로 사용하는 컴포넌트, 리액트 훅, 유틸을 모아놓기 위한 모노레포를 만들고 있습니다. 이를 통해 나만의 slash 라이브러리를 만들어보자는 계획을 세웠는데요. 디자인 시스템을 설계하면서 알게 된 내용을 포스팅하고자 합니다.
먼저 디자인 시스템이 무엇일까요?
디자인 시스템
디자인 원칙과 규격, 재사용 가능한 UI패턴과 컴포넌트, 코드를 포괄하는 시스템
디자인 시스템은 일관된 원칙과, 재사용 가능한 패턴을 통해 디자인 개발 작업의 신속화, 팀 내부의 커뮤니케이션 리소스 감소, 제품 간 시각적 일관성을 제공합니다.
디자인 시스템을 구현하면서 재사용 가능한 UI 패턴과 컴포넌트를 만드는 방법 중 한 가지를 소개하고자 합니다.
소개하는 과정에서 스타일과 관련된 부가적인 코드는 제외하고 작성하겠습니다.
해결하고 싶은 문제
컴포넌트 설계 전에 아주 간단한 버튼 컴포넌트를 예시로 들고자 합니다.
지금 보이는 UI는 아주 간단한 버튼 컴포넌트 입니다.
해당 컴포넌트는 Props로 넘기는 값을 button 태그의 속성으로 전부 넘겨주기 때문에 확장성 있는 컴포넌트라고 생각할 수 있습니다. 하지만 여기서 문제가 발생합니다.
만약 해당 버튼 UI를 사용하면서 클릭 시 다른 링크로 이동하고 싶다면 어떻게 해야 할까요?
아마 과거의 저라면 이런 식으로 a 태그를 사용하는 새로운 컴포넌트를 만들 것 같습니다.
하지만 이러한 방식은 여러 가지 문제점이 존재합니다.
- n 가지의 경우에 대해 n 가지의 컴포넌트를 만들어야 합니다.
- 내부 컴포넌트가 확장에 유연하지 않습니다.
- 서드 파티 라이브러리에서 제공하는 컴포넌트를 위해 새로운 Button 컴포넌트를 만들게 된다면 코드가 라이브러리에 의존하게 됩니다. (react-router, Next.js)
이를 위해서 styled-component, MUI, Mantine, Radix와 같은 라이브러리들은 강력한 리액트의 합성을 활용해 위와 같은 문제를 해결하고 있습니다.
Polymorphic Component
제가 참고한 블로그에서는 Polymorphic 한 UI 컴포넌트를 다음과 같이 설명하고 있습니다.
- 다양한 Semantic을 표현할 수 있는 UI 컴포넌트
- 다양한 속성을 가질 수 있는 UI 컴포넌트
- 다양한 스타일을 가질 수 있는 UI 컴포넌트
즉 Polymorphic 컴포넌트는 컴포넌트 내부에서 Element를 사용하지 않고 사용처에서 직접 Element를 주입받아 사용할 수 있습니다. 이를 통해 상황에 맞는 Semantic 태그를 사용할 수 있고, a 태그처럼 특수한 용도로 사용되는 컴포넌트가 될 수 있습니다.
구현된 Polymorphic Component
첫 번째 버튼 컴포넌트는 버튼 용도 자체로 사용되고 있고 두 번째 버튼 컴포넌트는 사용처에서 as prop을 통해 a 태그를 주입받아 a 태그로 사용되고 있음을 확인할 수 있습니다.
이때 두 버튼의 UI는 동일하다는 점에 주목해 주세요. 더 이상 같은 디자인의 컴포넌트를 재 정의할 필요는 없습니다. 사용처에서 직접 필요한 타입의 컴포넌트나 Element를 주입해 사용할 수 있습니다.
또한 TypeScript를 통해 구현한 Polymorphic Component는 IntelliSense 기능을 통한 자동 완성과 타입을 통해 잘못된 값을 주입하는 문제를 해결할 수 있습니다.
구현
완성된 코드는 다음과 같습니다. 핵심 코드는 props로 주입받은 as를 Element 변수에 할당해 렌더링 하는 것입니다.
타입은 다음과 같이 선언할 수 있습니다. 한 개씩 살펴보겠습니다.
- as : 제네릭을 통해 ElementType을 받아올 수 있습니다.
- Props : 사용자가 정의한 컴포넌트의 Props 타입을 받아와 타입으로 지정합니다.
- React.ComponentPropsWithoutRef : 컴포넌트의 Props 타입에서 사용자가 정의한 Props와 일치하는 항목을 제외한 Props을 타입으로 지정합니다.
- ref : 리액트의 ref 타입을 받아옵니다.
느낀 점
Polymorphic Component를 통해 만들어진 컴포넌트는 재사용성이 높습니다. 더 이상 특정 기능을 위해 필요로 하는 컴포넌트를 추가적으로 구현할 필요 없이 사용할 때 Polymorphic Component에 주입해 주면 됩니다.
하지만 TypeScript에서 Polymorphic Component를 구현하기 위한 복잡도가 상당히 높은 편이고, as 속성으로 확장한 컴포넌트에 Props를 할당할 때, 해당 Props가 어떤 컴포넌트의 것인지 알기 힘들다는 문제가 있습니다. (현재 코드에서 Polymorphic Component에 Polymorphic Component를 주입하면 제네릭에 의해 타입을 추론할 수 없습니다.)
이에 대한 대안으로 등장한 방법이 있습니다.
Render Delegation
Render Delegation을 직역하자면 렌더 위임이라고 할 수 있습니다. 이는 자신의 속성과 행동을 자식 컴포넌트에게 넘긴 후 자식이 직접 부모 컴포넌트를 대신해 렌더링 하는 방법입니다.
Render Delegation 기능을 제공하는 라이브러리는 보통 asChild 속성을 통해 해당 기능을 제공합니다.
Render Delegation 컴포넌트는 보통 Slot과 Slottable이라는 두 컴포넌트를 통해 구성됩니다. Slot은 자식 컴포넌트에게 렌더링을 위임하고 Props를 넘겨주는 역할을 하고, Slottable은 Slot에 들어갈 요소를 결정하는 역할을 합니다.
Render Delegation 컴포넌트는 기존의 Polymorphic Component와 해결하려는 목적은 같지만 기존 컴포넌트와 합성이 될 컴포넌트를 코드에서 분리한다는 차이가 있습니다.
구현된 Render Delegation
아까와 같은 컴포넌트를 가지고 왔습니다. 코드를 한번 보시죠
Polymorphic Component와 유사하지만 다른 점은 Button 컴포넌트에서는 asChild를 통해 자식에게 렌더링을 위임하고 있고 추가적인 사항은 자식 컴포넌트에서 선언하는 것을 확인할 수 있습니다. 실제로 렌더링 되는 tag가 자식인 a 태그인 것을 주목해 주세요.
Slot & Slottable
Render Delegation 컴포넌트를 구현하는 방법은 간단합니다. 앞선 Polymorphic Component 예시에서 Slot을 사용하면 됩니다.
(Radix에서 제공하는 Slot을 직접 사용하면 즉시 만들 수 있습니다.)
Slot을 이용해 모든 요소를 위임했다면 Slottable을 사용하면 사용자가 정의한 일부분만 위임할 수 있습니다.
Slottable 컴포넌트는 Slot 컴포넌트로 렌더링 될 컴포넌트의 children이 들어갈 곳을 결정할 수 있습니다.
결과적으로 icon 속성은 Button 컴포넌트의 설정을 따르지만 그 외 속성은 자식 컴포넌트에 위임됩니다.
구현
Slottable 컴포넌트는 구현이 매우 간단합니다.
Slottable 컴포넌트는 자신을 Slottable 컴포넌트라는 것을 알려주는 것 이상의 기능을 하지 않습니다.
Slot 컴포넌트의 구현은 다음과 같습니다.
Slot 컴포넌트의 핵심은 다음과 같습니다.
Slot 컴포넌트는 children 요소를 validation 한 뒤 children이 JSX 요소라면 Slot 컴포넌트의 Props와 children을 합성하여 새로운 컴포넌트를 만들어 렌더링 합니다.
느낀 점
Render Delegation 컴포넌트는 Polymorphic 컴포넌트와 비교했을 때 더욱 직관적으로 Props를 이해할 수 있습니다. 또한 Polymorphic 컴포넌트보다 타입 선언에 있어서 간편합니다.
하지만 Render Delegation 컴포넌트는 구현에 있어서 Polymorphic 컴포넌트보다 어렵고 JSX 구문이 길어질 수 있으며 JSX를 보고 이후 렌더링 될 돔을 직관적으로 이해하지 못할 수 있습니다.
그래서?
저는 두 가지 강력한 합성 패턴을 구현해 보면서 저의 디자인 시스템에 두 가지를 동시에 적용하고 있습니다.
asChild이 ture 면 Render Delegation을 사용해 자식 요소를 렌더링하고 asChild이 false면서 as에 tag가 주입되면 tag를 렌더링 합니다.
이를 통해 간단한 HTML Tag를 주입하거나 단순한 컴포넌트를 주입할 때는 Polymorphic을 이용하고, 다형성 컴포넌트의 중첩을 사용해야 하거나 복잡한 Props를 전달하는 컴포넌트를 사용할 때는 Render Delegation를 사용할 계획입니다.
두 가지 패턴을 통해 저의 디자인 시스템 컴포넌트들은 더 이상 추가적인 기능을 위해 서드 파티 라이브러리에 의존하지 않고 사용처에서 주입하는 방법을 통해 재 사용성 높은 컴포넌트가 되었습니다.
아직은 충분히 사용해 보지 못했기 때문에 이후 리팩터링이 필요하겠지만 충분히 만족스러운 코드가 된 것 같습니다. 구현한 디자인 시스템 컴포넌트들은 레포지토리에서 확인할 수 있습니다.