본문 바로가기

삽질기록

리액트 Portal로 깔쌈하게 모달창 구현하기

 나는 이번 팀 프로젝트의 첫 미션으로 헤더 컴포넌트를 담당하게되었다.

헤더 컴포넌트 UI

사실 이 컴포넌트가 헤더라고 하기엔 navigation의 역할을 해서, 한참 nav 컴포넌트라고 불러야할지, header 컴포넌트라고 불러야할지 고민이 많았다. (브랜치 명을 헤더로 했다 지웠다 네브로 했다 지웠다...) 


 그러나, 하단의 네비게이션 바가 존재하고, 페이지에 따라서 페이지의 title이 뜨는 경우도 있어서. . . 여러 고민끝에 헤더컴포넌트라고 이름을 지었다. 아마 다음에 이런 UI를 또 구현하게 된다면 top-nav, 아래 네비게이션 탭은 bottom-nav로 칭할 것 같다.

 아무튼 나는 이 헤더 컴포넌트의 페이지별 UI와 모든 기능을 담당했다. 헤더에 있는 더보기 버튼은 누르면 모달창이 뜨는 버튼인데, 헤더에서 뿐만 아니라, 댓글과 게시물에도 달려있었기 때문에 이 더보기 버튼을 가장 먼저 컴포넌트화 시켜야겠다고 생각을 했다. 이 버튼을 구현하기전, 팀원에게 모달 구현에 관한 막막함을 토로했을 때, 한 팀원이 React의 Portal로 모달을 구현했다는 이야기를 듣고 Portal에 대해 공부를 하며 모달창을 구현했다.

❓ Portal이란?

Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법을 제공합니다.
import ReactDOM from 'react-dom';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <ThemeProvider theme={theme}>
    <App />
  </ThemeProvider>,
);

리액트 src/index.js 파일을 보면 우리가 앞으로 만들 App컴포넌트를 id가 root인 요소를 도큐먼트에서 찾고, render을 통해 최상위 요소 안에 자식요소로 모든 컴포넌트들을 랜더링 시키고 있다는 것을 볼 수 있다. 하지만 모달창같은 경우 모든 요소의 상위에서 띄워져야 한다.

 

 하지만 컴포넌트 안의 컴포넌트 안의 컴포넌트로.. 깊숙히 박혀 있는 것은 HTML 구조상 맞지않다. 또한 다른 요소들의 z-index와 같은 css 속성에 영향을 받을 수 있다는 위험도 있다. 모달 컴포넌트가 root가 아닌 외부에서 랜더링이 된다면 얼마나 좋을까? 이 경우 사용하는 것이 createPortal이다. createPortal을 사용하면 마치 포탈처럼 원하는 DOM에 컴포넌트를 자식요소로 렌더링시킬 수 있다.

 

 첫번째 인자에 원하는 컴포넌트를, 두번째 인자는 컴포넌트를 넣어줄 DOM을 넣어주면 된다! 나는 index.html 파일에 #root와 같은 레벨의 형제요소로 #modal 요소를 만들어주고 그 하위요소로 모달 컴포넌트를 넣어주기 위해 포탈을 사용했다. 

  <body>
    <div id="root"></div>
    <div id="modal"></div> <!--이 요소의 자식 요소로 모달 컴포넌트를 넣어줍니다-->
  </body>
ReactDOM.createPortal(모달컴포넌트,document.getElementById('modal'));

  이렇게 사용해주면, 중첩된 컴포넌트 사이들에 낑겨있는 모달이아닌, 모달이라는 독립적인 요소에 렌더링이 되기 때문에 매우 깔쌈하게 구현할 수 있다.

 

❗️우당탕탕 모달 구현기

주의 : 상당히 조잡한 코드로 읽는 분들의 심기를 거슬리게 할 수 있읍니다. 이 코드에선 냄새가 납니다.

Before ) 모달 버튼 컴포넌트 구현하기 

 나는 모달창이 나오는 더보기 버튼을 컴포넌트화 하고 싶었기 때문에, 우선 모달 버튼 컴포넌트와 모달 컴포넌트 2가지 컴포넌트를 만들어주었다. 모달이 열고 닫히는 상태는 모달 버튼에서 관리를 해주고, 모달이 열리면 모달 컴포넌트가 보이는 식의 코드를 짰다. Modal 컴포넌트는 Portal로 연결해두었기 때문에 아까 본 <div id="modal"></div> 요소에서 랜더링이 된다. 

(참고로 엉망진창임 처음엔 다 그렇잖아요) 

//모달 버튼 컴포넌트

