- Published on
프론트엔드에서 URL이 부여되는 모달(Routable modal) 구현하기
웹 페이지에서 모달이 열릴 때, 마치 다른 페이지로의 이동(내비게이션)이 일어난 것처럼 브라우저 주소창의 URL이 변경되는 UI를 구현할 수 있습니다.
예를 들어 아래처럼 갤러리 페이지(/feed
)에서 특정 사진을 클릭하면 모달이 열리는데, 이 순간 URL이 /photo/123
등으로 변경되어 마치 모달에 별도의 URL이 부여되는 것처럼 보이는 경우입니다.
이는 Reddit이나 Facebook과 같은 서비스에서도 볼 수 있는데, 진행 중인 프로젝트에서도 별도의 페이지로 이동하지 않고도 많은 정보를 빠르게 탐색하게 하는 UI를 고민하던 끝에 이러한 기능을 구현하게 되었습니다.
Routable 모달은 UX 관점에서 다음과 같은 장점을 가집니다.
- 각각의 모달에 URL이 부여되므로, 사용자는 이 URL을 복사하여 다른 사용자에게 공유할 수 있습니다(permalink라고 불리기도 합니다).
- 따라서 사용자는 이 URL로 다시 모달을 열어서 해당 항목에 바로 접근할 수 있습니다.
- 또한, 브라우저가 새로고침된 경우에도 모달을 열었던 상태로 돌아갈 수 있습니다.
- 모달을 연 후에, 브라우저의 뒤로가기 동작을 통해 모달을 닫을 수 있습니다. 특히, 뒤로가기 버튼이 OS 레벨에 존재하는 Android 기기에서는 뒤로가기 버튼을 눌러 모달을 닫을 수 있어 편리합니다.
- 물론 앞으로가기 동작을 통해 다시 모달을 열 수도 있습니다.
세부 유형
추가로, 세부 동작에 따라 Routable 모달을 아래와 같이 두 가지 유형으로 한 번 더 나누어 생각해볼 수 있습니다.
- 모달에 부여된 URL로 직접 접속했을 때 모달이 열리는 방식
- 모달에 부여된 URL로 직접 접속했을 때 모달이 아닌 별도의 페이지로 이동하는 방식
예를 들어, 위 예시에서 사용자가 /photo/123
이라는 URL로 직접 접속했을 때, 아래와 같은 두 가지 UI를 생각해볼 수 있습니다.
- 갤러리 페이지(
/feed
)가 열리고, 그 위에 해당 사진 모달이 자동으로 열리는 UI
- 갤러리 페이지가 열리는 것이 아니라, 해당 사진에 대한 별도의 페이지(
/photo/123
)가 열리는 UI
이 2번 케이스는 URL에 직접 접속했을 때 1번 케이스처럼 모달이 자동으로 열리는 것이 아닌 모달 안에 들어있던 내용이 전체화면으로 열리는 형태가 됩니다.
구현 방법
Routable 모달을 구현할 때 핵심이 되는 것은 아래와 같이 크게 두 가지입니다.
- 사용자가 모달을 열었을 때 다른 페이지로 이동(내비게이션)이 실제로 발생하지는 않지만, 마치 발생한 것처럼 URL을 변경하는 것
- 모달에 부여된 URL로 직접 접속했을 때의 처리
1번 과정은 프로젝트에서 사용하고 있는 라우터 라이브러리(혹은 Next.js)에 따라 부르는 명칭은 조금씩 다르지만, 대부분은 Route masking
이라는 용어로 설명하고 있습니다. 개념적으로는 브라우저의 history 스택에 열릴 모달의 URL을 새롭게 push하지만 화면상에서는 내비게이션이 일어나지 않게 되어 결과적으로는 브라우저에 표시되는 URL만 변경됩니다.
물론 브라우저의 뒤로가기 동작을 사용해 이전 URL로 돌아갈 수 있지만(history 스택에서 pop을 하게 되므로) 역시 화면상에서는 아무 변화가 없을 것입니다.
다음, 2번 과정은 위에서 언급한 두 가지 유형에 따라 구현을 달리해야 합니다.
이어서 갤러리 예제를 통해 Next.js의 pages 라우터에서 Routable 모달을 실제 구현하는 과정을 살펴보겠습니다.
Next.js의 app 라우터에서 Routable 모달을 구현하기 위해서는 Parallel Routes나 Intercepting Routes와 같은 방법을 이용해야 합니다. tanstack-router를 이용할 때는, Location Masking을 이용하여 구현할 수 있습니다.
기본 아이디어
Routable 모달은 유저가 모달을 여는 동작을 수행할 때 동시에 브라우저의 히스토리 스택에 새로운 URL을 추가하여 실제 페이지를 내비게이션하지 않고 브라우저 주소창에 표시되는 URL만을 변경합니다.
따라서 위 사진처럼 갤러리 페이지(/gallery
)에서 썸네일을 클릭하면 다음 두 가지 동작이 수행되어야 합니다.
- 모달을 연다.
- 브라우저의 히스토리 스택에 새로운 URL을 추가한다(여기서 URL은
/gallery/{사진의 고유 ID}
의 형태가 될 것입니다).
이 작업에 추가로, 모달에 부여한 URL로 사용자가 직접 접근했을 때의 처리를 해주어야 합니다. 위에서 언급했던 설명한 두 가지 세부 유형으로 나누어 구현해보겠습니다.
- 모달에 부여된 URL로 직접 접속했을 때 모달이 열리는 방식
- 모달에 부여된 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 라우터의 Link
의 as
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
를 이용하여 쿼리 스트링의 photoId
를 undefined
로 설정해야 한다는 점입니다.
그러면 이 작업에 의해 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.push
나 router.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
)로 직접 접속했을 때는 모달이 아닌 전체 페이지로 열리게 됩니다.