Parallel Routes을 이용한 Modal 창 띄우기

Parallel Routes란

Parallel route란 Next.js 13.3 버전에서 새로 도입된 기능이다.

13.3버전은 2023년 4월초에 출시되었고

App router가 공식적으로 개시된 13.4버전은 2023년 5월초에 출시되었다.

그래서 자료를 찾아도 많이 없다.

우선 공식문서의 주소 : https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

Parallel routes가 무엇인지에 대해 공식문서에 설명이 아주 길지만,

 

 

나름 한 줄로 요약하자면,

같은 layout에서 slot을 이용해 여러 개의 page를 동시에 rendering하고 독립적으로 streaming이 가능하며

각각 폴더에 loading.tsx과 error.tsx만 생성하면 독립적으로 로딩과 에러 상태 정의가 가능하다

...라고 하는 것인데 너무 결론부터 얘기하는 것 같으니

우선은 내가 무엇을 만들려고 하는지 소개부터하겠다.

 

구성하고 싶은 Modal 창

위와 같은 창에서 왼쪽 하단 전체메뉴를 클릭하면

요런 팝업 모달창이 뜨게 하고 싶다.

 

과거 React 17버전에서 Modal 창

Next.js 13 버전은 React 18을 도입하고 있으나

2년전에 Next.js가 아닌 React 17을 사용했을 때 Modal창은 다음과 같이 구성하였다.

React는 public 폴더에 index.html이란 파일이 있었다.

<body>
  <div id="root" />
</body>

body 태그 사이에는 위와 같이 id가 root인 div가 딱 한 개 있다.

Client Side Rendering이기 때문에 모든 코드들이 이 한 개의 div에 수십번 nested되고 또 nested되는 것이다.

그러나 정작 최상위 부모단에서 독립적인 modal창을 띄우고 싶을 때는

(물론 z-index를 엄청 크게 줄 수도 있지만 z-index 숫자 맞추기도 번거로운 작업이다.)

바로 이 index.html파일에 div를 하나 더 생성한다.

<body>
  <div id="modal"></div>
  <div id="root"></div>
</body>

이런 식으로 반드시 root 윗줄에 다른 id의 div를 생성하고, jsx 파일에서 createPortal이라는 것을 사용하였다.

  return ReactDOM.createPortal(
    <>
      ...
    </>,
    document.querySelector('#modal')
  );

물론 위의 경우는 순수한 Client side rendering만 했던 React의 경우였고

Parallel routes는 Server component도 지원하기 때문에 뒷단에서의 로직은 조금 다르겠지만

개념은 위와 비슷할 것이라 생각한다.

게다가 parallel routes는 React 18부터 도입된 loading과 error 상태 관리의 간편화 기능까지 넣었다.

 

코드 구현

코더에게는 이론보다 구현이 더 중요하다.

우선 아래와 같이 app/@modal/menu-list/page.tsx를 생성한다.

그리고 root폴더의 layout.tsx에 modal  (위에 골뱅이 뒤의 이름) prop을 추가한다.

import type {ReactNode} from 'react';
...
interface LayoutProps {
  children: ReactNode;
  modal: ReactNode;
}
const RootLayout = ({children, modal}: LayoutProps) => {
  return (
    <html lang="ko">
      <body>
        {modal}
        <main>{children}</main>
      </body>
    </html>
  );
};

위에 children은 원래 있던 코드고, modal을 추가한 것이다. 위처럼 추가하는 것을 공식문서에서 slot이라고 명명하였다.

위에서 중요한 것은 { modal }이 꼭 { children } 위에 있어야한다는 것.

 

이제 component 폴더에 Modal창을 위한 container를 만든다.

이건 어쩔 수 없이 client component으로 만들 수 밖에 없다.

'use client';

import {useCallback, useRef, useEffect, MouseEventHandler} from 'react';
import {useRouter} from 'next/navigation';
import {MdClose} from 'react-icons/md';

