Tech

공용 컴포넌트와 Compound 패턴

공용 컴포넌트가 늘어날수록 무거워지는 props를 Compound 패턴으로 풀기

컴파운드 패턴

암묵적 상태 공유와 자유로운 조합

// 사용하는 쪽: 상태를 넘기지 않아도 부품들이 알아서 동작한다
<Select onChange={handleChange}>
  <Select.Trigger>과일 선택</Select.Trigger>
  <Select.Options>
    <Select.Option value='apple'>사과</Select.Option>
    <Select.Option value='banana'>바나나</Select.Option>
  </Select.Options>
</Select>
// 사용하는 쪽: 상태를 넘기지 않아도 부품들이 알아서 동작한다
<Select onChange={handleChange}>
  <Select.Trigger>과일 선택</Select.Trigger>
  <Select.Options>
    <Select.Option value='apple'>사과</Select.Option>
    <Select.Option value='banana'>바나나</Select.Option>
  </Select.Options>
</Select>
  • 컴파운드 패턴
    • 여러 컴포넌트가 하나의 작업 단위를 이루면서, 부모가 가진 상태를 자식들이 암묵적으로 공유하는 구조
      • <Select> 안에 <Select.Trigger />, <Select.Option />을 배치
      • 열림/닫힘 상태나 선택값을 props로 일일이 내려주지 않아도 알아서 동작한다.
    • 조합이 자유로워서 다양한 레이아웃 구성에 유리하다.
  • 사용
    • 컴포넌트 제공자는 부품만 잘 만들면 되고
    • 소비자는 화면 요구에 맞게 조립을 잘하면 된다.

React.Children.map + cloneElement ← 기존 구현방식

// 부모가 자식을 순회하며 props를 강제로 주입
function Flyout(props) {
  const [open, setOpen] = useState(false);
  const toggle = () => setOpen(!open);

  // 1. props.children으로 들어온 자식들을 하나씩 순회
  return React.Children.map(props.children, (child) => {
    // 2. 자식 컴포넌트를 복제하면서 부모의 상태와 함수를 주입
    return React.cloneElement(child, { open, toggle });
  });
}

<Flyout>
  <Flyout.Toggle />
  <Flyout.List />
</Flyout>;
// 부모가 자식을 순회하며 props를 강제로 주입
function Flyout(props) {
  const [open, setOpen] = useState(false);
  const toggle = () => setOpen(!open);

  // 1. props.children으로 들어온 자식들을 하나씩 순회
  return React.Children.map(props.children, (child) => {
    // 2. 자식 컴포넌트를 복제하면서 부모의 상태와 함수를 주입
    return React.cloneElement(child, { open, toggle });
  });
}

<Flyout>
  <Flyout.Toggle />
  <Flyout.List />
</Flyout>;
  • 초기 컴파운드 패턴
    • 부모가 React.Children.map으로 자식을 순회하며 cloneElement로 상태를 주입하는 방식

기존 구현 방식의 한계

// 개발자는 이렇게 조립했지만
<Flyout>
  <Flyout.Toggle />
  <Flyout.List />
</Flyout>

// 문제1 : 사용하지 않는 props 까지 넘긴다.
<Flyout>
  <Flyout.Toggle open={open} toggle={toggle} />
  <Flyout.List open={open} toggle={toggle} />
</Flyout>

// 문제2 : 레이아웃용 div 하나만 감싸도 주입이 끊긴다.
<Flyout>
  <div className="wrapper">
    <Flyout.Toggle />
    <Flyout.List />
  </div>
</Flyout>
// 개발자는 이렇게 조립했지만
<Flyout>
  <Flyout.Toggle />
  <Flyout.List />
</Flyout>

// 문제1 : 사용하지 않는 props 까지 넘긴다.
<Flyout>
  <Flyout.Toggle open={open} toggle={toggle} />
  <Flyout.List open={open} toggle={toggle} />
</Flyout>

// 문제2 : 레이아웃용 div 하나만 감싸도 주입이 끊긴다.
<Flyout>
  <div className="wrapper">
    <Flyout.Toggle />
    <Flyout.List />
  </div>
