합성 컴포넌트

우디(박연기)·2025년 5월 11일
8
post-thumbnail

서론

이번 우테코 레벨2 두 번째 미션은 모달 라이브러리를 만들어 NPM에 배포하는 것이었다. 요구사항을 한마디로 요약하자면, 슈퍼 모달을 만드는 것이었다.

처음에는 지금까지 늘 그랬듯이, props를 받아서 그 값에 따라 렌더링을 제어하는 방식으로 구현했다. 이 방식으로도 초기 요구사항은 충분히 대응할 수 있었다. 하지만 새로운 요구사항이 생길 때마다 props를 계속 뚫고 내려야 했고, 분기문도 점점 복잡해지는 문제가 있었다. 또한 라이브러리를 만드는 입장에서 봤을 때, 이런 방식은 사용자에게 제공하는 자유도가 부족하다고 느꼈다.

함께 미션을 진행한 페어의 제안으로 합성 컴포넌트 방식을 적용해 보기로 했고, 이를 통해 더 높은 자유도와 재사용성을 확보할 수 있었다. 다만, 합성 컴포넌트라는 개념이 낯설어서, 미션을 진행하면서도 정확히 이해하지 못한 부분이 있었다.

그래서 이번 글에서는, 직접 겪었던 경험을 바탕으로 합성 컴포넌트가 무엇인지, 왜 사용하는지 알아보고자 한다.

합성 컴포넌트란?

합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미한다. 즉 쉬운 말로 컴포넌트에서 다른 컴포넌트 담기라고 정리할 수 있다.

const Parent = ({children}) => {
	return (<div>{children}</div>)
}

const App = () => {
	<Parent>
    	<Child />  
  </Parent>
}

간단한 예시로 html의 select 태그를 생각해보자. select는 select와 option 태그의 조합으로 이루진다.

<select>
	<option value="1">Option 1</option>
	<option value="2">Option 2</option>
</select>

select와 option은 각각 독립적으로는 큰 의미가 없지만 사용하는 곳에서 이를 조합해 사용함으로써 화면에 의미 있는 요소가 된다.

합성 컴포넌트 어떻게 사용할까?

<select><option>처럼 합성 컴포넌트부모 → 자식 구조로 함께 사용하는 방식이다. 부모 태그 내부에 자식 태그를 선언함으로써, 합성 컴포넌트를 구성할 수 있다.

이번에는 모달 컴포넌트를 예시로 들어, 합성 컴포넌트 패턴을 적용해보자.

우선, <option> 태그처럼 단독으로는 큰 의미를 가지지 못하지만, <select> 내부에 있을 때 의미를 가지는 요소들을 서브 컴포넌트라고 부르자. 그리고 이 서브 컴포넌트들을 적절히 감싸서 UI를 구성하는 Wrapper 성격의 컴포넌트메인 컴포넌트라고 칭하겠다.

먼저, 모달에서 자주 사용되는 요소인 모달 타이틀모달 버튼을 서브 컴포넌트로 만들어보자.

interface DialogTitleProps {
    children?: ReactNode;
}

function **DialogTitle**({children}: DialogTitleProps){
    return <div css={/*DialogTitle 스타일*/}>{children}</div>
}

interface DialogButtonProps {
    children?: ReactNode;
    onClick?: (e: MouseEvent) => void;
}

function **DialogButton**({children}: DialogLabelButtonProps){
    return <div css={/*DialogButton 스타일*/}>{children}</div>
}

이 두 컴포넌트는 그 자체로 렌더링이 가능하긴 하지만, <Dialog> 컴포넌트 안에서 사용되지 않는다면 그 의미가 모호해지고, 잘못된 방식으로 재사용될 가능성도 생긴다.

즉, 이 서브 컴포넌트들은 메인 컴포넌트인 <Dialog> 안에서 사용할 때 의미를 명확히 가질 수 있다.

이제 메인 컴포넌트인

를 만들어보자.

interface DialogProps {
    children?: ReactNode;
    isOpen: boolean;
}

function Dialog({children, isOpen}: DialogProps){
    if(!isOpen) {
        return null;
    }
    return createPortal(<div>{children}</div>, document.body)
}

이제 메인 컴포넌트가 준비되었으니, 위에서 만든 서브 컴포넌트들과 조합해서 사용할 수 있다.

서브 컴포넌트와 메인 컴포넌트들을 조합해서 사용하면 아래와 같이 사용할 수 있다.

<Dialog>
    <DialogTitle>모달의 **제목**</DialogTitle>
    <DialogButton>모달의 버튼</DialogButton>
</Dialog>

지금 구조에서는 사용자 입장에서 어떤 컴포넌트가 Dialog 안에 들어가야 하는지 직관적으로 파악하기 어렵다. 즉, Product가 무엇으로 합성되어야 하는지 명확하지 않다는 점에서 사용에 어려움이 생길 수 있다.

이 문제를 해결하기 위해, 합성에 사용될 서브 컴포넌트들을 객체 형태로 메인 컴포넌트에 보관하는 방식을 사용할 수 있다.

// 객체에 보관
Dialog.Title = DialogTitle
Dialog.Button = DialogButton

// 실제 사용
<Dialog>
    <Dialog.Title>모달의 **제목**</Dialog.Title>
    <Dialog.Button>모달의 버튼</Dialog.Button>
</Dialog>