export default function Modal({children}: {children: React.ReactNode}) {
  const overlay = useRef(null);
  const wrapper = useRef(null);
  const router = useRouter();

  const onDismiss = useCallback(() => {
    router.back();
  }, [router]);

  const onClick: MouseEventHandler = useCallback(
    e => {
      if (e.target === overlay.current || e.target === wrapper.current) {
        if (onDismiss) onDismiss();
      }
    },
    [onDismiss, overlay, wrapper],
  );

  const onKeyDown = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') onDismiss();
    },
    [onDismiss],
  );

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [onKeyDown]);

  return (
    <div
      ref={overlay}
      className="fixed transition z-30 left-0 right-0 top-0 bottom-0 bg-black/60"
      onClick={onClick}
    >
      <div
        ref={wrapper}
        className="absolute left-0 top-0 h-full w-3/4 bg-bgprimary"
      >
        <div className="bg-white rounded-r-xl p-8 relative">
          <button
            className="absolute text-4xl top-2 right-2  active:text-blue-300 hover:text-blue-500"
            onClick={onDismiss}
          >
            <MdClose />
          </button>

          {children}
        </div>
      </div>
    </div>
  );
}

이 방식의 장점은 다른 component(예를들면 modal 내부 UI 컴포넌트)들에서 modal창을 닫는 함수에 대해서 이리저리 props로 넘겨줄 필요없이 router.back()으로 창을 닫아줄 수 있다는 것이다. 물론 단점은 useRouter를 쓰는 순간 그 component는 client component로 바꿔줘야하는 큰 단점이 있긴 하다.

 

Modal과 Dialog의 차이는?

여기서 잠깐, modal과 dialog의 차이는 무엇인가?

이것도 여기저기 블로그보면 엉터리 정의들이 참 많다. (검색하기 힘들어)

dialog는 div 창이 하나 떠있는 개념이고,

Modal이라 함은 엄밀하게는 두 가지를 동시에 지닌 것을 말하는데

  하나는 뒤의 배경과 interaction을 막는 것이고,

  또 하나는 dialog안에서 user와 interaction이 이루어져야 한다는 것 (=> 당연한 거지만, 그래서 client component를 쓸 수 밖에 없다.)

프로그래머 입장에서는 대충 정의하자면

modal은, 투명도 몇%인 회색 div가 전체화면을 덮고 있고, 그 위에 dialog창이 떠 있는 것을 말한다.

중요한 것은 뒷 배경과 interaction이 안 되어야하기 때문에 window scroll 기능만 뒤더라도 chrome 개발자 console에 warning이 정말 미치듯이 뜬다. (알았어 알았다고) 이것을 방지하기 위해서 다음 코드가 필요하다.

우선 재사용을 위해 components폴더에 Icon.tsx라는 component를 만들었다.

const Icon = ({Icon, nameOfIcon, linkAddress}: Props) => {
  return (
    <Link
      href={linkAddress}
      scroll={false}
    >
    ...

중요! : 위에 scroll={false}를 반드시 넣어야 한다.

 

자, 이제 위의 재사용 가능한 Icon component의 {linkAddress}에 "/menu-list" 만 넣으면 Modal 창이 뜨고, 다른 component 주소인 /my-page를 넣으면 일반적인 Link로 동작한다.

이제 footer의 아이콘을 디자인하는 component의 코드를 깔끔하게 구성할 수 있다.

  import Icon from ...
  ...
  return (
    <div className="sticky z-20 bottom-0 w-full border-t border-primary shadow-sm bg-bgprimary/90 py-3 left-0 flex justify-around items-start px-6 lg:hidden">
      <Icon Icon={FaBars} nameOfIcon={'전체메뉴'} linkAddress={'/menu-list'} />
      <Icon Icon={FiHeart} nameOfIcon={'위시리스트'} linkAddress={'/'} />
      <Icon Icon={FiUser} nameOfIcon={'마이페이지'} linkAddress={'/my-page'} />
    </div>

여기서 드는 질문은... modal창이 단순히 "/menu-list"라는 url로 띄우는 것이라면 (=> 이것을 공식문서에서 Soft Navigation이라고 부른다)

브라우저에서 수동으로 똑같은 url을 (localhost:3000/menu-list)를 치면 이 모달 창이 나타나지 않을까 (=> 이것을 공식문서에서 Hard Navigation이라고 부른다)하는 의문인데

Hard Navigation으로는 안 뜨는 것이 기본 설정으로 되어있다.

 

설명이 복잡해졌는데

이제 아까 생성해놓은 @modal/menu-list/page.tsx안에 모달 창안의 UI만 디자인만 하면 된다.

import Modal from '@/components/modal/modal';

export default async function MenuListModal() {
  return (
    <Modal>
      ...
    </Modal>
  );
}

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유