- #React
createPortal로 모달 탈출 시키기
Prolip
2024-04-24
createPortal 사용해서 부모 컴포넌트 DOM 계층 구조에서 벗어나기 (CSS 상속 회피)
시작
우선 오늘 게시글을 시작하기에 앞서 제가 이전에 모달을 어떻게 구현했었는지 부끄럽지만 보여드리려고 합니다..
export default function Modal({type, text}) { const { enableScrollLock, disableScrollLock } = useScrollLock(); const { isOpen, open, confirm, close } = useModal({ enableScrollLock, disableScrollLock, handleResetRegion, handleConfirm, }); return ( <section className={styles.section}> <button type='button' className={styles.openButton} onClick={open}> {text} </button> {isOpen && type === "confirm" && ( <Confirm /> )} {isOpen && type === "alert" && ( <Alert /> )} {isOpen && type === "description" && ( <Description /> )} </section> ); }
이게 무슨?? 모달 하나에 책임이 아주 막중하군요..
여담이지만 요즘 예전에 작성했던 코드를 확인할 때면 뭔가 처참하다는 것을 느낄 때가 있습니다. 불과 2달, 3달 전의 나인데 왜 그렇게 구현했을까.. 하여튼 요새 리팩토링할 목록을 구성 중입니다. 사실 좀 기대도 됩니다. 어떻게 개선할 수 있을지.. 그 과정에서 또 공부하고 좋잖아요.
하여튼 기존에는 이렇게 모달 컴포넌트를 구현해 모달을 보여주고 싶은 곳에서 사용했습니다.
무엇이 문제였나요?
저는 css 때문에 아주 골머리를 앓았습니다..
과거에 책 좀 편하게 찾으려고 시작한 프로젝트가 있었는데 이 때 모달 구현하다가 CSS가 자꾸 부모 컴포넌트에서 상속 돼서 이리 저리 고치고 z-index 겹쳐서 수정하고.. 분명히 width 설정했는데 안 돼서 아주 그냥 환장했었습니다.
왜 그런가요?
일반적으로 제가 구현한 모달 처럼 부모 컴포넌트에서 직접적으로 사용할 경우 부모 컴포넌트의 DOM 계층 내부에 위치하게 됩니다. 그렇다면 이 경우에 모달은 부모 컴포넌트의 CSS 스타일을 상속 받게 됩니다.
이러한 이유로 제가 처음에 기획한 스타일이 부모 컴포넌트에서 정의한 font size, margin, width 등을 상속해 의도치 않게 변경 됐던 것입니다..
또한 일반적으로 모달이라 함은 페이지의 다른 요소들 위에 떠서 나타나야만 하는 레이어 입니다. 그런데 이를 제가 구현한 방식과 같이 구현한다면 다른 요소들과 z-index가 겹치는 등 경쟁에 빠지는 수가 있습니다..
그리고.. 저기 useScrollLock이라는 커스텀 훅이 보이시나요? 처음 모달 구현했을 때 자꾸 뒤에 배경이 이리저리 움직여서 화가 머리 끝까지 차올랐던 경험이 있습니다.
export function useScrollLock() { const enableScrollLock = () => { const { body } = document; if (!body.getAttribute("scrollY")) { const pageY = window.pageYOffset; body.setAttribute("scrollY", pageY.toString()); body.style.overflow = "hidden"; body.style.position = "fixed"; body.style.left = "0px"; body.style.right = "0px"; body.style.bottom = "0px"; body.style.top = `-${pageY}px`; } }; const disableScrollLock = () => { const { body } = document; if (body.getAttribute("scrollY")) { body.style.removeProperty("overflow"); body.style.removeProperty("position"); body.style.removeProperty("top"); body.style.removeProperty("left"); body.style.removeProperty("right"); body.style.removeProperty("bottom"); window.scrollTo(0, Number(body.getAttribute("scrollY"))); body.removeAttribute("scrollY"); } }; return { enableScrollLock, disableScrollLock }; }
그래서 이렇게 스크롤 고정 시키려고 별 짓을 다 했었습니다.
다시 돌아와서 createPortal!
createPortal – React 공식 문서 참고해봅시다.
createPortal lets you render some children into a different part of the DOM.
와! createPortal을 사용하면 내가 원하는 요소를 DOM의 다른 부분으로 렌더링할 수 있다고 합니다!!
즉, 물리적으로 부모 컴포넌트의 DOM 계층 구조의 바깥으로 위치 시킬 수 있는 것입니다.
그럼 이 과정에서 당연히 부모 컴포넌트에게서 상속 받던 CSS도 사라질 것이니 스타일링이 격리 되어 독립적인 스타일을 유지할 수 있습니다. 또한 z-index 관리도 용이하곘습니다..
사실 이것 말고도 더 이점이 존재합니다. 스크린 리더가 더 자연스럽게 해석하도록 도와 접근성이 향상 된다거나 하지만 전 CSS에게서 해방된다는 점이 제일 좋습니다.
createPortal(children, element)
공식 문서에 따르면 첫 번째 인자로 DOM에 마운트할 React 노드를 전달하고, 2번째 인자로 child가 마운트 될 DOM 엘리먼트 위치라고 합니다.
그럼 어떻게 구성할 수 있을까요??
import { createPortal } from "react-dom"; import BackDrop from "./BackDrop"; import { CloseIcon } from "./icons"; type Props = { children: React.ReactNode; onClose: () => void; }; export default function Modal({ children, onClose }: Props) { if (typeof window === "undefined") { return null; } const element = document.getElementById("modal") as HTMLElement; return ( <> {createPortal( <section className='fixed top-0 left-0 z-20 w-full h-full flex flex-col justify-center items-center'> <BackDrop onClose={onClose} /> <button className='fixed top-5 right-10 text-white' onClick={onClose}> <CloseIcon /> </button> {children} </section>, element )} </> ); }
아 이건 지금 제가 사용 중인 모달을 그대로 가져와봤습니다. SSR로 동작하는 과정에서 실행되는 환경이 브라우저인지 서버인지 구분해 window 객체가 정의 되어 있지 않다면 null을 반환하도록 설정해두었습니다.
BackDrop 컴포넌트를 이용해 뒤의 배경을 rgba(0, 0, 0, 0.6)으로 설정한 뒤 모달에서 보여주는 콘텐츠가 아닌 영역을 누르면 모달을 끌 수 있도록 구현했습니다.
그리고 element 변수에 modal이라는 아이디를 찾고, 이를 createPortal의 두번째 인자로 전달해 모달을 해당 DOM 노드로 이동 시키고 있습니다.
근데 이거 createRoot랑 비슷해 보이는데 알아보니 최상위 레벨의 컴포넌트를 웹 페이지의 루트 위치에 렌더링할 때 사용하는 방법이라는군요. entry point에서 한번만 실행된다고 합니다.
// 이건 Next에서 사용 중인 예시입니다. // 최상위 RootLayout에서 modal 요소를 생성해 관리하고 있습니다. export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang='en' className={pretendard.className}> <body className='flex flex-col w-full'> <main>어쩌구</main> <div id='modal' /> </body> </html> ); } // 아래는 React의 경우로 index.html에 div 요소를 생성해 관리 중입니다. <body> <div id="root"></div> <div id="modal" /> <script type="module" src="/src/main.jsx"></script> </body>
주의할 점
portal을 사용해 컴포넌트의 렌더링 위치를 변경한다고 해도 물리적으로만 DOM 계층 구조의 바깥에 위치하고 결국 렌더 트리 상으로 부모 컴포넌트에 속해 있어서 이벤트는 bubble up 돼서 전달 됩니다.
사실 이는 렌더링 로직에 유연성을 제공하면서도, 이벤트 핸들링 체계 유지에 유리하다는 것이기도 하겠죠..
그리고..!
createPortal을 사용하면서 모달 컴포넌트에 fixed 속성을 사용하니 스크롤이 고정 되어 있었습니다..
알아보니 fixed 속성은 해당 요소를 뷰포트에 상대적으로 배치해 스클롤을 해도 해당 요소가 항상 같은 위치에 머물게 하는데 이 과정에서 해당 요소는 페이지 레이아웃의 흐름에서 제거 되어 화면에 고정 되는 것이었습니다..!
useEffect(() => { const origin = window.getComputedStyle(document.body).overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = origin }, [])
사실 이거 준비해놨는데 안 써도 됐습니다.
마치며
사실 이번 글은 제가 지금은 어떻게 모달을 구현하고 있는지에 대한 포스팅 입니다.
저 끔찍한 모달은 조만간 리팩토링할 생각인데 지금 너무 바빠서 건들지 못하는 중입니다.. 세상엔 배울게 너무 많은 거 같습니다.