합성 컴포넌트 도입을 통한 UI 컴포넌트 아키텍처 개선

0. 기존 방식의 문제점

합성 컴포넌트 패턴을 도입하기 전에는 모든 옵션을 props로 전달하는 방식을 주로 사용했습니다. 이 방식에는 여러 문제점이 있었습니다:

Props 폭발(Props Explosion)

컴포넌트가 복잡해질수록 props의 수가 기하급수적으로 증가하여 인터페이스가 복잡해졌습니다.

// 합성 컴포넌트를 사용하지 않은 예시
<Modal
  title="크루원 초대"
  content={<MemberInviteForm formMethods={formMethods} />}
  showOverlay={true}
  overlayClickHandler={handleClose}
  leftButtonText="닫기"
  leftButtonAction={handleClose}
  rightButtonText="초대"
  rightButtonAction={handleSubmit}
/>

가독성 저하

많은 props로 인해 코드의 가독성이 떨어지고, 컴포넌트의 실제 구조를 이해하기 어렵습니다.

1. 합성 컴포넌트를 사용하는 이유

합성 컴포넌트 패턴은 이러한 복잡한 UI 요소를 구현할 때 널리 사용되는 패턴입니다. 이 패턴을 사용하는 주요 이유는 다음과 같습니다:

관심사의 분리

각 서브 컴포넌트가 자신의 책임에만 집중할 수 있게 해줍니다.

<Select>
  <Select.Trigger>선택하기</Select.Trigger>
  <Select.Options>
    <Select.Option value="apple">사과</Select.Option>
    <Select.Option value="banana">바나나</Select.Option>
  </Select.Options>
</Select>

선언적 사용

React의 본질은 선언적 프로그래밍이며, 합성 컴포넌트는 이를 극대화합니다. 개발자가 "어떻게" 구현할지가 아니라 "무엇을" 구현할지에 집중할 수 있게 해줍니다.

<Tabs>
  <Tabs.List>
    <Tabs.Tab>첫 번째 탭</Tabs.Tab>
    <Tabs.Tab>두 번째 탭</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel>첫 번째 내용</Tabs.Panel>
    <Tabs.Panel>두 번째 내용</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

구성 가능성

필요한 부분만 선택적으로 사용할 수 있습니다.

// 필요한 부분만 선택적으로 사용 가능
<Dialog>
  <Dialog.Title>알림</Dialog.Title>
  {/* Dialog.Description은 필요 없어 생략 */}
  <Dialog.Content>
    <p>이 작업을 진행하시겠습니까?</p>
  </Dialog.Content>
  <Dialog.Actions>
    <Button>취소</Button>
    <Button primary>확인</Button>
  </Dialog.Actions>
</Dialog>

2. 기본 합성 컴포넌트

처음에는 각 서브 컴포넌트가 특정 레이아웃을 가지도록 한 단계 더 감싸는 상위 컴포넌트를 추가하는 방식으로 구현하였습니다.

// 사용 예시
<Modal>
  <Modal.Title>크루원 초대</Modal.Title>
  <Modal.Content>
    <MemberInviteForm formMethods={formMethods} />
  </Modal.Content>
  <Modal.ButtonsBox> {/*레이아웃을 위한 서브 컴포넌트*/}
    <Modal.Button handleClick={handleClose}>닫기</Modal.Button>
    <Modal.Button handleClick={handleSubmit} variant="primary">초대</Modal.Button>
  </Modal.ButtonsBox>
</Modal>
  • 예를 들어 버튼들의 레이아웃을 지정하기 위해 ButtonBox라는 레이아웃을 나타내는 상위 컴포넌트를 만들어 감싸주었습니다.

의문점

이 방식을 사용하다보니 문득 의문이 들었습니다. 버튼들에 단순히 레이아웃을 적용하기 위해 추가 래퍼 컴포넌트를 두는 것이 맞는것일까?

이러면 나중에 복잡한 합성 컴포넌트 같은 경우 무수히 많은 래퍼 컴포넌트들이 생기지 않을까? 합성 컴포넌트들을 사용할 때마다 래퍼 컴포넌트들을 기억해야한다는게 과연 좋은 방법일까? 이럴 때는 어떻게 해결하면 좋을까? 싶은 의문이 들었습니다.

이를 해결하기 위해, props.children을 활용하여 원하는 컴포넌트를 추출하고, 컴포넌트 내부에서 자동으로 배치하는 방식을 고려했습니다.

3. 심화된 합성 컴포넌트: Children API 활용

