프로젝트를 위한 공통 컴포넌트 만들기

오예준오예준
6 min read

이번 시리즈에서는 회사 프로젝트를 위해 ‘왜 공통 컴포넌트를 만들게 되었는지’ 그리고 ’어떻게 만들었는지‘ 대해서 작성해보려고 한다.

🤔시작하기

먼저, 회사 프로젝트는 기능은 유사하지만 디자인만 다른 다수의 사이트를 만들어야 하는 과제를 안고 있다. 따라서 반복되는 코드를 최대한 줄이고, 빠른 개발과 일관된 기능을 구현해야 했다.

또한, 프로젝트의 개수가 늘어갈수록 하나의 기능 추가는 프로젝트 개수 만큼의 기능 추가가 된다는 것을 의미한다. 이러한 문제들을 위해서 공통된 기능들을 최대한 모아서 개발하고 유지보수 할 수 있도록 만들어야 했다.

이 글은 첫 번째로 위 문제들을 위해 공통 컴포넌트를 만든 이유와 방법에 대해서 소개해보려 한다.

공통 컴포넌트 만들기

컴포넌트를 적절한 기준 없이 만들면 재사용이 어려워진다. 기능이 추가될 때마다 컴포넌트의 props가 계속 늘어나거나, 코드가 복잡해져 하나의 컴포넌트가 지나치게 많은 역할을 담당하게 될 수 있다.

공통 컴포넌트를 설계할 때 첫 번째 기준은 '하나의 역할을 갖는 것'이었다. 예를 들어, 모달 컴포넌트는 ’이벤트에 맞춰서 화면 중앙에 나타나고 사라진다.‘ 라는 정의를 가진다면, 이 목적에만 집중하도록 만든다. 그 외에 모달 안에서 일어나는 이벤트들을 모달과 결합되는 다른 컴포넌트에서 작동하도록 구성했다.

두 번째 기준은 '높은 재사용성'이다. 프로젝트마다 UI가 달라진다. 같은 버튼이라도 아이콘의 위치나 색상, 폰트 크기 등 다양한 부분이 달라질 수 있기 때문에, 구조를 분리하고 결합할 수 있도록 설계하는 것이 중요했다.

이 두 가지 기준을 충족시키기 위해 Headless와 Compound 컴포넌트 디자인 패턴을 참고했다.

Headless 컴포넌트

Headless 컴포넌트는 UI와 기능 혹은 로직을 분리한 컴포넌트다. 비즈니스 로직과 상태 관리만을 제공하고, UI는 원하는 대로 구성할 수 있는 특징을 가진다. 이러한 방식은 UI 에 종속되지 않아 높은 유연성과 재사용성을 제공한다.

  • 특정한 UI에 종속되어 있지 않아, 다양한 디자인이나 스타일에 맞춰서 UI를 구성할 수 있어 유연하게 작성가능

  • 다양한 디자인에 동일한 로직 재사용할 수 있어, 코드의 중복을 줄이고 유지 보수가 간편해짐

컴파운드 컴포넌트

컴파운드 컴포넌트는 여러 자식 컴포넌트가 하나의 부모 컴포넌트와 함께 동작하는 디자인 패턴이다. 부모 컴포넌트는 각 하위 컴포넌트의 상태를 제어하며, 사용자는 이들을 조합해 UI를 구성한다.

  • 부모 컴포넌트가 하위 컴포넌트의 상태를 관리하여 일관된 상태를 유지함

  • 하위 컴포넌트를 조합하여 다양한 레이아웃과 기능을 쉽게 구현할 수 있음

  • 각 하위 컴포넌트를 독립적으로 재사용할 수 있어 유지보수가 용이함


모달 컴포넌트 만들기

이제 컴포넌트를 만들어보자. 먼저 모달 컴포넌트의 필요한 하위 컴포넌트들을 나열해 보자.

  1. Modal: 모달의 상태 관리 및 스크롤 방지.

  2. Portal: 모달을 독립적인 DOM 요소에 렌더링.

  3. ModalBackdrop: 배경, 클릭 시 모달 닫기.

  4. ModalPositioner: 모달 위치 조정, 클릭 시 닫기.

  5. ModalContent: 모달 내용 표시, 내부 클릭 시 닫히지 않음.

  6. ModalTitle: 모달 제목 표시.

  7. ModalDescription: 모달 설명 표시.

  8. ModalButton: 클릭 시 동작 수행 버튼.

  9. ModalCloseButton: 클릭 시 모달 닫기 버튼.

생각보다 많은 하위 컴포넌트들이 있다.

Context API로 상태관리 하기

