본문 바로가기
> 레벨업의 테크노트

[Next.js] Context API 를 활용하여 Modal, Toast 개발

by 쫑's 2022. 10. 25.

우리가 서비스를 만들다보면 Modal 이나 Toast 메세지를 구현해야하는 경우가 흔히 발생합니다.

기존 우리 회사가 쓰던 프레임웍인 Vue 프레임웍에서는 이러한 Modal이나 Toast를 plugin으로 개발하여 전역적으로 사용하기 쉽게 만들었어서 굉장히 간편했었는데 이번에 React 라이브러리로 전환하면서 전역적으로 쉽게 사용할 수 있도록 Modal과 Toast를 만들어보았는데 그 과정을 여러분께 공유해볼까하여 글을 작성하게 되었습니다.

 

1. Redux? Context API? 둘 중에 어떤걸 선택해야하나..?   

사실 이 부분을 가장 공유해보고 싶었던 것 같습니다. 

모달을 열고 닫을때나 토스트 메세지를 띄울때 모달 state와 토스트 state를 mutation 해야하는데 이럴려면 함수를 호출해야하고 그 함수를 받아와야 합니다.

그러려면 modal 및 toast state값을 갖고 있는 컴포넌트에서 prop drilling을 해줘야하겠죠?

하지만 이렇게 prop drilling으로만 하게 되었을 경우 저희가 생각했던 '전역적으로 쉽게 사용한다'의 취지와 맞지 않기 때문에 Redux나 Recoil 같은 상태 관리 툴을 이용하거나 Context API를 활용해야 하는게 좋습니다.

 

여기까지는 모두 생각을 하셨을테고 당연한 이야기지만 rerender를 고려해본다면 조금 고민이 생기기 시작합니다.

저는 ReduxToolKit(RTK)를 사용하기 때문에 지금부터 RTK와 Context API를 비교하며 설명하도록 하겠습니다!

 

상태 관리 툴을 사용하면 rerender 이슈는 피할 수 있을거야!

 

Redux는 useSelector를 쓰지 않으면 state가 바뀐다고 하더라도 리랜더가 일어나지 않고 reducer만 호출해서 mutation을 해줄 수 있습니다. 리랜더 이슈가 크게 없겠죠? 

하지만 이렇게 개발을 하면 몇가지 단점이 발생하게 됩니다.

 

- 다른 프로젝트에서 상태 관리 툴을 바꾸면?

다른 프로젝트를 진행하게 되면 상태 관리 툴을 다르게 사용할 수도 있습니다. 

그렇게 되면 기껏 개발해놓았던 모달이나 토스트 로직을 다시 손봐서 수정을 해야할 수도 있습니다. 

저희가 모듈을 만드는 이유는 한번 만들면 다시 사용할 수 있는 재사용성이 높기 때문이지 않겠습니까?

react에는 여러 상태 관리 툴이 존재하고 지금도 계~~속 뭐가 트렌드니 뭐가 요즘 뜨고 있다느니 하면서 새로운 상태 관리 툴들이 속속 나오고 있는데 이러한 상태 관리 툴에 의존하며 작동하는 모달..? 음 저는 약간 아쉬웠습니다.

 

- 소스 복잡도가 높아지며 파편화된 파일들

RTK로 모달 개발을 하게 되면 상태 관리 파일들을 담아놓는 store 디렉토리에도 modal.ts를 따로 만들어야하고 모달을 wrapping 하는 컴포넌트, 모달컴포넌트 등을 또 컴포넌트들을 모아놓는 디렉토리에 넣어야합니다.

뿐만 아니라 이 모달을 사용하기 쉽도록 custom hook을 만든다면 hooks 디렉토리 안에도 모달 관련 파일이 들어가 있게 됩니다.

모달 기능 하나를 만드는데 소스 복잡도가 높아지고 파일들도 파편화 되서 유지보

수 하는데 비효율적이게 보여집니다.

 

