3.3
App Router
App router가 처음 등장한 것은 Next.js 버전13에서 입니다. 당시 정식 출시가 아닌 실험적인 기능으로 소개되었으며, 현재 최신 버전의 Next.js에서는 표준 방법으로 권장되는 Router입니다. 즉, 앞에서 설명한 Pages Router의 후속 버전이라고 보면 됩니다.
App Router는 아래 그림처럼 app이라는 이름의 폴더를 기준으로 작동합니다. 그리고 pages 폴더를 기반으로 작동하는 Pages Router와 함께 사용할 수도 있습니다.
App Router는 React Server Components(서버 컴포넌트)를 기반으로 구축된 Router입니다. 그래서 서버 컴포넌트를 사용해서 웹애플리케이션을 구축하기 위해서는 App Router를 사용해야 하며, app 폴더에 포함된 리액트 컴포넌트는 기본적으로 서버 컴포넌트로 간주됩니다.
NOTE. React Server Components란?
Server Components는 서버에서 컴포넌트를 렌더링하고 그 결과를 클라이언트로 전송함으로써 클라이언트 측의 렌더링 부담을 줄이고 성능을 향상시키는 데 도움을 주는 React의 컴포넌트입니다.
App Router는 Pages Router와 마찬가지로 폴더 구조 기반의 라우터입니다. 그래서 App Router에서 각 페이지의 경로를 정의하기 위해서는 app 폴더 내에 정해진 규칙에 맞게 폴더와 파일을 중첩시키면 됩니다. 여기서 폴더는 경로를 나타내며, 파일은 실제 UI를 담당하는 컴포넌트가 됩니다.
위 그림과 같이 각 폴더는 route segment를 나타내며, 각 route segment는 URL path의 해당 segment에 매핑됩니다. 결과적으로 /dashboard/analytics route는 아래 세 가지 segment의 조합이 되는 것이죠.
/: Root segmentdashboard: Segmentanalytics: Leaf segment그리고 폴더 내에 들어가는 파일의 이름은 임의로 지으면 되는 것이 아니라, 아래와 같이 역할에 따라 정해진 파일 이름을 사용해야 합니다. 참고로 파일의 확장자는 .js, .jsx, 또는 타입스크립트를 사용할 경우 .tsx를 사용할 수 있습니다.
| 파일 이름 | 설명 | 역할 |
|---|---|---|
layout |
segment와 그것의 children을 위한 Shared UI | 레이아웃 |
page |
공개적으로 접근 가능한 route에 대한 Unique UI | 페이지 |
loading |
segment와 그것의 children을 위한 Loading UI | 로딩 화면 |
not-found |
segment와 그것의 children을 위한 Not found UI | Not found 페이지 |
error |
segment와 그것의 children을 위한 Error UI | 에러 화면 |
global-error |
Global Error UI | 글로벌 에러 화면 |
route |
Server-side API endpoint | API 엔드포인트 |
template |
특별한 re-rendered Layout UI | 재렌더링 되는 레이아웃 |
default |
Parallel Routes를 위한 Fallback UI | 대체 UI |
이렇게 정의된 각 파일에 정의된 리액트 컴포넌트는 아래와 같은 계층 구조로 렌더링됩니다.
layout.jstemplate.jserror.js: React Error boundary (Error)loading.js: React Suspense boundarynot-found.js: React Error boundary (NotFound)page.js 또는 중첩된 layout.js또한 중첩 경로에서는 아래 그림과 같이 segment의 컴포넌트가 상위 segment의 컴포넌트 내에 중첩된다고 이해하면 됩니다.
이처럼 Next.js의 App Router에서는 일반적인 React 애플리케이션과 다르게 폴더와 파일을 이용해서 Route를 구성하게 됩니다. 따라서 App Router에서 route를 구성하는 방법과 정해진 각 파일 이름과 역할에 대해서 잘 기억해두는 것이 중요합니다. 참고로 레이아웃과 페이지에 대한 자세한 내용은 4장에서 자세히 배울 예정입니다.
앞에서 배운 것처럼 app 폴더에서 중첩된 폴더는 기본적으로 URL의 각 경로에 매핑됩니다. 하지만 개발을 하다보면 폴더가 경로에 포함되지 않도록 하고 싶은 경우가 발생하게 됩니다. 이러한 경우에 사용할 수 있는 것이 바로 Route들을 그룹화 하는 Route Groups라는 기능입니다.
Route Groups는 폴더가 URL의 경로에 포함되는 것을 방지하고, app 폴더에 있는 route segments와 파일들을 논리적으로 그룹화 할 수 있게 해주는 기능입니다. Route Groups를 사용하면 여러 개의 routes를 그룹으로 묶거나, 중첩된 레이아웃을 같은 route segment 레벨로 만들 수 있습니다.
Route Groups를 사용하는 방법은 app 폴더 내에 (폴더 이름) 형태로 괄호로 감싼 이름의 폴더를 만들고, 그 안에 그룹화 할 폴더 또는 파일들을 넣으면 됩니다. 아래는 Route Groups를 사용하면 유용한 여러가지 경우를 나타낸 것입니다.
아래 그림과 같이 (content), (user)라는 이름의 폴더를 만들어서 각각 컨텐츠, 사용자에 관련된 route들을 그룹화 하게 되면, 해당 폴더의 이름은 URL 경로에 영향을 주지 않게 됩니다. 그래서 각 페이지의 경로는 그룹화하기 전과 동일하게 /about, /blog, /account가 됩니다. 경로는 변하지 않았지만 논리적으로 그룹화 함으로써 관리하기에 용이하다고 볼 수 있습니다.
Route Groups를 사용하면 각 segment에 선택적으로 레이아웃을 적용할 수 있습니다.
먼저 아래 그림과 같이 각 Route Group에 layout.js 파일을 작성해서 넣어주면, 각 그룹에 다른 레이아웃을 적용할 수 있습니다.
또한 아래 그림과 같이 레이아웃을 적용하고 싶은 route들만을 그룹화해서 레이아웃을 적용하면, (content)라는 Route Group에 속한 페이지들(예: about, blog)에는 해당 Route Group의 레이아웃이 적용되며, Route Group에 속하지 않은 페이지들(예: settings)에는 적용되지 않습니다.
일반적으로 App Router에서 Root Layout은 하나만 만들 수 있습니다. 하지만 Route Groups를 사용하면 여러 개의 Root Layout을 만들 수도 있습니다.
먼저 app 폴더의 최상위에 위치한 Root Layout 파일(layout.js 파일)을 제거하고, 아래 그림과 같이 각 Route Group에 layout.js 파일을 작성하면 됩니다. 이렇게 하게 되면 (content)와 (user)는 각각의 Root Layout을 갖게 됩니다.
이 기능은 웹애플리케이션을 완전히 다른 두 개의 UI로 나눠서 다른 사용자 경험을 제공하고 싶을 때 유용합니다. 그리고 이렇게 할 경우 각 Root Layout에 <html> 태그와 <body> 태그가 추가되어야 한다는 점을 꼭 기억하기 바랍니다.
우리가 앞에서 배운 것처럼 App Router에서는 폴더의 계층 구조를 통해 Route를 정의하게 됩니다. 그리고 각 폴더의 이름은 URL path의 각 segment에 매핑됩니다. 하지만 여기서 주의할 점은 폴더를 생성했더라도 그 안에 page.js 또는 route.js 파일이 존재하지 않으면, 해당 Route는 공개적으로 접근할 수 없다는 점입니다. 마치 아래 그림과 같이 말이죠.
그래서 아래 그림과 같이 폴더 내에 page.js 또는 route.js 파일을 생성해야만 해당 경로로 공개적으로 접근이 가능해집니다. 하지만 여기서도 한 가지 주의할 점은 page.js 또는 route.js 파일이 반환하는 내용만 클라이언트에 전송된다는 점입니다. 즉, 같은 폴더 내에 있는 다른 이름의 파일들은 접근 불가능합니다.
결론적으로 page.js 또는 route.js 파일이 아닌 다른 파일들을 App Router 내에 있는 폴더에 넣어서 안전하게 사용할 수 있습니다. 아래 그림과 같이 다른 컴포넌트 파일, 상수 파일, DB 접근 정보를 담고 있는 파일 등을 같은 폴더에 위치시켜서 사용해도 문제가 없다는 것입니다.
NOTE. Pages Router와의 차이점
App Router와는 다르게 Pages Router에서는pages디렉토리의 모든 파일이 Route로 간주됩니다. 그렇기 때문에 Pages Router의 폴더 내에 파일을 위치시킬 때는 주의가 필요합니다.
웹사이트를 개발하다 보면 동적으로 경로가 정해져야하는 경우가 있습니다. 예를 들면, /post/1, /post/2처럼 URL segment에 동적인 파라미터의 값이 들어가는 경우가 있습니다. Next.js에서는 이러한 경우를 위해 동적인 데이터로부터 Route를 생성해주는 Dynamic Segments 기능을 제공합니다. Dynamic Segments 기능은 페이지를 요청하는 시점에 segment를 채우거나(Dynamic Segments), 또는 빌드 시점에 prerendering(Static Params) 하는 기능입니다.
Dynamic Segments를 사용하기 위해서는 App Router의 폴더 내에서 폴더의 이름을 대괄호로 감싸서 작성하면 됩니다. 예를 들면, [id] 또는 [slug]처럼 말이죠. 그리고 여기서 대괄호 안에 있는 문자열은 해당 값의 이름이자 값에 접근하기 위한 키 값이 됩니다. 이러한 동적인 값들은 params라는 prop으로 layout, page, route, 그리고 generateMetadata() 함수로 전달됩니다.
아래 코드는 Dynamic Segments를 사용한 예시 코드입니다. 여기에서는 동적 파라미터의 이름이 postId가 됩니다.
// app/post/[postId]/page.tsx
interface PageProps {
params: { postId: string };
}
function Page(props: PageProps) {
const { params } = props;
return <div>Post ID: {params.postId}</div>;
}
export default Page;
아래 표와 같이 접속한 각 경로에 따라 동적으로 파라미터의 값이 변경된다고 이해하면 됩니다. 참고로 이렇게 전달된 값의 타입은 문자열이 됩니다.
| Route | 예시 URL | params |
|---|---|---|
app/post/[postId]/page.js |
/post/1 |
{ postId: '1' } |
app/post/[postId]/page.js |
/post/2 |
{ postId: '2' } |
app/post/[postId]/page.js |
/post/3 |
{ postId: '3' } |
Dynamic Segments를 사용하면 동적인 모든 값에 대해서 경로를 매핑할 수 있습니다. 하지만 일반적으로 블로그 글 같은 정적인 컨텐츠를 주로 보여주는 웹사이트의 경우에는 동적인 값의 범위가 한정되어 있습니다. 예를 들면, 블로그 글의 아이디가 1부터 시작하고 총 10개의 글이 있다고 하면, 1~10까지의 값만을 가질 수 있는 것이죠.
이러한 경우 페이지 파일에서 generateStaticParams() 함수를 export 함으로써 빌드 시점에 Dynamic Segments 기능을 사용하면서도 각 경로를 정적으로 생성할 수 있습니다. 아래 예시 코드는 generateStaticParams() 함수를 사용하여 빌드 시점에 데이터베이스에 존재하는 모든 postId 값에 대해 정적인 경로를 생성하도록 만든 것입니다.
// app/post/[postId]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((res) =>
res.json()
);
return posts.map((post) => ({
postId: post.id,
}));
}
...
여기서 굳이 generateStaticParams() 함수를 사용하지 않아도 서비스를 운영하는데 큰 지장은 없습니다. 하지만 이렇게 정적인 경로를 생성하도록 하면, 각 페이지들도 정적 페이지로 생성할 수 있고 그로인해 데이터베이스 접근 없이도 사용자에게 빠르게 페이지를 보여줄 수 있다는 장점이 있습니다. 데이터베이스에 접근해서 데이터를 가져와서 렌더링 하는 것보다 미리 생성되어 있는 정적인 페이지를 곧바로 보여주는 것이 훨씬 더 빠르기 때문이죠.
또한 generateStaticParams() 함수 내에서 fetch 요청을 사용하여 데이터를 가져오면, 이러한 요청이 자동으로 캐싱됩니다. 그래서 다른 레이아웃이나 페이지에서 동일한 요청을 할 경우, 중복으로 요청을 보내지 않고 캐싱된 데이터를 사용하기 때문에 빌드 시간을 단축시킬 수 있습니다.
Dynamic Segments는 [...folderName]처럼 대괄호 안에 줄임표(...) 를 추가하여 이후의 모든 segments를 포착하도록 확장할 수 있습니다.
예를 들어, 아래 표에 나타난 것처럼 app/search/[...keyword]/page.js는 /search/food뿐만 아니라 /search/food/drinks, /search/food/drinks/soda 등도 포착하게 됩니다. 참고로 이 때 전달되는 값의 타입은 문자열 배열이 됩니다.
| Route | 예시 URL | params |
|---|---|---|
app/search/[...keyword]/page.js |
/search/food |
{ keyword: ['food'] } |
app/search/[...keyword]/page.js |
/search/food/drinks |
{ keyword: ['food', 'drinks'] } |
app/search/[...keyword]/page.js |
/search/food/drinks/soda |
{ keyword: ['food', 'drinks', 'soda'] } |
위에서 배운 방법을 사용하면 모든 Segments를 포착할 수 있지만, 그렇지 않고 선택적(optional)으로 포착하고 싶은 경우가 있을 수 있습니다. 그런 경우에는 [[...folderName]]처럼 이중 대괄호와 줄임표(...) 를 사용하면 됩니다. 이러한 선택적 segment 방식의 경우, 매개변수가 없어도 해당 경로로 Routing이 된다는 특징이 있습니다.
예를 들어, app/search/[[...keyword]]/page.js는 /search/food, /search/food/drinks, /search/food/drinks/soda뿐만 아니라 /search도 포착하게 되는 것입니다. 앞에 나온 대괄호를 하나만 사용하는 방식과는 차이가 있는 것이죠.
| Route | Example URL | params |
|---|---|---|
app/search/[[...keyword]]/page.js |
/search |
{} |
app/search/[[...keyword]]/page.js |
/search/food |
{ keyword: ['food'] } |
app/search/[[...keyword]]/page.js |
/search/food/drinks |
{ keyword: ['food', 'drinks'] } |
app/search/[[...keyword]]/page.js |
/search/food/drinks/soda |
{ keyword: ['food', 'drinks', 'soda'] } |
TypeScript를 사용해서 Dynamic Routes를 구성하는 경우, 동적으로 구성된 Route segments에 따라 params에 대한 타입을 명시할 수 있습니다.
아래 코드는 params에 포함된 postId가 문자열임을 명시하는 코드입니다. 이렇게 params의 타입을 명시함으로써 코드의 가독성과 안정성을 높일 수 있습니다.
// app/post/[postId]/page.tsx
interface PageProps {
params: { postId: string };
}
function Page(props: PageProps) {
const { params } = props;
return <div>Post ID: {params.postId}</div>;
}
export default Page;
아래 표는 각 Route에 대해 params의 타입을 정의하는 예시를 나타낸 것입니다.
| Route | params 타입 정의 |
|---|---|
app/post/[postId]/page.js |
{ postId: string } |
app/search/[...keyword]/page.js |
{ keyword: string[] } |
app/search/[[...keyword]]/page.js |
{ keyword?: string[] } |
app/[categoryId]/[itemId]/page.js |
{ categoryId: string; itemId: string } |
이처럼 Dynamic Routes를 사용할 경우에는 각 파라미터의 타입을 명시해주는 것이 좋다는 것을 기억하기 바랍니다.
Parallel Routes는 우리 말로 병렬 라우트라고 부르며, 병렬적으로 여러 개의 라우트들을 렌더링 할 수 있게 해주는 기능입니다. Parallel Routes를 사용하면 동일한 레이아웃 내에서 하나 이상의 페이지를 동시에 렌더링 하거나 조건부로 렌더링 할 수 있습니다.
Parallel Routes는 주로 동적으로 컨텐츠가 결정되는 페이지에 사용하면 유용합니다. 예를 들어, 아래 그림과 같이 대시보드 페이지에서 팀과 관련된 컨텐츠와 분석과 관련된 컨텐츠가 존재하는 경우 Parallel Routes를 사용하면 팀 페이지와 분석 페이지를 동시에 렌더링할 수 있습니다.
병렬 라우트는 이름을 가진 slots을 사용해서 생성됩니다. 슬롯은 쉽게 말해서 병렬 라우트를 통해 화면에 렌더링 되는 각 영역을 의미한다고 보면 됩니다. 슬롯을 정의하기 위해서는 app 폴더 내에 @folder 형태의 이름을 가진 폴더를 생성하면 됩니다. 아래 그림은 @members와 @analytics 두 개의 슬롯을 정의한 예시를 나타낸 것입니다.
이렇게 정의된 슬롯은 공통된 부모 레이아웃에 props로 전달됩니다. 이 때 키는 슬롯의 이름이 되고, 값은 React.ReactNode 타입의 리액트 노드가 됩니다. 아래 코드는 @members와 @analytics 슬롯을 props로 받아서, children prop과 함께 병렬로 렌더링하는 예시 코드입니다.
// app/layout.tsx
interface RootLayoutProps {
children: React.ReactNode;
members: React.ReactNode;
analytics: React.ReactNode;
}
function RootLayout(props: RootLayoutProps) {
const { children, members, analytics } = props;
return (
<html lang="en">
<body>
{children}
{members}
{analytics}
</body>
</html>
);
}
export default RootLayout;
Slot을 사용할 때 유의할 점은, Slot은 route segment가 아니기 때문에 URL 구조에 영향을 주지 않는다는 점입니다. 예를 들어, /@analytics/views의 경우, @analytics는 슬롯이기 때문에 URL은 /views가 됩니다.
기본적으로 Next.js는 각 슬롯의 활성 상태(또는 하위 페이지)를 추적합니다. 그러나 슬롯 내에 렌더링되는 콘텐츠는 아래와 같이 내비게이션 유형에 따라 달라집니다.
default.js 파일을 렌더링하거나, default.js가 없으면 404를 렌더링NOTE. 참고 사항
일치하지 않는 라우트에 대한404페이지는 의도하지 않은 페이지에서 병렬 라우트를 실수로 렌더링하지 않도록 도와줍니다.
default.jsdefault.js 파일은 병렬 라우트에서 경로가 일치하지 않는 슬롯에 대해 초기 로드나 전체 페이지 새로 고침 시에 화면에 보여줄 대체 컴포넌트를 정의하는 파일입니다. 예를 들어, 아래와 같은 폴더 구조에서 @members 슬롯에는 /settings 페이지가 있지만 @analytics 슬롯에는 없습니다.
사용자가 / 경로에서 /settings 경로로 이동할 때(소프트 내비게이션), @members 슬롯은 /settings 페이지를 렌더링하면서 현재 @analytics 슬롯의 활성 페이지를 유지합니다. 하지만 브라우저 새로 고침 시(하드 내비게이션)에는 @analytics 슬롯에 대해 default.js를 렌더링합니다. default.js가 없는 경우 404가 대신 렌더링됩니다.
또한, children은 암묵적인 슬롯이기 때문에, Next.js가 부모 페이지의 활성 상태를 복구할 수 없는 경우 children을 대신해서 렌더링하기 위해 app/default.js 파일을 생성해야 합니다.
useSelectedLayoutSegment(s) 훅useSelectedLayoutSegment()와 useSelectedLayoutSegments() 훅을 사용하면 parallelRoutesKey 매개변수를 통해 슬롯 내에서 활성 상태인 route segment를 읽을 수 있습니다.
const segment = useSelectedLayoutSegment(parallelRoutesKey?: string);
const segments = useSelectedLayoutSegments(parallelRoutesKey?: string);
아래 코드는 useSelectedLayoutSegment() 훅을 사용하는 예시 코드입니다. 이 경우 사용자가 /settings 경로(app/@members/settings)로 접속하게 되면 segment는 "settings"가 됩니다.
'use client';
import { useSelectedLayoutSegment } from 'next/navigation';
export default function Layout({ members }: { members: React.ReactNode }) {
const segment = useSelectedLayoutSegment('members');
return <p>활성 상태인 segment: {segment}</p>
}
인터셉트 라우트(Intercepting Routes)는 이름이 가진 의미 그대로 현재 레이아웃 내에서 다른 라우트를 가로채서 로드할 수 있는 기능입니다. 이 기능은 사용자가 다른 컨텍스트로 전환하지 않고도 특정 라우트의 콘텐츠를 표시하려는 경우에 유용합니다.
예를 들어, 아래 그림과 같이 피드에서 사진을 클릭할 때, 사진을 피드 위에 오버레이하는 모달로 표시할 수 있습니다. 이 경우 Next.js는 /photo/123 라우트를 가로채어 URL을 마스킹하고 이를 /feed 위에 오버레이합니다.
그러나 아래 그림과 같이 브라우저의 주소창에 /photo/123 경로를 직접 입력하거나, 공유된 URL을 클릭해서 해당 페이지로 이동할 때는 모달 대신 전체 사진 페이지가 렌더링되어야 합니다. 즉, 이때는 라우트 인터셉션이 발생하지 않아야 합니다.
인터셉트 라우트는 괄호와 점을 사용하여 (..) 형태로 정의할 수 있으며, 이는 segment에 대해 상대 경로 컨벤션 ../와 유사합니다. 인터셉트 라우트의 기호와 의미는 아래와 같습니다.
(.): 동일한 레벨의 segment와 일치(..): 한 레벨 위의 segment와 일치(..)(..): 두 레벨 위의 segment와 일치(...): 루트 앱 디렉토리에서 segment와 일치예를 들어, feed segment 내에서 photo segment를 인터셉트하려면 아래와 같이 (..)photo 디렉토리를 생성하면 됩니다.
NOTE. 유의사항
(..)형태는 라우트 segment에 기반을 두고 있으며, 파일 시스템의 상대 경로와는 무관합니다.
마지막 업데이트: 2025년 10월 24일 01시 56분
이 문서의 저작권은 이인제(소플)에 있습니다. 무단 전재와 무단 복제를 금합니다.