현재 코드에서는 단순한 구조라 이 방식의 필요성이 크게 와닿지 않을 수 있다. 하지만 합성해야 할 컴포넌트가 많아지거나 구조가 복잡해질 경우, 이처럼 객체로 명시적으로 보관해두는 것이 어떤 컴포넌트가 함께 사용되어야 하는지를 명확하게 드러내고, 사용자 입장에서도 훨씬 효율적으로 사용할 수 있다.

합성 컴포넌트의 장점

  1. 관심사를 분리할 수 있다.

    const AwesomeConponent = () => {
    	const { data, isLoading, isError } = useAwesomeFetch();
      
      	if(isError) return <div>error...!<div
      	if(isLoading) return <div>loading...!<div>;
      	return <div>{data}<div>;
    }

    현재는 데이터 패칭, 에러 핸들링, 로딩에 대한 처리가 하나의 컴포넌트에서 일어난다. 이를 합성 컴포넌트 방식으로 변경하면 아래와 같이 변경할 수 있다.

    const AwesomeUI = () => {
    	<**ErrorBoundary** fallback={<div>error...!</div>}>
          <**Suspense** fallback={<div>loading...!</div>}>
          	<AwesomeComponent />                   
          </**Suspense**>
        </**ErrorBoundary**>                     
    }

    이렇게 하면, 에러를 처리하는 컴포넌트, 데이터 로딩을 처리하는 컴포넌트를 각각 분리하여 관심사에 따라 컴포넌트를 분리할 수 있다.

  2. 재사용성과 확장성이 높다

    const AwesomeModal = ({ isOpen, onClose, title, content }) => {
      if (!isOpen) return null;
    
      return (
        <div className="modal">
          <h1>{title}</h1>
          <p>{content}</p>
          <button onClick={onClose}>닫기</button>
        </div>
      );
    };

    위 예시처럼 합성 컴포넌트를 사용하지 않은 경우에는 title, content고정된 구조와 props만 허용되기 때문에, 사용자 입장에서 디자인을 커스터마이징하기 어렵고, 구조를 유연하게 바꾸기도 힘들다.

    // 선언
    const Modal = ({ isOpen, children }) => {
      if (!isOpen) return null;
    
      return <div className="modal">{children}</div>;
    };
    
    Modal.Title = ({ children }) => <h1>{children}</h1>;
    Modal.Content = ({ children }) => <p>{children}</p>;
    Modal.CloseButton = ({ onClick, children }) => (
      <button onClick={onClick}>{children}</button>
    );
    
    // 실제 사용
    <Modal isOpen={isOpen}>
      <Modal.Title>모달 제목</Modal.Title>
      <Modal.Content>모달 내용입니다.</Modal.Content>
      <Modal.CloseButton onClick={onClose}>닫기</Modal.CloseButton>
    </Modal>

    합성 컴포넌트를 사용하면 Modal.Title, Modal.Content, Modal.CloseButton과 같은 하위 컴포넌트를 필요에 따라 자유롭게 추가하거나 생략, 순서를 바꾸는 등 유연하게 커스터마이징할 수 있다.

    또한, 디자인이나 동작을 변경할 때 개별 컴포넌트만 수정하면 되기 때문에 유지보수도 쉽고, Modal.Content 같은 서브 컴포넌트를 다른 곳에서 독립적으로 재사용할 수도 있어 재사용성과 확장성 모두 뛰어나다.

언제 사용해야 할까?

이렇게 봤을 때, 합성 컴포넌트 방식이 더 좋아보이니 모든 컴포넌트를 합성 컴포넌트로 만들어야 할 것 같다. 하지만 무작정 합성 컴포넌트 방식을 사용하는 것은 좋지 않다.

너무 단순한 UI에도 합성 컴포넌트를 사용하는 것은 오히려  복잡하고 사용하기 불편해진다. 단일 책임을 지키려다가 오히려 조작이 번거롭고 비효율적이 될 수 있다.

또한 Context API를 함께 사용하는 경우 컴포넌트가 많이 분리되어 있으면, 컴포넌트가 서로 어떤 상태/컨텍스트를 공유하는지 파악하기 어려워질 수 있다.

그래서 아래와 같은 상황일 때 컴포넌트 합성 방식을 고려해 보자.

  1. 자식 컴포넌트 간에 공통 상태나 컨텍스트가 필요한 경우
  2. 다양한 조합을 허용하고 싶을 때
  3. 라이브러리처럼 사용자가 조립할 때
  4. 컴포넌트 간의 관계가 명확할 때

참고 자료

https://tech.kakaoent.com/front-end/2022/220731-composition-component/

https://happysisyphe.tistory.com/70

https://velog.io/@hyeon9782/10%EB%B6%84-%ED%85%8C%EC%BD%94%ED%86%A1-%ED%95%A9%EC%84%B1-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%ED%8C%A8%ED%84%B4

profile
프론트엔드 개발하는 사람

2개의 댓글

comment-user-thumbnail
2025년 5월 11일

합성 컴포넌트에 대해 이해하지 못한 부분을 다시 정리하는 모습이 멋있는데요 ~
우디 덕분에 합성 컴포넌트에 대해서 자세히 알 수 있어서 좋았습니다 👍

답글 달기
comment-user-thumbnail
2025년 5월 11일

내용 정리 잘하시네요~ 덕분에 저도 합성 컴포넌트 개념을 다시 돌아봤어요 😊

특히 객체의 프로퍼티로 하위 컴포넌트를 등록해두면, Modal.만 입력해도 자동완성으로 사용할 수 있는 컴포넌트들이 쭉 나오는 점이 편리하더라고요~

답글 달기