레이아웃 컴포넌트
RootLayout과 중첩 layout.tsx로 헤더·사이드바 같은 공통 UI를 유지하고 페이지 콘텐츠와 책임을 나눕니다.
Next.js App Router의 주요 기능 가운데 하나가 레이아웃(Layouts)입니다. 레이아웃은 여러 페이지가 함께 쓰는 UI를 한곳에서 정의하게 해 코드의 중복을 줄이고 일관된 사용자 경험을 유지하도록 돕습니다. 이 글에서는 layout.tsx의 개념을 더 깊이 이해하고, 실제 프로젝트에 적용하는 방법까지 살펴봅니다.
레이아웃이란 무엇인가요?
레이아웃은 특정 라우트 세그먼트와 그 하위 라우트가 함께 쓰는 UI를 감싸는(wrapping) React 컴포넌트입니다. 웹사이트의 헤더와 푸터, 사이드바, 내비게이션 바처럼 여러 페이지에 똑같이 나타나는 부분을 한곳에서 관리할 수 있게 해 줍니다.
레이아웃 안에 정의한 UI는 그 레이아웃이 적용되는 모든 하위 페이지에 저절로 들어갑니다. App Router에서는 레이아웃을 중첩할 수 있어, 루트 레이아웃(Root Layout)부터 특정 라우트 세그먼트에만 적용되는 하위 레이아웃까지 계층적으로 구성할 수 있습니다. 같은 공유 레이아웃 아래에서 클라이언트 측으로 이동하면 그 안의 클라이언트 컴포넌트 상태(State)가 유지되지만, 전체 문서를 다시 불러오거나 그 레이아웃을 벗어나는 이동까지 이 성질이 적용되지는 않습니다. layout.tsx는 기본으로 서버 컴포넌트로 동작합니다.
최상위 레이아웃 (RootLayout) 이해하기
Next.js 프로젝트를 만들면 src/app/layout.tsx가 자동으로 생깁니다. 이 파일은 애플리케이션의 루트 레이아웃으로, 모든 페이지에 적용되는 가장 기본적인 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>
);
}children prop은 이 레이아웃 안에 렌더링될 하위 콘텐츠, 곧 다른 레이아웃이나 최종 page.tsx를 나타내며, 모든 레이아웃 컴포넌트는 이 prop을 받아야 합니다. 최상위 레이아웃은 웹 페이지의 기본 구조인 <html>과 <body> 태그를 반드시 담아야 합니다. 예시에서는 간단한 <header>와 <footer>를 더했으며, 이 부분은 어느 페이지로 이동하든 언제나 똑같이 보입니다.
위 코드를 src/app/layout.tsx에 붙여 넣고 개발 서버(npm run dev)에서 확인해 보세요. 이 루트 레이아웃을 쓰는 페이지에서는 위쪽의 헤더와 아래쪽의 푸터가 보입니다. 다만 예시의 <a>는 클릭하면 문서 전체를 새로 불러오므로, 여기서는 공통 UI의 배치만 확인하고, 클라이언트 상태의 유지는 앞 글의 <Link> 이동 조건과 구분해 뒤에서 따로 확인합니다.
중첩 레이아웃 (Nested Layouts) 사용하기
특정 라우트 세그먼트에만 적용되는 별도의 레이아웃을 정의할 수도 있으며, 이것이 중첩 레이아웃입니다. 예를 들어 대시보드 페이지와 그 하위 페이지(dashboard/settings, dashboard/profile 등)에는 공통 사이드바가 필요할 수 있습니다. 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)을 둡니다. 이 레이아웃도 children prop을 받아, /dashboard나 그 하위 경로의 page.tsx 내용이 main 태그 안에 렌더링되게 합니다.
파일을 만들어 코드를 붙여 넣은 뒤, 터미널에 나온 개발 서버의 실제 주소에서 경로마다 확인합니다. 이 글의 localhost:3000은 기본 포트를 쓰는 경우의 예입니다. http://localhost:3000/과 /about에는 루트 레이아웃만 적용되고, /dashboard에는 루트 레이아웃 안에 DashboardLayout이 중첩되어 그 안에 /dashboard의 page.tsx가 렌더링되므로 헤더, 사이드바와 대시보드 콘텐츠, 푸터의 순서로 보입니다. /dashboard/settings도 마찬가지로 루트 레이아웃 안에 DashboardLayout이 중첩되고 그 안에 /dashboard/settings의 page.tsx가 렌더링됩니다.
NEXT · CH3-3
두 children 위치가 설정 페이지를 감쌉니다
RootLayout의 children 안에 DashboardLayout이, 그 children 안에 settings/page.tsx가 들어갑니다. 공통 UI와 페이지의 포함 관계를 나타냅니다.
상자는 컴포넌트가 children을 감싸는 포함관계입니다. 실제 DOM의 모든 노드를 나타내지는 않으며, Sidebar는 DashboardLayout 안에서 페이지와 나란히 배치됩니다.
위 그림은 /dashboard/settings 페이지를 두 레이아웃이 어떻게 감싸는지 보여 줍니다. 그림의 요약처럼 RootLayout의 children 안에 DashboardLayout이 들어가고, 그 children 안에 settings/page.tsx가 들어갑니다. RootLayout의 공통 header와 footer 사이에 사이드바와 children을 가진 DashboardLayout이 있고, 그 안에 설정 페이지가 놓입니다. 그림의 마지막 설명처럼 상자는 컴포넌트가 children을 감싸는 포함 관계일 뿐 실제 DOM의 모든 노드를 나타내지는 않으며, 사이드바는 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도 그 공유 영역 안에 있기 때문입니다. 원래 루트 레이아웃의 일반 a 링크나 주소창 이동으로 같은 실험을 하면 전체 문서를 다시 불러오는 조건이 섞이므로, 위 nav의 Link를 씁니다. 이 조건은 Next.js 문서의 레이아웃과 페이지가 설명하는 공유 레이아웃의 이동 범위와 이어집니다.
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를 다시 만드는 것은 아닙니다.
위 그림은 이동하는 방식에 따라 레이아웃이 유지되는 범위가 달라진다는 점을 보여 줍니다. 그림의 요약처럼 상태를 유지하려면 공유 layout과 그에 맞는 이동 조건이 필요하며, 같은 공유 레이아웃 안의 Link 이동에서만 클라이언트 상태가 유지됩니다. 카운터를 2로 만든 같은 출발 상태에서, 같은 DashboardLayout 안의 Link로 이동하면 기존 Counter가 유지되어 2이고 현재 page의 내용만 바뀌며, 전체 문서를 새로고침하면 저장하지 않은 useState의 초기값 0으로 새 Counter가 만들어집니다. 그림의 캡션이 밝히듯 두 결과는 독립된 비교이며 차례로 실행한 흐름이 아닙니다.
그림 아래의 표는 이동 조건마다 유지되는 범위와 바뀌는 범위를 정리합니다. 대시보드 안에서 Link로 이동하면 RootLayout과 DashboardLayout의 공유 UI와 클라이언트 상태가 유지되고 현재 page만 바뀌며, 소개 페이지에서 대시보드로 Link 이동하면 같은 RootLayout은 유지되고 About 페이지 대신 DashboardLayout과 그 page가 들어옵니다. 일반 a나 새로고침, 다른 루트 레이아웃으로의 이동은 전체 문서를 다시 불러올 수 있어 이전 React 컴포넌트의 상태 유지를 보장하지 않습니다. template의 키가 바뀌면 그 template 바깥의 공통 layout은 유지되지만 그 아래는 다시 마운트되어 상태와 Effect를 새로 구성합니다. 그림의 마지막 설명처럼 template은 그 수준의 세그먼트 키를 기준으로 하므로, 모든 하위 경로 이동이나 검색 매개변수의 변경이 상위 template을 다시 만드는 것은 아닙니다.
더 깊이: 레이아웃이 현재 경로와 검색 매개변수를 받지 않는 이유
레이아웃이 이동할 때 유지되는 것은 클라이언트 상태만이 아닙니다. Next.js는 불필요한 서버 요청을 줄이려고 레이아웃을 클라이언트에 캐시해 두고 다시 쓰며, 같은 레이아웃 아래에서 이동할 때는 레이아웃을 다시 렌더링하지 않습니다. 그래서 레이아웃이 받는 props는 children과 동적 세그먼트의 params, 그리고 병렬 라우트를 쓸 때의 슬롯뿐이고, 페이지와 달리 searchParams나 현재 경로(pathname)는 받지 않습니다. 이동할 때마다 바뀌는 값을 받는다면, 다시 렌더링되지 않는 레이아웃에서는 그 값이 금세 오래된 값이 되기 때문입니다(Next.js 문서의 layout.js).
이런 값이 레이아웃 영역에 필요하다면 이동할 때마다 다시 렌더링되는 쪽에서 읽습니다. 검색 매개변수는 페이지의 searchParams prop으로 읽거나, 클라이언트 컴포넌트에서 useSearchParams 훅으로 읽습니다. 현재 경로는 클라이언트 컴포넌트의 usePathname으로, 레이아웃 아래에서 선택된 하위 세그먼트는 useSelectedLayoutSegment나 useSelectedLayoutSegments로 읽습니다. 그래서 현재 메뉴를 강조하는 내비게이션은 레이아웃 전체를 클라이언트 컴포넌트로 바꾸지 않고, 이 훅을 쓰는 작은 클라이언트 컴포넌트만 떼어 레이아웃 안에 둡니다.
레이아웃의 데이터 페칭
레이아웃은 기본으로 서버 컴포넌트이므로, 서버에서 데이터를 미리 가져와 UI를 구성할 수 있습니다. 그 레이아웃이 적용되는 모든 페이지에 공통으로 필요한 데이터를 불러올 때 쓸모가 있습니다. 아래 예시는 http://localhost:4000의 로컬 목 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를 기다리는 시간은 안쪽의 “페이지 로딩 중...”으로 대신 보이지 않으며, 그 fallback은 <Suspense> 아래의 children을 기다릴 때만 적용됩니다.
Next.js 15 이후로 fetch의 응답은 기본적으로 요청 사이에 캐시되지 않습니다. 다만 같은 서버 렌더링 작업 안에서 URL과 옵션이 같은 GET fetch는 중복 호출의 결과를 재사용할 수 있으며, 이는 요청 사이의 Data Cache와는 별개입니다. 요청 사이에서도 응답을 재사용하려면 cache: 'force-cache'나 재검증 정책을 적습니다. 한편 클라이언트 측 이동에서 공유 레이아웃이 재사용되면 그 서버 코드가 다시 실행되지 않을 수 있으므로, no-store라고 해서 페이지를 이동할 때마다 이 fetch가 실행된다는 뜻은 아닙니다.
정리
- 레이아웃은 그 세그먼트와 하위 라우트가 함께 쓰는 UI를
children을 감싸는 방식으로 정의하며, 중첩할 수 있고 기본은 서버 컴포넌트입니다. - 루트 레이아웃은
<html>과<body>를 담고,dashboard/layout.tsx같은 중첩 레이아웃은 그 아래의 경로에만 적용됩니다. - 같은 공유 레이아웃 안에서
Link로 이동하면 레이아웃 안의 클라이언트 상태가 유지되지만, 일반a나 새로고침은 상태를 새로 만듭니다. - 레이아웃에서 서버 데이터를 가져올 수 있으며,
fetch는 기본으로 요청 사이에 캐시되지 않고 공유 레이아웃은 이동할 때 다시 실행되지 않을 수 있습니다.
연습 문제
1. Link로 이동한 뒤 새로고침한다면
위 Counter는 useState(0)만 쓰고 저장소나 URL에 count를 기록하지 않습니다. 대시보드 홈에서 2로 늘린 뒤 “설정” Link로 이동하고, 그다음 브라우저에서 전체 문서를 새로고침했습니다. Link로 이동한 직후와 새로고침한 직후의 count는 각각 얼마일까요? 새로고침한 뒤에도 DashboardLayout이 화면에 있다는 사실만으로 이전 상태도 되살아날까요?
해설 보기
Link로 이동한 직후에는 2, 전체 문서를 새로고침한 뒤 새로 만들어진 Counter는 0입니다. 같은 공유 레이아웃을 재사용하는 이동과, 새 문서에서 컴포넌트를 처음부터 만드는 일은 다른 조건입니다.
이 예제에는 count를 저장했다가 되살리는 코드가 없으므로 이전 숫자가 돌아오지 않습니다. UI의 모양이 같은 것과 컴포넌트 상태의 수명이 같은 것은 구별해야 합니다.
2. 레이아웃이 적용되는 경로
src/app/layout.tsx, src/app/dashboard/layout.tsx, src/app/about/page.tsx, src/app/dashboard/settings/page.tsx가 있습니다. /about과 /dashboard/settings에 각각 어떤 레이아웃이 적용될까요?
해설 보기
/about에는 루트 레이아웃만 적용됩니다. about 폴더는 dashboard의 하위가 아니므로 DashboardLayout이 적용되지 않습니다. /dashboard/settings에는 루트 레이아웃 안에 DashboardLayout이 중첩되고, 그 안에 설정 페이지가 렌더링됩니다.
3. Suspense가 가리지 못하는 대기
데이터 페칭 예시의 레이아웃에서 목 API가 3초 뒤에 응답합니다. 그 3초 동안 “페이지 로딩 중...”이 보일까요?
해설 보기
보이지 않습니다. DashboardLayout은 await getSharedData()가 끝난 뒤에야 <Suspense>를 포함한 JSX를 반환하므로, 레이아웃 자신이 기다리는 3초는 그 안의 fallback으로 대신할 수 없습니다. fallback은 <Suspense> 아래의 children이 기다릴 때만 보입니다.
레이아웃의 대기에도 로딩 UI를 보여 주려면 더 위에 loading.tsx를 두거나, 데이터를 기다리는 부분을 별도의 컴포넌트로 나누어 그 컴포넌트를 <Suspense>로 감싸는 방법을 검토합니다.
다음 글 페이지 간 링크 생성에서는 <Link>로 페이지 사이를 이동하는 방법과 현재 경로를 표시하는 내비게이션을 자세히 다룹니다.