옆에 이미지는 제가 이전에 react로 진행했던 프로젝트인데 보시다시피 여기저기 파편화된 파일들이 보이시죠? 리랜더를 줄이려 RTK를 활용해서 모달을 구현했는데 리랜더는 없앴지만 다소 복잡한 소스 구성을 얻게 된거 같습니다...ㅠㅠ 

 

이것 말고도 RTK를 사용해서 모달이나 토스트를 구현하면 Portal을 사용해서 특정 Element안에 모달이나 토스트를 그려줘야 하기 때문에 app.js나 Next의 경우 _app.js에 ModalContainer.jsx를 import 해주고 element도 따로 넣어줘야하는 번거로움도 발생합니다.

(나중에는 Portal을 따로 구현하여 따로 element를 넣지 않아도 알아서 element를 생성하도록 하였지만요...ㅎ) 




 

 

2. 그렇다면 Context API로 만들어보자!

하지만 Context API를 사용하게 되면 하위 컴포넌트들에 리랜더 이슈가..!!

Context API는 리액트에 내장된 기능으로 Props를 사용하지 않아도 특정 값이 필요한 컴포넌트끼리 쉽게 값을 공유할 수 있게 해 줍니다.

주로 프로젝트에서 전역 상태를 관리할 때 많이 사용하는데 Context API 에서 상태값을 변경하면, provider 로 감싼 모든 자식 컴포넌트들이 리렌더링한다는 단점이 있습니다.

하지만 Context API를 활용해서 모달이나 토스트를 만들게 되면 상태 관리 툴이나 다른 써드파티 라이브러리의 영향을 받지 않고 소스 복잡도도 줄어들고 소스를 활용할때도 좀 더 직관적으로 알아보기 쉽게 구현할 수 있다는 장점이 있습니다. 단점보다는 장점이 좀 더 크네요?

 

아마 이 글을 보시는 분들 중에서 '모달이 자주 연달아서 나타나는게 아닌데 굳이 이렇게까지 리랜더에 신경을 써야하나?'라고 생각하시는 분도 있을거라 생각합니다. 네 맞습니다. 모달은 연달아서 이중 삼중 모달로 뜨지 않게 하는게 좋은 UI 기획이라 모달이 연달아서 자주 뜨지는 않을거라 리랜더 이슈를 그냥 무시할 수도 있습니다. 그런데.. 모달의 종류 중 하나인 토스트 메세지의 경우는 어떨까요?? 

 

 

React-Toastify 데모 사이트를 활용해서 예시 화면을 준비해보았습니다..ㅎㅎ 뭐 암튼 보시면 토스트 메세지는 여러개가 연달아서 나타날 수 있기 때문에 하위 컴포넌트들의 리랜더가 일어난다면 비효율적이겠죠? 

 

그렇다면 Context API를 활용하면서 리랜더를 피할 수 있는 방법을 찾아야합니다. 어떻게 해야할까요?

제가 사용했던 방법은 modal 정보를 담아 놓는 array 타입의 state를 useState로 선언/초기화하고 동시에 modal 내용을 같이 담아 놓을 수 있는 객체를 useRef를 활용하여 만들어서 modal 정보를 추가해주거나 삭제해주는 함수에서 현재의 모달 정보를 파악 할 수 있도록 했습니다. 글로 설명하기 보다는 소스로 확인하는게 낫겠죠..?

 

...

const ModalProvider = ({ children, scrollRelease, scrollFreeze }: PropsType) => {
  const modalList = useRef<ModalType[]>([]);
  const [modals, setModals] = useState<ModalType[]>([]);

  const value = useMemo(() => ({
    ...initialValue,
    modals: modalList,
    setModals,
    scrollRelease,
    scrollFreeze,
  }), [scrollRelease, scrollFreeze]);

  useEffect(() => {
    modalList.current = modals;
  }, [modals]);

  return (
    <ModalContext.Provider
      value={value}
    >
      <ModalContainerStyle>
        {modals.map(modal => (
          <ModalComponent key={modal.id} modal={modal} />
        ))}
      </ModalContainerStyle>
      {children}
    </ModalContext.Provider>
  );
};

export default ModalProvider;

 

 

