본문으로 건너뛰기
면접 학습
복습세션 →

과제 기반 — 기차예약

토픽 8 · Phase 2

컴포넌트 합성 — overlay-kit·BottomSheet 패턴

질문 5개
  1. 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는 끌어올리면 오히려 결합도가 높아져요.
  2. Q8-2

    CountBottomSheet 같은 BottomSheet에서 `useState`로 임시 count를 받았다가 `onConfirm`으로 부모에 넘기는 패턴 — 자식이 자기 atom을 직접 읽지 않고 props로 받는 이유는요?

    핵심 포인트

    • BottomSheet는 재사용 가능한 위젯이라야 함 — 자기가 다루는 데이터의 storage를 가정하지 않음.
    • atom 직접 의존하면: (1) jotai 외 환경에서 재사용 불가, (2) cancel 흐름이 깨짐, (3) "확인 시점에만 commit" 패턴이 불가능.
    • props로 initialCount/onConfirm을 받으면: 호출자가 atom·redux·useState 어떤 storage든 자유롭게 결정.
    • "확인 시점 commit" 패턴의 본질 — 자식이 임시 useState를 들고, 부모가 commit point의 atom write 책임.
    모범 답안먼저 답해보고 펼치기

    BottomSheet의 책임을 작게 가져가고 싶었어요. CountBottomSheet 안에서 직접 useAtom(passengerCountAtom)을 부르는 길도 있지만 — 그러면 두 가지 문제가 생깁니다.

    첫째, BottomSheet가 atom storage에 강결합돼서 재사용성이 떨어져요. 다른 페이지에서 "임시 인원 입력"만 받아오고 싶을 때 — 예를 들어 미리보기 모달 같은 곳에서 atom과 무관하게 카운트를 받는 시나리오 — 그 자리에서 새 BottomSheet를 또 만들어야 합니다.

    둘째, cancel 흐름이 깨집니다. atom에 직접 쓰면 spinner를 한 번 누르는 즉시 atom이 update돼요. 사용자가 "X"로 닫으면 변경된 채로 남죠. "취소"의 의미를 살리려면 BottomSheet가 열릴 때 atom 백업본을 만들고 cancel 시 복구해야 하는데, useState로 임시값을 받는 것보다 더 복잡한 코드가 됩니다.

    그래서 패턴을 — CountBottomSheet는 props로 initialCount(atom 값의 스냅샷)와 onConfirm(commit 콜백)을 받습니다. 내부에서는 useState<PassengerCount>(initialCount)로 임시값을 관리해요. 사용자가 spinner를 누르면 임시 useState만 변하고 atom은 미동도 안 합니다. "확인" 버튼을 누르면 onConfirm(count)로 부모에 넘기고, 부모(CountField 컴포넌트)가 setCount(next)로 atom에 commit합니다.

    이 패턴이 가지는 의미는 — 자식은 임시 작업장, 부모는 commit point라는 책임 분리예요. 자식이 storage를 모르니까 jotai/redux/useState 어떤 환경에도 끼울 수 있고, cancel 흐름도 자동으로 풀립니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q8-2-a. initialCount는 prop으로만 받지만 그 안에서 useState 초기값으로 한 번만 쓰이는데, 부모 atom이 BottomSheet 열려 있는 동안 바뀌면 어떻게 되나요? → useState의 initial value는 첫 마운트 때만 사용되고 이후 prop이 바뀌어도 자동 sync되지 않아요. 다만 overlay-kit으로 BottomSheet를 열면 매번 새로 마운트되는 구조라 — open할 때마다 최신 atom value가 initialCount로 들어옵니다. open/close = mount/unmount가 일치하면 stale 문제가 없어요. 만약 BottomSheet가 항상 트리에 있고 open prop으로 visibility만 토글하는 구현이라면 useEffect([initialCount], () => setCount(initialCount))로 sync해야 합니다.

    • Q8-2-b. 같은 패턴(useState 임시 + onConfirm commit)이 DateBottomSheet에도 들어가 있던데, 두 번 반복됐으니 hook으로 추상화하는 게 맞지 않나요? → Rule of Three — 두 번까지는 명시적으로 두는 게 가독성이 더 좋다고 봅니다. 세 번째 BottomSheet가 같은 패턴을 쓰는 시점에 useDraftState(initial) 같은 hook으로 추상화하는 게 자연스러운 흐름이에요. 토스 가이드의 "화려한 방법보다 익숙한 방법" 원칙도 같은 방향입니다.

    • Q8-2-c. onConfirm 시점에 검증 실패면 BottomSheet를 닫지 않도록 막아야 할 텐데요? → 이번 과제 BottomSheet는 그 시나리오가 안 생겼어요. CountBottomSheet의 handleChange에서 증가 시점 검증을 미리 하니까 "확인" 시점에는 항상 valid한 count여서요. 다만 일반 form에서는 onConfirm 안에서 valid 여부를 부모가 판단하고 false 리턴 시 sheet를 안 닫는 패턴이 표준이에요. overlay-kit의 close()를 onConfirm 안에서 명시적으로 호출하는 모델이라 자연스럽게 표현됩니다.

    CS · 이론
    • Controlled vs Uncontrolled Component: BottomSheet는 자체 useState로 내부 상태를 들고, 부모에게는 commit point만 노출하는 "uncontrolled" 패턴. controlled로 가면 모든 spinner 변경이 부모에 전파되어 commit-on-confirm이 어려워짐.
    • State as Source of Truth: 한 데이터의 source가 어디인지를 명확히 하는 원칙. atom이 source면 자식은 그 source의 임시 view를 들고, 부모만 source에 commit.
    • Composability of UI components: storage를 가정하지 않는 컴포넌트가 가장 재사용성이 높음 — props로 입출력을 받는 단순한 인터페이스가 컴포넌트 합성의 토대.
  3. Q8-3

    SearchPage가 43줄까지 줄어든 게 인상적이었는데요, 페이지가 그렇게 얇아질 수 있었던 합성 패턴을 자세히 풀어주세요.

    핵심 포인트

    • SearchPage는 조립만 — 자식 Field 컴포넌트들이 자기 atom을 직접 읽고 BottomSheet를 자기 자리에서 열음.
    • props drilling 0 — atom이 자식에게 직접 도달, 부모는 데이터를 알 필요가 없음.
    • container/presentational 구분이 아니라 co-location 원칙 — 데이터를 쓰는 곳에서 데이터를 읽음.
    • 결과: SearchPage = 5개 Field + 1개 CTA의 세로 stack — 화면 구조가 코드에 그대로 보임.
    모범 답안먼저 답해보고 펼치기

    페이지가 얇아진 건 분해의 기준을 명확히 했기 때문이에요.

    전통적으로 React에서는 container/presentational 분리를 많이 들 텐데, 이번에는 그 패턴을 의식적으로 안 썼어요. SearchPage가 모든 atom을 useAtom으로 읽고 자식에게 props로 내려주는 container 역할을 하면 — SearchPage가 6개 atom을 읽고 5개 props 묶음을 만들어 5개 자식에 내리는 코드가 됩니다. 가독성도 떨어지고, 자식 컴포넌트의 시그니처가 page에 강결합돼요.

    대신 co-location 원칙으로 갔어요. "데이터를 쓰는 곳에서 데이터를 읽는다"는 발상입니다. CountField는 자기 안에서 useAtom(passengerCountAtom)을 직접 호출해 atom 값을 읽고 set합니다. SearchPage는 <CountField />를 렌더하기만 할 뿐 인원 데이터의 존재조차 몰라요. StationSectionuseAtomValue(departureStationAtom), useAtomValue(arrivalStationAtom)을 직접 읽고, DateSectionuseAtomValue(departureDateAtom), useAtomValue(returnDateAtom), useAtomValue(tripTypeAtom)을 직접 읽습니다.

    결과적으로 SearchPage는 — <StationSection />, <TripTypeTab />, <DateSection />, <CountField />, <SearchBottomCTA /> 자식 5개를 세로로 쌓는 조립공이에요. props가 거의 없습니다. 화면을 보면서 코드를 읽으면 위에서 아래로 컴포넌트 순서가 화면 구조에 그대로 대응돼서 — "이 자리에서 무슨 데이터가 보여야 하지?"를 묻기 좋습니다.

    이 패턴이 가능했던 이유는 atom이 React Context와 달리 트리 위치에 의존하지 않아서예요. atom은 모듈 스코프에 살아 있어서 어떤 컴포넌트든 import만 하면 바로 쓸 수 있습니다. props drilling이 필요한 자리가 거의 없어졌어요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q8-3-a. co-location 원칙이 모든 경우에 답인가요? container가 더 적합한 자리도 있을 텐데요. → 네, container 패턴이 어울리는 자리가 있어요. 같은 데이터를 여러 자식에게 다양한 형태로 주입해야 하는 경우, 또는 여러 자식 간 동기화가 필요한 경우, container 부모가 한 번 데이터를 잡고 분배하는 게 자연스럽습니다. 다만 atom처럼 글로벌 접근 가능한 storage가 있으면 그런 case가 많이 줄어요. ConfirmPage도 비슷한 패턴인데 — ConfirmHeader, ConfirmTicketCard, ConfirmBottomCTA가 각자 atom을 직접 읽습니다.

    • Q8-3-b. atom이 너무 글로벌하면 컴포넌트 간 의존이 보이지 않아 추적이 어렵지 않나요? → 그게 atom-based 상태관리의 trade-off예요. props로 명시적 전달하는 패턴은 의존을 코드로 드러내고, atom은 의존을 숨깁니다. 다만 (1) atom 정의가 한 파일에 모여 있고, (2) atom 이름이 도메인 의미를 분명히 표현하면 — 추적 비용이 크지 않아요. 또 IDE의 "Find usages"로 atom 사용처를 즉시 찾을 수 있죠. props drilling으로 6단계 내려가는 것보다는 모듈 스코프 atom + Find usages가 더 빠른 추적이라고 봤습니다.

    • Q8-3-c. SearchPage가 너무 얇아지니까 화면별로 어떤 데이터가 쓰이는지 한눈에 안 보이지 않나요? → 맞아요, trade-off가 있어요. 페이지 차원에서 "이 화면이 다루는 모든 데이터"를 한눈에 보고 싶으면 page-level container가 더 적합해요. 다만 이번 과제에서는 페이지 자체가 단순한 form 흐름이라 — 화면 구조 = 코드 구조의 1:1 대응이 더 가치 있다고 봤습니다. 페이지가 복잡한 SaaS 화면이라면 container 패턴이 더 적합할 수 있어요.

    CS · 이론
    • Co-location Principle (Kent C. Dodds): "데이터·로직·스타일을 사용하는 곳에 가깝게 두라". atom-based 상태관리가 자연스럽게 풀어주는 원칙.
    • Container/Presentational Pattern (Dan Abramov, 2015): 한때 React 표준으로 통했지만 hooks 도입 후 의미가 약해짐. Dan Abramov 본인도 이후 update로 "이 패턴을 더 이상 promote하지 않는다"고 밝힘.
    • Props drilling vs Global state: 깊이 3단계 이상 props drilling이 보이면 global state(atom·context)로 옮기는 게 전형적 휴리스틱. atom은 context보다 가벼운 도구라 망설일 필요가 적음.
  4. Q8-4

    `components/common/EmptyResult` 같은 공용 컴포넌트는 어떤 기준으로 빼셨고, 무엇은 일부러 빼지 않았나요?

    핵심 포인트

    • EmptyResult는 station 검색 결과 + ticket 검색 결과 두 곳에서 같은 빈 상태 UI를 그려야 해서 분리.
    • "두 곳 이상에서 같은 시각적·기능적 의미를 가질 때만" 공용으로 — Rule of Three 더 보수적으로 적용.
    • 한 화면에서만 쓰이는 StationSection/DateSection/CountField 등은 feature 폴더 안에 그대로 — premature abstraction 회피.
    • 공용 컴포넌트 무리한 추출은 토스 가이드 "화려한 방법보다 익숙한 방법" 원칙과 충돌.
    모범 답안먼저 답해보고 펼치기

    components/common/에는 거의 아무것도 안 넣었어요. 이게 의식적인 결정이었습니다.

    기준은 — "두 곳 이상에서 같은 시각적·기능적 의미를 가질 때만 공용으로 뺀다"였어요. 빈 상태 UI(EmptyResult)가 station 검색 결과와 ticket 검색 결과 두 곳에서 거의 똑같은 형태로 등장합니다. 아이콘 + "검색 결과가 없어요" 같은 메시지 + 안내 텍스트. 둘이 같은 디자인이라 분리하는 게 자연스러웠어요. 두 사용처에서 같은 의미를 표현하니까 분리해도 의미가 흐려지지 않습니다.

    반대로 StationSection, DateSection, CountField 같은 Field 컴포넌트는 SearchPage 한 곳에서만 써요. 이걸 "재사용 가능성이 있을 수도 있으니까" 공용으로 추출하는 건 premature abstraction이라고 봤어요. 토스 PDF의 "화려한 방법보다 평소에 하던 가장 익숙한 방법" 원칙에 직접 반하는 결정입니다. 6시간짜리 과제고 미래 재사용은 가설이라, 지금 한 군데에서만 쓰이는 건 그 자리에 두자고 결정했어요.

    분리·미분리의 기준을 다른 각도로 보면 — 변경 이유가 같은 컴포넌트끼리는 한 폴더에입니다. StationSearchField는 StationPage의 검색 입력이고, 디자인이나 동작이 바뀌는 이유가 StationPage의 요구와 직결됩니다. 공용으로 빼면 두 화면의 변경 이유가 한 컴포넌트에 모이게 돼서, 한 화면을 위한 변경이 다른 화면에 의도치 않은 영향을 줄 수 있어요. feature 폴더 안에 두면 변경 영향이 자연스럽게 격리됩니다.

    이 원칙으로 가니까 components/common/이 한산해졌어요 — EmptyResult 정도만. 이게 오히려 의도한 결과예요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q8-4-a. 만약 새 화면이 추가돼서 StationField와 비슷한 게 또 필요해지면 그때 어떻게 하시겠어요? → 그때가 정확히 공용으로 빼는 시점이에요. 두 번째 사용처가 등장한 순간 — Rule of Three까지 안 가더라도 — 분리를 검토합니다. 이 시점에 두 사용처의 공통 부분과 다른 부분을 보고, 공통이 본질적이면 빼고, 다르면 따로 둡니다. 미리 빼면 두 사용처가 어떻게 다를지 예측에 의존하게 되는데, 실제로 등장했을 때 만드는 게 정확합니다.

    • Q8-4-b. 컴포넌트 분리의 nominal cost(파일 수, import 줄)와 cognitive cost(어디 있는지 찾는 시간)가 trade-off일 텐데, 어떻게 균형을 잡으셨나요? → 도메인 단위(search/station/ticket/confirm/complete/common) 폴더가 그 균형을 잡아주는 도구였어요. 한 화면 안에서만 쓰이는 컴포넌트는 그 화면의 폴더 안에 — 찾을 때 화면 이름으로 시작하면 됩니다. 두 곳에서 쓰이면 common으로. nominal cost가 도메인 안에서 응집되니까 cognitive cost를 누르고, common 폴더는 정말 공용인 것만 모이니까 거기서도 cognitive cost가 낮아요.

    • Q8-4-c. EmptyResult는 한 컴포넌트이고 props도 단순할 텐데, 그 정도면 굳이 분리하지 않고 inline JSX 두 번 두는 것도 답일까요? → 그게 더 단순한 길이 될 수도 있어요. 다만 EmptyResult가 디자인적으로 일관돼야 한다는 — 즉 두 화면이 같은 빈 상태 UI를 보여줘야 한다는 — 디자인 의도가 있으면 분리가 맞다고 봅니다. 인라인으로 두 번 두면 한쪽이 바뀌면 다른 쪽이 안 바뀌어서 두 화면이 서로 다른 빈 상태를 보여주는 사고가 일어날 수 있어요. 일관성이 명시적 요구일 때는 분리가 정답이고, 그게 아니면 인라인이 정답입니다.

    CS · 이론
    • Rule of Three (Martin Fowler): 같은 패턴이 세 번 반복될 때까지 추상화를 미루라는 휴리스틱. premature abstraction의 비용을 강조.
    • Single Responsibility & Reason to Change (Robert Martin): 한 모듈은 한 가지 변경 이유만 가져야 함. 공용으로 빼면 변경 이유가 합쳐지는 위험을 의식해야 함.
    • AHA Programming (Avoid Hasty Abstractions, Kent C. Dodds): "잘못된 추상화의 비용은 중복의 비용보다 크다"는 원칙. 6시간짜리 과제에서는 이 원칙이 특히 강하게 적용됨.
  5. Q8-5

    overlay-kit + BottomSheet 패턴이 토스의 디자인 시스템(`tosslib`)과 잘 맞물려 있던데, `tosslib` API를 쓰는 과정에서 의식한 점이 있나요?

    핵심 포인트

    • tosslib이 깔아둔 컴포넌트(BottomSheet, NumericSpinner, ListRow, Button, FixedBottomCTA, Toast)을 그대로 활용 — 직접 markup을 짜지 않음.
    • Toastoverlay.open(...) 패턴으로 호출 — showWarnToast 함수로 캡슐화해서 호출 사이트는 한 줄.
    • tosslib이 정해둔 props 시그니처에 의존하므로, 디자인 변경이 일어나도 라이브러리 업데이트만으로 따라갈 수 있음.
    • 토스 가이드 "익숙한 방법" — tosslib이 익숙한 방법의 핵심 도구.
    모범 답안먼저 답해보고 펼치기

    tosslib은 토스가 자체 디자인 시스템을 패키지화한 것이라 — 시작점에서 package.json"tosslib": "file:./tosslib.tgz"로 들어와 있었어요. 면접 평가의 한 축이 "tosslib을 잘 활용하느냐"라고 봤습니다.

    활용한 컴포넌트 목록을 보면 — BottomSheet(인원·날짜 선택), NumericSpinner(인원 카운터), Calendar(날짜 선택), ListRow(필드 표시), Button + FixedBottomCTA(하단 CTA), Toast(검증 실패 메시지), Text/Flex/Spacing/colors(레이아웃·스타일), Assets.Icon(아이콘). 직접 markup을 짠 자리가 거의 없어요. 마크업도, 색상도 (colors.grey400, colors.grey700, colors.grey800), spacing도 (Spacing size={20}) 라이브러리가 이미 정의한 토큰을 쓰는 길로 갔습니다.

    Toast는 overlay-kit을 통해 호출하는 패턴이 가장 자연스러웠어요. CountBottomSheetshowWarnToast(message: string) 함수를 두고, 안에서 overlay.open(({ isOpen, close, unmount }) => <Toast position="top" type="warn" isOpen={isOpen} message={message} close={() => { close(); unmount(); }} />)로 호출합니다. 호출 사이트는 showWarnToast('최대 9명까지 예약할 수 있어요') 한 줄이에요. 라이브러리의 declarative overlay API와 토스트 컴포넌트가 깔끔하게 맞물립니다.

    이렇게 라이브러리에 강하게 의존하는 게 trade-off가 있긴 해요. 라이브러리 업데이트 시 props 시그니처가 바뀌면 따라가야 합니다. 다만 토스 환경에서는 (1) 디자인 시스템이 제품 일관성의 토대이고, (2) 라이브러리 변경은 design system 팀이 마이그레이션 가이드를 함께 제공할 가능성이 높아서 — 그 trade-off를 받아들이는 게 자연스럽다고 봤어요. "토스 환경에서 일하는 방식"의 일부라고 생각합니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q8-5-a. tosslib이 제공하지 않는 컴포넌트가 필요했다면 어떻게 처리했을까요? → 두 가지 길이 있어요. 첫째, 라이브러리의 primitives(Flex, Text, colors)를 조립해서 만든 다음 feature 폴더 안에 두는 것. 둘째, 정말 디자인 시스템이 다뤄야 할 일반 컴포넌트라면 — 디자인 시스템 팀에 PR 또는 요청을 넣는 것. 이번 과제에서는 첫 번째 길로 풀 수 있는 수준이었습니다. 예를 들어 confirm/ConfirmTicketCard 같은 건 ListRow + Text + Spacing 조합으로 만든 feature-specific 컴포넌트예요.

    • Q8-5-b. tosslib에 강결합되면 다른 프로젝트로의 이식성이 떨어지지 않나요? → 맞아요, 이식성이 떨어집니다. 다만 이번 과제는 토스 환경에 맞춰 제출하는 코드라 — 이식성보다 토스 컨벤션·디자인 일관성이 더 중요한 가치예요. 만약 같은 프로젝트가 multi-platform이거나 디자인 시스템이 여러 개 공존해야 한다면 — abstraction layer를 두는 게 정당화되지만, 그건 이번 컨텍스트에서는 over-engineering입니다.

    • Q8-5-c. 만약 tosslib의 BottomSheet가 declarative overlay 패턴과 맞지 않는 형태로 설계됐다면 어떻게 풀었을까요? → 그러면 overlay-kit 같은 외부 라이브러리 도입이 더 가치 있어졌을 거예요. 라이브러리가 declarative한데 디자인 시스템이 imperative open prop만 지원한다면, overlay-kit이 그 둘을 잇는 어댑터 역할을 합니다. 실제로 overlay-kit이 토스가 만든 라이브러리이고, tosslib의 BottomSheet/Toast가 정확히 이 패턴을 가정해서 설계됐기 때문에 — 이번 조합이 자연스럽게 동작했어요.

    CS · 이론
    • Design System adoption의 trade-off: 일관성 ↔ 이식성. 디자인 시스템에 강결합하면 제품 일관성과 개발 속도가 올라가지만, 다른 환경으로 이식이 어려워집니다. 한 회사 안에서는 보통 일관성이 이긴다.
    • Declarative overlay 모델: imperative API(setOpen(true))와 declarative React 트리 사이의 mismatch를 해소하는 패턴. modal·toast·dialog 같은 임시 UI에 자연스러운 모델.
    • 컴포넌트 라이브러리의 추상화 깊이: primitives만 제공(headless UI) vs full component(material UI 류) vs token-based(tosslib 같은 디자인 토큰 + 컴포넌트). 회사 규모와 디자인 일관성 요구에 따라 선택.