</Flyout>
  • 직계 자식에게만 상태를 전달할 수 있어서div 하나만 중간에 끼워도 상태 주입이 끊겨 컴포넌트가 깨진다.
  • 주입한 props와 사용자가 넘긴 props가 충돌
  • 데이터가 어디서 오는지 코드만 봐서는 추적하기 어려워 타입 안정성과 디버깅 모두 취약
  • 이런 이유로 React 공식 문서ChildrencloneElement를 legacy API로 분류하고 사용 자제를 권고한다.

현대의 해결법: Context API

const SelectContext = createContext(null);

function Select({ children, onChange }) {
  const [selected, setSelected] = useState(null);
  return (
    <SelectContext.Provider value={{ selected, setSelected, onChange }}>
      {children}
    </SelectContext.Provider>
  );
}

// 중간에 어떤 래퍼가 있어도 동작한다
Select.Option = function Option({ value, children }) {
  const { setSelected, onChange } = useContext(SelectContext);
  return (
    <li
      onClick={() => {
        setSelected(value);
        onChange(value);
      }}
    >
      {children}
    </li>
  );
};
const SelectContext = createContext(null);

function Select({ children, onChange }) {
  const [selected, setSelected] = useState(null);
  return (
    <SelectContext.Provider value={{ selected, setSelected, onChange }}>
      {children}
    </SelectContext.Provider>
  );
}

// 중간에 어떤 래퍼가 있어도 동작한다
Select.Option = function Option({ value, children }) {
  const { setSelected, onChange } = useContext(SelectContext);
  return (
    <li
      onClick={() => {
        setSelected(value);
        onChange(value);
      }}
    >
      {children}
    </li>
  );
};
  • 부모가 Provider로 상태를 감싸고 자식들이 각자 useContext로 꺼내 쓴다.
  • 자식의 깊이, 순서, 중간 래퍼에 아무런 제약이 없어서 마크업 구조를 완전히 자유롭게 결정할 수 있다.
  • Radix UI 같은 주요 라이브러리들이 전부 Context 기반 컴파운드 구조를 채택

실제 사용사례 : 어드민 페이지

어드민이라는 환경의 특수성

반복되는 UI 패턴으로 구성된 어드민 목록 화면

같은 구조가 되풀이되는 어드민 상세 화면

  • 어드민은 사용자가 내부 관리자이기 때문에 심미성보다 기능 완성 속도가 우선순위가 높다.
  • 요구사항의 변주가 크지 않고, 목록, 검색, 폼, 상세와 같이 반복되는 UI 패턴으로 화면간 통일성이 강하다.
  • 따라서, 비슷한 컴포넌트의 조합으로 화면이 구성되는, 컴파운드 패턴 기반 공용 컴포넌트 체계에 적합하다.

실제로도 조합만으로 새 기능 완성

function PlanListView() {
  return (
    <ListView>
      <ListView.Header as={<ListViewHeader />} />

      <ListView.Filter>
        <PlanListFilter />
      </ListView.Filter>

      <ListView.Menu>
        <ListView.Menu.Left>
          <PlanListMenu />
        </ListView.Menu.Left>

        <ListView.Menu.Right>
          <SelectListSize />
        </ListView.Menu.Right>
      </ListView.Menu>

      <ListView.Table>
        <PlanListTable />

        <ListView.Table.Pagination>
          <PlanListPagination />
        </ListView.Table.Pagination>
      </ListView.Table>
    </ListView>
  );
}

export default PlanListView;
function PlanListView() {
  return (
    <ListView>
      <ListView.Header as={<ListViewHeader />} />

      <ListView.Filter>
        <PlanListFilter />
      </ListView.Filter>

      <ListView.Menu>
        <ListView.Menu.Left>
          <PlanListMenu />
        </ListView.Menu.Left>

        <ListView.Menu.Right>
          <SelectListSize />
        </ListView.Menu.Right>
      </ListView.Menu>

      <ListView.Table>
        <PlanListTable />

        <ListView.Table.Pagination>
          <PlanListPagination />
        </ListView.Table.Pagination>
      </ListView.Table>
    </ListView>
  );
}

export default PlanListView;
  • 새 기능 요구가 들어오면 별도의 디자인 시안 없이, 기획 문서만 가지고 이미 만들어진 컴포넌트를 조합해 화면을 완성한다.
  • 어떤 컬럼이 들어갈지, 요소의 순서나 배치, 해당 정보 구성만 결정되면 빠르게 개발이 가능하다.

패턴 유지하기

잘 관리되는 팀의 경우,, 패턴 유지 및 관리 비용