1. Context 만들기

가장 먼저, 모달이 열렸는지 여부와 이를 제어하는 함수를 공유할 수 있도록 Context를 만들어보자.

const ModalContext = createContext<ModalContextProps | undefined>(undefined);

export const useModalContext = (): ModalContextProps => {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error('useModalContext must be used within a ModalContext');
  }
  return context;
};

useModalContext는 커스텀 훅으로, ModalContext에서 모달 상태와 관련된 데이터를 불러오는 역할을 한다. 모달이 열렸는지 여부와 모달을 여닫는 함수를 하위 컴포넌트들이 쉽게 사용할 수 있도록 공유한다. 또한 ModalContext.Provider 외부에서 호출되면 에러를 발생시켜 올바르게 사용되는지 확인한다.


export const ModalProvider = ({ 
  children, 
  open, 
  onOpenChange 
}: ModalProviderProps) => {
  const setOpen = (open: boolean) => {
    onOpenChange(open);
  };

  return (
    <ModalContext.Provider value={{ open, setOpen }}>
      {children}
    </ModalContext.Provider>
  );
};

ModalProvider는 모달 상태와 상태를 변경하는 함수를 제공하는 컨텍스트 프로바이더 역할을 한다. 부모 컴포넌트로부터 모달의 상태와 이를 변경하는 함수를 받아와 ModalContext를 통해 하위 컴포넌트에 전달한다.

마지막으로 가장 외부에서 기본적인 이벤트를 관리하고 상태를 전달하는 provider를 감싸는 컴포넌트를 만든다.

const Modal = ({ children, open, onOpenChange, ...props }: ModalProps) => {
  useEffect(() => {
    // scroll이 되지 않도록 이벤트 작성
  }, []);

  return (
    <ModalProvider open={open} onOpenChange={onOpenChange} {...props}>
      {open && children}
    </ModalProvider>
  )
}

React Portal로 바깥에 그리기

Portal을 사용하면 DOM의 특정 위치에 컴포넌트를 렌더링할 수 있다. 이는 모달이나 알림창처럼 화면의 특정 상위 레이어에 띄워야 하는 UI를 구현할 때 유용하다. 일반적으로 부모 컴포넌트 안에 속하게 되면 스타일이 상속되거나 이벤트가 상속되는 경우가 있다. 이때 Portal을 사용하면 독립적으로 관리가 가능하기 때문에 z-index 또는 overflow 등 스타일을 간섭받지 않고 사용이 가능하다.

const Portal: React.FC<{ children: ReactNode }> = ({ children }) => {
  const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const $portal = document.getElementById('portal');
    setPortalRoot($portal);
  }, []);

  if (!portalRoot) return null;

  return createPortal(children, portalRoot);
};

이 코드는 idportal인 요소를 찾아 그 하위에 컴포넌트를 렌더링한다.

모달 외부 구성요소 만들기

모달의 콘텐츠 부분이 아니라 외부에서 클릭이 되었을 경우 닫히거나, 모달의 외부 배경이나 레이아웃 마지막으로 이벤트 버블링을 방지하고 실제 콘텐츠가 나타나는 부분을 작성한다.

모달의 배경과 콘텐츠 외 부분을 클릭하면 닫히도록 만든 ModalBackdrop
모달을 가운데 위치하도록 조정하게 도와주는 ModalPositioner
모달의 실제 콘텐츠가 담기고 내부의 이벤트가 상위로 전파되지 않도록 만든 ModalContent 3개의 외부 영역을 만든다.

const ModalBackdrop = ({ ...props }) => {
  const { setOpen } = useModalContext();
  return <div
    onClick={() => setOpen(false)}
    {...props}
  />
}

const ModalPositioner = ({ children, ...props }: ModalBasicProps<HTMLDivElement>) => {
  const { setOpen } = useModalContext();

  const handleClick = (e) => {
    e.preventDefault();
    setOpen(false)
  }

  return <div
    onClick={handleClick}
    {...props}
  >
    {children}
  </div>
}

const ModalContent = ({ children, ...props }: ModalBasicProps<HTMLDivElement>) => {
  return <div
    onClick={(e) => {
      e.stopPropagation();
    }}
    {...props}
  >
    {children}
  </div>
}

모달 콘텐츠 만들기

마지막으로 모달의 실제 콘텐츠가 들어가는 내부를 만든다. 모달의 제목과 내용이 들어가는 컴포넌트를 만든다. 그리고 버튼을 두 개로 나눴는데 하나는 정말 닫힘의 역할만 하는 버튼과 클릭했을 경우 다른 콜백함수 등을 포함할 수 있는 클릭 이벤트가 붙은 버튼을 따로 작성했다.

