logo

합성 컴포넌트 패턴을 응용하여 디자인 시스템 개선하기

작성 2025년 2월 17일

업데이트 2025년 2월 17일

Yess 팀에서는 빠른 속도와 높은 퀄리티를 함께 달성하기 위한 최선의 툴이 디자인 시스템이라고 판단했다. 이 두 가지를 쉽게 달성할 수 있도록 도와주는 도구로 Headless UI 라이브러리를 선택했고, Radix Primitives(이하 Radix)를 도입하였다. 대체적으로 만족하며 운영하고 있지만, 당연히 완벽한 시스템은 아니다. 이번 글에서는 디자인 시스템을 구현한 이후 합성 컴포넌트 패턴을 응용하여 개선한 사례에 대하여 정리해 보고자 한다.

초기 스타트업에서 디자인 시스템을 어떻게 도입하게 되었는지에 대해서는 팀 블로그에 관련 글을 게재하였는데, 해당 글에서 자세한 내용을 확인할 수 있다.

합성 컴포넌트 패턴

Radix를 도입하고 유지보수 하면서 느낀 가장 큰 장점은 합성 컴포넌트 패턴의 확장성과 유연함이다. 합성 컴포넌트 패턴(Compound Component Pattern)은 객체지향 프로그래밍의 디자인 패턴 중 Composite Pattern을 React의 컴포넌트 설계에 반영한 것이다. 세부 역할을 담당하는 컴포넌트들을 조합하여 전체를 구성하는 형태이다.

// prop 전달 패턴
<Dialog
  title="Discard unsaved changes?"
  description="It looks like you have unsaved changes. Are you sure you want to leave now?"
  onClose={handleClose}
  onCancel={handleCancel}
  onConfirm={handleConfirm}
/>

// 합성 컴포넌트 패턴
<Dialog.Content>
  <Dialog.Title>Discard unsaved changes?</Dialog.Title>
  <Dialog.Description>It looks like you have unsaved changes. Are you sure you want to leave now?</Dialog.Description>
  <Dialog.CloseButton onClick={handleClose}>X</Dialog.CloseButton>
  <Dialog.CancelButton onClick={handleCancel}>Cancel</Dialog.CancelButton>
  <Dialog.ConfirmButton onClick={handleConfirm}>Confirm</Dialog.ConfirmButton>
</Dialog.Content>

합성 컴포넌트 패턴으로 컴포넌트를 구현하면 언뜻 보았을 때는 사전에 구조 학습도 해야 하고 사용처에서 작성할 코드가 늘어나는 것처럼 보이기도 한다. 그러나 그 덕분에 사용처에서 컴포넌트의 구조를 쉽게 파악할 수 있으며 필요에 따라 가감하는 것도 쉬워진다. 특히 디자인 시스템은 내부 로직을 자주 들여다 볼 필요 없이 빠르게 빌딩 블록을 쌓아 올리는 방향으로 구현되어야 편의성이 증대된다. 어쩌다가 내부 로직을 수정해야 할 때에도 컴포넌트가 작은 단위로 분리되어 있는 편이 코드 파악의 난이도가 낮아지는 효과가 있다.

합성 컴포넌트 패턴이 유지보수에 유리한 이유는 SOLID 원칙이 반영되었기 때문이다. 하나의 컴포넌트에 너무 많은 prop들을 부여하고 컴포넌트 내부에서는 이 prop들이 서로 의존성으로 엮여 있을수록 복잡도가 올라간다. 최종적으로 담당하는 기능은 동일함에도 불구하고, 이런 prop 전달 패턴에서는 prop을 하나 하나 파악하는 것부터가 일이다. 불필요한 prop을 마구 추가하거나 문제적인 prop을 제거하기 어려워지는 등 계속해서 문제를 가중시키는 잘못된 경향성으로 이어지기도 쉽다.

이는 굳이 의식하지 않고 React로 컴포넌트를 구현하다 보면 잘 빠지게 되는 함정이기도 하다. 그렇다고 해서 모든 경우에 합성 컴포넌트 패턴을 적용해야 하는 것도 아니기 때문에, 일반적으로는 컴포넌트의 역할이 과도해지지 않도록 주의하는 정도로 충분하다고 생각한다. 만약 유지보수를 하다가 특정 컴포넌트가 너무 비대해지고 있다면 그때 어떤 식으로 리팩토링해야 좋을지 고민해 보고, 필요에 따라 합성 컴포넌트 패턴을 적용하면 된다고 생각한다.

