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

이력서 기반

토픽 4 · Phase 1

디자인 시스템 Compound Component 패턴 전환

질문 5개
  1. Q4-1

    v1의 단일 Props 방식이 어떤 문제가 있었나요? Compound Component가 그걸 어떻게 해결했나요?

    핵심 포인트

    • v1은 단일 Props 방식 — 새 요구사항마다 props가 누적, 일부 컴포넌트가 props 10개 초과
    • 해결 1: 마크업 자유도 부족 (헤더 위치 바꾸기, 우측에 액션 추가 같은 변형이 막힘)
    • 해결 2: 학습 곡선 (props 10개 의미를 다 외워야 함)
    • Compound는 "JSX 트리 자체로 구성을 표현" → 자유도와 직관성 동시 확보
    모범 답안먼저 답해보고 펼치기

    v1 디자인 시스템은 빠르게 만들기 위해 단일 Props 방식으로 갔어요. <Accordion title="..." subtitle="..." rightIcon={...} expandable showDivider ... /> 같은 형태죠. 처음엔 빨랐지만 새 화면 요구사항이 나올 때마다 prop이 하나씩 늘었어요. 1년쯤 지나니 prop이 10개를 넘는 컴포넌트가 생겼고, 동료들이 새 prop이 있는지 매번 storybook을 열어봐야 했습니다.

    진짜 문제는 두 가지였어요. 첫째, 마크업 자유도. "헤더 우측에 토글 버튼을 넣고 싶다", "헤더와 본문 사이에 사용자 정보를 넣고 싶다" 같은 변형이 들어오면 prop으로 표현이 어려워서 억지로 prop을 추가하거나 새 컴포넌트를 fork 하는 식으로 처리됐어요. 둘째, 학습 곡선. prop의 이름과 효과를 다 외워야 동료들이 쓸 수 있는 상태였습니다.

    Compound Component 패턴으로 바꾸니 두 문제가 동시에 풀렸어요. <Accordion> 안에 <Accordion.Summary>, <Accordion.Details>, <Accordion.Item> 같은 하위 컴포넌트를 두고, 사용자가 JSX 트리 자체로 구조를 표현합니다. 헤더 우측에 뭔가 넣고 싶으면 그냥 <Accordion.Summary> 안에 넣으면 끝이고, prop으로 모든 변형을 미리 예측할 필요가 없어졌어요. 동료들이 처음 보는 컴포넌트라도 HTML과 비슷한 사고로 쓸 수 있어서 학습 비용이 거의 0이 됐습니다. 결과적으로 v1 대비 prop 개수가 절반 이하로 줄었어요.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. 그러면 props가 다 사라졌나요? 어떤 건 여전히 props로 받나요?

      "구성"은 children으로, "동작 정책"은 props로 분리했어요. 예를 들어 헤더 모양은 <Summary> children으로, "단일/다중 펼침 가능 여부"는 Accordion 본체의 prop으로 받는 식입니다.

    • Q-b. Compound 패턴은 잘못 쓰면 컴포넌트 트리가 깨지지 않나요? (예: Summary 빼먹기)

      맞아요. TypeScript로 children의 타입을 강제할 순 있지만 강제력이 약합니다. 그래서 storybook에 정상 사용 예시를 명확하게 두고, 런타임에 핵심 자식이 없으면 dev mode에서 경고를 찍는 안전장치를 뒀어요.

    • Q-c. shadcn/ui나 Radix UI 같은 트렌드와 비교하면 어떤가요?

      Radix가 정확히 이 방향(headless + compound)이고, shadcn/ui도 Radix 기반이에요. 우리가 도입한 시점은 그보다 앞이었는데, 지금 보면 업계 흐름과 같은 사고였다고 평가합니다.

    CS · 이론
    • Compound Component pattern: React 커뮤니티에서 정착된 패턴. select/option 처럼 부모-자식이 함께 동작하는 구조를 React로 표현
    • Inversion of Control: 라이브러리가 마크업을 정하지 않고 사용자에게 위임하는 사상. compound, render props, hooks가 모두 이 갈래
    • Headless UI: Radix, Headless UI 라이브러리는 동작만 제공하고 시각은 사용자에게 위임. compound와 결합도가 높음
    • Slot pattern (Vue/Svelte): 같은 문제를 다른 프레임워크에서 푸는 방식. JSX children + Context로 React에서 비슷하게 구현 가능
  2. Q4-2

    Render Props, HOC, Compound Component 셋을 비교하고 왜 Compound를 골랐는지 말해 주세요.

    핵심 포인트

    • Render Props: children에 함수 전달 → 콜백 지옥, JSX가 평탄하지 않음
    • HOC: 컴포넌트를 함수로 감싸 prop 주입 → prop 충돌, displayName 디버깅 불편
    • Compound: Context로 상태 공유, 하위 컴포넌트가 평탄한 JSX 트리로 사용
    • 결정적 이유: 선언적 직관성 + JSX 자유도 + 타입 안전성
    모범 답안먼저 답해보고 펼치기

    세 패턴 다 검토했어요. 각자 트레이드오프가 다릅니다.

    Render Props는 children에 함수를 전달해서 부모가 자식의 마크업을 구성하는 방식이에요. <Accordion>{(state) => (...)}</Accordion> 같은 형태인데, 중첩이 깊어지면 콜백 지옥이 됩니다. 게다가 JSX 트리가 평탄하지 않아서 가독성이 떨어져요. 정말 필요한 케이스는 hooks가 등장한 이후엔 거의 사라졌습니다.

    **HOC (Higher-Order Component)**는 withTheme(Component) 처럼 컴포넌트를 한 번 감싸서 prop을 주입하는 방식이에요. 문제는 두 개입니다. 첫째, prop 충돌 — 사용자가 같은 이름의 prop을 넘기면 누가 이기는지 명확하지 않아요. 둘째, displayName이 깨져서 React DevTools에서 디버깅이 불편해요. hooks 도입 이후 HOC를 새로 만드는 일은 거의 없는 흐름입니다.

    Compound Component는 Context로 부모-자식 사이에 상태를 공유하면서 사용자에겐 평탄한 JSX 트리를 노출해요. <Accordion><Accordion.Summary>...</Accordion.Summary></Accordion> 처럼 HTML과 흡사한 사고가 가능합니다. prop 충돌도 없고, JSX가 그대로 트리 구조라 가독성이 가장 좋아요. 더불어 TypeScript에서 Accordion.Summary 같은 attached 타입 선언이 자연스럽게 됩니다.

    선택 이유는 결국 DX였어요. 동료가 처음 봐도 직관적으로 이해할 수 있는지가 디자인 시스템의 핵심 KPI인데, Compound가 그걸 가장 잘 만족시켰습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. Custom Hook + 일반 컴포넌트 조합으로도 비슷한 자유도를 얻을 수 있지 않나요?

      가능합니다. useAccordion() hook을 노출하고 사용자가 컴포넌트를 마음대로 짜는 방식이죠. Radix의 일부 API가 그 방향이에요. 우리는 "마크업 일관성"이 중요한 디자인 시스템이라 컴포넌트로 한 단계 더 감싸는 쪽이 가이드를 강하게 줄 수 있어 Compound로 갔습니다.

    • Q-b. Render Props가 적합한 케이스도 있나요?

      자식이 부모의 내부 상태를 매우 동적으로 받아 렌더링해야 할 때, 그리고 hook으로 표현이 어려울 때 (예: scroll position을 fps급으로 내려줘야 하는 경우) 정도예요. 흔치 않습니다.

    • Q-c. HOC를 아직도 쓰는 영역이 있나요?

      라우터의 withRouter 같은 legacy 호환, 일부 분석 라이브러리에서 자동 트래킹용으로 쓰이는 정도예요. 신규 코드에서 HOC를 짜는 경우는 거의 없다고 봅니다.

    CS · 이론
    • 함수형 컴포넌트 + Hooks 시대의 컴포지션: render props·HOC가 hook으로 흡수된 흐름
    • Component composition vs configuration: 같은 문제를 prop으로 푸는가 children으로 푸는가의 사고 차이
    • Type-safe attached components: Accordion.Summary 같은 패턴은 TypeScript의 namespace declaration과 잘 맞음
    • API surface 설계: 사용자에게 노출되는 표면을 작게 유지하면서 자유도를 주는 것이 라이브러리 설계의 핵심
  3. Q4-3

    Compound Component는 Context API에 의존하는데, Context value가 바뀔 때마다 모든 구독자가 리렌더되지 않나요? 어떻게 막으셨나요?

    핵심 포인트

    • Context value의 참조가 바뀌면 모든 구독자가 리렌더 — 이게 React Context의 잘 알려진 함정
    • 해결 1: provider value를 useMemo로 감싸서 참조 안정성 확보
    • 해결 2: 자식 컴포넌트를 React.memo로 감싸 구독자 수 자체를 줄이기
    • 해결 3: 필요시 context를 더 잘게 나누기 (state context와 dispatch context 분리)
    모범 답안먼저 답해보고 펼치기

    이건 Context API의 가장 흔한 함정이에요. provider에 value={{ count, setCount }} 처럼 객체 리터럴을 그냥 넘기면 부모가 리렌더될 때마다 새 객체가 만들어져서, Context를 구독하는 모든 자식이 리렌더됩니다.

    세 단계로 막았어요. 첫째, value를 useMemo로 감싸서 참조 안정성 확보. const value = useMemo(() => ({ ... }), [count]) 같은 식으로 의존성이 진짜 바뀔 때만 새 객체를 만듭니다. 둘째, 하위 컴포넌트를 React.memo로 감싸기. 부모가 리렌더돼도 props가 같으면 자식은 리렌더 안 하도록 하는 보호막이에요. 셋째, 그래도 부족하면 Context를 분리. 자주 바뀌는 state와 거의 안 바뀌는 dispatch/setter를 다른 Context로 나누면, dispatch만 쓰는 컴포넌트는 state 변경에 영향 안 받습니다.

    이렇게 했더니 리렌더 프로파일이 훨씬 깔끔해졌어요. 측정은 React Profiler로 했고, "한 Item 토글 시 다른 Item이 리렌더되지 않는다"를 회귀 테스트 케이스로 두고 검증했습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. use-context-selector 같은 외부 라이브러리는 검토하셨나요?

      검토했어요. Context value 일부만 골라 구독해서 리렌더를 더 잘게 통제할 수 있는데, 의존성을 추가하는 비용 대비 우리 케이스에선 useMemo + React.memo로 충분해서 도입하지 않았습니다. atom 단위 분리가 필요해지면 차라리 Jotai를 쓰는 방향이었어요.

    • Q-b. React.memo를 무조건 붙이는 게 좋은가요?

      아니에요. memo는 props 비교 비용이 있어서, 가벼운 컴포넌트에 무차별 적용하면 오히려 손해입니다. "Context를 구독해서 영향 범위가 넓은 컴포넌트", "props가 거의 안 바뀌는 컴포넌트"에 선택적으로 적용했어요.

    • Q-c. React 19의 use() 훅이 이 문제를 바꾸나요?

      use()는 Promise/Context를 더 유연하게 읽을 수 있는 인터페이스지만, Context의 리렌더 모델 자체를 바꾸진 않아요. 여전히 value 참조 안정성이 중요합니다.

    CS · 이론
    • React Context의 리렌더 모델: value 참조가 바뀌면 모든 구독자가 리렌더. selector 개념 없음
    • Reference equality와 useMemo: useMemo는 의존성 비교로 참조 안정성을 보장하는 가장 가벼운 도구
    • React.memo와 props 비교: 기본은 shallow compare. 커스텀 비교 함수도 가능하지만 잘 써야 함
    • Context splitting: 큰 context를 여러 작은 context로 쪼개는 패턴. 변경 빈도와 구독자 영역에 맞춰 분리
    • React DevTools Profiler: 리렌더를 측정하는 표준 도구. flame graph + ranked chart 두 모드 활용
  4. Q4-4

    Controlled / Uncontrolled 모드를 동시에 지원하셨다고 했는데, 무슨 뜻이고 왜 필요한가요?

    핵심 포인트

    • Controlled: 부모가 value/onChange를 모두 관리 — 외부 폼·검증 라이브러리와 통합 가능
    • Uncontrolled: 컴포넌트가 내부 state로 자기 값을 관리 — 단순 사용 케이스에서 부모가 보일러플레이트 안 적어도 됨
    • 동시 지원: value prop이 주어졌으면 controlled, 없으면 uncontrolled로 동작
    • HTML input의 동일 패턴 — 동료의 학습 비용이 거의 0
    모범 답안먼저 답해보고 펼치기

    HTML input을 떠올리시면 됩니다. <input value={x} onChange={...} />은 controlled, <input defaultValue="..." />은 uncontrolled예요. 같은 컴포넌트가 두 모드를 다 지원하면, 외부 폼 라이브러리(react-hook-form 등)와 통합하고 싶을 땐 controlled로 쓰고, 그냥 단순 토글이면 uncontrolled로 부모 코드 없이 쓸 수 있어요.

    내부 구현은 단순합니다.

    function Accordion({ value, defaultValue, onChange, children }) {
      const [internal, setInternal] = useState(defaultValue);
      const isControlled = value !== undefined;
      const current = isControlled ? value : internal;
      const setCurrent = (next) => {
        if (!isControlled) setInternal(next);
        onChange?.(next);
      };
      // current와 setCurrent를 Context로 내려보냄
    }
    

    value가 주어졌으면 외부가 source of truth, 안 주어졌으면 내부 state가 source of truth. setter는 항상 onChange를 호출하면서, uncontrolled일 때만 내부 state도 갱신합니다.

    이걸 모든 핵심 컴포넌트에 동일한 패턴으로 적용해서, 동료들이 "토스 디자인 시스템 컴포넌트는 input처럼 쓰면 된다"는 멘탈 모델 하나로 전체를 다룰 수 있게 했어요. 학습 비용을 최소화한 설계 결정 중 가장 만족스러운 부분이었습니다.

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. Controlled와 Uncontrolled를 중간에 전환하면 어떻게 되나요?

      React가 경고를 찍는 게 표준 동작이고, 우리도 그 동작을 따라 dev mode에서 경고를 찍었어요. 사용자가 value를 주다가 undefined로 바꾸면 의도치 않은 동작을 부르니까, 명시적인 안티패턴으로 표시했습니다.

    • Q-b. defaultValue 외에 초기값이 외부에서 와야 한다면요?

      그 경우는 controlled로 가는 게 정석입니다. 부모가 외부 데이터를 받아 value로 내려주는 흐름이라야 일관성이 보장돼요.

    • Q-c. useImperativeHandle로 외부에서 강제로 값을 set 할 수 있게 하는 건 어떻게 보세요?

      가능은 하지만 React의 단방향 데이터 흐름과 어긋나서 권장하지 않았어요. 정말 필요할 땐 controlled 모드로 가게 가이드했습니다.

    CS · 이론
    • Controlled vs Uncontrolled (React 공식 용어): form input 영역에서 정착된 개념. 모든 stateful 컴포넌트에 일반화 가능
    • Source of truth 일원화: 데이터를 어디서 신뢰할지 명시하는 설계 원칙. 양쪽이 동시에 source면 불일치 위험
    • Hybrid component design: 두 모드를 한 컴포넌트가 지원하는 패턴. shadcn/ui, Radix, Mantine 등 대부분 라이브러리가 채택
    • React-hook-form / Formik 호환성: controlled 인터페이스만 정확하면 외부 폼 라이브러리와 자연스럽게 결합
  5. Q4-5

    v1 → v2 전환을 어떻게 했나요? 동료들의 마이그레이션 비용은 어떻게 통제하셨나요?

    핵심 포인트

    • v2를 별도 패키지로 분리 — 같은 레포에 v1, v2가 공존
    • 팀 단위로 원하는 시점에 import 경로만 바꾸면 전환 가능
    • 컴포넌트별로 1:1 대응되는 v2 API를 미리 설계 (이름·기본값 보존)
    • 마이그레이션 가이드 문서 + storybook에 v1↔v2 동시 비교
    모범 답안먼저 답해보고 펼치기

    v1을 통째로 바꿔치는 빅뱅 전환은 리스크가 너무 컸어요. 대신 v2를 별도 패키지로 분리해서 v1과 함께 살게 했어요. 같은 모노레포 안에 core-v1core-v2가 공존하고, 팀이 원하는 시점에 import 경로만 v2로 바꾸면 마이그레이션이 끝나는 구조입니다.

    핵심은 v2 API가 v1과 1:1 대응되도록 미리 설계한 것이었어요. 컴포넌트 이름, 기본 동작, 핵심 prop은 그대로 유지하고, 새로 추가된 자유도는 children 슬롯으로만 노출. 그래서 단순 케이스는 import 경로 변경만으로 끝나고, 변형이 필요한 경우만 children 마크업을 손대면 됐습니다.

    추가로 두 가지를 했어요. 첫째, 마이그레이션 가이드 문서. 컴포넌트별로 "v1에선 이렇게, v2에선 이렇게"의 before/after 코드 스니펫을 정리. 둘째, storybook에 v1과 v2를 같은 카테고리에 두고 옆에 띄워서 비교. 동료들이 직접 두 버전을 보면서 변경점을 이해할 수 있게 했어요.

    이 전략 덕에 마이그레이션을 강제하지 않고도 6개월 안에 자연스럽게 v2로 수렴했습니다. (※ 기간은 본인 기억에 맞게)

    이 답변, 어땠나요?

    꼬리 질문
    • Q-a. v1 deprecate 시점은 어떻게 정했나요?

      사용량(import 통계)이 일정 수준 아래로 떨어지면 deprecate 공지를 띄우고, 한 분기 유예 후 패키지를 freeze 했어요. 강제하지 않되 명확한 일정은 줬습니다.

    • Q-b. 두 버전 공존이 번들 사이즈를 키우지 않나요?

      한 프로덕트에서 둘을 동시에 쓰는 일이 사실상 없도록 가이드했어요. 마이그레이션 도중에는 일시적으로 두 버전이 섞일 수 있지만, 한 화면 안에서 섞어 쓰진 않게 함으로써 트리쉐이킹 효과를 유지했습니다.

    • Q-c. Codemod로 자동 마이그레이션도 검토하셨나요?

      단순 import 경로 변경은 jscodeshift로 자동화 가능했어요. 다만 children 마크업 변경이 들어가는 케이스는 자동화가 위험해서 수동 마이그레이션으로 갔습니다.

    CS · 이론
    • SemVer (Semantic Versioning): major 변경의 의미와 deprecation 절차의 표준
    • API parity 설계: 새 버전에서도 기존 사용자가 최소 변경으로 옮길 수 있도록 고려하는 설계 원칙
    • Codemod (jscodeshift, ts-morph): AST 기반 자동 리팩토링 도구. 대규모 마이그레이션의 무기
    • Strangler Fig 패턴 (재등장): 토픽 2의 Recoil→Jotai와 같은 사고. 디자인 시스템 v1→v2도 같은 패턴
    • Deprecation 절차: 공지 → 유예 기간 → freeze → 삭제. 라이브러리 운영의 표준 흐름