본문으로 바로가기

[React] Compound Pattern 파헤치기

category 1. 웹개발/1_1_5 React JS 2024. 7. 13. 12:15

 
 
이번 글에서는 Compound Pattern에 대해 알아보겠습니다. 그러기 위해선 먼저 Compound(합성)에 대해 알아볼 필요가 있습니다. 합성에 대해 익숙하지 않은 분은 아래의 포스팅을 보시고 돌아와서 다시 글을 읽는 것을 권장드립니다.
[React] 컴포넌트 합성이란?

 

 

Compund Pattern이란?

Compund(합성) 컴포넌트 패턴은 컴포넌트 합성의 장점을 활용하기 위해서 구안된 패턴입니다.
즉, “하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 방식” 이라고 볼 수 있습니다.
 
저희가 즐겨 쓰는 select 컴포넌트가 바로 합성 컴포넌트 패턴의 형태를 잘 띈 대표적인 예시라고 볼 수 있습니다.
select 컴포넌트는 변경에 대한 관심사를 담고 있고, option은 value에 대한 관심사를 갖고 있습니다. 그리고 각각의 컴포넌트들은 독립적으로 사용될 수 없고 사용하는 쪽에서 조합을 했을 경우 사용이 될 수 있는 형태입니다.

<select>
  <option value="value1">value1</option>
  <option value="value2">value2</option>
  <option value="value3">value3</option>
</select>

 
 

Compound Pattern 적용

아래의 코드를 Compund Pattern을 적용하여 변경해보도록 하겠습니다.

const ReviewFilterModal = () => {
  const [isOpen, openModal, closeModal] = useModal(); 
	
  return ( 
    <>
      <AwesomeButton onClick={openModal}>필터</AwesomeButton>
      {isOpen && ( 
        <Portal>
          <Backdrop onClick={closeModal) /> 
          <AwesomeModalBox>
            <Header>
              <div>리뷰 검색 필터</div>
              <Close>X</Close> 
            </Header> 
            {/* Modal Content */}
          </AwesomeModalBox> 
        </Portal>
       )}
    </>
  )
};

 
텍스트로 하드코딩되어있는 “필터” 부분과 “리뷰 검색 필터”와 같은 부분들은 딱 봐도 변경에 유연하지 못하다는 부분들은 눈에 띕니다. 이 부분들을 한 번 변경해보도록 하겠습니다.

 const Modal = ({ trigger, title }) => { 
  const [isOpen, openModal, closeModal] = useModal(); 
	
  return ( 
    <>
        {trigger} // 바뀐 부분
        {isOpen && ( 
          <Portal>
            <Backdrop onClick={closeModal) /> 
            <AwesomeModalBox>
              <Header>
                {title} // 바뀐 부분
                <Close>X</Close> 
              </Header> 
              {/* Modal Content */}
            </AwesomeModalBox> 
          </Portal>
        )}
    </>
  )
};

 
변경이 예상되는 부분들을 props로(trigger, title) 변경해주었습니다. props로 변경을 하고 나니, 컴포넌트 내에서 도메인이 제거되어 컴포넌트도 좀 더 범용적인 ReviewFilterModal에서 Modal 컴포넌트로 변경할 수 있을 것 같습니다.
 
변경된 컴포넌트로 기존의 컴포넌트를 재구성하면 아래와 같은 형태가 됩니다. 이런식으로 코드를 작성하면 변경에 더 유연해질 수 있습니다. Modal 컴포넌트가 trigger와 title과는 서로 무관한 컴포넌트이기 때문입니다.

const ReviewFilterModal = () = (
  <Modal
    trigger={<AwesomeButton>필터</AwesomeButton>}
    title={<Title>리뷰 검색 필터</Title>}>
    {/* Modal Content */}
  </Modal>
)

 
하지만, 아래와 같은 요구사항들이 계속 온다면 어떻게 될까요?

  1. 닫기 버튼을 오른쪽으로 옮겨주세요~
  2. 여기는 백드랍을 제거해 주세요!
  3. 닫기 아이콘을 별로 변경해 주세요!

물론 위와 같은 방식으로 props를 받으면 크게 어렵지 않을 수 있습니다. 하지만 아래 코드를 보시면 아시겠지만, 작성한 코드가 뭔가 불안하고 찝찝하다느 느낌을 받으실 수 있습니다. 왜냐하면 props가 늘어날수록 구현부가 복잡하기 때문입니다.

const ReviewFilterModal = () = (
  <Modal
    trigger={<AwesomeButton>필터</AwesomeButton>}
    title={<Title>리뷰 검색 필터</Title>}
    backdrop={null}
    closePosition='right'>
    {/* Modal Content */}
  </Modal>
)

 
따라서, Modal이 가지고 있는 기본적인 기능을 기준으로 집합체들을 한 번 분리해 보겠습니다.
기능들로 구분하여 Trigger, Backdrop, Content, Close로 나타내봤습니다.

// 메인 컴포넌트
const Modal = ({ children }) => children;

// 서브 컴포넌트들
const ModalTrigger = ({ children, style }) => (
  <button style={style || defaultTriggerStyle}>{children}</button>
);

const ModalBackdrop = ({ style }) => (
  <div aria-hidden style={style || defaultBackdropStyle} />
);

const ModalContent = ({ style }) => (
  <div style={style || defaultContentStyle} />
)