합성 컴포넌트 패턴을 응용하여 디자인 시스템 개선하기

개선 사례 1: Dialog, Popover

Radix의 DialogPopover API에서 제공하는 컴포넌트 구조는 크게 세 가지 요소로 이루어져 있다.

  • Root
    • 컴포넌트 최상위 요소로, React의 Context API를 통해 하위 컴포넌트들에서 참조하는 데이터를 관리한다.
  • Trigger
    • 클릭시 Content를 표시하는 버튼
  • Content
    • Trigger 클릭시 표시되는 UI

Radix는 Headless UI 라이브러리여서 정해진 구조를 지키는 것 외에는 커스텀 자유도가 높다. 다만 정해진 구조 내에서 특정 요소는 커스텀으로 인해 과도하게 복잡해질 수 있는 여지가 있다. 사용처마다 요구사항이 다르기 때문에 이런 한계는 의도적인 것이라고 볼 수 있다. 이런 이유로 Radix에서는 Content의 하위 요소를 제공하지 않는다. 그러다 보니 Content UI가 복잡해질수록 합성 컴포넌트의 장점이 희석되고 UI 버그도 잦아졌다. 이 문제를 해결하기 위해서 Content 컴포넌트를 보다 더 세부적인 요소들로 쪼개어 nested 합성 컴포넌트로 개선했다. 위계는 조금 다르지만 HTML의 header, body, footer를 차용했다.

  • Root
  • Trigger
  • Content
    • Header
      • 상단에 고정되어 있다. Title, Description, CloseButton 등이 위치한다.
    • Body
      • max-height을 넘어가면 자동으로 스크롤된다. 메인 UI가 표시된다.
    • Footer
      • 하단에 고정되어 있다. 주로 SubmitButton을 비롯한 버튼들이 위치한다.
<Dialog.Root>
  <Dialog.Trigger>
    <Button>Open dialog</Button>
  </Dialog.Trigger>
  <Dialog.Content {...props}>
    <Dialog.Header>
      <HFlexBox justifyContent="space-between">
        <Dialog.Title>Discard unsaved changes?</Dialog.Title>
        <Dialog.CloseButton>
          <CloseButton ref={closeButtonRef} />
        </Dialog.CloseButton>
      </HFlexBox>
      <Dialog.Description>
        It looks like you have unsaved changes. Are you sure you want to leave
        now?
      </Dialog.Description>
    </Dialog.Header>
    <Form>
      <Dialog.Body>
          <Input.Field>
            <Input.Label>Enter &apos;Leave anyway&apos;</Input.Label>
            <Input.TextInput ref={textInputRef} />
          </Input.Field>
      </Dialog.Body>
      <Dialog.Footer>
        <HFlexBox gap={8} justifyContent="flex-end">
          <Button onClick={closeModal}>
            Cancel
          </Button>
          <SubmitButton onClick={handleSubmit}>Confirm</SubmitButton>
        </HFlexBox>
      </Dialog.Footer>
    </Form>
  </Dialog.Content>
</Dialog.Root>

개선 사례 2: DatePicker, DateRangePicker

DatePicker와 DateRangePicker의 경우 레거시 컴포넌트에서 사용하던 react-dates 라이브러리의 사용성이 좋지 않아서 스타일 커스텀이 어렵고 버그에도 취약했다. 또한 모든 설정을 prop으로 제어하는 점이 불편하여 새로운 디자인 시스템의 패턴에 맞춰서 개선이 필요했다. Radix에서 캘린더 관련 API는 제공하지 않아서 레퍼런스를 찾던 중, 디자이너 분께서 shadcn/ui의 캘린더 컴포넌트 디자인이 괜찮아 보이는데 그대로 사용할 수 있겠냐고 여쭤봐 주셨다.

확인해 보니 shadcn/ui는 스타일링을 Tailwind CSS로 하고 있어 styled-components를 사용하고 있는 Yess 프론트엔드 코드베이스와 호환성이 우려되었다. 빌드 설정을 건드리는 것에 블랙박스가 있기도 했지만, 무엇보다도 라이브러리 3중 충돌 이슈를 겪고 난 시점이다 보니 캘린더 하나를 위해 리스크를 감수하는 것보다는 다른 방법을 찾는 게 나을 것 같았다. 마침 shadcn/ui의 캘린더는 react-day-picker라는 라이브러리에 스타일만 입힌 컴포넌트였고, 스타일 자체도 그리 복잡하지 않았다. 다행히 해당 스타일만 가져와서 styled-component로 동일하게 구현할 수 있었다.