1. useState로 [modals, setModals]를 반환 받고 modals가 mutation 이 일어날때 useRef로 만들어준 modalList에 sync 될 수 있도록 useEffect dependency에 modals를 추가하였습니다.

 

2. 리랜더 방지를 위해 전달할 context를 useMemo로 감싸서 넣어주었습니다.

 

3. setModals는 mutation이 일어나지 않기 때문에 이 setModals를 활용해서 모달을 추가해주거나 삭제해주는 custom hook을 구현한다면 어디서든 쉽게 모달을 사용할 수 있게 됩니다. 

 

4. 모달이 나타났을 때 뒤에 화면이 스크롤이 안되도록 막아야 할 수도 있기 때문에 스크롤을 막는 함수와 스크롤을 다시 할 수있도록 하는 함수를 scrollRelease, scrollFreeze 라는 props로 받을 수 있도록 하여 각기 다른 사정을 갖고 있는 여러 프로젝트에서도 확장성 있게 사용 가능하도록 하였습니다.

 

 

다음은 모달을 추가하거나 삭제 또는 이미 같은 모달이 존재하는지 확인 하는 기능 등을 하는 custom hook 로직입니다.

 

import { CheckModalType, CloseModalType, ModalType, OpenModalType, ResolveModalType } from '../type/modal';
import { useContext, useEffect } from 'react';
import { ModalContext } from '../ModalProvider';

const useModal = () => {
  const { modals, setModals, scrollRelease, scrollFreeze } = useContext(ModalContext);

  const checkModal : CheckModalType = (
    component,
    onlyLastCheck = false,
  ) => {
    const modalList = modals.current;

    if (onlyLastCheck) {
      return modalList.length > 0
        ? modalList[modalList.length - 1].component.name === component.name
        : false;
    }
    return modalList.some(m => m.component.name === component.name);
  };

  const openModal : OpenModalType = (
    component,
    props,
    duplicateCheck = false,
  ) => {
    return new Promise((resolve, reject) => {
      const modal: ModalType = {
        id: -1,
        props,
        component,
        resolve,
        reject,
      };

      scrollFreeze && scrollFreeze();

      const modalList = modals.current;

      let duplicate = checkModal(modal.component, true);
      if (duplicateCheck) duplicate = checkModal(modal.component);
      if (duplicate) return;

      modal.id = (modalList[modalList.length - 1]?.id ?? -1) + 1;

      setModals([...modalList, modal]);
    });
  };

  const closeModal : CloseModalType = id => {
    const newModalList = modals.current.filter(m => m.id !== id);

    setModals(newModalList);
    if (!newModalList.length && scrollRelease) scrollRelease();
  };

  const resolveModal : ResolveModalType = (modal, result) => {
    modal.resolve(result);
    closeModal(modal.id);
  };

  const resetModal = () => {
    setModals([]);
    scrollRelease && scrollRelease();
  };

  useEffect(() => {
    return () => {
      resetModal();
    };
  }, []);

  return {
    modals,
    modal: openModal,
    openModal,
    closeModal,
    resolveModal,
    checkModal,
    resetModal,
  };
};

export default useModal;

 

열고자 하는 modal 컴포넌트를 openModal의 첫번째 인자에 넣어주고 해당 컴포넌트의 props를 두번째 인자에 전달해주면 손쉽게 modal을 띄울수 있는 함수를 구현하였습니다.

openModal 함수는 Promise를 사용하여 modal 정보에 resolve 함수를 전달해주어 모달이 긍정적으로 닫혔을 경우 비동기적으로 결과 값을 전달 받을 수 있도록 구현하였습니다. 그리고 같은 모달이 떠있는지 확인을 하고자 한다면 세번째 인자에 true 값을 전달하면 같은 모달 컴포넌트가 띄워져 있는지 확인 후 같은 모달 컴포넌트가 있다면 modal 정보를 추가해주지 않도록 하였습니다.

