배워서 남 주자

디자인 패턴 - Compound 패턴

미래에서 온 개발자 2023. 11. 5. 12:07

들어가기에 앞서

https://www.patterns.dev/posts/compound-pattern 에 있는 내용을 바탕으로 정리한 포스팅임을 밝힙니다. 

 


Compound 패턴

  • 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 한다. 
  • select, dropdown, menu 컴포넌트에서 사용할 수 있다. 

 

 

예제 코드) 사진 목록에 버튼을 추가하여 각각의 사진을 수정하거나 삭제할 수 있게 한다. 

 

구현 목표
버튼을 토글하면 수정 및 편집 메뉴가 제공된다

FlyOut 컴포넌트를 위해서 세 가지 구현이 필요하다.

 

  1. 토글 버튼과 메뉴 리스트를 포함한 Wrapper
  2. 메뉴를 토글할 수 있는 Toggle 버튼
  3. 메뉴를 포함한 List 컴포넌트 

React의 Context API와 컴파운드 패턴을 활용해 예제를 구현해 보자. 

 

 

Step 1) FlyOut 컴포넌트 

- 토글 여부에 대한 상태를 포함하고, 자식 컴포넌트들이 받게 될 토글 값을 가진 FlyoutProvider 컴포넌트를 return 한다. 

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  const providerValue = { open, toggle };

  return (
    <FlyOutContext.Provider value={providerValue}>
      {props.children}
    </FlyOutContext.Provider>
  )
}

 

 

Step 2) Toggle 컴포넌트 

- 토글 컴포넌트는 사용자가 토글 버튼을 눌렀을 때 나타날 메뉴를 렌더링한다.

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext)

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  )
}

 

Toggle 컴포넌트가 FlyOutContext Provider에 접근할 수 있으려면 해당 컴포넌트는 FlyOut의 자식 컴포넌트여야 한다. 단순히 자식 컴포넌트로 렌더링되게 할 수도 있지만 여기서는 FlyOut 컴포넌트의 static property로 만들어 보자. 

 

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  )
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  )
}

FlyOut.Toggle = Toggle;

 

이렇게 하면 FlyOut 컴포넌트를 사용할 때 토글 버튼이 필요한 경우에도 그냥 FlyOut 컴포넌트만 import해서 사용할 수 있다.

// 사용부
import { FlyOut } from './FlyOut';

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
    </FlyOut>
  )
}

 

 

Step 3) List 컴포넌트 

- 토글 버튼을 클릭했을 때 보이는 메뉴를 렌더링한다. Toggle 컴포넌트와 마찬가지로 컨텍스트를 통해 상태를 가져와 처리할 수 있다. 

 

function List({ children }) {
  const { open } = useContext(FlyOutContext);
  return open && <ul>{children}</ul>
}

function Item({ children }) {
  return <li>{children}</li>
}

List 컴포넌트는 컨텍스트의 open 여부에 따라 메뉴를 보여주거나 감춘다. List와 Item도 FlyOut 컴포넌트의 static property로 추가해 보자.

 

const FlyOutContext = createContext()

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  )
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  )
}

function List({ children }) {
  const { open } = useContext(FlyOutContext);
  return open && <ul>{children}</ul>
}

function Item({ children }) {
  return <li>{children}</li>
}

FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;

 

지금까지 구현한 것을 모두 FlyOut 컴포넌트만 import 해서 사용할 수 있다. 예제에서는 '수정' 메뉴와 '삭제' 메뉴를 제공해야 하므로 FlyOut.Item을 두 개 가진 FlyOut.List 컴포넌트를 사용하면 된다. 

 

import { FlyOut } from './FlyOut'

export default function FlyOutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  )
}

 

FlyOutMenu 컴포넌트 자체는 아무런 상태도 가지고 있지 않다. 

 

컴파운드 패턴은 컴포넌트 라이브러리를 만들 때 유용하게 사용할 수 있다. 실제로 회사 프로젝트에서 ant design 라이브러리를 사용하고 있는데, 굉장히 자주 볼 수 있는 패턴이다. static property를 구조분해할당으로 미리 꺼내서 사용하는 경우가 많다. 내부 작동 원리는 숨겨져 있지만 어떻게 동작하는지 바로 유추할 수 있어서 가독성이 굉장히 좋다. 

 

import { FlyOut } from './FlyOut'

const { Toggle, List, Item } = FlyOut;

export default function FlyOutMenu() {
  return (
    <FlyOut>
      <Toggle />
      <List>
        <Item>Edit</Item>
        <Item>Delete</Item>
      </List>
    </FlyOut>
  )
}

 

 

 

위의 예제 코드를 활용해 모달의 open과 toggle에 대한 상태를 사용부에서는 노출하지 않는 모달 컴포넌트를 컴파운드 패턴으로 작성해 보았다. 예제 코드에서 Toggle과 List, Item을 static property로 가진 것처럼, 모달을 오픈할 수 있는 Button과 모달의 Wrapper를 static property로 작성했다. 

import { useState, createContext, useContext } from 'react';
import { Modal as AntdModal, Button as AntdButton } from 'antd';

export const ModalContext = createContext({
  isOpen: false,
  setIsOpen: (value: boolean) => {},
});

type Props = {
  children: React.ReactNode;
};

export function Modal({ children }: Props) {
  const [isOpen, setIsOpen] = useState(false);
  const providerValue = { isOpen, setIsOpen };

  return <ModalContext.Provider value={providerValue}>{children}</ModalContext.Provider>;
}

function Button({ children }: Props) {
  const { setIsOpen } = useContext(ModalContext);

  return <AntdButton onClick={() => setIsOpen(true)}>{children}</AntdButton>;
}

type WrapperProps = {
  title: string;
  [property: string]: any; // all other props
};

function Wrapper({ title, children, ...rest }: WrapperProps & Props) {
  const { isOpen, setIsOpen } = useContext(ModalContext);

  return (
    <AntdModal title={title} open={isOpen} onCancel={() => setIsOpen(false)} {...rest}>
      {children}
    </AntdModal>
  );
}

Modal.Button = Button;
Modal.Wrapper = Wrapper;

 

 

이렇게 작성한 모달 컴포넌트를 사용부에서는 다음과 같이 호출한다. 모달을 구성할 내용이 바뀔 때마다 Inner의 컨텐츠만 바꿔서 사용할 수 있다. 

import { ConfigProvider, Calendar } from 'antd';
import type { Dayjs } from 'dayjs';
import koKR from 'antd/locale/ko_KR';
import { Modal } from '@/components/ui/Modal';
import { disallowPastDates } from '@/utils/dates';

function Inner() {
  const onSelect = (date: Dayjs) => {
    console.log(date.format('YYYY-MM-DD'));
  };

  return (
    <ConfigProvider locale={koKR}>
      <Calendar fullscreen={false} onSelect={onSelect} disabledDate={disallowPastDates} />
    </ConfigProvider>
  );
}

export default function DueDateModal() {
  return (
    <Modal>
      <Modal.Button>추가</Modal.Button>
      <Modal.Wrapper title="마감일 선택" centered width={360} footer={null}>
        <Inner />
      </Modal.Wrapper>
    </Modal>
  );
}

 

 

장점

- 컴파운드 패턴은 동작 구현에 필요한 상태를 내부적으로 가지고 있고, 사용부에서는 해당 로직이 드러나지 않는다. 

- 자식 컴포넌트들을 일일이 import 할 필요가 없다.

- 가독성이 좋아진다.