TIL

모달창 구현하기 - RootLayout에 위치 vs createPortal

inz1234 2024. 7. 10. 23:04

🚨  TroubleShotting

보이는 것과 같이 모달창이 다른 요소들보다 가장 상단에 와야하는데 가장 밑에 위치한다.


문제 발생의 배경 - As Is

처음에 모달창 구현법에 대해서 고민을 했다.

React-dom에서 제공하는 createPortal을 사용할 것인가?
vs
RootLayout.tsx에서 Modal.tsx 컴포넌트를 가장 상단에 위치시킬 것인가?

 

두 방법의 원리를 생각해봤다.

createPortal
(1) HTML에 모달이 될 컴포넌트와 모달이 렌더링 될 DOM 노드를 (ex. <div id='root'>) 미리 위치시킨다.
(2) 모달창 열기를 실시하면, 미리 위치시킨 DOM 노드를 document.querySelector 또는 getElementById로 가져와서 그 위치에 모달창을 렌더링 한다.
장점: 모달창을 렌더링하고자 하는 특정 지점만 미리 정해두면,
부모 DOM tree "외부" 특정 DOM 노드에도 모달창을 렌더링 할 수 있는 장점이 있다.

RootLayout 최상단에 Modal.tsx 
말그대로
(1) RootLayout.tsx 최상단에 Modal 컴포넌트를 위치시킨다.
(2) 전역 상태관리로 모달 open이 true가 되면 Modal 컴포넌트를 렌더링 시킨다.

두 방법 중 고민을 하다가 
미리 DOM 요소를 정해두고 querySelector 또는 getElementById로 해당 요소를 참조하는 createPortal은 비교적 위에서 아래로 내려가며 렌더링하는 React의 본질적인 흐름에 거스르는 듯하여 후자인 RootLayout 최상단에 Modal.tsx을 택했다. 
결과는 성공이었다.

그런데 문제는, 중간에 기획을 바꾸면서 window.open으로 새로운 창을 구현하면서부터 발생했다.


현재 RootLayout.tsx 최상단에 Modal.tsx가 위치해 있다.

export default function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <QueryProvider>
          <Provider>
            <div id="root"></div>
            <Modal /> -----------> 모달 컴포넌트
            <NavBar />
            {children}
          </Provider>
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryProvider>
      </body>
    </html>
  );
}

새로운 창에서도 원래 창(이하 부모 DOM)에서처럼
모달을 열고 닫을 전역상태를 이용해서 모달창을 렌더링하려고 시도했다.

// 모달창 열고 닫는 전역상태(jotai)
export const openModal = atom(
  (get) => get(modalState),
  (_, set, { elementId, type, title, content, onFunc }: ModalProps) => {
    set(modalState, (prev) => ({
      ...prev,
      elementId,
      isOpen: true, -------------> 모달창 열기 버튼을 누르면 true가 된다.
      type,
      title,
      content,
      onFunc: onFunc
        ? () => {
            onFunc();
            set(modalState, (prev) => ({ ...prev, isOpen: false }));
          }
        : () => set(modalState, (prev) => ({ ...prev, isOpen: false })),
      offFunc: () => set(modalState, (prev) => ({ ...prev, isOpen: false }))
    }));
  }
);

 그랬더니! 제일 상단의 일이 벌어졌다. 모달창이 열리기는 하지만, 새로운 창의 다른 요소들 가장 하단에 열려버린 것.


원인

1. window.open으로 새로운 창을 열면
원래 창의 DOM JavaScript 실행컨텍스트를 상속받아
원래 DOM에서 정의한 React 컴포넌트(Modal.tsx)를 참조하고 렌더링할 수 있다. 

2. 하지만, 참조는 가능하지만 DOM 요소 자체의 위치를 옮길 수는 없다.

=> 따라서, 부모 DOM의 Modal.tsx이 참조는 가능해서 렌더링은 되지만,
위치를 바꾸어 가장 상단에 오지 못하기 때문에 위와 같은 상황이 발생한 것.


해결방안

2가지의 해결방안이 떠올랐다.

1. 처음에 사용하지 않은 createPortal로, 부모 DOM tree에 종속되지 않고 새로운 DOM에서도 모달창 끌어 올리기

2. 기존처럼 새로운 DOM layout.tsx에도 기존 Modal.tsx와 똑같되, 이름만 다른 새로운 컴포넌트를 만들어서 위치시키기


How(과정) ?

시도 1 : createPortal로 부모 DOM tree에 종속되지 않고, 새로운 창(새로운 DOM)에서 모달창 렌더링하기

import { PropsWithChildren } from 'react';