그리고 모달을 단순히 닫아주는 closeModal 함수와 모달을 열때 모달 정보에 저장한 resolve 함수를 실행시켜 결과값을 반환해줄 수 있는 resolveModal, 중복된 모달이 있는지 체크하는 checkModal, 모든 모달을 닫게 해주는 resetModal 함수를 구현해줍니다.

아래에 이 custom hook을 호출한 컴포넌트가 unmount 될때 모달을 모두 닫게 해주는 resetModal를 호출하여 모달 뒤 화면에서 어떤한 이벤트가 발생했을때 모달이 전부 리셋 되도록 해줍니다.

 

자 이제 모달 컴포넌트에서 모달을 닫는 closeModal 함수와 resolveModal 함수를 좀 더 호출된 모달 컴포넌트에서 쉽게 사용할 수 있게끔 한번 wrapping 해줘서 더 단순한 함수로 만들어서 props로 전달받아 사용할 수 있도록 해보겠습니다.

 

import React, { ComponentClass, FunctionComponent, useEffect } from 'react';
import { ModalType } from '../type/modal';
import useModal from '../hooks/useModal';

interface PropsType {
  modal: ModalType;
}

const Component = <P extends {}>({ is, props } : { is?: FunctionComponent<P> | ComponentClass<P> | string, props?: any }) : JSX.Element => {
  if (is) return React.createElement(is, props);
  // eslint-disable-next-line react/jsx-no-useless-fragment
  return (<></>);
};

const ModalComponent = ({ modal }: PropsType) => {
  const { closeModal, resolveModal } = useModal();

  const close = (modal: ModalType) => {
    closeModal(modal.id);
  };

  const resolve = <T extends unknown>(modal: ModalType, result: T) => {
    resolveModal(modal, result);
  };

  return (
    <Component
      is={modal.component}
      key={modal.id}
      props={{
        ...modal.props,
        close: () => close(modal),
        resolve: <T extends unknown>(result: T) => resolve(modal, result),
      }}
    />
  );
};

export default ModalComponent;

 

호출된 모달 컴포넌트는 close 함수와 resolve라는 함수를 props로 전달받아 쉽게 자신을 조작할 수 있습니다. 
참고로 ModalComponent를 따로 빼놓은 이유는 close함수와 resolve함수를 만들어내기 위한것 말고도 뒤로가기를 감지해서 모달을 닫게 하거나 하는 기능도 추가해줄수 있고 해당 모달만의 사정에 따라 조작이 가능하도록 하기 위해 wrapping을 한 이유도 있습니다.

여기서 주의해야 할것은 ModalComponent는 스타일적인 부분을 전혀 관여하지 않는다는 것입니다.

모달이 나타날때 fadein fadeout 같은 단순 인터렉션 등이나 dim 영역에 대한 background-color, 레이아웃 등은 ModalComponent는 관여하지 않으며 바로 자식 컴포넌트에게 일임합니다.

 

그렇다면 이러한 스타일적인 부분이나 레이아웃 즉 모달의 스타일을 담당하는 스타일 컴포넌트가 있으면 사용하기 좋겠죠? 모달 컴포넌트를 만들때마다 마크업을 전부 다시하고 스타일을 또 다시 일일히 작성해주는 것보다는 스타일 컴포넌트로 감싸서 사용하는게 더 편리할테니까요

 

저는 ModalTemplate이라는 스타일 컴포넌트를 해당 프로젝트 전용으로 따로 만들었는데요

이 컴포넌트는 프로젝트 컴포넌트만 분류해놓은 디렉토리에서 관리하도록 하였습니다.

각 프로젝트 사정에 따라 얼마든지 바뀔 수 있는 부분이라 분리해서 관리하도록 하였습니다.

한번 보여드릴건데 그냥 이렇게 구현했구나.. 정도만 보시고 넘어가주셔도 됩니다..^^

 

import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { makeClass } from '@/utils/forReactUtils';

interface PropsType {
  children: React.ReactNode;
  showCloseBtn?: boolean;
  nonModal?: boolean;
  close?: () => void;
  className?: string;
  animationClass?: string;
  animationDuration?: number;
  useLeaveAnimation?: boolean;
}