const ModalTitle = ({ children, ...props }: ModalBasicProps<HTMLHeadingElement>) => {
  return <h2
    {...props}
  >
    {children}
  </h2>
}

const ModalDescription = ({ children, ...props }: ModalBasicProps<HTMLParagraphElement>) => {
  return <p
    {...props}
  >
    {children}
  </p>
}

type ModalCallbackButtonProps = ModalBasicProps<HTMLButtonElement> & {
  onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}

const ModalButton = ({ children, onClick, ...props }: ModalCallbackButtonProps) => {
  return <button onClick={onClick} {...props}>{children}</button>;
}

const ModalCloseButton = ({ children, ...props }: ModalBasicProps<HTMLButtonElement>) => {
  const { setOpen } = useModalContext();
  return <button onClick={() => setOpen(false)} {...props}>{children}</button>;
}

모달 사용하기

이제는 만들어진 모달의 컴포넌트들을 조합해서 하나의 컴포넌트로 만들어야 한다.

const ModalExample = () => {
  const [isOpen, setIsOpen] = useState(false);

  const handleModalOpen = (bool) => {
    console.log(bool);
    setIsOpen(false);
  };

  const handleButtonClick = () => {
    console.log('확인');
    setIsOpen(false);
  };

  return (
    <>
      <button
        onClick={() => {
          setIsOpen((prev) => !prev);
        }}
      >
        모달
      </button>
      <Modal
        open={isOpen}
        onOpenChange={handleModalOpen}
      >
        <Modal.Portal>
          <Modal.Backdrop />
          <Modal.Positioner>
            <Modal.Content>
              <Modal.Title>Modal Title</Modal.Title>
              <Modal.Description>Modal Description</Modal.Description>
              <Modal.Button onClick={handleButtonClick}>확인</Modal.Button>
              <Modal.CloseButton>닫힘</Modal.CloseButton>
            </Modal.Content>
          </Modal.Positioner>
        </Modal.Portal>
      </Modal>
    </>
  );
};

이번에는 만들어진 모든 요소들을 사용해서 컴포넌트를 구성했다.

css는 최소한으로 적용하여 버튼을 클릭할 경우 모달이 나타나도록 만들었다. 외부 클릭이나 닫힘 등 이벤트를 보여주지는 못하지만 그래도 작동하는 것을 확인을 했다.

개발자 도구에서 기본적으로 컴포넌트가 구성되는 root 바깥에 portal 밑에 요소들이 배치된 것도 확인할 수 있었다.

이번에 만든 모달 컴포넌트는 사용하는 역할에 따라 자유롭게 내부 콘텐츠가 구성되도록 만들었다. 제목을 빼고 싶거나 내부에 이미지를 넣고 싶거나 사용자의 필요성에 맞게 변할 수 있다.

마무리

이번에 공통 컴포넌트를 만드는 과정에서 두 가지에 대해서 고민을 많이 했다. 첫 번째로 “다양한 콘텐츠들에 맞춰서 변할 수 있는가?”, 두 번째로 “상태를 어떻게 관리해야 하는가?”.

기본적으로 모달은 어떤 이벤트에 맞춰 나타난다고 생각했다. 그때마다 내부의 콘텐츠가 달라질 수 있도록 컴파운드 컴포넌트 패턴을 사용해 봤다. 컴포넌트를 재사용한다 라는 측면에서 다양하게 사용될 수 있다는 것을 많이 느꼈다.

또한, 열고 닫힌다는 기능과 상태를 외부에서 가져올지 내부에서 모는 것을 처리해야 할지에 대한 고민도 컸다. 처음에는 내부에서 모든 것을 완성하려고 했다. 그러나 만들다 보니 “외부에서 일어나는 이벤트들을 전달하기가 어렵지 않을까?” 라는 생각이 들었고 상태를 외부에서 전달하도록 만들었다.

이 컴포넌트에서 가장 아쉬운 부분은 마지막에도 작성한 상태관리에 있다. 내가 만들었기 때문에 나는 쉽게 사용할 수 있지만 다른 사람에게는 직관적이지 못해보이기도 한다. 외부에서 상태를 전달하지 않아도 이벤트만으로 상태를 변경할 수 있는 방식이 있을 것 같다.

그래도 여러 콘텐츠에 따라 재사용할 수 있다는 것, 조립해서 사용할 수 있다는 역할에는 충실하게 만들었다고 생각한다.

0
Subscribe to my newsletter

Read articles from 오예준 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

오예준
오예준