junglast
Published on

프론트엔드에서 URL이 부여되는 모달(Routable modal) 구현하기 (2) - 실전편

이제 이전 글에 이어 갤러리 예제를 통해 Next.js의 pages 라우터에서 Routable 모달을 실제 구현하는 과정을 살펴보겠습니다.

Next.js의 app 라우터에서 useRouter는 클라이언트 컴포넌트에서만 사용할 수 있는 훅입니다. 또한, 쿼리 스트링이나 pathname을 읽기 위해서는 usePathname, useSearchParams와 같은 훅을 사용해야 합니다. 따라서 본문의 코드가 제대로 작동되지 않을 수 있습니다.

기본 아이디어

Routable 모달은 유저가 모달을 여는 동작을 수행할 때 동시에 브라우저의 히스토리 스택에 새로운 URL을 추가하여 실제 페이지를 내비게이션하지 않고 브라우저 주소창에 표시되는 URL만을 변경합니다.

따라서 위 사진처럼 갤러리 페이지(/gallery)에서 썸네일을 클릭하면 다음 두 가지 동작이 수행되어야 합니다.

  1. 모달을 연다.
  2. 브라우저의 히스토리 스택에 새로운 URL을 추가한다(여기서 URL은 /gallery/{사진의 고유 ID}의 형태가 될 것입니다).

이 작업에 추가로, 모달에 부여한 URL로 사용자가 직접 접근했을 때의 처리를 해주어야 합니다. 이전 글에서 설명한 두 가지 세부 유형으로 나누어 구현해보겠습니다.

  1. URL로 직접 접속했을 때 모달이 열리는 방식
  2. URL로 직접 접속했을 때 모달이 아닌 별도의 페이지로 이동하는 방식

1. URL로 직접 접속했을 때 모달이 열리는 방식

이 방식에서는 모달에 부여한 URL(/gallery/123)에 접근했을 때 실제로는 갤러리 페이지(/gallery)가 열리고, 그 위에 123번 사진이 모달로 열려야 합니다.

따라서, 라우터에서는 /gallery/123으로 직접 접속한 경우 실제로는 /gallery로 이동되어야 하고, 동시에 123번 사진 모달을 열 것이라는 정보도 함께 전달해야 합니다. 이를 위해 쿼리 스트링을 이용할 수 있습니다. 정리하자면 아래와 같습니다.

이를 코드로 간략하게 나타내면 아래와 같습니다.

/* pages/gallery.tsx */
...
<Link
  href={{
    query: {
      ...router.query,
      photoId: 123,
    },
  }}
  as="/gallery/123"
>
  <img src="..." />
</Link>
...

갤러리 페이지에서는 썸네일을 클릭했을 때 Next 라우터의 Linkas prop을 이용하여 히스토리 스택에는 /gallery/123을 넣고, 실제로는 현재 페이지에 photoId라는 쿼리 스트링을 덧붙이도록(/gallery?photoId=123) 설정합니다.

이와 같이 설정하면 링크를 눌렀을 때 브라우저 주소창에는 /gallery/123으로의 내비게이션이 일어난 것처럼 보이게 됩니다.

또한, 갤러리 페이지에서는 모달의 열림/닫힘 여부를 관리하기 위해 별도의 state가 아닌 쿼리 스트링의 photoId 값을 이용하게 됩니다.

/* pages/gallery.tsx */
...
const { photoId } = router.query;
...
{
  photoId &&
    <Modal
      photoId={photoId}
      onClose={() => {
        router.push({
          query: {
            ...router.query,
            photoId: undefined,
          },
        })
      }}
    />
}

위와 같이 photoId가 존재할 때만 모달을 엽니다. 또 하나 중요한 것은, 모달 내부에서 "닫기" 동작이 일어날 때 router.push를 이용하여 쿼리 스트링의 photoIdundefined로 설정해야 한다는 점입니다. 그러면 이 작업에 의해 photoId 값이 없어지므로 모달은 닫히게 될 것입니다.

이제 /gallery/123으로 직접 접속했을 때의 처리를 해 주어야 합니다. 본 예제에서는 Next를 사용하고 있으므로 미들웨어 등을 활용해 페이지가 클라이언트에서 렌더링 되기 전 서버단에서 리다이렉트를 처리할 수도 있습니다. 하지만 여기서는 클라이언트 사이드에서 useEffect를 통해 리다이렉트를 처리하도록 하겠습니다.

/* /pages/gallery/[photoId].tsx */
...
const { photoId } = router.query;

useEffect(() => {
  router.replace(
    {
      pathname: '/gallery',
      query: {
        ...router.query,
        photoId,
      },
    },
    `/gallery/${photoId}`
  )
}, [router])
...

위와 같이 구현하면, /gallery/123으로 직접 접속했을 때 실제로는 /gallery?photoId=123으로 리다이렉트 되고, as 속성에 의해 브라우저 주소창에는 쿼리 스트링이 보여지지 않은 채로 /gallery/123이 노출되게 됩니다 (router.pushrouter.replace 메서드에 전달하는 두 번째 parameter가 as가 됩니다).

하지만, 갤러리 페이지로 replace될 때에 실제로는 쿼리 스트링에 photoId가 존재하므로 페이지에 입장하자마자 모달이 열리게 됩니다.

2. URL로 직접 접속했을 때, 모달이 아닌 별도의 페이지로 이동하는 방식

이 방식에서는 모달에 부여한 URL(/gallery/123)에 접근했을 때, 갤러리 페이지(/gallery)가 아닌 123번 사진이 나와 있는 별도의 페이지(/gallery/123)로 이동해야 합니다.

1번 유형과 다른 점은, 사진의 상세 페이지(/gallery/123)이 실제로 존재하는 페이지라는 것입니다.

/* pages/gallery.tsx */
<Link
  href={{
    query: {
      ...router.query,
      photoId: 123,
    },
  }}
  as="/gallery/123"
>
  <img src="..." />
</Link>

...

{
  photoId &&
    <Modal
      onClose={() => {
        router.push({
          query: {
            ...router.query,
            photoId: undefined,
          },
        })
      }}
    >
      <PhotoDetailModalContent photoId={photoId} />
    </Modal>
}

1번 유형과 전체적인 구현은 같지만 모달 내부의 컨텐츠를 재사용하기 위해 별도의 컴포넌트로 분리했습니다.

한편, /gallery/123 페이지에서는 아래와 같이 모달이 아닌 전체 페이지로 열리는 형태가 되어야 합니다.

/* pages/gallery/[photoId].tsx */
...
const { photoId } = router.query;
...

return () => (
  <PhotoDetailModalContent photoId={photoId} />
)

위와 같이 구현하면 /gallery 페이지에서 썸네일을 클릭했을 때는 주소가 부여된 모달이 열리고, 이 주소(/gallery/123)로 직접 접속했을 때는 모달이 아닌 전체 페이지로 열리게 됩니다.