Compound Component에 대한 고찰
Compound Component에 대한 종합적인 고민거리를 적어봤다. 정답은없다 뭐든 적재적소에 사용하는게 중요하겠지.
얼마전 사내 디자인시스템을 재개발했다.. 기존 디자인시스템은 모놀리스인데 base를 두고 확장하는게아닌 base 자체가 커진 형태였다. 기능만 커지면 정상참작이 가능한데 일부 layout을 대응하기위해 필요한가 싶은 props도 늘어나고.. base가 분기처리도 많아지고 props도 커지고 점차 괴물이 되어갔다. 당연히 유지보수,dx 둘다 안좋아졌다.
하여 컴파운드 패턴으로 전면 재개발 하고싶은 생각이 마구 들때쯤 개인적으로 flame/ui 라이브러리를 만들었다. 헤드리스를 추구해 컴파운드 구조로 개발하면서 고민한 지점들을 적어보려고 한다.
불필요하게 늘어나는 사이즈
컴파운드 컴포넌트는 유연함이라는 확실한 장점이 있는데 반해 단점도 확실하다. 기본적으로 작성하기에 양이많다. 물론 모놀리스도 확장에 열려있는 형태로 사용하지않는다면 props가 비대해지고 컴포넌트가 많은 책임을 들고있어 마찬가지로 안좋아진다.
하지만 컴파운드는 그런개념이 아니고 기본적으로 양이 많다. 하여 작은 수정인데도 꽤나 귀찮아지기도 한다.. 아래는 shadcn/ui의 Select컴포넌트다.
<Select items={items}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
네이티브 select를 생각해보면 사이즈가 상당하다. 물론 네이티브는 자유로운 스타일링에 제약이있는 등 이유가 있다고는 하지만 비단 Select만의 문제라기보단 Compound라면 전부 가지고있는 문제라고 본다.
그리고 각 컴파운드마다 핵심이 되어 사용하지않으면 의미가없어지는 서브-컴포넌트들이 있기때문에 작성하다보면 어쩔땐 boiler-plate처럼 느껴진다.
하여 새 디자인 시스템에는 base는 compound로 잡고 합친형태를 따로 제공해서 ui상 큰 유연함이 필요없을땐 합친형태를 사용할 수 있게 양방향모두 제공했다. ex) modal-compound, modal
// 1) 평소에는 props로 간단하게.
<Modal title="알림" content="저장되었습니다." onConfirm={handleSave} />
// 2) 유연하지만 일종의 boiler-plate가 생겨버림
<Modal>
<Modal.Header>
{header}
</Modal.Header>
<Modal.Body>
{children}
</Modal.Body>
</Modal>
props 위치에 대한 고민
아래는 flame/ui의 drawer 컴포넌트 예시다
<Drawer key={placement} placement={placement}>
<Drawer.Trigger>
<button type="button">trigger</button>
</Drawer.Trigger>
<Drawer.Content>
<div className="p-6">
<h2>{t.title}</h2>
<p>{t.description}</p>
<Drawer.Closer>
<button type="button">{t.close}</button>
</Drawer.Closer>
</div>
</Drawer.Content>
</Drawer>
trigger, closer는 기본적으로 내부에 열고닫는 함수가 내장되어있다. 다만 onClose, onOpen을 받아서 추가동작을 삽입할수있는 기능도 제공한다. 여기서 두가지 선택을 고민했다.
- 실직적으로 함수를 호출하는
Closer의 props로 받게한다. root에서 받아context로 모든 props를 공유해서 각 서브-컴포넌트가 활용할 수 있는 구조로한다.
결국 사용자가 props를 주입할때 특정위치를 기억하게 맞나? 라는 고민에서 root에 몰기로 결정했다. 근데 또 생각해보면 다른 라이브러리를 사용했을때 서브-컴포넌트가 받는경우는 거의 없었던 것 같기도 하다.
느슨해지는 type
아래는 flame/ui의 dialog 예시다.
<Dialog>
<Dialog.Trigger>
<button type="button">trigger</button>
</Dialog.Trigger>
<Dialog.Content> {/* 없어도 동작됨.. 그러나? */}
<div className="p-6">
<h2>Dialog</h2>
<p>Dialog content</p>
<Dialog.Closer>
<button type="button">closer</button>
</Dialog.Closer>
</div>
<Dialog.Closer><button type="button">close</button></Dialog.Closer>
</Dialog.Content>
</Dialog>
사실상 Content 서브모듈이 없다면 이건 trigger 텍스트만 존재하는 버튼의 역할밖에 못한다. 그래서 type으로 Content 서브모듈을 강제하고싶지만.. 아래를보자
type TriggerChildren = ReactElement<TriggerProps, typeof Trigger>
type ContentChildren = ReactElement<ContentProps, typeof Content>
type DialogChildren = [TriggerChildren, ContentChildren];
// ✅
<Dialog>
<Dialog.Trigger />
<Dialog.Content />
</Dialog>
// 🚫
<Dialog>
<Dialog.Content />
{isOpen && <Dialog.Content />}
<Dialog.Trigger />
</Dialog>
벌써 어지럽다.. compound의 장점인 유연함이 저멀리 사라진다. 저 타입을 통과하려면 정말 순수하게 Trigger, Content만 순서지켜서 존재햐아한다, conditional-render라던지 map을통해 동적으로 컴포넌트를 생성하는등 모~든 케이스에서 막힌다.
회사라면 사용하는사람끼리 맞출수도 있고 docs에 명시하면 막 문제되진 않을테니까. 다만 컴포넌트의 역할을 하기위해 반드시 있어야하는 props를 강제하지 못하는게 아쉬운건 사실이다.
구조에 대한 고민
어떤 단위로 서브-컴포넌트를 찢을까, 어떻게 모듈 구성을 가져갈까 에 대한 고민을 했다. flame/ui를 개발할땐 초기에 서브-컴포넌트마다 모듈을 전부 별도로 두어 분리했었다. 왜? 당연히 별도 컴포넌트라고 생각했으니까.
근데 개발할때 불편해서 고민좀해보니 서브-컴포넌트는 root가없이 동작할수 없는 root와 강한 응집도를 가진 녀석인데 별도모듈로 존재하니 당연할수밖에 없었다.. 그래서 root에 몰아줬다.
어떤사람은 당연한거아니야? 라고 할수있겠지만 안해도될 고민을 한거같아서 적어본다..
마무리
그럼 컴파운드가 별로냐? 그건 아니다. 서비스를 운영하다보면 ui대응시 애매한 상황을 겪는다. 아이거 컴포넌트가 있긴한데 이 레이아웃에 대응을못하네? 해서 하나쯤이야 라는 마음으로 요상한 slot props가 생기고. 점점 컴포넌트가 괴상해진 결과를 많이봤다.
이런 상황에서 유연한 대응을 하기위해 너무좋은 선택지라고 생각한다.
다만 모놀리스가 주는 장점을 버리기도 싫다.. 아니 얘는 컴파운드네? 아니얘는 왜 props API로 되어있어? 통일되어있어야지! 그래야 유지보수가 좋은거아니야?! 라고 생각할 수도 있다.. 막말로 둘다 사용가능한형태로 만드는것도 오버 엔지니어링 아니야? 할수도 있고.. 정답은 나도 모르겠다.. 다만 지금은 디자인 시스템이라면 둘다 제공하는게 좋다고 결론지었다 🥲