본문 바로가기
개발이야기/js

리액트로 효과적인 모달을 만들어보자

by 박제아성 2023. 5. 4.

언제까지 모달 라이브러리 쓸 것인가! 서비스 내 10가지 모달을 사용한다면 어떻게 효율적으로 모달을 만들어서 사용하는가 고민하면서 만든 내 모달을 공유하려고 한다!

 

모달이 뭔데?

모달의 원래 이름은 다이얼로그, 팝업이라고 한다. 흔히 어떤 이벤트에 의해 작은 창이 튀어나오는 것을 모달이라고 하며, 웹 서비스에서 모달이 없는 서비스는 없을 정도이다. 

때문에 매번 써야 하는 모달! 이제는 만들어 보자~

 

필요 개념들

일단 react와 typescript를 사용해서 만들 것이다. 추가적으로 recoil과 react portal, cssTransition의 개념을 적용해서 더 좋은 모달을 만들어 보자.

 

간단하게 얘기하면 recoil은 모달의 open 상태와 특별히 처리할 데이터를 관리하게 위해 사용한다. 서비스 내에 모달이 여러 가지가 있기 때문에 recoil을 통해 열고 닫고, 특정 상태를 넘기기 위해 사용한다. 

 

react portal은 리액트 공식문서를 통해 알 수 있다. 딱 한 문장으로 설명하면, 자식 요소에 다른 돔을 렌더링 하는 것이다. 예를 들면 cra로 만든 프로젝트는 root 엘리먼트 안에서 다양한 컴포넌트가 렌더링이 된다. react portal을 사용하면 root 엘리먼트 외부에서 만든 컴포넌트를 렌더링 시킬 수 있다.

 

cssTransition은 리액트 공식 팀들이 만든 아주 좋은 transition 모듈이다. 굳이 모션이나 라이브러리 도움 받지 않고, 깔끔하게 컴포넌트에 Transition을 줄 수 있다. 다양한 활용 방법이 있으니 꼭 익히자!

 

기초 구조!

일단 모달 컴포넌트를 위치 시키는 곳을 이해해 보자.

 

1. 모달이 필요한 부분에 import 한 후 상태값을 통해 불러 모달을 띄운다.

 

import Modal from '@components/modal';

const Home = () => {
  const [isOpen, setIsopen] = useState(false);
  return (
    <Container>
      <h1>안녕</h1>
      {isOpen && <Modal />}
    </Container>
  );
};

처음에는 이런 식으로 시작할 것이다. 하지만 많은 모달을 만들다 보면 불편해진다. 모달을 사용하는 곳마다 상태코드와 모달 코드를 써야한다. 코드가 반복되다 보면 뭔가 싸한 기분을 느끼게 되는 것은 개발자라면 어쩔 수 없을 것이다. 

 

그럼 모달을 여는 코드를 훅으로 분리하면 좀 편하겠지라는 생각을 한다. 하지만 훅으로 만든다고 하더라도 위 코드와 달라지는 점은 거의 없다. 

 

뭘 해야 좀 더 좋아질까 고민을 잠시 해보며..

 

훅으로 바꾸는 게 아니라 모달 컴포넌트를 한 곳에만 두고 쓰면 안되나..? 이런 생각이 들 수 있다. '필요한 상황에만 모달이 보이면 얼마나 좋겠어~' 하는 생각에 이제 두 번째 방법을 사용해 본다.

 

2. 모달을 전역에 두고 사용하는 방법

 

// App.tsx
<RecoilRoot>
  <SuspenseForFetching fallback={<Loader />}>
    <Routes />
    <Modal />
  </SuspenseForFetching>
</RecoilRoot>

이곳이 어디인가 생각해보면 App.tsx이다. recoil이 있는 이유는 전역으로 두었기 때문에 언제 모달을 열어야 하는지 알기 위해서다. 하지만 이 방법도 약간의 문제가 있다. 

 

"저러면 모달 렌더링될 때 다른 컴포넌트에 너무 많은 영향을 주는 거 아닌가..? 좀 독립적이게 쓰고 싶은데..." 

 

매우 정상!

 

그냥 쓰면 모달이 열리고 닫힐 때 모든 컴포넌트가 렌더링이 된다. 그러고 싶지 않아도 그렇게 된다. 그래서 "portal"이 있다.

 

포탈!

