루프팩 4주차 과제는 두 개였다. 하나는 생김새가 제각각인 select 3종(묶음가격·사이즈·썸네일)을 로직 한 벌로 커버하는 것, 다른 하나는 Dialog를 compound로 조립하는 것. 표면 요구는 딴판인데, 만들다 보니 두 번 다 같은 벽에 부딪혔다.
재사용하려고 공통 컴포넌트로 묶으면 셋이 똑같이 생겨버리고, 자유도를 주려고 열어두면 상태·접근성·이벤트 배선 같은 기능이 사용처로 새어나간다. 결국 매번 같은 질문 하나였다.
무엇을 컴포넌트가 쥐고, 무엇을 사용처에 넘길 것인가.
headless(Select)와 compound(Dialog)는 이 경계를 서로 다른 축에서 긋는 방식이었다. 그 두 축을 나란히 두고 보니 "재사용성 대 자유도"가 상충이 아니라 경계 긋기 문제라는 게 보였다.
위임의 절반짜리 함정 — render prop은 태그를 못 놓는다
Select를 처음 설계할 때 자연스러운 첫 안은 render prop이었다. renderValue(selected), renderOption(option)을 받아 사용처가 내용을 채우는 방식.
그런데 이러면 위임이 절반만 된다. renderOption이 채우는 건 <li> 안쪽 내용뿐이고, "이 자리는 li고 저 자리는 button이다"라는 태그·레이아웃 결정권은 여전히 Select가 쥔다. 요구사항은 "생김새는 사용처가"인데, 태그를 못 놓으면 생김새의 절반은 아직 컴포넌트 것이다.
그래서 render prop 대신 슬롯 컴포넌트 주입을 택했다. Select는 Trigger/Option을 컴포넌트 타입으로 받아 상태만 계약된 props로 내려주고, 태그가 무엇인지는 전혀 모른다. 의존성 역전(DIP) — 고수준(언제 열리고 무엇이 선택됐는지)이 저수준(구체 마크업)이 아니라 공통 props 계약에만 의존하게 만드는 것이다.
export interface OptionSlotProps<T> {
option: T;
selected: boolean;
highlighted: boolean;
disabled: boolean;
onSelect: () => void;
onHighlight: () => void; // 마우스 진입 알림 — 사용처가 onMouseEnter에 건다
}
interface SelectProps<T> {
options: T[];
onChange?: (option: T) => void;
isOptionDisabled?: (option: T) => boolean;
Trigger: ComponentType<TriggerSlotProps<T>>;
Option: ComponentType<OptionSlotProps<T>>;
}
Select가 실제로 그리는 전부는 이 뼈대뿐이다. 마크업은 한 줄도 없다.
<Root isOpen={isOpen}>
<Trigger selected={selected} isOpen={isOpen} onToggle={toggle} onKeyDown={handleKeyDown} />
{isOpen && (
<List>
{options.map((option, index) => (
<Option
key={index}
option={option}
selected={option === selected}
highlighted={option === highlighted}
disabled={isOptionDisabled(option)}
onSelect={() => handleChange(option)}
onHighlight={() => highlight(option)}
/>
))}
</List>
)}
</Root>
Select가 쥔 것 vs 놓은 것
경계를 표로 정리하면 이렇게 갈렸다.
| 소유 (구조·행동) | 위임 (디자인) |
|---|---|
useSelect 호출, 상태 → 슬롯 props 매핑 | Trigger 내부 마크업(버튼/뱃지/화살표) 전부 |
options.map 순회, isOpen일 때만 리스트 렌더 | Option 내부 마크업(레이아웃/색상/강조) 전부 |
<ul role="listbox"> 한 겹 — a11y 계약이라 구조로 간주 | selected/highlighted/disabled를 어떻게 보여줄지 |
3종 UI는 Select 코드를 한 줄도 바꾸지 않고 Trigger/Option 구현체만 갈아끼운다. "언제 열리고 무엇이 선택됐는가"(변하지 않는 것)는 쥐고, "그걸 어떻게 그리는가"(사용처마다 다른 것)는 놓는다.
컨테이너엔 콘텐츠가 없다 — 그래서 슬롯이 아니라 통로
여기서 한 번 더 갈래가 생겼다. 트리거·옵션은 넘겼는데, Select가 직접 쥔 바깥 <div>와 드롭다운 <ul>은 스타일을 넣을 통로가 없었다. 3종은 컨테이너 테두리·라운드·패널 높이가 다 다른데, 지금 구조로는 <section style={...}><Select/></section>처럼 바깥을 한 겹 더 감싸 우회할 수밖에 없었다.
컨테이너·리스트도 트리거처럼 컴포넌트 슬롯으로 만들까 고민했는데, 성격이 달랐다. 트리거·옵션은 뱃지·이미지·가격 같은 데이터 기반 마크업을 그리지만, 컨테이너·리스트는 렌더할 콘텐츠 없이 테두리·라운드·배경 같은 겉모습만 갈린다. 스타일 하나 바꾸려고 매번 래퍼 컴포넌트를 정의하게 만드는 건 과잉이다.
그래서 콘텐츠가 필요한 곳엔 컴포넌트, 겉모습만 필요한 곳엔 className/style 패스스루로 메커니즘을 요소 성격에 맞췄다. 기본 SelectRoot/SelectList는 접근성 속성(role, data-open)만 쥐고 스타일은 그대로 흘려보낸다.
// role="listbox"·data-open은 기본 구현이 소유, className/style은 통과시킨다
export function SelectRoot({ isOpen, className, style, children }: RootSlotProps) {
return (
<div className={className} style={style} data-open={isOpen}>
{children}
</div>
);
}
사용처는 이 기본 컴포넌트를 부분 적용해 자기 스타일만 얹는다. 래퍼 한 겹을 새로 정의하는 게 아니라 한 줄이면 된다.
// BundleSelect.tsx — 프리미티브에 자기 CSS 모듈만 주입
const Root = (props: RootSlotProps) => <SelectRoot {...props} className={styles.box} />;
const List = (props: ListSlotProps) => <SelectList {...props} className={styles.list} />;
열림 상태는 data-open으로 흘려보내, 사용처가 [data-open='true'] { border-color: … }처럼 JS 분기 없이 CSS만으로 컨테이너 겉모습을 바꿀 수 있게 했다. 상태는 이미 셸이 아니 속성으로 내보내기만 하면 됐다.
검증이 숨은 결합을 찾아냈다
슬롯을 다 나눈 뒤, "이 셋이 정말 독립인가"를 확인하려고 앞으로 들어올 법한 요구 3개를 각각 어디를 고쳐야 하는지 추론해봤다.
| 가상 요구 | 기대 수정 위치 | 실제 |
|---|---|---|
| 썸네일 옵션 디자인 변경 | ThumbnailOptionRow만 | 소비처 슬롯 소유 → OK |
| 사이즈 트리거 placeholder 변경 | SizeTrigger만 | 소비처 슬롯 소유 → OK |
| 번들 highlight 색만 변경 | BundleOptionRow만이어야 | 공유 헬퍼 때문에 3종이 같이 바뀜 |
세 옵션 행이 공유 optionRowStyle(index, highlighted, disabled)를 함께 썼고, highlight 배경색이 그 안에 박혀 있었다. "한 곳 고치면 나머지도 바뀐다"는 결합이 남아 있었던 것이다. headless의 전제(사용처는 서로 모르는 채 제각기 그린다)와 정면으로 어긋난다.
그래서 값이 같아도 select별로 쪼갰다. optionRowStyle → bundleRowStyle / sizeRowStyle / thumbnailRowStyle, 공유 StyledList → 각자의 List. 중복 3벌은 실수가 아니라 의도된 독립 비용이다. DRY의 이점은 "셋이 항상 같아야 한다"는 전제에서만 유효한데, headless의 전제는 그 반대다. 값이 우연히 같아도 공유는 곧 결합이고, 나중에 하나만 바꿀 때마다 마찰이 된다. 분리 후엔 "번들 highlight만" = 한 곳 수정, 나머지 영향 0이 됐다.
compound는 축이 다르다 — 구조를 통째로 넘긴다
Dialog로 넘어오니 같은 목표(생김새 위임)를 다른 축으로 풀게 됐다. headless가 행동과 뷰를 가른다면, compound는 구조 조립을 통째로 사용처에 넘긴다. <Dialog><Dialog.Title/><Dialog.Content/>…</Dialog>처럼 소비처가 서브컴포넌트를 JSX 자식 위치로 직접 배치·생략한다.
여기서 스타일 자유도를 가른 결정은 Portal 구조였다. 초안은 Content를 Overlay의 자식으로 두고 Overlay 하나만 Portal에 태웠는데, 그러면 Content가 Overlay의 스태킹 컨텍스트·opacity를 상속해 두 컴포넌트 스타일이 결합됐다. 그래서 둘을 형제로 바꿔 각자 Portal로 독립 렌더했다.
// Overlay와 Content가 각자 document.body로 — 스타일이 서로 독립
function DialogOverlay() {
const { open, setOpen } = useDialogContext();
// …스크롤 잠금 effect…
if (!open) return null;
return createPortal(<div className={styles.overlay} onClick={() => setOpen(false)} />, document.body);
}
function DialogContent({ children }: { children?: React.ReactNode }) {
const { open } = useDialogContext();
// …ESC 닫기 effect…
if (!open) return null;
return createPortal(<div className={styles.content}>{children}</div>, document.body);
}
형제가 되니 Overlay에 배경 페이드를 걸어도 Content가 함께 흐려지지 않고, 두 컴포넌트가 서로의 DOM 구조를 몰라도 됐다. 덤으로 초안에 있던 stopPropagation이 죽은 코드가 됐다 — 형제 구조에선 Content 클릭이 Overlay를 거쳐 버블링될 경로 자체가 없어, 막을 게 없어졌기 때문이다. 아무것도 막지 않는 방어 코드는 지웠다.
여기서도 같은 분리 — 소유권과 알림
Dialog의 상태 소유에서 Select와 판박이인 결정이 다시 나왔다. 처음엔 useState로 열림/닫힘을 전부 Dialog가 소유하는 uncontrolled만 다뤘는데, "폼에 저장 안 한 변경이 있으면 닫기를 부모가 거부"하거나 "Trigger 없이 API 에러 시 코드로 연다" 같은 요구는 Dialog 스스로 판단할 수 없는 조건이라 상태를 부모가 쥐어야 했다. 그래서 controlled를 더했다.
function Dialog({ children, open: openProp, onOpenChange }: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = openProp !== undefined; // open={false}도 유효한 닫힘 → undefined로 판별
const open = isControlled ? openProp : uncontrolledOpen; // 파생값, effect로 복사 안 함
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next); // 두 모드 공통 — 소유하지 않아도 알림은 보낸다
};
return <DialogContext.Provider value={{ open, setOpen }}>{children}</DialogContext.Provider>;
}
핵심은 소유권과 알림을 나눈 것이다. open prop은 상태의 소유권을 부모에게 준다. onOpenChange는 소유하지 않아도 바뀌는 시점을 통지한다. Select에서 value(소유권)를 걷어내면서도 onChange(알림)는 남긴 것과 정확히 같은 논리다. controlled가 필요한 조건은 "외부에서 제어하고 싶다"는 선호가 아니라 "컴포넌트 스스로 판단할 수 없는 조건으로 상태가 결정된다"는 사실이었다.
경계를 한 장으로
두 구현을 나란히 두면, 같은 원칙이 다른 축에서 반복된 그림이 남는다.
| 쥔 것 (기능) | 놓은 것 (생김새) | 자유도를 주는 축 | |
|---|---|---|---|
| Select (headless) | 상태(useSelect)·옵션 순회·role="listbox" | 트리거·옵션 태그와 스타일 전부 | 행동 ↔ 뷰 분리 |
| Dialog (compound) | 상태·ESC·스크롤 잠금·Portal 렌더 | 서브컴포넌트 배치·마크업·스타일 | 구조 조립 위임 |
여기서 매번 통했던 판단 규칙 두 개:
- 데이터 기반 마크업(뱃지·이미지·서브컴포넌트)이 필요하면 컴포넌트 슬롯으로 넘기고, 겉모습만 다르면
className·data-*로 넘긴다. - 상태는 소유권(주입) 과 알림(통지) 을 분리한다. 소유하지 않아도 통지는 보낸다.
검증은 도구와 브라우저 양쪽으로 했다. pnpm lint·pnpm build 통과, 3종 select의 컨테이너·highlight가 서로 독립적으로 바뀌는지, 슬롯 교체 시 Select 코드가 안 바뀌는지 확인했다. Dialog 스크롤 잠금은 처음 document.body에 걸었더니 실제 브라우저에서 wheel 스크롤이 안 막혀서, root scroller인 documentElement로 옮기고 열림 중 scrollY 불변·닫힘 후 복원까지 브라우저로 재확인했다.
만들기 전엔 "재사용하려고 묶으면 자유도가 죽는다"가 상충처럼 느껴졌는데, 실제로는 경계를 어디에 긋느냐의 문제였다. 변하지 않는 것(기능)은 컴포넌트가 쥐고, 사용처마다 다른 것(생김새)은 놓는다. 그 선을 정확히 그으면 재사용성과 자유도는 같이 산다.