const MemberLayout = ({ children }: PropsWithChildren) => {
  return (
    <div>
      <div className="w-full max-w-[1080px] h-full max-h-[600px] mx-auto flex flex-col justify-center ">
        새로운 창 레이아웃
        {children}
      </div>
      <div id="new-root"></div> (1) ---> 모달창이 렌더링 될 위치에 특정노드-A 위치시키기 
    </div>
  );
};
export default MemberLayout;
// ModalPortal.tsx
'use client';

import { openModal } from '@/atom/modalAtom';
import { useAtom } from 'jotai';
import { PropsWithChildren } from 'react';
import { createPortal } from 'react-dom';

const ModalPortal = ({ children }: PropsWithChildren) => {
  const [{ elementId }, _] = useAtom(openModal); // (2) ---> 전역상태로 특정노드-A의 id를 공유 
  const element = document.getElementById(elementId); // (3) ---> 해당 id의 DOM 요소 가져오기

  // (4) ---> 해당 DOM 요소가 있다면 그 위치에 모달창 렌더링
  return element ? createPortal(children, element) : <></>; 
  

};

export default ModalPortal;
'use client';

...

const Modal = () => {
  const [{ elementId, isOpen, type, title, content, onFunc, offFunc }, _] = useAtom(openModal);
  const modalRef = useRef<HTMLDivElement>(null);

  ...

  if (!isOpen) return;

  return (
      <ModalPortal> // (5) ---> ModalPortal의 children에 Modal 배경과 그 외 컴포넌트 넘기기
        <ModalBackground />
        <div
          ref={modalRef}
          className={`min-w-[40%]  ${elementId === 'new-root' ? 'max-w-[50%]' : `max-w-[80%]`} fixed z-[${
            ZINDEX.modalZ
          }]
          max-h-[80vh] top-[40%] left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 bg-white opacity-0 p-4 overflow-hidden`}
        >
          ...
          </div>
      </ModalPortal>
  );
};

export default Modal;

이렇게 하면 의도한 대로 모달창이 잘 구현된다.


여기서 질문

새로운 DOM의 layout.tsx에는 기존 DOM의 RootLayout.tsx에서처럼
Modal.tsx 컴포넌트를 위치시키지 않아도 어떻게 모달창이 가장 상단에 렌더링 되는걸까?

위에서도 이야기 했지만 window.open으로 새로운 창을 열면 원래 DOM의 실행컨텍스트를 상속받기 때문에 기존 창의 DOM 요소를 참조할 수 있는데 + 거기에 createPortal을 사용하면 부모 DOM 요소를 참조만 할 수 있던 이전과 달리,

부모 DOM Modal.tsx를 참조하는 것 뿐만 아니라 미리 지정한 <div id="new-root">에 부모로부터 참조한 Modal.tsx를 새로운 DOM의 다른 위치에 렌더링까지 할 수 있기 때문이다.


시도 2 : 기존처럼 새로운 DOM layout.tsx에도 기존 Modal.tsx와 똑같되, 이름만 다른 새로운 컴포넌트를 만들어서 위치시키기

'use client';
...
const UpperModal = () => { // (1) ---> Modal.tsx와 똑같되 이름만 다른 모달 컴포넌트
  const [{ elementId, isOpen, type, title, content, onFunc, offFunc }, _] = useAtom(openModal);
	...
  if (!isOpen) return;

  return (
    <>
      <ModalBackground />
      <div
        ref={modalRef}
        className={`min-w-[40%]  ${elementId === 'new-root' ? 'max-w-[50%]' : `max-w-[80%]`} fixed z-[${ZINDEX.modalZ}]
          max-h-[80vh] top-[40%] left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col gap-2 bg-white opacity-0 p-4 overflow-hidden`}
      >
       ... 
    </>
  );
};

export default UpperModal;
const MemberLayout = ({ children }: PropsWithChildren) => {
  return (
    <div>
      <div className="w-full max-w-[1080px] h-full max-h-[600px] mx-auto flex flex-col justify-center ">
        새로운 창{children}
      </div>
      <UpperModal /> // (2) ---> 기존 창처럼 새로운 DOM의 layout.tsx에도 UpperModal.tsx 위치 
    </div>
  );
};
export default MemberLayout;

이렇게 해도 똑같이 모달창이 잘 구현된다.
이 방법이 훠어어얼씬 쉽다ㅠㅠ


what(결과) - To Be


💡 새롭게 알게된 점 / 정리

결국 관건은 컴포넌트를 재사용 할 것인가 였던 것 같다.
만약 DOM이 총 1개라면 재사용 안 하는 게 나을 것 같지만,
DOM이 2개 이상이라면 같은 내용의 컴포넌트를 계속 만드는 것보다는 createPortal을 사용해서 Modal.tsx를 재사용하는 게 나을 것 같다.