const DEFAULT_ANIMATION_DURATION = 300;

const ModalTemplate = ({ children, showCloseBtn = false, nonModal, close, className, animationClass = 'fade', animationDuration = DEFAULT_ANIMATION_DURATION, useLeaveAnimation = false }: PropsType) => {
  const dimEl = useRef<HTMLDivElement | null>(null);

  const [open, setOpen] = useState(false);
  const [transitionClass, setTransitionClass] = useState('');

  const closeModal = (dimClosed?: boolean) => {
    if (dimClosed && nonModal) return;
    if (close && dimEl) {
      setOpen(false);
      setTimeout(() => {
        close();
      }, useLeaveAnimation ? animationDuration : 0);
    }
  };

  useEffect(() => {
    setOpen(true);
  }, []);

  useEffect(() => {
    setTimeout(() => {
      setTransitionClass(`${open ? 'enter' : 'leave'}`);
    }, 10);
  }, [open]);

  return (
    <div className={className}>
      <div ref={dimEl} tabIndex={-1} role="button" className="dim" onClick={() => closeModal(true)}>dim</div>
      <div className={makeClass([transitionClass, animationClass, 'cont'])}>
        <div className="modal-box">
          {showCloseBtn && (
            <button className="btn-modal-close" onClick={() => closeModal()}>
              <img src="/imgs/icon/ico-close.svg" alt="modal-close" />
            </button>
          )}
          {children}
        </div>
      </div>
    </div>
  );
};

// noinspection LessResolvedByNameOnly
export const ModalStyle = styled(ModalTemplate)`
  .flex-center; .fix; .lt(0,0); .z(1);  .wh(100%);
  
  .fade{ .o(0); transform: scale(0.95); transition: opacity, transform; transition-duration: ${props => `${props.animationDuration || DEFAULT_ANIMATION_DURATION}ms`};
    &.enter { .o(1); transform: scale(1); }
    &.leave { .o(0); transform: scale(0.95); }
  }
  
  .dim { .fix; .lt(0,0); .z(1);  .wh(100%); .bgc(rgba(0, 0, 0, 0.5)); text-indent: -999999px;
    &.non-modal { .bgc(transparent);  }
  }

  .cont { .rel; .z(2);
    .modal-box{ .rel; }
  }

  .btn-modal-close { .abs; .rt(0,0); .z(1); .flex-center; .wh(24);
    > img { .wh(15); }
  }
`;

// noinspection LessResolvedByNameOnly
export const BottomModalStyle = styled(ModalTemplate)`
  .dim { .flex-center; .fix; .lt(0,0); .z(1); .wh(100%); .bgc(rgba(0, 0, 0, 0.5)); backdrop-filter: blur(0px);
    .cont { .fix; .lt(0,0); .z(1); .wf; .h(220); .bgc(white); .br-t(20); .t-y(100%); }
  }

  .btn-modal-close {  }

  &.fade-enter-done {
    .dim {
      transition: backdrop-filter ease-out 0.2s;
      backdrop-filter: blur(2px);
    }

    .cont {
      transition: transform ease-out 0.1s 0.25s;
      transform: translateY(0px);
    }
  }
`;

// noinspection LessResolvedByNameOnly
export const FullScreenModalStyle = styled(ModalTemplate)`
  .dim {
    .cont { .fix; .lt(0,0); .z(1); .wh(100%); .bgc(white); }
  }

  .btn-modal-close {  }
`;

