중첩 라우트와 레이아웃 구성
대시보드의 계층적 URL을 폴더로 구성하고 중첩 레이아웃으로 공통 헤더와 사이드바를 유지합니다.
앞에서는 App Router의 파일 기반 라우팅과 page.tsx가 UI를 렌더링하는 구조, 그리고 동적 라우트로 바뀌는 URL을 처리하는 방법을 살펴봤습니다. 이 글에서는 이를 넓혀 중첩 라우트(Nested Routes)를 구현합니다. 중첩 라우트는 UI의 특정 부분이 다른 UI 안에 들어가는 계층 구조를 만들 때 쓰며, 대시보드의 여러 하위 페이지가 공통 사이드바나 헤더를 함께 쓰는 경우처럼 웹 애플리케이션에서 아주 흔한 패턴입니다.
앞 글의 동적 라우트와 이번의 대시보드 경로는 서로 다른 파일 가지입니다. 앞 장에서 만든 Next.js 프로젝트의 루트 레이아웃을 그대로 두고 아래의 dashboard 파일들을 더합니다. URL이 어느 page.tsx를 고르는지 먼저 찾고, 그 파일에서 위로 올라가며 layout.tsx만 모으면 화면을 감싸는 순서를 알 수 있습니다.
중첩 라우트의 기본 원리
App Router에서 중첩 라우트는 폴더 구조와 layout.tsx의 조합으로 구현됩니다. app 디렉터리의 일반 라우트 폴더를 중첩하면 URL의 세그먼트도 중첩되므로, 예를 들어 app/dashboard/settings는 /dashboard/settings 경로가 되며, 라우트 그룹 (group)이나 슬롯 @slot은 URL에 들어가지 않습니다. 각 폴더에 layout.tsx를 정의하면 그 레이아웃이 그 폴더와 모든 하위 폴더의 page.tsx나 다른 layout.tsx를 감싸며, 이때 상위 폴더의 레이아웃이 하위 폴더의 레이아웃을 감싸는 형태로 중첩됩니다.
NEXT · CH4-2
설정 본문을 감싸는 것은 두 layout입니다
/dashboard/settings의 설정 페이지는 RootLayout과 DashboardLayout의 children 안에 들어갑니다. dashboard/page.tsx가 하위 페이지를 감싸지는 않습니다.
상자는 UI 포함 관계입니다. 사이드바는 DashboardLayout 안에서 settings 본문과 나란히 배치됩니다. 공유 layout의 상태 유지에는 클라이언트 탐색 조건이 필요합니다.
위 그림은 /dashboard/settings의 설정 페이지가 어떤 레이아웃에 감싸이는지 보여 줍니다. 그림의 요약처럼 설정 페이지는 RootLayout과 DashboardLayout의 children 안에 들어가며, dashboard/page.tsx가 하위 페이지를 감싸지는 않습니다. 바깥에서 안으로 헤더와 푸터를 가진 RootLayout, 대시보드 사이드바를 가진 DashboardLayout, 현재 URL의 설정 본문인 settings/page.tsx가 차례로 중첩됩니다. 그림의 마지막 설명처럼 상자는 UI의 포함 관계를 나타내며, 사이드바는 DashboardLayout 안에서 설정 본문과 나란히 배치되고, 공유 레이아웃의 상태가 유지되려면 클라이언트 측 이동이라는 조건이 필요합니다.
이런 중첩 구조는 공통 UI를 레이아웃에 정의해 여러 페이지에서 재사용하게 하고, 웹사이트 전체나 특정 영역의 디자인과 레이아웃을 일관되게 유지하게 해 줍니다. 또 공유 레이아웃 아래에서 클라이언트 측으로 이동하면 그 레이아웃을 재사용하고 바뀐 라우트 부분만 갱신할 수 있어 성능에도 유리합니다.
중첩 라우트 실습: 대시보드 구조 만들기
앞 장에서 만든 대시보드 예시를 넓혀, 중첩 라우트와 레이아웃의 관계를 분명히 이해해 봅시다. 목표 구조는 다음과 같으며, 이 모든 페이지가 공통 대시보드 레이아웃(사이드바 등)을 함께 쓰고, 그 대시보드 레이아웃은 다시 최상위 루트 레이아웃 안에 중첩됩니다.
먼저 모든 페이지에 적용되는 최상위 레이아웃인 src/app/layout.tsx를 확인하거나 고칩니다. 3장 3절과 비슷하지만, 이번에는 내비게이션을 Link로 바꾸고 본문을 main으로 감쌉니다.
import './globals.css';
import Link from 'next/link';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header style={{ backgroundColor: '#f0f0f0', padding: '10px', borderBottom: '1px solid #ddd' }}>
<nav>
<Link href="/">홈</Link> | {' '}
<Link href="/about">소개</Link> | {' '}
<Link href="/dashboard">대시보드</Link>
</nav>
<h1>나 혼자 Next.js</h1>
</header>
<main style={{ padding: '20px' }}>
{children} {/* 여기에 중첩된 레이아웃 또는 페이지 콘텐츠가 들어옵니다 */}
</main>
<footer style={{ backgroundColor: '#e0e0e0', padding: '10px', marginTop: '20px', borderTop: '1px solid #ddd' }}>
<p>© 2024 나 혼자 Next.js. All rights reserved.</p>
</footer>
</body>
</html>
);
}루트 레이아웃은 모든 경로에서 헤더, <main>, 푸터를 그리고, 현재 경로의 내용을 <main> 안의 children 자리에 넣습니다. 헤더의 링크는 <a> 대신 Link이므로, 이동할 때 문서 전체를 새로 받지 않고 바뀐 부분만 갱신합니다. 다음으로 /dashboard와 그 아래의 모든 경로에 적용될 src/app/dashboard/layout.tsx를 만들거나 고칩니다. 이 레이아웃은 왼쪽 <aside>에 대시보드 메뉴를, 오른쪽 <section>에 children을 둡니다.
import Link from 'next/link';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div style={{ display: 'flex', minHeight: 'calc(100vh - 180px)', border: '1px solid #ccc', borderRadius: '8px', overflow: 'hidden' }}>
<aside style={{ width: '200px', padding: '20px', backgroundColor: '#f9f9f9', borderRight: '1px solid #eee' }}>
<h2 style={{ marginBottom: '15px', color: '#333' }}>대시보드 메뉴</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard">대시보드 홈</Link>
</li>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard/overview">개요</Link>
</li>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard/analytics">분석</Link>
</li>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard/settings">설정</Link>
</li>
</ul>
</aside>
<section style={{ flexGrow: 1, padding: '20px', backgroundColor: '#fff' }}>
{children} {/* 대시보드 하위 페이지 콘텐츠가 여기에 렌더링됩니다 */}
</section>
</div>
);
}이 레이아웃은 RootLayout의 children으로 렌더링되며, 자기 사이드바와 메인 콘텐츠 영역을 가집니다. children prop으로는 /dashboard 폴더의 page.tsx나, 그 하위 폴더의 layout.tsx·page.tsx의 내용을 받습니다.
이제 dashboard 폴더 안에 각 페이지를 만듭니다. 대시보드 홈인 src/app/dashboard/page.tsx는 다음과 같습니다.
export default function DashboardHomePage() {
return (
<div>
<h3>환영합니다, 대시보드 홈입니다!</h3>
<p>여기에 대시보드의 주요 정보를 요약하여 표시합니다.</p>
</div>
);
}이 페이지는 children을 받지 않고 자기 본문만 돌려주며, 그 본문은 /dashboard에서만 대시보드 레이아웃의 <section> 안에 들어갑니다. 이제 하위 경로의 페이지를 하나씩 더합니다. src/app/dashboard 안에 overview 폴더를 만들고 그 안에 page.tsx를 만듭니다. 폴더 이름 overview가 URL의 다음 세그먼트가 되므로 이 페이지의 주소는 /dashboard/overview입니다.
export default function DashboardOverviewPage() {
return (
<div>
<h3>대시보드 개요</h3>
<p>이 페이지는 전체 시스템의 개요를 보여줍니다.</p>
</div>
);
}overview 폴더에는 layout.tsx가 없으므로, 개요 페이지는 그 위의 대시보드 레이아웃과 루트 레이아웃에만 감싸입니다. 같은 방식으로 analytics 폴더와 page.tsx를 만듭니다. 이 페이지의 주소는 /dashboard/analytics이고, 사이드바의 분석 링크가 이 주소를 가리킵니다.
export default function DashboardAnalyticsPage() {
return (
<div>
<h3>대시보드 분석</h3>
<p>사용자 데이터 및 트래픽 분석 정보를 제공합니다.</p>
</div>
);
}하위 페이지들은 같은 레이아웃 아래의 형제이므로, 본문의 글자만 다르고 컴포넌트의 모양은 같습니다. 마지막으로 settings 폴더와 page.tsx를 만듭니다. 이 파일까지 만들면 사이드바의 네 링크가 모두 실제 페이지를 가리키게 됩니다.
export default function DashboardSettingsPage() {
return (
<div>
<h3>대시보드 설정</h3>
<p>계정 및 대시보드 관련 설정을 변경할 수 있습니다.</p>
</div>
);
}개발 서버(npm run dev)를 실행한 뒤 URL마다 레이아웃이 어떻게 적용되는지 확인해 보세요. http://localhost:3000/과 /about에는 루트 레이아웃만 적용되고, /dashboard에는 루트 레이아웃과 대시보드 레이아웃, 대시보드 홈 페이지가, /dashboard/overview, /dashboard/analytics, /dashboard/settings에는 루트 레이아웃과 대시보드 레이아웃, 그리고 각각 개요·분석·설정 페이지가 적용됩니다. 대시보드의 각 페이지에서 위쪽의 헤더와 아래쪽의 푸터(루트 레이아웃)가 유지되면서, 왼쪽에 대시보드 메뉴(대시보드 레이아웃)가 나타나고 오른쪽에 그 페이지의 내용이 바뀌는 것을 볼 수 있습니다.
/dashboard/overview를 열면 RootLayout의 헤더와 푸터, DashboardLayout의 사이드바, overview 본문이 함께 구성됩니다. 사이드바의 설정 Link로 이동하면 설정 본문이 골라지며, 공유 레이아웃 아래의 클라이언트 측 이동이라는 조건에서 공통 영역을 재사용합니다. /about으로 이동하면 대시보드 경로 밖이므로 사이드바를 감싸는 DashboardLayout도 지금의 화면 구성에서 빠집니다. 각 페이지의 출력은 서로 형제이므로, 대시보드 홈의 본문이 다른 본문 위에 한 번 더 붙는 구조가 아닙니다(Next.js 문서의 layout).
레이아웃의 장점과 고려할 점
레이아웃을 쓰면 웹사이트의 특정 영역에 일관된 디자인과 기능을 적용하기 쉽고, 공통 UI 로직을 한곳에 모아 코드의 중복을 줄이고 유지보수를 쉽게 할 수 있습니다. 공유 레이아웃을 재사용하는 클라이언트 측 이동에서는 그 안의 클라이언트 상태도 유지됩니다. 다만 새로고침이나 다른 루트 레이아웃으로의 이동까지 같은 수명을 보장하지는 않으며, 레이아웃의 데이터 함수가 영원히 한 번만 실행된다는 뜻도 아닙니다.
고려할 점도 있습니다. 공통 사이드바처럼 레이아웃 자신이 쓰는 데이터는 그 레이아웃에서 조회하되, 특정 페이지에서만 필요한 데이터는 그 페이지에 두어 다른 화면의 대기가 늘어나지 않게 합니다. 레이아웃은 기본으로 서버 컴포넌트이므로, 클릭 이벤트나 상태 관리처럼 상호작용이 필요한 UI는 클라이언트 컴포넌트로 따로 정의해 레이아웃에서 가져와 씁니다. children으로 들어오는 라우트의 내용은 이동에 따라 바뀌며, 유지되는 클라이언트 컴포넌트도 자기 상태나 context가 바뀌면 다시 렌더링될 수 있습니다.
정리
- 중첩 라우트는 폴더의 중첩과
layout.tsx의 조합으로 만들며, 일반 폴더를 중첩하면 URL 세그먼트도 중첩됩니다. - URL이 고른
page.tsx에서 위로 올라가며 만나는layout.tsx들이 바깥에서 안으로 그 페이지를 감쌉니다. dashboard/page.tsx는/dashboard의 페이지일 뿐 하위 페이지를 감싸지 않으며, 각 페이지의 출력은 서로 형제입니다.- 공유 레이아웃 아래의 클라이언트 측 이동에서는 레이아웃과 그 안의 클라이언트 상태가 재사용됩니다.
- 레이아웃 자신이 쓰는 데이터만 레이아웃에서 조회하고, 상호작용이 필요한 UI는 클라이언트 컴포넌트로 나눕니다.
연습 문제
1. 새 하위 페이지를 감싸는 레이아웃
같은 프로젝트에 다음 파일을 더하려고 합니다. settings 아래에는 page.tsx만 있고 따로 layout.tsx는 없다고 가정합니다. 이 페이지의 주소와 그것을 감싸는 레이아웃을 예측해 보세요. 기존 settings/page.tsx의 내용도 저절로 위에 나타날까요?
export default function SettingsHistoryPage() {
return <h3>설정 변경 이력</h3>;
}해설 보기
주소는 /dashboard/settings/history입니다. 지금의 본문은 history의 page.tsx이고, DashboardLayout과 RootLayout이 안쪽에서 바깥쪽으로 감쌉니다. settings/page.tsx는 /dashboard/settings에서 골라지는 다른 페이지이므로 history를 감싸거나 저절로 합쳐지지 않습니다.
여러 설정 페이지가 공통 탭을 함께 써야 한다면, 그 공통 UI를 settings/layout.tsx로 나누는 방법을 검토합니다. 이는 파일의 역할로 예측한 결과이며, 실제 클라이언트 상태나 네트워크 요청 횟수를 측정한 기록은 아닙니다.
2. 대시보드 밖으로의 이동
/dashboard/analytics에서 루트 레이아웃의 “소개” Link를 눌러 /about으로 이동하면, 대시보드 사이드바와 루트 레이아웃의 헤더는 각각 어떻게 될까요?
해설 보기
/about은 dashboard 아래의 경로가 아니므로 DashboardLayout이 화면 구성에서 빠지고, 사이드바도 사라집니다. 루트 레이아웃은 두 경로가 함께 쓰므로 헤더와 푸터는 유지되며, 본문이 About 페이지로 바뀝니다.
3. 페이지에만 필요한 데이터
분석 페이지에만 필요한 통계를 DashboardLayout에서 조회하도록 옮기면 어떤 문제가 생길 수 있을까요?
해설 보기
DashboardLayout은 대시보드의 모든 하위 페이지를 감싸므로, 통계가 필요 없는 홈이나 설정 페이지에서도 레이아웃이 그 데이터를 기다리게 되어 화면이 늦게 보일 수 있습니다. 분석 페이지에서만 필요한 데이터는 analytics/page.tsx에서 조회하고, 레이아웃에는 사이드바처럼 레이아웃 자신이 쓰는 데이터만 둡니다.
다음 글 Catch-all 세그먼트 사용에서는 여러 단계의 경로를 한 번에 받는 Catch-all 세그먼트를 알아봅니다.