import Modal from '../Modal/Modal';
import React, { useState } from 'react';
import BasicModalContent from '../BasicModalContent/BasicModalContent';
import CommentModalContent from '../CommentModalContent/CommentModalContent';
import ChatModalContent from '../ChatModalContent/ChatModalContent';
import PostModalContent from '../PostModalContent/PostModalContent';
export default function ModalBtn({ contents }) {
  //모달상태를 관리해주는 state
  const [isOpenModal, setIsOpenModal] = useState(false);
  const handleOpenModal = () => {
    setIsOpenModal(true);
  };
 //alert 상태를 관리해주는 state
  const [isOpenAlert, setIsOpenAlert] = useState(false);
  return (
    <>
      {/*모달 버튼을 클릭하면 모달이 열리게 해주세용*/}
      <button onClick={handleOpenModal}>
        <S.MoreIcon />
      </button>
      {/*기본 헤더 더보기 버튼이면 <ModalBtn contents="basic"/> */}
      {isOpenModal && contents === 'basic' && (
        <>
          <Modal setIsOpenModal={setIsOpenModal} setIsOpenAlert={setIsOpenAlert}>
            <BasicModalContent />
          </Modal>
        </>
      )}
      {/*채팅페이지 헤더의 더보기 버튼이면 <ModalBtn contents="chat"/> */}
      {isOpenModal && contents === 'chat' && (
        <>
          <Modal setIsOpenModal={setIsOpenModal} setIsOpenAlert={setIsOpenAlert}>
            <ChatModalContent />
          </Modal>
        </>
      )}
      {/*댓글의 더보기 버튼이면 <ModalBtn contents="comment"/> */}
      {isOpenModal && contents === 'comment' && (
        <>
          <Modal setIsOpenModal={setIsOpenModal} setIsOpenAlert={setIsOpenAlert}>
            <CommentModalContent />
          </Modal>
        </>
      )}
      {/* 게시물의 더보기 버튼이면 <ModalBtn contents="post"/> */}
      {isOpenModal && contents === 'post' && (
        <>
          <Modal setIsOpenModal={setIsOpenModal} setIsOpenAlert={setIsOpenAlert}>
            <PostModalContent />
          </Modal>
        </>
      )}
    </>
  );
}

 일단 모달의 내용이 페이지별로 다르니까, 모달 버튼에 contents라는 Props를 만들어줘서 contents에 따라 다른 내용컴포넌트가 보이도록 구현했다. 다른 페이지를 개발하는 사람들이 가져다 쓰기 편하게 이렇게 구현해야겠다! 라고 해맑은 생각으로 이렇게 짜놨다. 이 코드의 문제는 한눈에 봐도 중복되는 것이 너무 많고, 수많은 import를 보라,, 의존성이 너무 높고, 주석이 필수적으로 필요한 코드라는 것이다. 리액트를 사용하는 이유가 전혀 없게 만들어버린, 재사용이 불가능한 코드이다. 냄새나!

 

Before ) 모달 컴포넌트 구현하기 

 모달 컴포넌트는 포탈을 사용해 모달의 위치를 잘 잡아주었지만, 역시나 조잡한 코드로 만들어두었다. 일단 페이지별로 모달에 들어가는 내용들이 모두 다르기 때문에, Modal 컴포넌트로 감싼 부분이 모달의 내용이 들어갈 수 있도록 props의 childern으로 나름 재사용이 가능하게 만들어두었다. 

  이 코드의 의도는 모달창의 백그라운드 요소를 클릭했을 때 이벤트가 발생한 노드가 해당 요소이면 모달과, 모달에 있는 버튼을 클릭했을 때 나오는 alert창 또한 함께 닫힐 수 있도록 구현을 한 것이다. setState함수를 props로 받아 값을 바꿔주고있는데, 이름도 명확하지 않고, false가 열린다는 것인지 닫힌다는 것인지 알려면 부모 컴포넌트에 가서 확인을 해야하고, 모달 컴포넌트인데 alert의 상태를 변화시켜주는 기능이 마구 섞이고 조잡한 코드가 된 것이다.  

//모달 컴포넌트

import React, { useRef } from 'react';
import ReactDOM from 'react-dom';
import * as S from './modal.style';
const Modal = ({ children, setIsOpenModal, setIsOpenAlert }) => {
  const outSection = useRef();
  return ReactDOM.createPortal( //포탈사용
    <article>
      <S.ModalContainer>
        <S.PanningBox>
          <S.PanningBar></S.PanningBar>
        </S.PanningBox>
        <S.ModalList>{children}</S.ModalList>
      </S.ModalContainer>
      <S.Background
        ref={outSection}
        onClick={(e) => {
          if (outSection.current === e.target) {
            setIsOpenModal(false); //모달이 닫힌다는거야 열린다는거야
            setIsOpenAlert(false); //alert는 왜 여기서 넘겨주는거야
          }
        }}
      />
    </article>,
    document.getElementById('modal'),//이 요소의 자식요소로 랜더링이 된다
  );
};
export default Modal;

