Compound pattern

Rosevillage·2023년 12월 8일
0

디자인 패턴에 대해 찾아보며 공부하던 중 compound 패턴에 대해 발견하게 되었다. 리액트의 디자인 패턴이기에 다른 패턴들보다 이해가 잘 돼고, 응용하기 좋다고 느껴서 간단하게 정리해본다.

Compound Pattern

Compound Pattern(or Compound Components Pattern)는
리액트의 디자인 패턴중 하나로 props drilling을 해결하고, 유연한 레이아웃을 통한 재사용성의 특징을 가진다.

이름 그대로 여러 구성요소(ex: component)를 합성하는 방식이기에 Atomic 패턴과도 잘 어울린다.

Compound Components

합성 컴포넌트(compound components)는 여러 작은 컴포넌트를 가져와 공통으로 의존하는 상태를 기준으로 결합한 컴포넌트이다.

여기까지는 그저 하위 컴포넌트를 가지는 부모 컴포넌트와 다를게 없다고 느낄 수 있으나, 두 가지 특징을 통해 일반적인 컴포넌트와 차별성을 가진다.

Props drilling 해결

compound pattern은 ContextAPI를 통해 합성되는 컴포넌트들에 공통적으로 의존하는 상태를 관리한다.

다음과 같이 중첩된 컴포넌트에서 child 컴포넌트만 사용하는 data를 GrandParent 컴포넌트가 가지고 있다고 가정해보자

function GrandParent() {
  const data = {...};
  const childData = [...];
  return (
    <div>
      <Parent data={data} childData={childData}/>
    </div>
  )
}

function Parent({data, childData}) {
  return (
    <div>
      <h2>{data.title}</h2>
      <p>{data.contents}</p>
      <Child childData={childData}/>
    </div>
  )
}

function Child({childData}) {
  return (
    <div>
      {childData.map(data=>(
        //...
      ))}
    </div>
  )
}

Parent는 childData를 사용하지 않지만 Child에게 넘겨주기 위해서 props로 받아야 한다.
Parent에 필요없는 props를 제거하기 위해 ContextAPI를 사용하여 다음과 같이 개선할 수 있다.

const OnlyChildContext = createContext();

function GrandParent() {
  const data = {...};
  const childData = [...];
  return (
    <div>
      <OnlyChildContext.Provider value={childData}>
        <Parent data={data} childData={childData}/>
      </OnlyChildContext.Provider>
    </div>
  )
}

function Parent({data, childData}) {
  return (
    <div>
      <h2>{data.title}</h2>
      <p>{data.contents}</p>
      <Child />
    </div>
  )
}

function Child() {
  const {childData} = useContext(OnlyChildContext);
  return (
    <div>
      {childData.map(data=>(
        //...
      ))}
    </div>
  )
}

유연한 레이아웃을 통한 재사용성

UI컴포넌트를 만들다 보면 비슷하지만 조금의 차이때문에 새롭게 컴포넌트를 만들어야 하나라는 고민을 하게 된다.
이럴때 Compound Pattern이 하나의 해결책이 될 수도 있다.

다음과 같은 비슷하지만 다른 두 페이지를 만들 예정이다.

재사용성을 위해서 Article이라는 컴포넌트를 만들때 어떻게 해야 재사용성 있는 하나의 컴포트를 만들 수 있을까 조건문을 통해서 해결해 볼까 생각하지만 SOLID라는 단어가 떠오르면서 조금 주저하게 된다. 위의 이미지 정도의 차이라면 그냥 진행해도 문제가 없지만 조금 더 차이가 나면 더 애매해진다.

우선 따로 만들어보기로 하고 다음과 같이 작성했다.

export default function MainArticle({data}) {
  //data = {title, description, id}
  const navigate = useNavigate()
  const onClickHandle = () => {
    navigate('/article/detail/'+data.id)
  } 
  return (
    <div className='article_item' onClick={onClickHandle}>
      <h4>{data.title}</h4>
      <p>{data.description}</p>
    </div>
  )
}

export default function MyArticle({data}) {
  //data = {title, description, id, like}
  const [isOpen, setIsOpen] = useState(false);
  const navigate = useNavigate()
  const onClickHandle = () => {
    navigate('/article/detail/'+data.id)
  }
  const OpenHandler = () => setIsOpen(!isOpen);
  const fetchDelete = async() => {
    await fetch(`${baseURL}/delete/${data.id}`, {
      method: 'DELETE'
    })
    setIsOpen(false)
  }
  return (
    <div className='my_article_item' onClick={onClickHandle}>
      <p className='like'>Like {data.like}</p>
      <div className='article_text_area'>
        <h4 className='title'>{data.title}</h4>
      	<p className='description'>{data.description}</p>
      </div>
      <div className='toggle_menu'>
        <div className='toggle_menu_button' onClick={OpenHandler}>
          <MenuIcon/>
        </div>
        {isOepn && (
          <ul>
            <li className='toggle_menu_button' onClick={fetchDelete}>Delete</li>
          </ul>
        )}
      </div>
    </div>
  )
}