// noinspection LessResolvedByNameOnly
export const SelectModalStyle = styled(ModalTemplate)`
  .dim { .flex-center; .fix; .lt(0,0); .z(1); .wh(100%); .bgc(rgba(0, 0, 0, 0.5)); backdrop-filter: blur(0px);
    .cont { .fix; .lt(0,0); .z(1); .wf; .m(8,0); .p(0,16); .bgc(transparent); .t-y(100%); border:none;

      .modal-box { .mb(8); .bgc(rgba(221, 221, 221,0.95)); .br(13);

        &::before{ .cnt('매칭방 더보기'); .block; .p(12, 0); .fs(13, 18); .c(#3c3c43); .tc; .o(0.6); }

        .btn{ .wf; .h(60); .-a; .-t(#b1b1b1, 1); .br(0);
          button{ .fs(20); .c(#007aff); letter-spacing: 0.38px; }
        }
      }
    }
  }

  .btn-modal-close { .block !important; .h(60); .bgc(white); .-a(white); .br(13);
    button { .fs(20); .c(#007aff); .semi-bold; }
  }

  &.fade-enter-done {
    .dim {
      transition: backdrop-filter ease-out 0.2s;
      backdrop-filter: blur(2px);
    }

    .cont {
      transition: transform ease-out 0.1s 0.25s;
      transform: translateY(0px);
    }
  }
`;

 

자 이제 모든 준비는 끝났습니다. 

_app.js에서 Provider를 추가해주면 어느 컴포넌트에서든지 useModal 훅을 사용하면 손쉽게 modal을 띄워주고 필요하다면 비동기 처리를 하여 결과값을 전달 받을 수도 있습니다.

 

사용 예시를 한번 보도록 하겠습니다.

 

_app.js

....

import ModalProvider from '@/provider/modal/ModalProvider';
import { scrollRelease, scrollFreeze } from '@/utils/browserUtils';

....

function MyApp({ Component, pageProps }: AppProps) {
    return (
        <ModalProvider scrollRelease={scrollRelease} scrollFreeze={scrollFreeze}>
          <Header />
          <Container>
            <Component {...pageProps} />
          </Container>
          <Footer />
        </ModalProvider>
    );
}

sample.js

...
// useModal Hook 호출
const { modal } = useModal();

...

const handleStatusChange = useCallback(async ({
  partyIds,
  state,
}: { partyIds: string[], state: LeagueUpdatePartyStateRequestState }) => {
  const params = { partyIds, leagueId, state, refuse: null } as LeagueUpdatePartyStateRequest & { leagueId: string };
  if (state === 'REFUSE' || state === 'DISQUALIFY') {
    const partys = partyList.filter(party => partyIds.includes(party.partyId)) as PartyResponse[];
    
    // PartyRefuseModal 컴포넌트에서 resolve 한 값을 반환 받음
    const result = await modal(PartyRefuseModal, { partys });
    
...

 

그럼 다음으로 상태 관리 툴을 이용했을때 보다 파일 구성은 얼마나 단순화 되어졌는지 한번 볼까요?

 

어떻습니까?! 엄청 단순하고 깔끔하게 정리되서 한눈에 딱 들어와 유지보수하기 보다 쉬워진거 같지 않나요?

 

 

 

 

 


결론

Context API가 상태값이 변할때마다 하위 컴포넌트들도 리랜더링 되는 큰 단점을 갖고는 있지만 전역적으로 사용할 모듈이나 라이브러리를 만들때는 Context API가 로직을 단순화하면서 확장성있게 활용될 수 있다는 점을 다시 한번 돌아보게 된 개발 사항이였던거 같습니다. 또한 설계에 따라 리랜더링을 피해갈수도 있고 설계만 잘 한다면 장점을 극대화 해서 사용할 수 있다는 것도 다시 한번 상기시키는 개발이였습니다.

modal 소스를 볼때마다 아 이걸 한번 리팩토링 해야하는데... 해야하는데... 하면서 미루다가 결국 리팩토링을 하게 되었는데 뭔가 속이 후련~~하네요. 뒤로가기 이벤트 발생시 모달이 닫히는 기능은 아직 안정적이지 않아서 조금 더 개발한 뒤에 글을 업데이트 하도록 하겠습니다!

Toast Provider도 만들었는데 Modal Provider를 만든것과 유사하여 따로 적지는 않았습니다..^^ 
Modal과 Toast를 분리해서 만든건 작동 방식이 미세하게 달라져 억지로 합치는 것보다 따로 다른 모듈로 관리하는게 좋을 거 같아 분리하게 되었네욥!

긴글 읽어주셔서 감사합니다.. 언제나 피드백은 대환영입니다~!!

댓글