After ) 모달 버튼 컴포넌트 구현하기 

 앞선 코드들에서 조잡함과 냄새를 조금 개선해본 코드이다.

//개선된 모달 버튼 컴포넌트

import React, { useState } from 'react';
import * as S from './modalBtn.style';
import Modal from '../Modal/Modal';

export default function ModalBtn({ children }) {
  //모달열기
  const [isOpenModal, setIsOpenModal] = useState(false);
  const handleOpenModal = () => {
    setIsOpenModal(true);
  };

  const handleCloseModal = () => {
    setIsOpenModal(false);
  };

  return (
    <>
      <button onClick={handleOpenModal}>
        <S.MoreIcon />
      </button>
      {isOpenModal && (
        <>
          <Modal handleCloseModal={handleCloseModal}>{children}</Modal>
        </>
      )}
    </>
  );
}

일단 setState함수를 직접적으로 넘겨주지 않고 handleOpenModal, handleCloseModal로 명확하게 분리시켜준 후, 버튼을 클릭시 모달리 열리도록, 모달 컴포넌트 내부에서는 모달을 닫을 수 있도록 handler함수로 넘겨주었다. 자식에서 부모에 있는 상태를 바꿔주는 것이 리액트가 지향하는 단방향 데이터 흐름에 위배되기도 하고, 애매한 setOpenModal이라는 이름으로 혼동을 줄 수있기 때문이다.

 

 그리고 이전 코드에서는 Alert의 상태를  모달 버튼에서 모달에 넘겨주어 모달의 백그라운드에서 모달 alert를 close하게 해두었는데, 이 부분의 경우 모달의 content 컴포넌트에서 관리하도록 분리시켜주었다. 

//모달 내용 컴포넌트

import React, { useState } from 'react';
import LogoutAlert from '../../alert/LogoutAlert/LogoutAlert';
export default function BasicModalContent() {
  //alert창 열기
  const [isOpenAlert, setIsOpenAlert] = useState(false);
  const handleOpenAlert = () => {
    setIsOpenAlert(true);
  };
  const handleCloseAlert = () => {
    setIsOpenAlert(false);
  };
  return (
    <>
      <li>
        <button>설정 및 개인정보</button>
      </li>
      <li>
        <button onClick={handleOpenAlert}>로그아웃</button>
      </li>
      {isOpenAlert && <LogoutAlert handleCloseAlert={handleCloseAlert} />}
    </>
  )

 

 그리고 contents를 치워버리고 ModalBtn으로 감싼 요소가 모달의 내용을 구성할 수 있도록 children이라는 props만 받기로 했다. 기존에 모달 contents들은 그대로 두고 모달 버튼을 사용하는 곳에 해당 콘텐츠를 감싸면 모달창이 뜨는 모달 버튼이 구현된다. 조금 더 재사용에 가까워진 것 같다. 수많은 import도 사라졌다! 야호!

 

After ) 모달 컴포넌트 구현하기

//개선된 모달 컴포넌트

import React, { useRef } from 'react';
import ReactDOM from 'react-dom';
import * as S from './modal.style';
const Modal = ({ children, handleCloseModal }) => {
  const outSection = useRef();
  return ReactDOM.createPortal(
    <article>
      <S.Background
        ref={outSection}
        onClick={(e) => {
          if (outSection.current === e.target) {
            handleCloseModal();
          }
        }}
      />
      <S.ModalContainer>
        <S.PanningBox>
          <S.PanningBar></S.PanningBar>
        </S.PanningBox>
        <S.ModalList>{children}</S.ModalList>
      </S.ModalContainer>
    </article>,
    document.getElementById('modal'),
  );
};
export default Modal;

이후 개선한 모달 컴포넌트는 setState를 바로 가져오는 것이 아닌 handleCloseModal를 전달받았다. 그리고 모달버튼에서 넘겨받은 children을 모달 내용부분으로 넣어두어서 아래처럼 모달버튼으로 모달의 내용을 감싸주면 모달버튼과 모달창이 구현되도록 마무리했다.

        <ModalBtn>
          <BasicModalContent />
        </ModalBtn>

개선하고 싶은 부분

모달 닫기 버튼이 따로 존재하지 않고 배경을 누르면 모달창이 닫아지는 구조라 웹 접근성 측면에서 좋지 않은 듯 하다. esc로 나갈 수 있게 기능을 추가하거나 아예 모달 닫기 버튼을 따로 추가하면 더 개선이 될 것 같다. 이번 모달창을 구현하면서 독립적인 컴포넌트를 만드는 방법에 아주 미세하게 가까워진 듯 하다. 아직도 컴포넌트화 해야할 것들이 많고 상태관리를 효율적으로 하는 방법도 찾아야겠지만, 모달창을 구현하기 전과 후의 나는 많이 성장한 것 같아 만족스럽다!

 

참고자료 

 

리액트 createRoot

리액트 포탈 공식 문서