차이가 나긴 하지만 중복되는 부분이 많고, 차이나는 부분은 분리해 컴포넌트화 하면 중복되는 코드를 줄일 수 있을 거 같다.
여기에 조건부 렌더링과 ContextAPI를 사용한 Compound Pattern를 통해 유연하고 재사용성 있는 Article 컴포넌트를 다음과 같은 과정으로 만들었다.

  1. 우선 Article 컴포넌트에 context를 추가한다.

    const ToggleContext = createContext(false);
    
    export default function Article({data, children}) {
      const navigate = useNavigate()
      const onClickHandle = () => {
        navigate('/article/detail/'+data.id)
      }
      const openState = useState(false)
      return (
        <div className='article_item' onClick={onClickHandle}>
          <ToggleContext.Provider value={openState}>
            {children}
          </ToggleContext.Provider>
        </div>
      )
    }
  2. 이제부터 필요한 요소들을 하나씩 컴포넌트로 만든다.

    export function LikeDisplay({like}) {
      return <p className='like'>Like {like}</p>
    }
    
    export function ArticleTextArea({title, description}) {
      return (
        <div className='article_text_area'>
          <h4 className='title'>{title}</h4>
          <p className='description'>{description}</p>
        </div>
      )
    }
    
    export function ToggleMenuButton() {
      const [isOpen, setIsOpen] = useContext(ToggleContext);
     
      return (
        <div className='toggle_menu_button' onClick={()=>setIsOpen(!isOpen)}>
          <MenuIcon/>
        </div>
      )
    }
    
    export function ButtonList({children}) {
      const [isOpen,_] = useContext(ToggleContext);
      return isOpen && <ul className='button_list'>{children}</ul>
    }
    
    export function ButtonListItem({ButtonClickHandle, name}) {
      const [isOpen, setIsOpen] = useContext(ToggleContext);
      const onClickHandle = async() => {
        await ButtonClickHandle()
        setIsOpen(!isOpen)
      }  
      return <li className='button_list_item' onClick={onClickHandle}>{name}</li>
    }
  3. 이제 Article 컴포넌트에 합쳐주면끝이다.

    export default function Article({dataId, children}) {
      const navigate = useNavigate()
      const onClickHandle = () => {
        navigate('/article/detail/'+dataId)
      }
      const openState = useState(false)
      return (
        <div className='article_item' onClick={onClickHandle}>
          <ToggleContext.Provider value={openState}>
            {children}
          </ToggleContext.Provider>
        </div>
      )
    }
    
    Article.LikeDisplay = LikeDisplay;
    Article.ArticleTextArea = ArticleTextArea;
    Article.ToggleMenuButton = ToggleMenuButton;
    Article.ButtonList = ButtonList;
    Article.ButtonListItem = ButtonListItem;

Compound Component에 필요한 컴포넌트들을 위와 같이 합성하면, 컴포넌트들의 순서를 사용처에 따라 변경하는 등 특정 부분의 다른 요구사항 등에 대응 할 수 있다. 최종적으로 호출 시 다음과 같은 모습을 지닌 Article 컴포넌트가 완성된다.


const deleteArticle = async(id) => {
  try{
    const res = await fetch(`${baseURL}/delete/${id}`, {
      method: 'DELETE'
    })
    if(res.ok) {
      const json = await res.json()
      return json.message
    }
    throw new Error('삭제 실패')
  } catch(e) {
    console.error(e.message);
  }
}

export default function ArticleListPage() {
   //... 
   
   return (
     //...
     {dataList.map(data=>(
       // All Article인 경우
       <Article dataId={data.id}>
         <Article.ArticleTextArea title={data.title} description={data.description} />
       </Article>
      
       // My Article인 경우
       <Article dataId={data.id}>
         <Article.LikeDisplay like={data.like} />
         <Article.ArticleTextArea title={data.title} description={data.description} />
         <Article.ToggleMenuButton />
         <Article.ButtonList>
           <Article.ButtonListItem ButtonClickHandle={()=> deleteArticle(data.id)} name={'Delete'} />
         </Article.ButtonList>
       </Article>
     ))}
     //...
   )
 }

간단한 코드에 적용해서 기존의 코드보다 행이 길어 큰 메리트가 안느껴질 수 있지만 레이아웃의 순서만 다르게 사용해야 하는 경우 활용성 측면에서 효과적이다.

다만 유연성이 상당히 높기 때문에 개발자의 실수 여부에 따라 필요없는 컴포넌트가 포함되는 등의 상황이 발생할 수 있다. 또한 컴포넌트가 사용되는 부분의 jsx의 행이 기존보다 길기 때문에 해당 Compound Component의 규모가 커질수록 가독성이 떨어지는 문제가 발생할 수 있다.

따라서

필요한 상황이 아니고서야 굳이 나서서 Compound Pattern을 남발하지 않는 것이 좋다. Atomic Pattern 에서도 Organism 단계에서 주로 같이 활용되니 특정 컨텍스트를 가지는 정도의 규모에서 선택적으로 사용하는 것이 바람직해 보인다.


Reference

patterns-Compound Pattern
: Attribution-NonCommercial 4.0 International (CC BY-NC 4.0) license

kakao FE 기술블로그-아토믹 디자인을 활용한 디자인 시스템 도입기
정호일(harry)
2022.05.05·15 min read

0개의 댓글

관련 채용 정보