공통 컴포넌트, 합성 구조로 바꾸니 확실히 달라졌다

seung·2025년 3월 26일
0

👉 공통 컴포넌트를 개선하게 된 이유


공통 컴포넌트, props만으로 괜찮을까?

회사 업무를 하다 보면 자연스럽게 공통 컴포넌트를 개발하게 되는 경우가 많습니다. 저 역시 다양한 프로젝트에서 수많은 공통 컴포넌트를 만들어봤는데요, 초기에는 대부분 props를 기반으로 조건을 분기 처리하는 방식으로 구현했습니다.

처음엔 큰 문제가 없었습니다. 단순한 구조일 때는 size, variant, loading 같은 조건 몇 가지를 props로 넘겨주고, 내부에서 분기 처리하면 끝. 간결하고 깔끔해 보였죠.
하지만 시간이 지날수록 문제가 생기기 시작했습니다. 요구사항이 추가되거나 디자인이 변경될 때마다 새로운 조건이 props로 늘어났고, 그 수많은 조건들을 모두 내부에서 분기 처리하게 되니 코드가 점점 복잡하고 지저분해졌습니다.

복잡한 로직은 곧 협업의 어려움으로 이어졌습니다. 동료 개발자들이 코드를 이해하는 데 시간이 오래 걸렸고, 코드 리뷰에서도 반복적으로 같은 질문이 나왔습니다. "이건 왜 이렇게 처리된 거지?", "이 조건은 어디서 오는 거야?"

결국, 구조 자체를 바꾸는 것이 장기적으로 낫겠다는 판단을 하게 되었습니다.


UI 라이브러리에서 발견한 힌트

마침, 업무 중 사용하던 Mantine이라는 UI 라이브러리가 떠올랐습니다. Mantine은 전반적으로 props 기반이긴 하지만, 일부 컴포넌트는 합성 컴포넌트 형태로 구성되어 있었습니다.

예를 들어 Tabs, Accordion, Menu, RadioGroup 등은 각각의 하위 요소를 조합해서 쓰는 구조였는데요, 이 구조는 마치 블록을 조립하듯 유연하고, 필요할 때만 필요한 요소만 조합할 수 있어 매우 인상 깊었습니다.
무엇보다 마음에 들었던 점은, 공통 컴포넌트의 일부분만 커스터마이징해야 하는 경우에도 유연하게 대응할 수 있었다는 점이었습니다. props만으로 모든 UI나 상태를 제어할 필요가 없었기 때문에 오히려 코드가 더 단순해졌습니다.


그래서 도입해봤습니다. 합성 컴포넌트

Mantine의 구조를 참고해, 공통 컴포넌트를 합성 컴포넌트 패턴으로 리팩토링해보자고 제안했고, 직접 적용해봤습니다.

  • 복잡한 분기 처리를 역할 단위 컴포넌트로 분리
  • children을 통해 UI 요소를 유연하게 구성
  • 커스터마이징이 필요한 부분만 선택적으로 조립 가능
  • props 개수 감소 → 코드 가독성 향상 + 유지보수 용이


👉 합성 컴포넌트 도입과 리팩토링 과정


합성 컴포넌트란?

합성 컴포넌트(Compound Component) 패턴은 말 그대로 하나의 큰 컴포넌트를 여러 개의 작은 컴포넌트로 조합해서 구성하는 방식입니다.

예를 들어, 하나의 <Modal /> 컴포넌트를 사용할 때 내부에
<Modal.Header />, <Modal.Body />, <Modal.Footer />와 같은 서브 컴포넌트를 조립해서 사용하는 형태입니다.

<Modal>
  <Modal.Header>제목</Modal.Header>
  <Modal.Body>내용</Modal.Body>
  <Modal.Footer>버튼</Modal.Footer>
</Modal>

이런 구조를 사용하면,

  • 구성 요소를 자유롭게 조립할 수 있고
  • 불필요한 props 전달 없이 역할에 따라 컴포넌트를 분리할 수 있으며
  • 복잡한 조건 분기 없이도 유지 보수성과 확장성이 높아지는 장점이 있습니다.

합성 컴포넌트는 마치 블록을 조립하는 것처럼 유연한 구조를 가능하게 합니다.


기존 공통 컴포넌트 구조

