레이아웃 컴포넌트
RootLayout과 중첩 layout.tsx로 헤더·사이드바 같은 공통 UI를 유지하고 페이지 콘텐츠와 책임을 나눕니다.
Next.js App Router의 주요 기능 중 하나가 레이아웃(Layouts)입니다.
레이아웃은 여러 페이지가 공유하는 UI를 한곳에서 정의하게 해 코드 중복을 줄이고 일관된 사용자 경험을 유지하도록 돕습니다.
이번 절에서는 layout.tsx 개념을 더 깊이 이해하고
실제 프로젝트 적용 방법까지 살펴보겠습니다.
레이아웃이란 무엇인가요?
레이아웃은 특정 라우트 세그먼트와 그 하위 라우트에서 공유되는 UI를 래핑하는(감싸는) React 컴포넌트입니다.
이는 웹사이트의 헤더, 푸터, 사이드바, 내비게이션 바 등 여러 페이지에 걸쳐 동일하게 나타나는 부분들을 한곳에서 관리할 수 있게 해줍니다.
레이아웃의 주요 특징- 공유 UI: 레이아웃 내부에 정의된 UI는 해당 레이아웃이 적용되는 모든 하위 페이지에 자동으로 포함됩니다.
- 중첩 가능: App Router에서는 레이아웃을 중첩하여 사용할 수 있습니다. 최상위 레이아웃(Root Layout)부터 특정 라우트 세그먼트에만 적용되는 하위 레이아웃까지 계층적으로 구성할 수 있습니다.
- 상태 유지: 같은 공유 레이아웃 아래에서 클라이언트 탐색을 하면 그 안의 클라이언트 컴포넌트 상태(State)가 유지됩니다. 전체 문서 로드나 해당 레이아웃을 벗어나는 이동까지 이 성질이 적용되지는 않습니다.
- 기본적으로 서버 컴포넌트:
layout.tsx파일은 기본적으로 서버 컴포넌트로 동작합니다.
최상위 레이아웃 (RootLayout) 이해하기
Next.js 프로젝트를 생성하면 src/app/layout.tsx 파일이 자동으로 생성됩니다.
이 파일은 애플리케이션의 최상위 레이아웃(Root Layout)으로, 모든 페이지에 적용되는 가장 기본적인 UI 구조를 정의합니다.
// src/app/layout.tsx
import './globals.css'; // 전역 스타일 임포트
export default function RootLayout({
children, // 필수 prop: 모든 하위 페이지 및 중첩 레이아웃이 여기에 렌더링됩니다.
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header style={{ backgroundColor: '#f0f0f0', padding: '10px' }}>
<nav>
<a href="/">홈</a> | <a href="/about">소개</a> | <a href="/dashboard">대시보드</a>
</nav>
<h1>나 혼자 Next.js</h1>
</header>
{children} {/* 여기에 페이지 또는 하위 레이아웃 콘텐츠가 들어옵니다 */}
<footer style={{ backgroundColor: '#e0e0e0', padding: '10px', marginTop: '20px' }}>
<p>© 2024 나 혼자 Next.js. All rights reserved.</p>
</footer>
</body>
</html>
);
}childrenprop: 이 레이아웃 내부에 렌더링될 하위 콘텐츠(다른 레이아웃 또는 최종page.tsx파일)를 나타냅니다. 모든 레이아웃 컴포넌트는 이childrenprop을 받아야 합니다.<html>,<body>태그: 최상위 레이아웃은 반드시<html>과<body>태그를 포함해야 합니다. 이는 웹 페이지의 기본 구조를 형성합니다.- 공통 UI 추가: 예시에서는 간단한
<header>와<footer>를 추가했습니다. 이 부분은 어떤 페이지로 이동하든 항상 동일하게 표시됩니다.
실습
위 코드를 src/app/layout.tsx 파일에 붙여 넣고 개발 서버(npm run dev)를 확인해 보세요.
이 루트 레이아웃을 사용하는 페이지에서는 상단 헤더와 하단 푸터가 표시됩니다. 원문 예시의 <a>는 다른 문서로 이동하므로, 여기서는 공통 UI의 배치를 확인합니다. 클라이언트 상태 유지는 앞 절의 <Link> 탐색 조건과 구분합니다.
중첩 레이아웃 (Nested Layouts) 사용하기
특정 라우트 세그먼트에만 적용되는 별도의 레이아웃을 정의할 수도 있습니다.
이것이 바로 중첩 레이아웃입니다.
예를 들어, 대시보드 페이지와 그 하위 페이지들(dashboard/settings, dashboard/profile 등)에는 공통된 사이드바가 필요할 수 있습니다.
dashboard 폴더에 레이아웃 파일 추가:
src/app/dashboard 폴더 안에 layout.tsx 파일을 생성합니다.
src/app/dashboard/layout.tsx 파일 내용 작성export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section style={{ display: 'flex', border: '1px solid #ccc', padding: '10px', marginTop: '10px' }}>
<aside style={{ width: '200px', padding: '10px', backgroundColor: '#f9f9f9', borderRight: '1px solid #eee' }}>
<h2>대시보드 메뉴</h2>
<ul>
<li><a href="/dashboard">대시보드 홈</a></li>
<li><a href="/dashboard/settings">설정</a></li>
{/* 추가 메뉴 아이템 */}
</ul>
</aside>
<main style={{ flexGrow: 1, padding: '10px' }}>
{children} {/* 대시보드 하위 페이지 콘텐츠가 여기에 렌더링됩니다 */}
</main>
</section>
);
}DashboardLayout은section태그로 전체를 감싸고, 왼쪽에 사이드바(aside)와 오른쪽에 메인 콘텐츠 영역(main)을 가집니다.- 이 레이아웃 역시
childrenprop을 받아,/dashboard경로 또는 그 하위 경로의page.tsx콘텐츠가main태그 내부에 렌더링되도록 합니다.
실습
src/app/dashboard/layout.tsx 파일을 생성하고 위 코드를 붙여 넣은 후, 터미널에 표시된 개발 서버의 실제 주소에서 아래 경로를 확인하세요. 목록의 localhost:3000은 기본 포트를 사용하는 경우의 예시입니다.
http://localhost:3000/: 루트 레이아웃만 적용됩니다.http://localhost:3000/about: 루트 레이아웃만 적용됩니다.http://localhost:3000/dashboard: 루트 레이아웃 안에DashboardLayout이 중첩되어 적용되고, 그 안에/dashboard의page.tsx내용이 렌더링됩니다. 즉, 헤더-사이드바-대시보드 콘텐츠-푸터 순서로 보일 것입니다.http://localhost:3000/dashboard/settings: 마찬가지로 루트 레이아웃 안에DashboardLayout이 중첩되어 적용되고, 그 안에/dashboard/settings의page.tsx내용이 렌더링됩니다.
NEXT · CH3-3
두 children 위치가 설정 페이지를 감쌉니다
RootLayout의 children 안에 DashboardLayout이, 그 children 안에 settings/page.tsx가 들어갑니다. 공통 UI와 페이지의 포함 관계를 나타냅니다.
상자는 컴포넌트가 children을 감싸는 포함관계입니다. 실제 DOM의 모든 노드를 나타내지는 않으며, Sidebar는 DashboardLayout 안에서 페이지와 나란히 배치됩니다.
예시
공유 상태를 직접 확인할 때는 원문의 a 배치 예시와 별도로 Link를 사용하는 레이아웃 단계로 바꿉니다. App Router 구조의 src/app/components/Counter.tsx와 페이지 생성의 대시보드 홈·설정 페이지가 준비되어 있다고 가정합니다. 아래 코드는 src/app/dashboard/layout.tsx의 대체 버전 하나이며, 앞의 DashboardLayout과 한 파일에 함께 넣지 않습니다.
import Link from 'next/link';
import Counter from '../components/Counter';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<aside>
<h2>공유 대시보드 영역</h2>
<Counter />
<nav>
<Link href="/dashboard">대시보드 홈</Link>{' | '}
<Link href="/dashboard/settings">설정</Link>
</nav>
</aside>
<main>{children}</main>
</section>
);
}/dashboard에서 Counter를 두 번 증가시켜 2로 만듭니다. 이 레이아웃 안의 ‘설정’ Link를 클릭하면 URL과 main의 페이지 제목은 바뀌지만 Counter는 2로 남습니다. 두 경로가 같은 DashboardLayout을 공유하고, Counter도 그 공유 영역 안에 있기 때문입니다. 원래 Root Layout의 일반 a 링크나 주소창 이동으로 같은 실험을 하면 전체 문서 로드 조건이 섞이므로, 위 nav의 Link를 사용합니다. 이 조건은 공식 레이아웃 설명의 공유 레이아웃 탐색 범위와 연결됩니다.
독립 연습: Link 이동 뒤 새로고침한다면?
위 Counter는 useState(0)만 쓰며 저장소나 URL에 count를 기록하지 않습니다. 대시보드 홈에서 2로 증가시킨 뒤 위 ‘설정’ Link로 이동하고, 그다음 브라우저의 전체 문서 새로고침을 완료합니다. Link 이동 직후와 새로고침 직후의 count는 각각 얼마인가요? 새로고침 뒤 DashboardLayout이 화면에 다시 있다는 사실만으로 이전 상태도 복원되나요?
풀이: Link 이동 직후에는 2, 전체 문서 새로고침 뒤 새로 구성한 Counter는 0입니다. 같은 공유 레이아웃을 재사용하는 탐색과 새 문서에서 컴포넌트를 만드는 일은 다른 조건입니다. 이 예제에는 count를 지속적으로 저장하고 복원하는 코드가 없으므로 이전 숫자가 돌아오지 않습니다. UI 모양이 같은 것과 컴포넌트 상태의 수명이 같은 것은 구별합니다.
레이아웃의 데이터 페칭
레이아웃은 기본적으로 서버 컴포넌트이므로, 서버에서 데이터를 미리 가져와 UI를 구성하는 것이 가능합니다.
이는 해당 레이아웃이 적용되는 모든 페이지에서 공통적으로 필요한 데이터를 효율적으로 로드하는 데 유용합니다.
아래 데이터 페칭 예시는 http://localhost:4000 로컬 Mock API가 실행 중인 상황을 전제로 합니다. 위의 상태 관찰용 레이아웃과는 대체 단계이며, API를 준비한 뒤 해당 파일의 내용으로 선택해 적용합니다. API 없이 상태 실습을 하는 동안에는 위 레이아웃을 유지합니다.
import { Suspense } from 'react'; // 로딩 상태 관리를 위한 Suspense 임포트
// 서버 컴포넌트에서 데이터 페칭
async function getSharedData() {
// 실제 API 호출 로직
const response = await fetch('http://localhost:4000/shared-dashboard-info', {
cache: 'no-store' // 캐시 사용 여부 설정 (필요에 따라)
});
if (!response.ok) {
// 에러 처리
throw new Error('데이터를 가져오지 못했습니다.');
}
return response.json();
}
export default async function DashboardLayout({ // async 키워드 추가
children,
}: {
children: React.ReactNode;
}) {
const sharedData = await getSharedData(); // 데이터 호출 (서버에서 실행)
return (
<section style={{ display: 'flex', border: '1px solid #ccc', padding: '10px', marginTop: '10px' }}>
<aside style={{ width: '200px', padding: '10px', backgroundColor: '#f9f9f9', borderRight: '1px solid #eee' }}>
<h2>대시보드 메뉴</h2>
<p>공유 데이터: {sharedData.message}</p> {/* 가져온 데이터 사용 */}
<ul>
<li><a href="/dashboard">대시보드 홈</a></li>
<li><a href="/dashboard/settings">설정</a></li>
</ul>
</aside>
<main style={{ flexGrow: 1, padding: '10px' }}>
{/* Suspense를 사용하여 children 로딩 중 대체 UI 표시 가능 */}
<Suspense fallback={<div>페이지 로딩 중...</div>}>
{children}
</Suspense>
</main>
</section>
);
}이 코드의 <Suspense>는 await getSharedData()가 끝난 뒤 반환됩니다. 따라서 sharedData를 기다리는 시간은 내부의 “페이지 로딩 중...”으로 대체되지 않으며, 그 경계 아래 children의 대기에만 적용됩니다.
참고: Next.js 15 이후 fetch 응답은 기본적으로 요청 사이에 캐시되지 않습니다.
다만 같은 서버 렌더링 작업 안에서 URL과 옵션이 같은 GET fetch는 중복 호출의 결과를 재사용할 수 있습니다. 이는 요청 사이의 Data Cache와 별개입니다.
요청 사이에서도 응답을 재사용하려면 cache: 'force-cache'나 재검증 정책을 명시합니다. 한편 클라이언트 탐색에서 공유 레이아웃을 재사용하면 그 서버 코드가 다시 실행되지 않을 수 있으므로, no-store가 모든 페이지 이동마다 이 fetch를 실행한다는 뜻은 아닙니다.
NEXT · CH3-3
상태 유지에는 공유 layout과 탐색 조건이 필요합니다
같은 공유 레이아웃 안의 Link 탐색에서 Client 상태를 유지합니다. 본문의 일반 a 예제는 전체 문서 이동이며, no-store가 모든 탐색마다 layout fetch를 실행하게 하지는 않습니다.
| 이동 조건 | 유지되는 범위 | 바뀌는 범위 |
|---|---|---|
| 대시보드 안 Link 이동 | RootLayout과 DashboardLayout의 공유 UI·클라이언트 상태 | 현재 page 콘텐츠; 예: 홈에서 settings로 이동 |
| 소개에서 대시보드로 Link 이동 | 같은 RootLayout | About 페이지 대신 DashboardLayout과 그 page가 들어옴 |
| 일반 a·새로고침·다른 루트 | 이전 React 컴포넌트 상태 유지를 보장하지 않음 | 전체 문서를 다시 로드할 수 있음 |
| template 키 변경 | 그 template 바깥의 공통 layout | 해당 template 아래를 재마운트해 상태·Effect를 다시 구성 |
- 대시보드 안 Link 이동
- 유지되는 범위: RootLayout과 DashboardLayout의 공유 UI·클라이언트 상태바뀌는 범위: 현재 page 콘텐츠; 예: 홈에서 settings로 이동
- 소개에서 대시보드로 Link 이동
- 유지되는 범위: 같은 RootLayout바뀌는 범위: About 페이지 대신 DashboardLayout과 그 page가 들어옴
- 일반 a·새로고침·다른 루트
- 유지되는 범위: 이전 React 컴포넌트 상태 유지를 보장하지 않음바뀌는 범위: 전체 문서를 다시 로드할 수 있음
- template 키 변경
- 유지되는 범위: 그 template 바깥의 공통 layout바뀌는 범위: 해당 template 아래를 재마운트해 상태·Effect를 다시 구성
template는 해당 수준의 세그먼트 키를 기준으로 합니다. 모든 하위 경로 이동이나 검색 매개변수 변경이 상위 template를 다시 만드는 것은 아닙니다.