React의 Children API와 isValidElement를 사용하여, 특정 컴포넌트들을 필터링하고, 상위 컴포넌트에서 하위 컴포넌트를 배치하는 방식입니다.

특정 타입의 컴포넌트만 필터링하는 유틸리티 함수

이 함수는 React의 Children.toArrayisValidElement를 활용하여 특정 타입의 컴포넌트만 필터링합니다.

import { Children, isValidElement, ReactNode } from 'react';

export const filterChildrenByComponent = (children: ReactNode, component: JSX.Element['type']) => {
  const childrenArray = Children.toArray(children);
  return childrenArray.filter((child) => isValidElement(child) && child.type === component);
};
  • Children.toArray(children): children을 배열로 변환

  • isValidElement(child) && child.type === component: 특정 컴포넌트 타입만 필터링

자동으로 레이아웃을 구성하는 상위 컴포넌트

메인 컴포넌트는 children으로부터 필요한 컴포넌트를 필터링하고, 이를 원하는 위치에 배치합니다. 이렇게 하면 사용자는 순서에 상관없이 서브 컴포넌트를 제공할 수 있으며, 레이아웃은 메인 컴포넌트에서 제어됩니다.

function Main({ children }: PropsWithChildren) {
  // 각 컴포넌트 타입별로 필터링
  const overlayElement = filterChildrenByComponent(children, Overlay);
  const titleElement = filterChildrenByComponent(children, ModalTitle);
  const bodyElement = filterChildrenByComponent(children, ModalBody);
  const leftButtonElement = filterChildrenByComponent(children, ModalLeftButton);
  const rightButtonElement = filterChildrenByComponent(children, ModalRightButton);

  return (
    <>
      {overlayElement}
      <div className={styles.modal}>
        <div className={styles.modalBox}>
          {titleElement}
          <div className={styles.modalContent}>
            {bodyElement}
          </div>
          <div className={styles.modalButtons}>
            {leftButtonElement}
            {rightButtonElement}
          </div>
        </div>
      </div>
    </>
  );
}
  • 컴포넌트 내부에서 레이아웃을 자동 적용

  • 사용자는 단순히 Modal 내부에 원하는 컴포넌트 배치

  • 버튼을 감싸는 ButtonBox 같은 불필요한 래퍼 제거

사용 코드


<Modal>
  <Modal.Overlay handleClick={handleClose} />
  <Modal.Title>크루원 초대</Modal.Title>
  <Modal.Body>
    <MemberInviteForm formMethods={formMethods} />
  </Modal.Body>
  <Modal.LeftButton handleClick={handleClose}>닫기</Modal.LeftButton>
  <Modal.RightButton handleClick={handleSubmit}>초대</Modal.RightButton>
</Modal>
  • 확실히 이전의 ButtonBox 래퍼 컴포넌트가 없어 깔끔해 보이기는 합니다.

  • 하지만 이 방식에는 확장성이 떨어지는 크리티컬한 문제가 존재합니다.

확장성 문제

  1. 하드코딩된 컴포넌트 구조:

    • 새로운 UI 요소(예: Modal.Description)를 추가하려면 메인 컴포넌트를 수정해야 합니다.

    • 배치와 스타일이 메인 컴포넌트에 하드코딩되어 있어 커스터마이즈가 제한적입니다.

  2. 제한된 유연성

    • 컴포넌트 순서나 중첩 구조를 쉽게 변경할 수 없습니다.

    • 사용자가 원하는 레이아웃을 자유롭게 구성하기 어렵습니다.

결론 및 느낀점

어떤 합성 컴포넌트 방식을 선택할지는 컴포넌트의 역할과 사용 목적에 따라 결정할 수 있을 것 같습니다. 만약 특정 UI 패턴이 정해져 있고, 사용자가 버튼을 어느 위치에 배치해야 하는지 고민할 필요가 없다면, 2번 방식(Children API 활용)이 적절할 것 같습니다. 예를 들어, 모달의 버튼이 항상 하단에 배치되어야 한다면 이 방식을 사용하면 일관된 디자인을 유지할 수 있고, 사용자가 불필요한 마크업을 신경 쓰지 않아도 됩니다.

반면, 버튼의 배치를 변경해야 하거나 특정 레이아웃을 커스텀할 필요가 있다면, 1번 방식(레이아웃 제공 래퍼 컴포넌트 활용)이 더 유용할 수 있습니다. 예를 들어, 버튼이 모달 하단이 아닌 특정 위치에 배치될 수도 있다면 1번 방식이 더 적합하며, 유연성과 재사용성을 극대화할 수 있습니다.

Last updated