예시로 기존 프로젝트에서 사용했던 Dialog 컴포넌트를 소개하겠습니다. 이 컴포넌트는 다양한 상황에 대응하기 위해 점점 많은 props를 받게 되었습니다.
처음에는 단순한 title, size, onConfirm 등 단순한 정도로 시작했지만, 요구사항이 쌓이면서 커스터마이징 옵션, 상태 플래그, 스타일 props까지 점점 늘어났죠.

결국 props만으로는 감당하기 어려운 구조가 되었고, 내부 로직도 점점 복잡해졌습니다.
정말 지금 보면 너무나 부끄럽고 말도 안되는 코드입니다.

const Dialog = ({
	title = '',
  children,
  size = 'lg',
  confirmBtnText = '확인',
  closeBtnText = '취소',
  confirmDisabled,
  onConfirm = () => {},
  onClose = () => {},
  onClickCloseBtn,
  useHeader = true,
  useFooter = true,
  useCloseBtn = true,
  customStyle,
  headerCustomStyle,
  bodyCustomStyle,
  footerCustomStyle,
  isLoading = false,
  isDirty,
  hasCancelBtn = false,
  // ...
}) => {
  // ...
}
<Dialog
  title={couponType.title}
  onClose={handleClose}
  customStyle={{ ... }}
  bodyCustomStyle={{ ... }}
  confirmBtnText={couponType.btnText}
  onConfirm={handleSubmit(handlePostCoupon)}
  isDirty={isDirty || formIsDirty}
  confirmDisabled={selectedMemberList?.length === 0}
>
  // ...
</Dialog>

더 큰 문제는, header나 footer 같은 일부 영역만 커스터마이징해야 할 때였습니다. props만으로는 표현할 수 없어, 결국 새로 컴포넌트를 만들거나 기존 구조를 억지로 끼워 맞춰야 했습니다.

또 props를 추가할 때마다 기존 사용처에 사이드 이펙트가 발생하는 경우도 있었고, “공통 컴포넌트”를 써야 하는데 오히려 재사용성과 유지보수가 더 힘들어졌습니다.
이 구조는 점점 확장에는 약하고, 복잡도만 커지는 구조가 되었습니다.

그래서 합성 컴포넌트 구조라면 이런 문제를 해결할 수 있을거라 판단했습니다.


합성 컴포넌트 구조로 변경하기

1. Title / Content / Footer를 역할 단위로 분리

일단 기존 Dialog 컴포넌트를 Title / Content / Footer 구조로 역할을 나누었습니다.

그리고, 각 역할 단위를 서브 컴포넌트로 정의하고 displayName을 지정해 children에서 식별할 수 있도록 구성했습니다.

const Title = props => <SC.Title {...props}>{props.children}</SC.Title>
Title.displayName = 'PopupTitle'

const Content = props => <SC.Content {...props}>{props.children}</SC.Content>
Content.displayName = 'PopupContent'

const Footer = props => <SC.Footer {...props}>{props.children}</SC.Footer>
Footer.displayName = 'PopupFooter'

2. props 기반 기본 구조도 유지

간단한 텍스트 기반 팝업의 경우, 여전히 props로 간편하게 사용할 수 있도록 PopupMain에서 기본 구조를 props로 정의했습니다.

export const PopupMain = ({
  children,
  title,
  content,
  confirmText = '확인',
  cancelText = '취소',
  direction = 'row',
  onConfirm,
  onCancel,
  buttonTheme = 'primary',
  disabled = false,
}) => {
	// ...
}

3. children 내부에서 서브 컴포넌트 탐색

서브 컴포넌트는 displayName을 기준으로 children 안에서 탐색하여 정해진 위치에 렌더링되도록 처리했습니다. 중첩된 Fragment 안에서도 찾을 수 있도록 재귀 로직도 포함했습니다.
이렇게 찾은 PopupTitle, PopupContent, PopupFooter를 우선적으로 렌더링하고, 없을 경우 props 값을 렌더링하도록 했습니다.

// displayName으로 컴포넌트 찾기
const findChildByDisplayName = (children, targetDisplayName) => {
  let found = null;

  React.Children.toArray(children).some(child => {
    if (React.isValidElement(child)) {
      if (child.type.displayName === targetDisplayName) {
        found = child;
        return true;
      }
      if (child.type === React.Fragment) {
        found = findChildByDisplayName(child.props.children, targetDisplayName);
        return found !== null;
      }
    }
    return false;
  });

  return found;
};

