Q8-1
BottomSheet를 `<BottomSheet open={state}>` 같은 명령형 prop이 아니라 `overlay.open(({ isOpen, close, unmount }) => <BottomSheet />)` 형태로 호출하는 declarative overlay 패턴을 쓰셨는데요. 차이가 뭐고 왜 이걸 골랐나요?
핵심 포인트
- 시작점에
<BottomSheet open={false}>가 컴포넌트 트리에 박혀 있어서 — open state를 부모가 useState로 관리해야 함. overlay-kit은 호출 사이트가 함수처럼 보임:overlay.open(...)— open state를 부모가 들고 있을 필요 없음.- BottomSheet는 페이지 어디서든 호출 가능한 "임시 UI"라 컴포넌트 트리에 강하게 박힐 이유가 없음.
- 결과: SearchPage가 BottomSheet open state를 5개 들고 있을 필요 없이, 각 Field 컴포넌트가 자기 자리에서
openSheet()호출.
모범 답안먼저 답해보고 펼치기
이게 시작점 코드를 바꾸면서 가장 많이 고민한 패턴 중 하나예요.
시작점에서는 <BottomSheet open={false}> 같은 자리표시자가 SearchPage 트리에 박혀 있었어요. 이걸 동작시키려면 부모 컴포넌트(SearchPage)가 useState<boolean>(false)로 open state를 들고, "열기" 핸들러에서 setOpen(true), BottomSheet의 onClose에서 setOpen(false)로 닫는 패턴이 표준이에요. 이걸 5개의 BottomSheet(역 검색, 가는날, 오는날, 인원)에 다 적용하면 SearchPage가 5개의 boolean state를 관리해야 합니다.
overlay-kit은 이 패턴을 declarative overlay 모델로 뒤집어요. overlay.open(({ isOpen, close, unmount }) => <CountBottomSheet open={isOpen} onClose={close} ... />)을 호출하면 라이브러리가 트리 외부에 portal로 그려주고, close는 라이브러리가 알아서 isOpen을 false로 만든 뒤 transition 후 unmount합니다. 부모는 open state를 들고 있을 필요가 없어요.
핵심은 — BottomSheet는 페이지 어디서든 호출되는 "임시 UI"인데, 컴포넌트 트리에 박혀 있어야 할 이유가 없다는 관찰이에요. 컴포넌트 트리에 박는 모델이 "선언적이라 더 React스럽다"고 생각하기 쉬운데, 실은 imperative API(setOpen(true))를 declarative하게 보이게 위장한 거예요. overlay-kit은 호출 사이트를 imperative하게 만들고("열어줘" → "닫아줘"), 트리 위치에 대한 결정 자체를 라이브러리에 위임합니다. 이게 더 자연스럽다고 봤어요.
결과적으로 SearchPage는 boolean state 0개. 각 Field 컴포넌트(CountField, DateField)가 자기 자리에서 overlay.open(...)을 호출합니다. 컴포넌트 분해가 자연스럽게 됐고, prop drilling도 없어졌어요.
이 답변, 어땠나요?
꼬리 질문
-
Q8-1-a. overlay-kit 없이
useState로 풀었다면 어떤 문제가 있었을까요? → 5개 BottomSheet를 SearchPage가 일일이 관리하거나, 각 Field 컴포넌트마다 자기 boolean state를 들게 됩니다. 후자가 그나마 분산이 잘 된 형태인데, 그래도 (1) state와 BottomSheet markup이 같은 컴포넌트 안에 있어야 해서 — Field 컴포넌트가 자기 trigger UI + BottomSheet UI 두 가지 책임을 갖게 되고, (2) 다른 화면에서 같은 BottomSheet를 재사용할 때 또 boolean state를 새로 만들어야 해요. overlay-kit은 호출 사이트와 BottomSheet 정의를 깔끔하게 분리해줍니다. -
Q8-1-b.
isOpen/close/unmount세 콜백을 분리한 이유가 뭐예요? → 라이브러리가 transition을 지원하기 위해서예요.close()를 호출하면 isOpen이 false로 바뀌면서 BottomSheet가 슬라이드 다운 애니메이션을 시작합니다. 애니메이션이 끝나야 진짜 DOM에서 사라져야 하는데, 그 시점이unmount()예요. 둘을 분리하면 (close → 애니메이션 → unmount) 흐름을 호출자가 컨트롤할 수 있어요. 보통은 onClose에서close(); unmount();를 같이 호출하면 되는데, transition을 더 정교하게 다룰 때(예: 닫히기 전 다른 액션) 분리가 가치를 발휘합니다. -
Q8-1-c. overlay-kit이 portal로 그려주는데, 이게 SSR이나 z-index 같은 문제를 안 일으키나요? → portal은 React의 일반 패턴이라 잘 알려진 함정들이 있어요. SSR 환경에서는
document접근이 hydration 시점에 일어나야 안전합니다. z-index는 트리 외부에 그리니까 부모의 stacking context와 무관해 — 이게 장점이자 단점입니다. 장점은 어떤 자식 컴포넌트의 z-index에도 묻히지 않는다는 거고, 단점은 스타일을 격리하기 위한 별도 컨테이너가 필요할 수 있어요. 이번 과제는 SPA고 토스 디자인 시스템이 z-index를 일관되게 관리해서 큰 문제가 없었습니다.
CS · 이론
- Imperative vs Declarative API의 적합한 자리: "선언적이 항상 좋다"는 도그마는 위험합니다. 일회성 호출(다이얼로그·토스트)은 imperative API가 더 자연스러워요. overlay-kit이 정확히 이 직관을 표현.
- React Portal: 컴포넌트 트리와 DOM 트리를 분리하는 React의 공식 메커니즘. modal, tooltip, dropdown 같은 "트리 위치와 시각적 위치가 다른" UI에 필수.
- State Lifting의 한계: state를 끌어올리는 패턴이 모든 경우에 답은 아닙니다. "임시 UI의 open state"처럼 자식의 lifecycle과 결합된 state는 끌어올리면 오히려 결합도가 높아져요.