위에서 말했듯이 포탈은 다른 돔에 있는 컴포넌트를 자식에 넣어주는 기능이다. 때문에 아예 레이어가 다른 컴포넌트가 나오기 때문에 부모 컴포넌트의 렌더링의 영향을 받지 않기 때문에 모달 상태가 변경 되어도 다른 컴포넌트들이 영향을 받지 않는다.

일단 포탈을 만들어 보자. 

 

import React, { useState, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';

function createWrapperAndAppendToBody(wrapperId: string) {
  if (document.getElementById(wrapperId))
    return document.getElementById(wrapperId) as HTMLDivElement;
  else {
    const wrapperElement = document.createElement('div');
    wrapperElement.setAttribute('id', wrapperId);
    document.body.appendChild(wrapperElement);
    return wrapperElement;
  }
}

function ReactPortal({
  children,
  wrapperId = 'react-portal-wrapper',
}: {
  children: React.ReactNode;
  wrapperId: string;
}) {
  const [wrapperElement, setWrapperElement] = useState<HTMLDivElement | null>(null);
  useLayoutEffect(() => {
    setWrapperElement(createWrapperAndAppendToBody(wrapperId));
    return () => {
      createWrapperAndAppendToBody(wrapperId)?.remove();
    };
  }, [wrapperId]);
  return wrapperElement ? createPortal(children, wrapperElement) : null;
}

export default ReactPortal;

복잡한 코드는 없다! wrapperId를 받아서 이미 있는지 없는지 확인 후 있으면 리턴 없으면 생성해서 리턴하고, 'createPortal'을 통해서 만들어 주면 끝!

 

이제 포탈을 통해 모달을 생성할 수 있는 길을 만들었다. 어떤 모달 컴포넌트를 만들어서 열어주면 개발자 도구를 통해 확인할 수 있다. 

 

모달을 만들어보자.

일단 모달의 디자인은 신경쓰지 않고 열고 닫는 로직에만 신경을 쓰자.

 

위에서 useState를 활용해서 모달을 열었지만 확장성을 위해 recoil을 사용하겠다. context를 사용해도 좋다. 편하게 recoil로 했다.

 

아톰

export const popupState = atom<popupParameter>({
  key: 'popupState',
  default: {
    open: false,
    type: '',
    parameters: {},
  },
});

type은 다양한 모달을 만들 때 확장성을 고려해서 써주었다. 이 방법보다 더 좋은 방법이 있다면 추천해 주세요~

아톰을 만들었으니 App.tsx에 import해줄 코드를 짜보자.

 

const Popup = () => {
  const { type } = useRecoilValue(popupState);
  if (type === POPUP.RESERVATION_CANCEL) return <Cancel />;
  if (type === POPUP.RESERVATION_CANCEL_IMPOSSIBLE) return <CancelImpossible />;
  if (type === POPUP.REQUEST_CONFIRM) return <RequestConfirm />;
  if (type === POPUP.REQUEST_REJECT) return <RequestReject />;
  if (type === POPUP.WITHDRAW) return <Withdraw />;
  if (type === POPUP.WITHDRAW_IMPOSSIBLE) return <WithdrawImpossible />;
  if (type === POPUP.REVIEW_CHECK_CLOSE) return <ReviewClose />;
  return <Basic />;
};

이래서 recoil default value에 타입을 넣어주었다. 해당 컴포넌트 하나를 전역에 두고 recoil state를 사용해서 원하는 모달을 띄울 수 있게 구현했다. 

 

그럼 저 많은 모달을 전부 만들어야 하는가..? 아니다!

 

Basic 모달에 props와 children을 통해서 다양하게 모달을 커스텀할 수 있다. 

 

기본 모달

 

기본적으로 모달은 이렇게 생겼다. 그럼 다양하게 사용하려면 제목, 본문, 버튼 텍스트, 버튼 이벤트를 전달받아 커스텀이 가능하도록 구현하면 아주 편하지 않을까요?

 

type Props = {
  title: string;
  children?: React.ReactNode;
  cancel?: string;
  confirm?: string;
  color?: string;
  onClick: () => void;
};

const Simple = ({ children, title, cancel, confirm = '확인', onClick }: Props) => {
	...
}

확인에는 다양한 로직이 붙을 수 있기 때문에 이런 구성을 했다.

 

본격적으로 모달을 구성해보자.

 

// 위에서 만들었던 리액트 포탈
<ReactPortal wrapperId='react-portal-modal'>
    <div className='modal' ref={nodeRef}>
      <div className='modal-content'>
        <section className='model-header'>{title}</section>
        <section className='modal-main'>{children}</section>
        <section className='modal-button'>
          {cancel && (
            <button className='modal-btn' onClick={closePopup}>
              {cancel}
            </button>
          )}
          <button className='modal-btn confirm' onClick={clickConfrim}>
            {confirm}
          </button>
        </section>
      </div>
    </div>
</ReactPortal>

 

 

마크업과 기본 구조는 끝입니다. 하지만 아직 처리하지 않은 부분은 모달을 열고 닫는 함수입니다. 가장 처음에 모달을 열고 닫을 때 모달을 쓰는 곳마다 로직을 적어줘야하는 번거로움이 있었기에 이 부분을 깔끔하게 처리해 보겠습니다.

 

모달 함수를 훅으로 만들자

닫는 함수

일단 모달을 닫는 건 여러 곳에서 사용할 필요가 없기 때문에 그냥 만드셔도 됩니다. 

 

const [popup, setPopup] = useRecoilState(popupState);
const closePopup = useCallback(() => {
    setPopup({ ...popup, open: false });
    setTimeout(() => {
      resetPopup();
    }, 200);
  }, [popup]);

이부분은 좀 별로인 부분입니다. 모달이 열리고 닫힐 때 적절한 애니메이션이 필요하겠죠? 그걸 위해 약간의 딜레이를 주었습니다. popup의 open에 따라 모달이 열리고 닫히는데 바로 초기화를 해버리면 type도 같이 초기화 되기 때문에 이를 늦게 처리해주는 로직을 사용했습니다. 그래야 transition이 먹히기 때문입니다... 이게 최악의 방법이라면.. 반성하겠습니다ㅠ

 

여는 함수

export const useOpenPopup = () => {
  const setPopup = useSetRecoilState(popupState);
  const openPopup = ({ type, parameters = {} }: Props) => {
    setPopup({ open: false, type, parameters });
    setTimeout(() => {
      setPopup({ open: true, type, parameters });
    }, 100);
  };
  return { openPopup };
};

 

setTimeout을 열고 닫는데 사용한 이유는 cssTransition과 modal type 사이에서 발생한 문제 때문에 그렇습니다..ㅠ

 

cssTransition

해당 기능은 자연스러운 모달의 애니메이션을 주기 위해 사용했습니다. 

<ReactPortal wrapperId='react-portal-modal'>
  <CSSTransition
    in={popup.open}
    timeout={{ enter: 0, exit: 300 }}
    unmountOnExit
    classNames='modal'
    nodeRef={nodeRef}
  >
    <div className='modal' ref={nodeRef}>
      <div className='modal-content'>
        <section className='model-header'>{title}</section>
        <section className='modal-main'>{children}</section>
        <section className='modal-button'>
          {cancel && (
            <button className='modal-btn' onClick={closePopup}>
              {cancel}
            </button>
          )}
          <button className='modal-btn confirm' onClick={clickConfrim}>
            {confirm}
          </button>
        </section>
      </div>
    </div>
  </CSSTransition>
</ReactPortal>

해당 코드와 상황별 className css를 사용해서 쉽게 구현이 가능합니다.

 

.modal {
  position: fixed;
  inset: 0; /* inset sets all 4 values (top right bottom left) much like how we set padding, margin etc., */
  background-color: rgba(0, 0, 0, 0.2);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease-in-out;
  overflow: hidden;
  z-index: 999;
  padding: 0 40px;
  opacity: 0;
  pointer-events: none;
  transform: scale(0.4);
}

.modal-enter-done {
  opacity: 1;
  pointer-events: auto;
  transform: scale(1);
}
.modal-exit {
  opacity: 0;
  transform: scale(0.1);
}

기본 상태는 opacity:0으로 모달이 보이지 않고, popup의 open이 변경 되었을 때, 'modal-enter-done'을 통해서 특정 css가 적용이 됩니다.

원래는 배경이 저렇게 안 보이는데.. 동영상이 왜 그럴까요

아무튼 이렇게 모달을 만들었습니다.

 

모달을 만드는 좋은 방법이 있다면 알려주세요..

 

모달을 사용하는 다른 서비스들 참고해서 만들었는데 확실히 대형 서비스에 비해 부족하네요..

 

감사합니다 :)

댓글