Pages Router의 한계, 우리가 잊고 있던 불편함
App Router를 이해하려면, Pages Router에서 무엇이 불편했는지를 먼저 떠올려보는 것이 좋습니다. 익숙해지면 잊게 되지만, 그 불편함들은 분명 존재했습니다.
레이아웃 공유가 불편했습니다. _app.js에 전역 레이아웃을 작성할 수는 있었지만, 그 이상의 구조는 개발자가 직접 만들어야 했습니다. 예를 들어 "대시보드 페이지들만 사이드바를 공유한다"는 요구사항을 구현하려면, 각 페이지 컴포넌트에서 수동으로 레이아웃 컴포넌트를 감싸거나, HOC 패턴을 써야 했습니다.
// Pages Router, 수동 레이아웃 구성
function DashboardPage() {
return (
<DashboardLayout>
<h1>대시보드</h1>
</DashboardLayout>
);
}페이지 이동 시 레이아웃이 리렌더링되었습니다. 사이드바, 헤더 같은 공통 요소가 페이지 전환마다 다시 마운트되었습니다. 결과적으로 사이드바의 스크롤 위치가 초기화되고, 검색 입력이 사라지고, 상태가 휘발되었습니다.
로딩과 에러 상태는 개발자가 직접 관리했습니다. 데이터를 기다리는 동안 스피너를 보여주려면 useState로 로딩 상태를 추적해야 했고, 에러가 나면 <ErrorBoundary>로 감싸야 했습니다. 모든 페이지에서 반복되는 작업이었습니다.
코드 콜로케이션이 어려웠습니다. 페이지 관련 컴포넌트, 스타일, 테스트 파일을 어디에 두느냐가 팀마다 달랐습니다. pages/ 폴더는 라우팅만 담당해서, 관련 파일들을 가까이 두기 어려웠습니다.
App Router는 이 모든 것을 파일 시스템 규칙만으로 해결합니다.
App Router의 기본 구조
app/ 디렉터리 아래에 폴더를 만들면, 그 폴더의 이름이 URL 경로가 됩니다. 각 폴더 안에는 특수한 이름의 파일들을 둘 수 있고, 각각 정해진 역할을 합니다.
app/
layout.tsx 루트 레이아웃 (모든 페이지의 공통)
page.tsx / 경로
about/
page.tsx /about 경로
dashboard/
layout.tsx 대시보드 레이아웃 (/dashboard, /dashboard/*)
page.tsx /dashboard 경로
loading.tsx 대시보드 로딩 UI
error.tsx 대시보드 에러 UI
settings/
page.tsx /dashboard/settings 경로이 구조 자체가 UI 계층 구조를 그대로 표현합니다. 대시보드 안의 설정 페이지는 자연스럽게 대시보드 레이아웃을 상속받습니다.
특수 파일들의 역할
| 파일명 | 역할 |
|---|---|
page.tsx | 해당 경로에 렌더링될 UI |
layout.tsx | 해당 경로와 하위 경로가 공유할 레이아웃 |
loading.tsx | Suspense 경계의 fallback UI |
error.tsx | Error Boundary의 fallback UI |
not-found.tsx | 404 UI |
template.tsx | 매 탐색마다 재마운트되는 레이아웃 |
default.tsx | 병렬 라우트의 기본 UI |
레이아웃, 중첩과 상태 유지
App Router의 레이아웃은 Pages Router와 근본적으로 다르게 동작합니다.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}이 레이아웃은 /dashboard, /dashboard/settings, /dashboard/users 같은 모든 대시보드 하위 경로에 자동으로 적용됩니다. 사용자가 /dashboard에서 /dashboard/settings로 이동할 때, DashboardLayout 자체는 리렌더링되지 않습니다. children만 교체됩니다.
이것은 단순한 최적화가 아니라 UX의 큰 차이를 만듭니다. 사이드바의 스크롤 위치, 검색 입력 값, 접혀있던 메뉴의 상태, 이 모든 것이 페이지를 이동해도 그대로 유지됩니다. 마치 SPA의 부드러운 전환과 같은 경험이지만, 서버 사이드 렌더링의 이점은 그대로 가져갑니다.
레이아웃은 중첩될 수 있습니다. 루트 app/layout.tsx 안에 app/dashboard/layout.tsx가 있고, 그 안에 app/dashboard/settings/layout.tsx가 있을 수도 있습니다. 각각의 레이아웃은 독립적으로 렌더링되고 상태를 유지합니다.
Loading과 Error, 파일만 만들면 끝
로딩과 에러 상태를 처리하는 방식도 근본적으로 달라졌습니다. loading.tsx나 error.tsx 파일을 만들기만 하면, 해당 라우트의 데이터 패칭이나 렌더링에서 발생하는 문제를 자동으로 처리합니다.
// app/dashboard/loading.tsx
export default function Loading() {
return <Skeleton />;
}// app/dashboard/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}내부적으로 Next.js는 <Suspense> 경계와 <ErrorBoundary>를 자동으로 생성하고, 이 파일들을 그 경계의 fallback으로 연결합니다. 개발자는 Suspense와 ErrorBoundary의 존재를 몰라도 같은 효과를 얻을 수 있습니다.
loading.tsx는 특히 중요합니다. 비동기 Server Component가 데이터를 기다리는 동안 자동으로 loading.tsx의 내용이 표시되고, 데이터가 도착하면 실제 페이지가 스트리밍으로 교체됩니다. 개발자는 로딩 로직을 따로 작성하지 않아도 됩니다.
Route Groups, URL은 그대로, 구조만 나누기
때로는 URL 구조와 파일 구조가 달라야 할 때가 있습니다. 예를 들어 마케팅 페이지와 대시보드 페이지가 다른 레이아웃을 사용하지만, URL 구조에는 그 차이를 드러내고 싶지 않을 수 있습니다.
Route Groups 는 폴더 이름을 괄호로 감싸서 표현합니다. 괄호로 감싼 폴더는 URL에 포함되지 않습니다.
app/
(marketing)/
layout.tsx 마케팅 레이아웃
page.tsx / (랜딩 페이지)
about/
page.tsx /about
(dashboard)/
layout.tsx 대시보드 레이아웃
dashboard/
page.tsx /dashboardURL 상으로는 /와 /about이 평범하게 보이지만, 내부적으로는 마케팅 레이아웃 그룹에 속합니다. 이 기능 덕분에 같은 URL 계층에 여러 다른 레이아웃을 적용할 수 있고, 관련된 페이지들을 논리적으로 묶어서 관리할 수 있습니다.
Parallel Routes, 한 페이지에 여러 슬롯
Parallel Routes 는 한 레이아웃 안에 여러 독립적인 슬롯을 가질 수 있게 해주는 기능입니다. 각 슬롯은 독립적으로 라우팅되고, 독립적으로 로딩 상태를 가집니다.
대시보드를 예로 들어봅시다. 왼쪽은 사용자 정보, 오른쪽은 알림 목록을 보여주고 싶다면:
app/
dashboard/
layout.tsx
page.tsx
@user/
page.tsx user 슬롯
@notifications/
page.tsx notifications 슬롯// app/dashboard/layout.tsx
export default function Layout({
children,
user,
notifications,
}: {
children: React.ReactNode;
user: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="grid grid-cols-2">
<div>{user}</div>
<div>{notifications}</div>
</div>
);
}@ 접두어가 붙은 폴더가 슬롯입니다. 레이아웃은 children 외에도 user, notifications라는 추가 props를 받습니다. 각 슬롯은 자기만의 loading.tsx와 error.tsx를 가질 수 있고, 하나가 느리게 로드되어도 다른 슬롯에 영향을 주지 않습니다.
이 기능은 복잡한 대시보드나 여러 독립 섹션이 있는 페이지에 유용합니다. 각 섹션의 로딩과 에러 상태를 독립적으로 관리할 수 있어, 하나의 API가 느려도 전체 화면이 멈추지 않습니다.
Intercepting Routes, 같은 URL, 다른 렌더링
Intercepting Routes 는 특정 URL을 기존 레이아웃 안에서 "가로채서" 다른 방식으로 렌더링하는 기능입니다. 대표적인 사용 사례는 Instagram 스타일의 사진 모달입니다.
Instagram 피드에서 사진을 클릭하면 모달로 사진이 열립니다. 하지만 그 모달 URL (/photo/123) 을 직접 방문하면, 전체 페이지로 사진이 열립니다. 두 경우 같은 URL이지만 다른 UI로 표시됩니다.
App Router는 이것을 (..)photo/[id] 같은 특수 폴더 문법으로 표현합니다.
app/
feed/
page.tsx /feed
@modal/
(..)photo/[id]/
page.tsx /feed에서 /photo/123으로 이동할 때 가로채기
photo/
[id]/
page.tsx /photo/123 직접 방문 시(.), (..), (..)(..), (...) 는 각각 "같은 레벨", "한 단계 위", "두 단계 위", "루트" 에서 가로채기를 의미합니다. 중요한 점은 이 레벨이 파일 시스템이 아니라 라우트 세그먼트 기준 이라는 것입니다. 위 예시에서 feed/의 슬롯 (@modal) 은 라우트 세그먼트로 취급되지 않으므로, feed/@modal/ 안에서 루트의 /photo를 가로채려면 한 단계 위인 (..) 가 맞습니다.
동적 라우트와 Catch-all
App Router도 Pages Router처럼 동적 세그먼트를 지원합니다. 폴더 이름을 대괄호로 감싸면 됩니다.
app/
blog/
[slug]/
page.tsx /blog/hello, /blog/world 등// app/blog/[slug]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <article>{slug}</article>;
}Next.js 15부터 params는 Promise 입니다. 이전 버전에서는 동기 객체였지만, 비동기 렌더링과의 통합을 위해 변경되었습니다. await params로 값을 읽습니다.
여러 세그먼트를 한번에 잡고 싶다면 [...slug] (catch-all) 또는 [[...slug]] (optional catch-all) 을 사용합니다.
콜로케이션, 관련 파일을 함께 두기
App Router의 또 다른 장점은 콜로케이션 입니다. 특정 라우트 폴더 안에 그 라우트와 관련된 모든 파일을 둘 수 있습니다.
app/
dashboard/
page.tsx
layout.tsx
loading.tsx
DashboardChart.tsx 이 라우트 전용 컴포넌트
dashboard.css 이 라우트 전용 스타일
use-dashboard-data.ts 이 라우트 전용 훅
__tests__/
dashboard.test.ts 이 라우트 전용 테스트Next.js는 page.tsx, layout.tsx 같은 특수 파일만 라우트로 인식합니다. 나머지 파일들은 라우트로 노출되지 않으므로, 관련 코드를 같은 폴더에 두어도 안전합니다. 이 구조는 라우트가 독립적인 모듈처럼 관리되게 합니다.
왜 이게 자연스러운가
App Router의 설계를 처음 보면 "그냥 파일 규칙이 많아진 것 아닌가"라고 생각할 수 있습니다. 하지만 각 특수 파일은 "이 라우트에서 일어나는 한 가지 일"에 정확히 대응됩니다.
page.tsx, UI를 렌더링한다layout.tsx, 공통 구조를 제공한다loading.tsx, 데이터를 기다릴 때 보여준다error.tsx, 문제가 생겼을 때 보여준다not-found.tsx, 존재하지 않는 것을 보여준다
이 추상화는 웹 페이지가 가질 수 있는 모든 상태를 파일로 매핑합니다. 개발자는 "이 상태는 어떻게 처리하지?" 대신 "어떤 파일을 만들지?" 를 고민하게 됩니다. 사고의 흐름이 바뀝니다.
그리고 이 파일 규칙의 뒤에는 React의 핵심 프리미티브인 Suspense , Error Boundary , Server Components 가 있습니다. 다음 글에서는 그 중 가장 중요한 Server Components를 살펴봅니다, 왜 React가 다시 서버로 돌아갔는지에 대한 이야기입니다.