또한 shadcn/ui에서 Radix의 Popover API를 기반으로 DatePicker와 DateRangePicker를 구현한 것 역시 차용했다. 해당 구조에서 캘린더 컴포넌트는 Content의 하위 요소이다. 이를 코드로 구현하면 다음과 같은 형태가 된다.

<DatePicker.Root {...props} value={value} onChange={setValue}> {/* Radix Popover API 사용 */}
  <DatePicker.Trigger
    placeholder="Select date and time"
    timeFormatOption={DateTime.DATETIME_MED}
  />
  <DatePicker.Content>
    <DatePicker.Calendar /> {/* shadcn/ui 스타일 참고 */}
    <VFlexBox gap={8}>
      <HFlexBox gap={8}>
        <DatePicker.DateDisplay />
        <DatePicker.TimeInput />
      </HFlexBox>
      <DatePicker.TimeZone />
    </VFlexBox>
  </DatePicker.Content>
</DatePicker.Root>

개선 사례 3: SearchableDropdown

SearchableDropdown은 Radix의 Popover API를 기반으로 구현한 컴포넌트이다. 이 컴포넌트에서는 Trigger 영역에 텍스트 입력 가능한 인풋이 위치한다. 해당 인풋에 텍스트를 입력하면 그 값과 매칭되는 선택지들만이 필터링되어 Content에 표시된다. 이 동작이 자동 완성이라고도 불리다 보니, 일부 라이브러리에서는 Autocomplete이라는 네이밍을 사용하기도 한다.

SearchableDropdown의 스펙 중 유의할 부분은 Trigger에서 값을 입력할 때 검색이 비동기로 이루어질 수도 있다는 것이다. 예를 들어 LocationSearchableDropdown이라는 컴포넌트는 입력값을 쿼리로 구글 맵 API에 요청을 보내 매칭되는 지역 목록을 받아와 Content에 표시한다. 이 기능을 구현하기 위해서 Root에 합성 컴포넌트 패턴을 적용하였다.

LocationSearchableDropdown 동작 예시|300

Root의 prop으로 제어하는 경우

검색이 비동기로 이루어지는지 여부에 따라서 SearchableDropdown의 구현이 심각하게 복잡해질 정도로 큰 차이는 없다. 하지만 차이가 있는 것은 사실이고 그렇기 때문에 사용처에서는 인터페이스를 파악하여 용도에 맞게 활용해야 한다. 디자인 시스템에서는 비동기 검색을 수행하기 위해 최소한 요청 함수 및 로딩 상태를 사용처로부터 전달받아야 한다. 이처럼 하위 요소들이 전역적으로 참조할 수 있는 데이터는 최상단의 Root에서 컨텍스트로 관리되는 것이 일반적이다. 이번에도 Root에 asyncSearchFn 같은 prop을 추가함으로써 요구사항을 만족할 수는 있지만, 하나의 Root 컴포넌트에서 서로 배타적인 케이스(동기와 비동기)의 prop이 혼재하는 것이 우려되었다. 또한 asyncSearchFn처럼 특정 케이스에서는 필수인 prop이 인터페이스에서는 옵셔널로 표시되기 때문에 타입이 모호해진다.

type SearchableDropdownMode = "sync" | "async";

type SearchableDropdownContextValue = {
  mode: SearchableDropdownMode;
  asyncSearchFn?: (value: string) => Promise<void>;
  asyncSearchFnIsLoading?: boolean;
}

const SearchableDropdownContext =
  React.createContext<SearchableDropdownValue | null>(null);

type Props = Popover.PopoverProps & SearchableDropdownContextValue