const ModalClose = ({ children, style }) => (
  <button style={style || defaultCloseStyle}>{children}</button>
);

 
위의 컴포넌트들을 사용하여 내부 구조를 한 번 예상해보면 아래와 같은 형태가 될 것입니다.

<Modal>
  <ModalTrigger />
  <ModalBackdrop />
  <ModalContent>
  <ModalClose />
  </ModalContent>
</Modal>

 
위의 코드들을 ReviewFilterModal에 적용해보면 아래와 같은 코드가 됩니다.

const ReviewFilterModal = () => {
  <Modal>
    <ModalTrigger>필터</ModalTrigger>
      <Portal>
        <ModalBackdrop />
        <ModalContent>
          <Flex>
            <Title>리뷰 검색 필터</Title>
            <ModalClose>X</ModalClose>
          </Flex>
          <FilterSection>custom하게 작성....</FilterSection>
        </ModalContent>
      </Portal>
  </Modal>
}

 
그런데 위와 같이 코드를 작성하면 상태를 조작하거나 영향을 받는 컴포넌트들은 상태에 어떻게 접근할 수 있을까요? 예를 들어, ModalTrigger, ModalClose의 경우 Modal을 열거나 닫아야하기 때문에 상태 조작이 필요합니다. 이 외에도 위에서사용되는 다른 컴포넌트들도 상태에 영향을 받을 수 있습니다.
 
위에서 정의한 서브 컴포넌트들은 다 독립적으로 개발되어있기 때문에 상태 공유가 쉽지 않다는 문제가 발생합니다. 따라서, 합성 컴포넌트 패턴은 이러한 문제를 Context API를 통해서 해결합니다.
 
 

합성 컴포넌트에서의 상태 관리 - Context API

메인 컴포넌트에서 상태를 정의하고 context API로 서브 컴포넌트들에게 상태를 전달하는 방식입니다.

const Modal = ({ children }) => {
  const [isOpen, openModal, closeModal] = useModal();
	
  return (
    <ModalProvider value={{ isOpen, openModal, closeModal }}>
      {children}
    </ModalProvider>
  )
}

 
그러면 서브 컴포넌트에서 아래와 같이 전달받아 사용할 수 있습니다.

const ModalTrigger = ({ children, style }) => {
  const { openModal } = useModalContext();
	
  return (
    <button style={style || defaultTriggerStyle} onClick={openModal}>
      {children}
    </button>
  )
}

 
아래의 코드로만 해도 잘 작동은 하겠지만, ModalTrigger, FilterSection 컴포넌트들을 사용한 부분에 있어서 가독성에 아쉬움이 있긴 합니다. modal이라는 prefix로 구분을 해놓았지만 한 눈에 들어오지는 않습니다.

const ReviewFilterModal = () => {
  <Modal>
    <ModalTrigger>필터</ModalTrigger>
    <Portal>
      <ModalBackdrop />
      <ModalContent>
        <Flex>
          <Title>리뷰 검색 필터</Title>
          <ModalClose>X</ModalClose>
        </Flex>
        <FilterSection>custom하게 작성....</FilterSection>
      </ModalContent>
    </Portal>
  </Modal>
}

 
그래서 합성 컴포넌트 패턴은 일반적으로 서브 컴포넌트를 메인 컴포넌트에 속성으로 추가함으로써 이 문제를 해결하곤 합니다.

const Modal = ({ children }) => {
  const [isOpen, openModal, closeModal] = useModal();
	
  return (
    <ModalProvider value={{ isOpen, openModal, closeModal }}>
      {children}
    </ModalProvider>
  )
}

Modal.Trigger = ModalTrigger;
Modal.Backdrop = ModalBackdrop;
Modal.Content = ModalContent;
Modal.Close = ModalClose;

 
이렇게 추가된 서브 컴포넌트들은 Modal. 이라는 prefix의 형태가 생기면서 같은 관심사인지 한 눈에 확인이 가능합니다.

const ReviewFilterModal = () => {
  <Modal>
    <Modal.Trigger>필터</Modal.Trigger>
      <Portal>
        <Modal.Backdrop />
        <Modal.Content>
          <Flex>
            <Title>리뷰 검색 필터</Title>
            <Modal.Close>X</Modal.Close>
          </Flex>
          <FilterSection>custom하게 작성....</FilterSection>
        </Modal.Content>
      </Portal>
  </Modal>
}

 
 

Compound Pattern의 장점

  1. 변경에 유연한 UI
  2. 더 적은 prop driling
  3. 가독성 (컴포넌트 계층 구조 직관적으로 확인 가능)

 

Compound Pattern의 단점

  1. 가독성 (계층구조가 드러나있기 때문에 관심사를 찾는데 가독성이 떨어질 수 있음)
  2. 코드의 길이(prettier나 lint 같은 포메터가 느려질 수 있음)
  3. 구현 복잡도(제어권, 분기 처리 필요)


가독성부분만 봐도 장점이 될 수 있고, 단점이 될 수 있습니다. 아무래도 패턴이 패턴이니만큼 장점과 단점이 모두 공존하는 것 같습니다. 필요에 따라, 프로젝트에 따라 필요하면 해당 패턴을 사용하여 유연한 UI를 적절하게 만드는 것을 권장드립니다:)
 
 

References
https://www.youtube.com/watch?v=jEiUyh_lnFI
https://www.patterns.dev/react/compound-pattern/
https://ko.legacy.reactjs.org/docs/composition-vs-inheritance.html