공용 컴포넌트 컨벤션을 린트 규칙과 에이전트 스킬로 관리하는 구조

  • 공용 컴포넌트 체계의 통제력을 유지하려면 체계 밖의 커스텀 컴포넌트를 극도로 지양해야 하고, 이는 자연스럽게 빡센 컨벤션으로 이어진다.
  • 어드민 서비스 관리팀은 이 컨벤션을 린트 규칙, 문서로만 두지 않고, 룰과 함께 에이전트 스킬 형태로 촘촘하게 관리해서 코드 작성 시점에 강제되도록 하고 있다.
  • 빠른 개발을 얻은 만큼 컨벤션 문서화, 리뷰, 스킬 등의 지속적인 관리가 필수적이다.
  • 주기적으로 마이그레이션 → 에이전트 스킬 활용

우리팀의 경우,, 공용 컴포넌트를 못 건드리는 딜레마

  • 공용 컴포넌트에서 불편한 점을 발견해 개선하려 하면, 그 컴포넌트를 사용하는 모든 화면의 영향 범위를 전수 점검해야 한다.

공용 컴포넌트 하나가 여러 프로젝트에 영향을 주는 모노레포 구조

  • 모노레포를 사용하여, 공용 컴포넌트가 여러 프로젝트에 영향을 주는 경우
    • 다른 프로젝트, 다른팀의 개발자들을 모두 태그하여 영향을 판단하고 리뷰를 받아야한다.
    • 따라서 잘 건들이지 않게 되거나, 옵셔널 props를 추가하여 최대한 영향이 적도록 수정이 된다.
    • 이것은 공용 컴포넌트로 통일성을 지킨다는 초기 취지에서 벗어나진다.

왜 이런일이 발생할까?

  • 프론트엔드 코어팀의 부재
  • 어드민 과 b2c의 차이
    • 기획실, 디자인팀, 개발팀, 사업팀의 이해관계
      • 어드민 → 기획 + 개발 + 피드백이 한곳에서 이루어짐
      • b2c → 기획실에서 큰틀 → 개발팀이 개발 → 사업팀이 피드백

그래서 B2C에서는 하이브리드 방식, 토큰은 강제 & 컴포넌트는 선택

  • B2C 프로젝트에서는 어드민과 달리 전면적인 컴포넌트 공용화 대신 하이브리드 방식을 택했다.
  • 폰트, 색상 같은 디자인 토큰 레이어는 반드시 공통 정의를 따르도록 강제한다.
  • 컴포넌트 레이어는 버튼, 모달, GNB처럼 사용 빈도가 높은 소수만 공용으로 사용한다.
  • 그 외의 컴포넌트는 해당 기능의 맥락에 맞게 자유롭게 구현
    • 토큰을 쓰는 한 브랜드 일관성의 하한선은 유지된다.
  • 즉 체계의 강제력을 레이어별로 다르게 가져가는 전략

마치며

패턴의 성패는 코드가 아니라 환경이 결정

  • 같은 컴파운드 패턴이라도 어드민에서는 최고의 생산성 도구였고, B2C에서는 유지 비용이 효익을 넘어섰다.
  • 요구사항의 변주가 적고 통일성이 강한 환경일수록 공용화의 경계를 넓게, 변형이 많은 환경일수록 좁게

일관성은 공짜가 아니다

  • "조합만으로 빠른 개발"이라는 장점은 빡센 컨벤션, 린트 규칙, 에이전트 스킬, 주기적 마이그레이션이라는 지속적인 관리 비용 위에서만 성립한다.
  • 이 비용을 지불할 주체(코어팀 또는 관리 조직)가 없다면, 공용 컴포넌트는 개선되지 못한 채 옵셔널 props만 쌓이며 초기 취지에서 멀어진다.

기술 문제처럼 보이는 것의 뿌리는 조직 구조

  • 어드민은 기획 & 개발 & 피드백이 한곳에서 이루어지지만,
    • B2C는 기획실 → 개발팀 → 사업팀으로 분리되어 아키텍처를 고려하지 않은 변형이 계속 주입된다.
  • 공용 컴포넌트가 자꾸 깨지는 이유를 코드에서만 찾으면 답이 안 나오는 이유다.

참고문헌

이 글이 도움이 되었나요?좋아요는 다음 글을 쓰는 힘이 됩니다

다른 글