이번 포스팅에서는...
- radix UI 라이브러리를 소개하고
- compound component 아키텍처에 대해 정리합니다.
- headless UI에 대해 알아봅니다
잦은 변경사항이 생기는 디자인과 기획에 유연하게 대응할 수 있는 컴포넌트는 어떻게 설계해야 할까요?
이에 대한 좋은 해결책 중 하나가 되어줄 Compound component 패턴에 대해알게 되어 정리해보려 합니다.
아래 예시는 Dialog 컴포넌트의 UI 및 기능을 필요에 따라 사용할 수 있도록 props를 통해 커스터마이징해준 일반적인 방식의 컴포넌트 구조입니다.
예시 출처: Kakao Ent 기술블로그 - 합성 컴포넌트로 재사용성 극대화하기
interface Props {
isOpen: boolean;
title: string;
buttonLabel: string;
onClickButton: (e: MouseEvent) => void;
isChecked?: boolean;
checkBoxLabel?: string;
onClickCheckBox? : (e: MouseEvent) => void;
descriptionList?: string[]
}
function Dialog({
isOpen,
title,
buttonLabel,
onClickButton,
isChecked,
checkBoxLabel,
onClickCheckBox,
descriptionList
}: Props){
if (!isOpen){
return null;
}
return React.createPortal(
<div>
<span>{title}</span>
{descriptionList && descriptionList.map(desc => <span key={desc}>{desc}</span>)}
{checkBoxLabel && <div>
<input checked={isChecked} onClick={onClickCheckBox} type="checkbox" id="dialog_checkbox">
<label for="dialog_checkbox">{checkBoxLabel}</label>
</div>}
<button onClick={onClickButton}>{buttonLabel}</button>
</div>
,document.body)
}
이러한 방식은 다음과 같은 한계가 있습니다.
이미 이 코드에서는 다음과 같은 변경사항에 대응하기 위해 props를 추가했습니다.
그리고 이미 해당 컴포넌트에 전달되고 있는 props의 갯수는 8개.
앞으로 이 dialog 컴포넌트가 더 많은 UI를 조건에 따라 표시해야 한다면 컴포넌트는 더욱 복잡해집니다.
더 복잡한 상황을 생각해보겠습니다. 이미 만든 descriptionList를 표시해주는 건 똑같은데... 이미 정의한 위치가 아닌 다른 곳, 이를테면 상단이 아닌 하단에 표시해주어야 하는 경우가 발생한다면 어떨까요?
이에 대한 조건을 또 props로 추가해주어야 합니다.
결과적으로 가독성 뿐 아니라 유지보수성이 하락하게 되고, 경우에 따라 컴포넌트가 분기처리된다면 재사용성 또한 떨어지게 됩니다.
실무에서 이러한 문제를 빈번히 마주치게 되었고, 어떻게 개선해야 할지에 대한 고민을 해오고 있었습니다.
Compound components is a pattern where components are used together such that they share an implicit state that lets them communicate with each other in the background.
Compound component 패턴에서는 여러 개의 작은 컴포넌트들이 각각의 역할을 분담합니다. 그리고 이 작은 컴포넌트들을 조립해 하나의 큰 컴포넌트를 만들어낼 수 있습니다. 이 때 하위 컴포넌트들이 상위 컴포넌트 내부의 상태를 공유하면서 비지니스 로직과 사용자 인터페이스와 관련된 부분을 구분하게 됩니다.
materail UI, Reach UI등 많은 UI라이브러리가 Compound component 패턴을 따르고 있습니다.
컴포넌트 사용 시 개발자가 필요로 하는 하위 컴포넌트만 합성해 사용할 수 있으므로 보다 자율성이 높아집니다.
하나의 컴포넌트 안에 많은 Props를 한번에 전달하지 않고 서브 컴포넌트에 적절히 분배해 관심사를 분리할 수 있습니다.
UI에 대한 자유도가 높다는 것은 그만큼 변화에 유연하게 대응할 수 있다는 의미이기도 하지만, 그만큼 의도대로 조립해 사용할 수 있도록 고려해야하기 때문에 설계 상의 복잡도가 올라가거나 코드의 길이가 증가할 수 있다는 것을 의미합니다. 따라서 필요에 따라 적절한 선택이 중요합니다.
위에서 언급했던 문제를 Compound component를 적용했을 때 어떻게 개선할 수 있을까요?
아래와 같이 여러 체크박스가 있고 각각의 체크박스에 대한 UI를 다르게 표시해야할 때를 가정해 보겠습니다.
이 경우 아래와 같이 mapping할 요소에 대한 데이터 배열을 props로 넘겨주는 등의 방식으로 코드를 작성하게 됩니다.
<Dialog
dimmed
title="타이틀"
checkBoxList={[
{
title: '버튼명',
isChecked: true,
hasArrowButton: true,
},
//...
{
title: '버튼명',
isChecked: false,
hasArrowButton: true,
},
]}
labelButtonList={[
{
title: '버튼레이블',
}
]}
/>
반면 Compound component 패턴으로 설계할 경우 다음과 같이 작성할 수 있습니다.
만약 동일한 UI에 특정 요소가 추가되거나 위치가 변경되어야 한다면, 하위 컴포넌트만 추가하거나 위치를 변경하면 되기 때문에 변화에 유연하게 대응할 수 있습니다.
<Dialog>
<Dialog.Dimmed />
<Dialog.Title>타이틀</Dialog.Title>
<Dialog.CheckBox isChecked hasArrowButton>
버튼명
</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
<Dialog.LabelButton>버튼레이블</Dialog.LabelButton>
</Dialog>
하지만 이런 Compound component 방식으로 모든 컴포넌트를 처음부터 재사용성이 높게 설계하는 것은 시간과 비용이 많이 드는 일입니다. 실무에서 이러한 구조로 컴포넌트를 빠르게 만들기 위해서는 어떻게 할 수 있을까요?
이러한 Compound component로 설계되었으면서 기능적인 컴포넌트를 지원하는 Headless UI library를 사용할 수 있습니다.
스타일 없이 로직만 존재하는 UI를 Headless UI라고 합니다.
유지보수하기 쉽고 UI 변경에 유연하게 대응하기 위한 목적으로 설게되었습니다.
유지보수하기 쉽고 UI 변경에 유연하게 대응
하기 위해서는 다음과 같은 기준에 부합해야 합니다.
1) 해당 컴포넌트의 역할을 알아야 하며,
2) 외부와 내부에 두어야 할 것을 완전히 분리해야 한다.
3) 외부가 변경되었다 하더라도 내부 컴포넌트가 영향을 받아서도 안되고,
내부가 수정되었다 하더라도 외부가 변경되어서도 안된다.
Headless UI library는 UI까지 결합되어 있는 Component UI library와 다르게 마크업과 스타일을 개발자가 모두 제어가능합니다. 또한 번들 사이즈가 작다는 것도 장점입니다.
만약 디자인이 중요하지 않고 빠르게 UI를 구성해야하거나 작은 부분만 커스텀해서 사용한다면 Component UI library를 사용하는게 좋겠지만,
디자인과 기능에 대한 변경 및 추가가 많이 발생한다면 Headless UI library가 좋은 선택지가 될 수 있습니다.
Headless UI library 중 하나인 radix-ui 역시 이 Compound component패턴을 따르고 있는데, 이 라이브러리로 어떻게 모달을 구현할 수 있을까요?
해당 자료를 참고해 직접 구현해보았습니다.
Youtube - Reusable Modals with Radix UI
import React, { ReactNode } from "react";
import * as Dialog from "@radix-ui/react-dialog";
interface Props {
open: boolean;
onOpenChange: () => void;
children: ReactNode;
}
function Modal({ open, onOpenChange, children }: Props) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
{children}
</Dialog.Root>
);
}
function ModalContent({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<Dialog.Portal>
<Dialog.Overlay/>
<Dialog.Content>
<div>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Close>
close
</Dialog.Close>
</div>
{children}
</Dialog.Content>
</Dialog.Portal>
);
}
Modal.Button = Dialog.Trigger;
Modal.Close = Dialog.Close;
Modal.Content = ModalContent;
export default Modal;
아래와 같이 사용할 수 있습니다.
import React, { useState } from "react";
import Form from "../components/Form";
import Modal from "../components/Modal";
function Page() {
const [isOpen, setIsOpen] = useState(false);
return (
<Modal open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<Modal.Button>
Close
</Modal.Button>
<Modal.Content title="Edit contact">
<Form afterSave={() => setIsOpen(false)} />
</Modal.Content>
</Modal>
);
}
export default Page;
위 예시는 모달안에 Close button과 그 하위에 특정 컨텐츠를 포함한 컴포넌트를 렌더링합니다.
만약 동일한 컨텐츠를 보여주되 Close button 대신 하단에 Submit, Cancel 버튼을 갖는 모달이 필요한 곳이 있다면 아래와 같이 작성하면 됩니다.
<Modal open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<Modal.Content title="Edit contact">
<Form afterSave={() => setIsOpen(false)} />
</Modal.Content>
<Modal.Button>
Submit
</Modal.Button>
<Modal.Button>
Cancel
</Modal.Button>
</Modal>
또한 컨텐츠가 바뀌어야 한다면 이 역시 서브 컴포넌트들을 조립해 만들면 됩니다.