// 사용처에서 Root로 넘겨준 prop이 바로 Context에 반영된다.
export function Root({
  children,
  mode,
  asyncSearchFn,
  asyncSearchFnIsLoading,
  ...props,
}: Props) {
  const contextValue = useMemo<SearchableDropdownContextValue>(() => {
    return {
      mode,
      asyncSearchFn,
      asyncSearchFnIsLoading,
    };
  }, [
    mode,
    asyncSearchFn,
    asyncSearchFnIsLoading,
  ]);

  return (
    <Popover.Root {...props}>
      <SearchableDropdownContext.Provider value={contextValue}>
        {children}
      </SearchableDropdownContext.Provider>
    </Popover.Root>
  )
}

// 논리적으로는 말이 되지 않는 이런 유즈케이스를 막을 수 없다.
<SearchableDropdown.Root
  mode="sync"
  asyncSearchFn={asyncSearchFn}
  asyncSearchFnIsLoading={asyncSearchFnIsLoading}
>
  {/* ... */}
</SearchableDropdown.Root>

Root를 용도 별로 분리하는 경우

위와 같은 우려 사항을 해소하기 위해서 각 케이스에 맞는 Root 컴포넌트를 분리하였다. 동기적으로 검색을 수행하는 케이스에는 SyncRoot, 비동기적으로 검색을 수행하는 케이스에는 AsyncRoot를 사용하도록 하고 각 Root 컴포넌트에서 Context 컴포넌트로 필요한 prop들을 넘겨주도록 했다. 이렇게 되면 Context가 은닉되는 효과가 발생하여 기존에 Root가 하나일 때처럼 사용처가 Context의 prop 인터페이스를 그대로 따르지 않아도 된다. 사용처에서는 필요한 Root를 선택하여 컴포넌트를 구성하기 때문에 코드를 작성하는 시점에 유즈케이스를 명확하게 인지하고 그에 맞는 인터페이스만 고려하면 되는 것이다.

// 필요하지 않은 ContextProps는 제외한다.
type SyncRootProps = PropsWithChildren<
  Omit<
    ContextProps,
    "asyncSearchFn" | "asyncSearchFnIsLoading" | "mode"
  >
>;

export function SyncRoot(props: SyncRootProps): ReactElement {
  return <Context {...props} mode="sync" />;
}

// 필요하지 않은 ContextProps는 제외하고, 필수 요소는 필수로 명시한다.
type AsyncRootProps = PropsWithChildren<
  Omit<
    ContextProps,
    "asyncSearchFn" | "asyncSearchFnIsLoading" | "mode"
  > &
  Required<Pick<ContextProps, "asyncSearchFn" | "asyncSearchFnIsLoading">>
>;

export function AsyncRoot({
  asyncSearchFn,
  asyncSearchFnIsLoading,
  ...props
}: AsyncRootProps): ReactElement {
  return (
    <SearchableDropdownContextRootProvider
      {...props}
      asyncSearchFn={asyncSearchFn}
      asyncSearchFnIsLoading={asyncSearchFnIsLoading}
      mode="async"
    />
  );
}

type SearchableDropdownMode = "sync" | "async";

type SearchableDropdownContextValue = {
  mode: SearchableDropdownMode;
  asyncSearchFn?: (value: string) => Promise<void>;
  asyncSearchFnIsLoading?: boolean;
}

const SearchableDropdownContext =
  React.createContext<SearchableDropdownValue | null>(null);

type ContextProps = Popover.PopoverProps & SearchableDropdownContextValue;

// Context는 export되지 않고 사용처가 선택한 Root를 통해 케이스 별로 초기화된다.
function Context({
  children,
  mode,
  asyncSearchFn,
  asyncSearchFnIsLoading,
  ...props,
}: ContextProps) {
  const contextValue = useMemo<SearchableDropdownContextValue>(() => {
    return {
      mode,
      asyncSearchFn,
      asyncSearchFnIsLoading,
    };
  }, [
    mode,
    asyncSearchFn,
    asyncSearchFnIsLoading,
  ]);

  return (
    <Popover.Root {...props}>
      <SearchableDropdownContext.Provider value={contextValue}>
        {children}
      </SearchableDropdownContext.Provider>
    </Popover.Root>
  )
}

// 용도 별로 명확하게 구분하여 사용한다.
<SearchableDropdown.SyncRoot>
  {/* ... */}
</SearchableDropdown.SyncRoot>

<SearchableDropdown.AsyncRoot
  asyncSearchFn={asyncSearchFn}
  asyncSearchFnIsLoading={asyncSearchFnIsLoading}
>
  {/* ... */}
</SearchableDropdown.AsyncRoot>