// 서브 컴포넌트를 가져옴
const getPopupTitle = children => findChildByDisplayName(children, 'PopupTitle');
const getPopupContent = children => findChildByDisplayName(children, 'PopupContent');
const getPopupFooter = children => findChildByDisplayName(children, 'PopupFooter');

export const PopupMain = ({
	// ...
}) => {
	// ...

  if (ref.current && mounted) {
    return createPortal(
      <SC.Popup>
        <SC.Background />
        <SC.Container>
          {popupTitle}
          {!popupTitle && title && (
            <Title>
	            // ...
            </Title>
          )}

          {popupContent}
          {!popupContent && content && (
            <Content>
              // ...
            </Content>
          )}

          {popupFooter ?? (
            <SC.Footer col={direction}>
              // ...
            </SC.Footer>
          )}
        </SC.Container>
      </SC.Popup>,
      ref.current
    )
  }

  return null
}

4. Popup에 서브 컴포넌트 바인딩

최종적으로 PopupMain에 서브 컴포넌트를 바인딩해 하나의 컴포넌트처럼 사용할 수 있도록 했습니다.

export const Popup = Object.assign(PopupMain, {
  Title,
  Content,
  Footer,
})

기본 구조를 유지하면서 유연한 확장도 가능하게

기본적인 title, content, confirmText, onConfirm 등은 props를 통해 간단하게 사용할 수 있도록 기본 구조도 그대로 유지했습니다.

<Popup
  title="전자 결제 신청이 완료되었습니다"
  content="30일 내 입점 심사 및 카드사 심사가 필요합니다."
  confirmText="제출"
  onConfirm={close}
/>

하지만 필요한 경우, children으로 합성 컴포넌트 방식의 유연한 구성도 가능합니다.

<Popup onConfirm={close} onCancel={close} direction="row">
  <Popup.Title>
    <Typography.Title3>전자 결제 신청이 완료되었습니다</Typography.Title3>
  </Popup.Title>
  <Popup.Content>
    <List>
      <List.Item>30일 내 입점 심사 및 카드사 심사가 필요합니다.</List.Item>
    </List>
  </Popup.Content>
</Popup>

기존 구조와 비교해 좋아진 점

  • 컴포넌트 복잡도 감소: props 분기 대신 역할 분리로 구조가 단순해짐
  • 유연한 커스터마이징: 특정 영역만 바꿔야 할 때도 컴포넌트 수정 없이 해결 가능
  • 협업 편의성 증가: 컴포넌트 의도가 명확해져 다른 개발자가 쉽게 이해하고 사용할 수 있음
  • 코드 재사용성 증가: 동일한 레이아웃을 다양한 형태로 손쉽게 구성 가능


👉 마무리하며


처음엔 그냥 props 몇 개 넘겨주면 되겠지 싶었습니다.
하지만 시간이 지나면서 "이 컴포넌트… 내가 만든 거 맞나?" 싶은 상황이 벌어졌죠.
조건문 덕지덕지, props 수십 개, 이해하기 어려운 분기들로 점점 괴물이 되어버린 공통 컴포넌트...

그러던 중 Mantine을 사용하면서 합성 컴포넌트 구조를 접하게 되었고, 역할 단위로 나누고 조립하는 방식이 훨씬 유연하고 직관적이라는 걸 깨달았습니다.

합성 컴포넌트 패턴을 도입한 뒤로는

  • 코드가 훨씬 단순하고 읽기 쉬워졌고,
  • 필요한 부분만 쏙 바꿔 끼울 수 있으니 유연하게 대응할 수 있었고,
  • 동료들과 협업할 때도 “이건 뭐지?”라는 질문이 확 줄었습니다.

props 기반의 간단한 사용 방식은 유지하면서도, 필요한 경우엔 블록처럼 조립 가능한 구조로 확장할 수 있는 점이 특히 유용했습니다.

복잡해진 공통 컴포넌트로 고민하고 있다면, 합성 컴포넌트 패턴은 충분히 도입해볼 만한 좋은 선택지라고 생각합니다.

profile
🌸 좋은 코드를 작성하고 싶은 프론트엔드 개발자 ✨

0개의 댓글

관련 채용 정보