[#1 - Parallel Route]
Parallel Route(병렬 라우트)
- Parallel Route란 병렬 라우트라는 의미로 한 개의 페이지 내에서 여러 페이지 컴포넌트들을 병렬로 구조화 할 수 있게 해주는 NextJS의 고급 라우팅 패턴 중 하나이다. NextJS는 라우팅 구조를 폴더 구조를 통해서 자동적으로 지원해주기 때문에 서로 다른 페이지 컴포넌트가 하나의 페이지에 공존할 수 없다는 특징을 갖고 있다. 따라서 그 한계점을 해결해주기 위한 라우팅 패턴이라고 볼 수 있다.
Slot(슬롯)
- 병렬로 렌더링 될 페이지 컴포넌트를 보관하는 폴더
- @{folderName} 형태로 지정한다.
- Route Group과 동일하게 URL 경로에는 아무런 영향을 미치지 않는 폴더이다.
Parallel Route 폴더 구조
- Slot 폴더로 구성된 Parallel Route의 기본 폴더 구조는 다음과 같이 구성된다.
예시를 위해 최상위 경로를 임의로 parallel로 설정했다. 하위 폴더에 Slot 폴더 형태로 sidebar 폴더를 생성해주고 각 폴더에는 각 페이지 UI에 해당하는 page 파일이 존재하고 최상위 폴더인 parallel 폴더에는 하위 페이지들을 props로 받아 렌더링을 해주기 위해 layout 파일까지 함께 존재하는 것을 볼 수 있다.
각 페이지 파일들은 <div>Page</div> 태그만 렌더링 해주도록 기본으로 설정을 해두었기에 생략하고 layout 파일에서 Slot 폴더 하위의 페이지를 어떻게 props로 받아와 렌더링하는지 알아보기 위해 layout 파일의 로직만 살펴보도록 하자.
// parallel/layout.tsx
import { ReactNode } from "react";
export default function Layout({children,sidebar}:{children:ReactNode,sidebar:ReactNode}){
return (
<div>
{sidebar}
{children}
</div>
)
}
생각보다 굉장히 간단한 형태로 구성된다. 위 코드 내에서 children props는 최상위 경로에 해당하는 page 파일을 렌더링 해주고 Parallel Route가 적용되어 병렬로 페이지를 구조화하기 위해 Slot 폴더명을 그대로 가져와 props로 전달해주었다. 위와 같이 작성해주면 자동으로 병렬 라우트가 적용되어 최상위 페이지인 parallel 페이지에 parallel page의 내용과 sidebar page의 내용이 함께 들어있는 것을 확인할 수 있다.
페이지 간 이동과 Default Page
위의 기본적인 Parallel Route 구조에서 하나의 페이지를 더 추가한 후 추가한 페이지의 하위 경로가 존재한다는 설정을 하여 라우팅 구조를 재구성 해보았다. 폴더 구조는 다음과 같이 변하였다.
간단히 설명을 하자면 @feed라는 Slot 폴더가 추가되며 최상위 페이지에 병렬로 렌더링 되는 페이지가 하나 추가 되었으며 @feed 폴더 하위에는 URL 경로로 인식이되는 settings 폴더가 추가되어 새로운 라우팅 경로가 추가되었다. 이 때 layout 파일에는 Link 태그를 통해 parallel/settings 경로로 이동할 수 있도록 코드를 추가해주었다. 코드는 다음과 같다.
// parallel/layout.tsx
import Link from "next/link";
import { ReactNode } from "react";
export default function Layout({
children,
sidebar,
feed
}: {
children: ReactNode;
sidebar: ReactNode;
feed: ReactNode;
}) {
return (
<div>
<div>
<Link href={"/parallel"}>Parallel Page</Link>
<Link href={"/parallel/settings"}>Setting Page</Link>
</div>
<br />
{sidebar}
{feed}
{children}
</div>
);
}
위의 코드에 추가된 Link 태그를 통해 각 경로로 이동하면 렌더링되는 하위 페이지들의 모습은 다음과 같다.
최상위 파일인 parallel page와 Slot 폴더로 추가된 페이지인 sidebar page는 하위에 settings 라는 경로가 존재하지도 않는데 /parallel/settings 경로로 이동을 했음에도 불구하고 기존의 페이지가 그대로 렌더링이 되는 것을 볼 수 있다.
여기서 알 수 있는 점은 Parallel Route가 적용된 페이지 내에서 특정 페이지가 자기 자신만 갖고 있는 하위 경로의 페이지로 이동을 하였을 때 다른 페이지들은 하위 페이지를 갖고 있지 않다면 하위 경로로 이동이 가능한 페이지들은 이동을 하도록 동작하고 이동이 불가능한 페이지들은 오류를 발생시키는 것이 아닌 이전의 페이지를 유지하도록 동작하게 된다. 따라서 sidebar page와 parallel page는 하위 경로로 이동할 곳이 없기 때문에 기존 페이지를 그대로 렌더링 해주는 것이다.
여기서 주의해야할 점이 존재한다. 초기에 /parallel 경로로 접속을 한 후 하위 경로인 /parallel/settings 경로로 이동을 하는 것은 SPA 형태로 인해 없는 페이지에 접속이 들어가도 기존의 페이지를 렌더링 해주는 것이 가능했던 것이다. 하지만 만약 초기 접속부터 하위 경로인 /parallel/settings로 바로 들어가게 된다면 어떨까? 이 경우에는 존재하지 않는 페이지로 다이렉트로 접속 요청을 보냈기 때문에 404 에러를 발생시키게 된다. 그렇기에 다이렉트로 하위 경로로 접속 요청이 발생하는 경우를 대비하여 별도의 처리를 해주어야 한다. 이럴 경우에는 각 페이지에 없는 페이지에 접속 요청이 발생했을 때 렌더링 해 줄 페이지인 default 페이지를 생성해주면 된다. 폴더와 파일 구조는 다음과 같이 구성된다.
폴더와 파일 구조를 위와 같이 설정해두면 초기 접속 요청이 바로 하위 페이지 경로로 들어가더라도 default 페이지를 렌더링 해주어 존재하지 않는 페이지를 대체해줄 수 있게 된다.
[#2 - Intercepting Route]
Intercepting Route
인터셉팅 라우트란 단어 뜻 그대로 가로채다라는 의미를 가지는 라우팅 방식을 의미한다. NextJS가 설정해둔 임의의 조건에 충족하는 경우 특정 페이지에 접속하는 요청을 가로채서 렌더링 해주는 방식이다. 이 때 임의의 조건은 "해당 페이지로의 초기 접속 요청이 발생하였는가" 이다. 정리하자면 특정 페이지에 접속을 할 때 초기 접속을 통해 들어왔는지 아니면 다른 페이지로부터 CSR 방식을 통해 Link 컴포넌트나 Router 객체의 push 메서드 등을 통해서 들어왔는지 여부를 확인하여 다른 경로로부터 들어오게 되었다면 인터셉팅 라우트가 동작하게 되는 것이다. 정의만 따졌을 때는 이해가 잘 되지 않을 것이다.
대표적인 예시로는 인스타그램이 있다. 인스타그램은 유저의 프로필 창에서 특정 게시글을 클릭했을 때 그 게시글 페이지로 이동하지 않고 모달 형태로 띄워서 보여준다. 그에 더해 뒤로 가기 버튼을 누르거나 창을 닫았을 때 기존의 프로필 창으로 되돌아오게 된다. 이렇게 동작하는 것이 특정 게시글 페이지로 이동하지 않고 중간에 라우팅을 가로채어 중간에 보여주는 인터셉팅 라우트 방식이다.
인터셉트 라우팅을 동작하게 하기 위해서는 특정 폴더를 생성해주어야 한다. 폴더 명은 가로채고자 하는 page의 경로에 해당하는 라우팅 폴더명의 맨 앞에 (.)을 추가해서 생성해주면 된다. 폴더를 생성하면 구조가 다음과 같아진다.
여기서 폴더명에 추가 된 (.)은 상대 경로를 의미한다. 온점(.)이 한 개면 같은 경로에 위치한 파일, 두 개면 상위 경로에 위치한 파일로 인식이 된다. 추가적으로 최상위 폴더인 app 폴더를 가리키고 싶다면 (...)으로 온점을 세 개 찍어주면 된다.
위와 같이 구성한 뒤 인터셉팅 라우트 폴더 내부에도 동일한 UI를 가진 page 파일을 생성해두면 인터셉팅 라우트가 동작하게 된다. 메인 페이지에서 "/book/1" 페이지로 접속 요청했을 때 "/book/1" 페이지로의 최초 접속이 아닌 다른 페이지로부터 CSR 방식을 통해서 들어온 것이기 때문에 인터셉팅 라우트가 동작하여 (.)book/[id] 폴더에 있는 페이지가 렌더링 되게 된다. 간단한 코드 예시를 통해 인터셉팅 라우트의 동작 방식을 확인해보자.
// main.tsx
import Link from 'next/link';
export defult function Home(){
return (
<Link href={"/A"}>Page A</Link>
)
}
// A.tsx
export default function Page(){
return (
<div>Detail Page</div>
)
}
//(.)A.tsx
export default function Page(){
return (
<div>Detail Page Intercepting</div>
)
}
위와 같이 페이지가 구성되어 있다고 가정할 때 메인 페이지에서 A 페이지로 접속을 하기 위해서는 Link 컴포넌트를 통해서 접속되기 때문에 CSR 방식으로 동작한다. 따라서 이 때 인터셉팅 라우트가 동작하여 A 페이지로 접속하였을 때 페이지 내부에는 "Detail Page Intercepting"이라는 문구가 보이게 될 것이다. 이게 인터셉팅 라우트의 동작 방식이다.
하지만 만약 이 사이트에 최초 접속을 하게 된 경로가 메인 페이지가 아닌 A 페이지라면 인터셉팅 라우트가 발동되지 않아서 "Detail Page"라는 문구가 보이는 A 페이지가 렌더링 되게 된다.
인터셉팅 라우트를 가장 쉽게 확인 가능한 실제 모델이 인스타그램이라고 앞서 말했다. 우리도 우리의 프로젝트 내부에서 인스타그램처럼 인터셉팅 라우트가 동작하며 해당 페이지가 모달 형태로 띄워지도록 만들어 보자.
먼저 모달로 띄워줄 새로운 컴포넌트를 하나 생성해준다. 이 때 모달 컴포넌트는 클라이언트 측에서 동작하는 클라이언트 컴포넌트이므로 "use client" 지시자를 붙여주어야 한다.
이어서 컴포넌트 내에서 react-dom이 제공하는 createPortal이라는 메서드를 사용할 것이다. 이 메서드는 인터셉팅 라우트가 동작하여 가로챈 페이지를 모달로 띄워주는 것을 도와주는 메서드이다.
이 메서드는 2-3개의 인수를 받는다. 첫번째 인수으로는 children을 props로 받아 렌더링 해주는 <dialog> 태그를 받는다. 두번째 인수로는 이 모달이 렌더링 될 위치인 DOM 요소를 넣어주어야 한다.
그러면 createPortal 메서드를 통해서 브라우저에 존재하는 특정 DOM 요소 아래에 dialog 태그가 렌더링 되게 되는 것이다. createPortal 메서드를 사용한 모달창 컴포넌트의 코드 구조는 다음과 같다.
//Modal.tsx
"use client";
import { ReactNode } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";
export default function Modal({ children }: { children: ReactNode }) {
return createPortal(
<dialog>{children}</dialog>,
document.getElementById("modal-root") as HTMLElement
);
}
이 모달 컴포넌트는 props로 children을 받아서 단순히 모달 형식으로 렌더링을 해주는 역할을 하게 되는 것이다. 그렇기 때문에 최상위 layout 파일 내부의 최하단에 modal-root라는 아이디를 가진 div 태그를 하나 생성해주고 인터셉팅 라우트 폴더 하위의 page 파일에 렌더링할 컴포넌트를 Modal 컴포넌트로 감싸주면 인터셉팅 라우트가 동작할 때 해당 페이지가 상위 페이지의 자식 컴포넌트로 렌더링 되는 것이 아닌 별도의 모달로 띄워지게 된다.
이 때 해주어야 하는 중요한 설정이 있다. 우리가 현재 모달을 띄워주기 위해 사용중인 dialog 태그는 모달만을 위해서 존재하는 태그이기 때문에 기본값으로는 화면에 보이지 않도록 설정이 되어 있다. 하지만 우리는 인터셉팅 라우트가 동작하자마자 즉, 모달 페이지가 마운트 되자마자 화면에 보여져야 하기 때문에 설정을 해주어야 한다. 위의 코드에 설정값을 넣어서 코드를 확장해주도록 하겠다. 코드는 다음과 같다.
//Modal.tsx
"use client";
import { ReactNode, useRef, useEffect } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";
export default function Modal({ children }: { children: ReactNode }) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if(!dialogRef.current?.open){
dialogRef.current?.showModal();
dialogRef.current?.scrollTo({
top:0
})
}
},[]);
return createPortal(
<dialog ref={dialogRef}>{children}</dialog>,
document.getElementById("modal-root") as HTMLElement
);
}
위와 같이 설정해주면 모달 컴포넌트가 마운트 되었을 때 바로 화면에 띄워지게 되고 스크롤이 최상단으로 이동하게 된다.
마지막 작업으로는 우리가 일반적으로 모달을 다룰 때 Esc 키를 누르거나 모달 창 외부를 클릭하여 기존의 창으로 되돌아가곤 하는데 그 동작을 위한 로직을 만들어주면 완성이다. 이 때 사용하는 객체는 "next/navigation"이 제공하는 useRouter 객체이다. 주의해야할 점이 "next/navigation"이 아닌 "next/router"으로부터 import 해오면 오류가 발생하니 주의해야 한다.
이어서 dialog 태그 안에 onClick과 onClose 메서드를 추가해주어서 각각 모달 외부 클릭과 Esc 키를 눌렀을 경우 뒤로가기 동작을 구현해주면 되겠다. 완성된 코드 로직은 다음과 같다.
"use client";
import { ReactNode, useEffect, useRef } from "react";
import style from "./modal.module.css";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
const router = useRouter();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!dialogRef.current?.open) {
dialogRef.current?.showModal();
dialogRef.current?.scrollTo({
top: 0,
});
}
}, []);
return createPortal(
<dialog
ref={dialogRef}
onClose={() => router.back()}
onClick={(e) => {
if ((e.target as any).nodeName === "DIALOG") {
router.back();
}
}}
className={style.modal}
>
{children}
</dialog>,
document.getElementById("modal-root") as HTMLElement
);
}
onClose 메서드는 Esc 키가 클릭되었을 때를 감지하여 뒤로 가기 동작을 실행해주고 onClick 메서드는 클릭 이벤트가 발생한 요소의 Node 이름이 dialog일 경우에는 모달 외부를 클릭한 것이 되기 때문에 뒤로 가기 동작을 실행해주도록 하면된다. 이 때 e.target의 타입을 any 타입으로 단언을 해주었는데 이것은 아직 NextJS가 nodeName이라는 속성을 받아들이기에 온전하지 않은 상태기 때문이라고 한다.
이렇게 인스타그램에서 많이 볼 수 있는 인터셉팅 라우트를 모달 화면을 통해 구현하는 법을 알아보았다.
[#3 - Parallel Route + Intercepting Route]
앞서 우리는 병렬 라우트와 인터셉팅 라우트의 개념에 대해서 알아보고 어떤 파일 구조와 어떤 메서드를 활용해서 구현하는지까지 간단하게 알아보았다. 앞의 두 가지 고급 라우트 방식을 사용하는 대표적인 예시가 인스타그램이라고 했는데 인스타그램을 자세히 살펴보면 우리가 특정 게시글을 인터셉팅 라우트를 통해 모달 형태로 보고 있는중에는 뒤의 배경화면은 기존의 게시글 목록 화면이 띄워져 있는 것을 볼 수 있다. 잘 생각해보면 우리가 앞서 구현한 인터셉팅 라우트 하나만으로는 인스타그램처럼 구현이 되지 않는 것을 알 수 있다. 인터셉팅 라우트만 사용하여 모달로 창을 띄워주게 되면 특정 게시글 자체는 인터셉팅 되어 모달 형태로 올바르게 띄워지겠지만 뒷 배경은 인터셉팅 라우트를 위해 생성한 폴더의 page 컴포넌트가 렌더링 되기 때문이다. 아래 예시를 보자.
우리가 기대하는 동작 방식은 위의 메인 화면에서 특정 게시글을 클릭했을 때 인터셉팅 라우트가 동작하면서 뒷 배경으로는 메인 화면이 유지되면서 게시글이 모달 형태로 띄워지는 모습이다. 하지만 실제로 동작하는 모습은 다음과 같다.
뒷 배경이 메인 화면이 유지되는 것이 아닌 인터셉팅 라우트를 위해 생성해놓은 컴포넌트가 보여진다. 이것을 방지하고 우리가 원하는 방식으로 동작하도록 만들기 위해서는 앞서 보았던 고급 라우트 방식 두 가지를 합쳐서 구현해야 한다.
모달 형태로 특정 게시글이 띄워지도록 구현하는 인터셉팅 라우트를 유지하되 서로 다른 페이지인 메인 페이지와 특정 게시글 페이지 두 개를 한 화면에 병렬로 띄워주어야 하기 때문에 병렬 라우트까지 동시에 구현이 되어야 한다.
그러기 위해서는 첫번째로 인터셉팅 라우트 폴더를 병렬 라우트로 처리해주기 위해 상위 폴더에 @modal 이라는 Slot 폴더를 생성해주어야 한다. 이어서 Slot 폴더 내부로 인터셉팅 라우트 폴더인 (.)book/[id]/page.tsx 파일을 옮겨준다.
이후 초기 접속 요청이 다이렉트로 특정 게시글 페이지로 접속하는 경우 404 페이지로 이동하는 것을 방지하기 위해서 Slot 폴더 바로 하위에 default.tsx 파일도 생성을 해준다. 마지막으로 Slot 폴더가 가리키고 있는 상위 레이아웃 파일인 최상위 레이아웃 파일의 최하단에 children props를 전달해준 것 같이 {modal}이라는 Slot 폴더명을 가져와 넣어주면 정상적으로 작동하게 된다. 폴더 구조와 최상위 레이아웃 파일의 형태를 살펴보자.
폴더 구조는 위와 같이 Slot 폴더를 생성해주어 병렬 라우트를 적용할 수 있도록 해주고 하위에는 인터셉팅 라우트가 적용될 수 있도록 (.)로 파일명을 생성해주었다.
루트 레이아웃 파일에 인터셉팅 라우트가 적용된 모달 페이지가 띄워질 수 있도록 div 태그를 추가해주고 별도로 Slot 폴더가 병렬 구조로 라우팅 될 수 있도록 Slot 폴더명으로 삽입해준 것을 볼 수 있다. 위와 같이 적용해준 결과 기대하던 방식으로 올바르게 동작하는 것을 확인할 수 있다. 정상 동작하는 화면은 다음과 같다.
인터셉팅 라우트를 통한 모달 화면도 정상적으로 동작하고 뒷 배경으로 메인 화면도 올바르게 병렬 구조를 통해 렌더링 되는 것을 확인할 수 있다.
개인 프로젝트에서 Parallel Route와 Intercepting Route를 통해 페이지들을 병렬 구조화를 한다던지 모달 형태로 인터셉팅 하여 렌더링 해준다던지의 동작을 처리하면 사용자 경험 개선에 좋은 영향을 줄 수 있다.
'NextJS' 카테고리의 다른 글
NextJS[NextJS v.13~] - (5) Server Action (2) | 2024.11.19 |
---|---|
NextJS[NextJS v.13~] - (4) Streaming (4) | 2024.11.01 |
NextJS[NextJS v.13~] - (3) Page Caching (3) | 2024.10.11 |
NextJS[NextJS v.13~] - (2) Data Fetching on App Router (1) | 2024.10.05 |
[NextJS v.13~] - (1) App Router (1) | 2024.09.26 |