레이아웃 컴포넌트 생성 및 중첩
children을 받는 레이아웃을 여러 세그먼트에 중첩해 유지되는 UI 범위와 데이터 공유 경계를 설계합니다.
Next.js App Router에서 레이아웃 컴포넌트는 UI를 함께 쓰는 데서 그치지 않고, 애플리케이션의 구조와 성능을 다듬는 데 중요한 역할을 합니다. 3장에서 레이아웃의 기본 개념과 최상위 레이아웃, 중첩 레이아웃의 사용법을 간단히 살펴보았으니, 이 글에서는 레이아웃 컴포넌트를 더 깊이 이해하고 실제 프로젝트에서 효율적으로 만들고 중첩하는 방법을 다룹니다.
레이아웃 컴포넌트의 역할
레이아웃 컴포넌트는 app 디렉터리의 특정 라우트 세그먼트 폴더 안에 있는 layout.tsx 파일로, 그 라우트와 모든 하위 라우트에 적용되는 공통 UI를 정의합니다. 헤더와 푸터, 사이드바, 내비게이션 바처럼 여러 곳에서 되풀이되는 UI 요소를 한곳에서 관리해 코드의 중복을 막고 디자인을 일관되게 유지합니다.
공유 레이아웃 아래에서 클라이언트 측으로 이동하면 그 레이아웃을 재사용해 안의 클라이언트 컴포넌트 상태를 유지하지만, 새로고침이나 그 레이아웃을 벗어나는 이동까지 같은 수명을 보장하지는 않습니다. layout.tsx는 기본으로 서버 컴포넌트로 동작하므로 레이아웃에서 데이터를 가져오거나 서버 전용 로직을 안전하게 실행할 수 있고, 서버 컴포넌트의 코드를 클라이언트 번들에 넣지 않으면서 공통 구조를 재사용할 수 있습니다. Data Cache가 적용되는지와 실제 속도는 조회 방식과 설정에 따라 판단합니다.
하위 라우트의 UI를 보여 주려면 children prop을 받아 원하는 위치에 렌더링해야 하며, 이 children으로 하위 레이아웃이나 최종 페이지 컴포넌트의 내용이 렌더링됩니다.
// 모든 레이아웃 컴포넌트의 기본 형태
export default function MyLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/* 레이아웃에 고유한 공통 UI */}
<nav>공통 내비게이션</nav>
{children} {/* 여기에 하위 콘텐츠가 렌더링됩니다 */}
<footer>공통 푸터</footer>
</>
);
}이 예에서 <nav>와 <footer>는 하위 경로가 바뀌어도 그대로 남고, 그 사이의 children 자리만 현재 경로의 내용으로 바뀝니다. 레이아웃이 children을 렌더링하지 않으면 하위 페이지는 파일을 만들어도 화면에 나타나지 않습니다.
레이아웃이 중첩되는 방식
App Router에서 레이아웃은 폴더 구조에 따라 저절로 중첩됩니다. 각 라우트 세그먼트(폴더)에 layout.tsx가 있으면, 그 폴더의 레이아웃은 부모 폴더의 레이아웃 안에 중첩되어 렌더링됩니다.
위 구조에서 /dashboard/settings에 들어가면 다음 순서로 레이아웃이 중첩되어 최종 페이지가 렌더링됩니다. 먼저 RootLayout이 최상위 HTML 구조(<html>, <body>)와 전역 스타일을 제공하고, 그 children으로 DashboardLayout이 들어가 사이드바 같은 대시보드 전용 UI를 제공합니다. settings 폴더에 레이아웃이 있다면 SettingsLayout이 더 중첩되어 설정 페이지에만 적용되는 UI를 제공하고, 마지막으로 settings 폴더의 page.tsx인 SettingsPage가 최종 내용을 보여 줍니다. 이런 계층적인 중첩 덕분에 애플리케이션의 복잡도에 맞춰 UI를 유연하게 구성할 수 있습니다.
레이아웃 컴포넌트 만들기 실습
이 실습은 children을 렌더링하는 일반 대시보드 레이아웃을 기준으로 합니다. 앞 장의 병렬 라우트 @analytics와 @team을 같은 프로젝트에 남겨 두었다면, 뒤의 DashboardLayout 전체 예시로 덮어쓰기 전에 기존 슬롯 props와 슬롯을 보여 주는 JSX도 유지해야 합니다. children만 받는 코드로 바꾸면 슬롯을 화면에 배치하던 부분이 사라지기 때문이며, 병렬 라우트 실습을 별도의 프로젝트로 나누었다면 일반 레이아웃 예시를 그대로 적용할 수 있습니다. 라우트 그룹 실습에서 폴더를 옮겼다면 아래의 src/app/dashboard 경로를 기존 src/app/(dashboard)/dashboard에 적용하고, 같은 URL의 폴더를 겹쳐 만들지 않습니다.
앞에서 만든 대시보드 예제를 바탕으로, 특정 하위 페이지에만 적용되는 중첩 레이아웃을 더해 봅니다. 목표는 /dashboard/profile에 사용자 프로필을 보여 주는 페이지를 만들고, 이 페이지들에만 적용되는 레이아웃을 더해 사이드 메뉴를 더 세분화하는 것입니다. 먼저 src/app/dashboard 안에 profile 폴더를 만들고, 그 안에 layout.tsx를 만들어 다음 내용을 작성합니다.
import Link from 'next/link';
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div style={{ display: 'flex', gap: '20px', padding: '15px', border: '1px dashed orange', borderRadius: '5px', marginTop: '15px' }}>
<aside style={{ width: '150px', backgroundColor: '#fffbe6', padding: '10px', borderRadius: '5px' }}>
<h4>프로필 메뉴</h4>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li style={{ marginBottom: '5px' }}>
<Link href="/dashboard/profile">기본 정보</Link>
</li>
<li style={{ marginBottom: '5px' }}>
<Link href="/dashboard/profile/edit">프로필 편집</Link>
</li>
<li style={{ marginBottom: '5px' }}>
<Link href="/dashboard/profile/security">보안 설정</Link>
</li>
</ul>
</aside>
<section style={{ flexGrow: 1, backgroundColor: '#fff', padding: '10px' }}>
{children} {/* 프로필 관련 페이지 콘텐츠가 여기에 들어옵니다 */}
</section>
</div>
);
}이 ProfileLayout은 주황색 점선 테두리로 보여 구분되며, DashboardLayout 안에 중첩되어 RootLayout > DashboardLayout > ProfileLayout > page.tsx 순서로 렌더링됩니다. 다음으로 profile 폴더 안에 page.tsx를 만들어 기본 프로필 정보를 보여 줍니다.
export default function ProfileHomePage() {
return (
<div>
<h3>내 프로필</h3>
<p>이곳은 사용자 프로필의 기본 정보를 표시하는 페이지입니다.</p>
<ul>
<li>이름: 홍길동</li>
<li>이메일: [email protected]</li>
</ul>
</div>
);
}이 페이지는 /dashboard/profile에서 ProfileLayout의 children 자리에 들어갑니다. 이어서 src/app/dashboard/profile 안에 edit 폴더와 security 폴더를 만들고, 각각의 안에 page.tsx를 만듭니다. 두 페이지의 주소는 /dashboard/profile/edit와 /dashboard/profile/security이며, 둘 다 ProfileLayout 안에 렌더링되므로 프로필 영역의 점선 테두리가 함께 보입니다.
export default function ProfileEditPage() {
return (
<div>
<h3>프로필 편집</h3>
<p>여기서 사용자 정보를 편집할 수 있습니다.</p>
{/* 실제로는 폼 컴포넌트가 들어갈 자리 */}
</div>
);
}// src/app/dashboard/profile/security/page.tsx
export default function ProfileSecurityPage() {
return (
<div>
<h3>보안 설정</h3>
<p>비밀번호 변경, 2단계 인증 등의 보안 설정을 할 수 있습니다.</p>
</div>
);
}원한다면 src/app/dashboard/layout.tsx의 사이드바에 프로필 링크를 더해 쉽게 들어갈 수 있게 합니다. 아래는 기존 <ul> 목록에서 개요 다음에 <li> 하나를 끼워 넣은 모습이며, 나머지 항목은 앞 장의 대시보드 레이아웃과 같습니다.
// ...
<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/profile">프로필</Link> {/* 새 링크 추가 */}
</li>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard/analytics">분석</Link>
</li>
<li style={{ marginBottom: '10px' }}>
<Link href="/dashboard/settings">설정</Link>
</li>
</ul>
// ...개발 서버(npm run dev)를 실행한 뒤 URL마다 레이아웃의 중첩을 확인해 보세요. http://localhost:3000/dashboard에는 루트와 대시보드 레이아웃이, /dashboard/profile에는 루트와 대시보드, 프로필 레이아웃과 프로필 홈 페이지가, /dashboard/profile/edit에는 루트와 대시보드, 프로필 레이아웃과 프로필 편집 페이지가 적용됩니다. 프로필 관련 페이지에서만 ProfileLayout의 주황색 점선 테두리와 안쪽 메뉴가 보이며, /dashboard/profile/edit에서는 세 레이아웃과 편집 페이지가 중첩될 뿐 대시보드 홈이나 프로필 홈의 page.tsx가 함께 렌더링되지는 않습니다.
NEXT · CH5-2
편집 화면 하나를 세 layout이 감쌉니다
/dashboard/profile/edit에서는 RootLayout → DashboardLayout → ProfileLayout의 children 안에 편집 페이지 하나가 들어갑니다.
대시보드 홈과 프로필 홈 page는 이 경로에서 함께 렌더되지 않습니다. 이 절의 userInfo는 DashboardLayout에만 사용되며 children에 자동 전달되지 않습니다.
위 그림은 /dashboard/profile/edit의 네 계층을 보여 줍니다. 그림의 요약처럼 편집 화면 하나를 세 레이아웃이 감싸, RootLayout → DashboardLayout → ProfileLayout의 children 안에 편집 페이지 하나가 들어갑니다. 바깥에서 안으로 전역 헤더와 푸터를 가진 RootLayout, 대시보드 메뉴를 가진 DashboardLayout, 프로필 메뉴를 가진 ProfileLayout, 그리고 가장 안쪽의 ProfileEditPage(profile/edit/page.tsx)가 놓입니다. 그림의 마지막 설명처럼 대시보드 홈과 프로필 홈의 page는 이 경로에서 함께 렌더링되지 않으며, 다음 절의 userInfo는 DashboardLayout에서만 쓰이고 children에 저절로 전달되지 않습니다.
레이아웃에서 데이터 쓰기
서버 레이아웃은 자신이 보여 주는 공통 UI에 필요한 데이터를 조회할 수 있습니다. 아래 예제는 500ms의 지연을 넣은 함수에서 고정된 사용자 정보를 받아 사이드바에 이름을 보여 주며, 실제 DB나 API 호출은 없습니다.
import Link from 'next/link';
// 데이터 페칭 함수 (서버에서 실행)
async function getUserInfo() {
// 실제로는 DB에서 사용자 정보를 가져오거나 API 호출
// 예시를 위해 딜레이 추가
await new Promise(resolve => setTimeout(resolve, 500));
return { username: '김넥스트', email: '[email protected]' };
}
export default async function DashboardLayout({ // async 키워드 추가
children,
}: {
children: React.ReactNode;
}) {
const userInfo = await getUserInfo(); // 서버에서 사용자 정보 페칭
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>
<p>환영합니다, {userInfo.username}님!</p> {/* 가져온 데이터 사용 */}
<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/profile">프로필</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>
);
}userInfo는 레이아웃 자신만 쓰며 children에 저절로 전달되지 않으므로, 하위 페이지에서도 필요하다면 데이터를 넘기는 방법을 따로 설계하거나 그 페이지에서 조회합니다. 예를 들어 /dashboard/profile/edit의 사이드바에 김넥스트라는 이름이 보이는 까닭은 DashboardLayout이 조회한 userInfo.username을 그 JSX에서 쓰기 때문이며, 편집 페이지를 감싸고 있다는 이유로 ProfileEditPage의 props에 userInfo가 더해지지는 않습니다. 또 편집과 보안 예제의 문장은 보여 주기 위한 안내일 뿐, 실제 저장 요청이나 비밀번호 변경을 구현한 코드가 아닙니다.
지금의 getUserInfo는 일반 비동기 함수이므로 다시 부르면 지연 코드도 다시 실행됩니다. 공유 레이아웃이 클라이언트 측 이동에서 재사용되는 것과 데이터 함수가 캐시되는 것은 서로 다른 개념이며, 같은 서버 렌더링 안에서 같은 fetch 요청의 중복을 없애거나 일반 조회 함수를 React의 cache로 감싸는 방법은 6장의 데이터 페칭에서 다룹니다(Next.js 문서의 layout).
정리
- 레이아웃은 그 라우트와 하위 라우트의 공통 UI를 정의하며,
childrenprop으로 받은 하위 내용을 원하는 위치에 렌더링합니다. - 각 세그먼트의
layout.tsx는 부모 레이아웃 안에 저절로 중첩되며, 한 URL에서는 그 경로의 레이아웃들과 최종 페이지 하나만 렌더링됩니다. - 특정 하위 경로에만 적용할 UI는 그 폴더에
layout.tsx를 더해 만듭니다. - 레이아웃이 조회한 데이터는 그 레이아웃 안에서만 쓰이고
children에 저절로 전달되지 않습니다. - 레이아웃의 재사용과 데이터 함수의 캐시는 서로 다른 개념이므로 따로 확인합니다.
연습 문제
1. 이동할 때 바뀌는 것과 유지되는 것
/dashboard/profile/edit에서 프로필 메뉴의 Link로 /dashboard/profile/security로 이동한다고 가정합니다. 어느 레이아웃이 두 화면을 함께 감싸고, 어떤 최종 페이지가 바뀔까요? 보안 페이지에서 사이드바의 userInfo가 필요하다면 포함 관계만으로 읽을 수 있을까요? 같은 이름이 두 번 보인다는 사실만으로 데이터 함수가 캐시되었다고 결론 내려도 될까요?
해설 보기
두 경로 모두 RootLayout, DashboardLayout, ProfileLayout 아래에 있고, 마지막의 edit/page.tsx가 security/page.tsx로 바뀝니다. 대시보드 홈이나 프로필 홈의 page.tsx가 부모 페이지로 함께 쌓이는 구조는 아닙니다. 같은 레이아웃을 함께 쓰는 클라이언트 측 이동에서는 그 레이아웃이 유지되는 범위를 고려할 수 있지만, 새로고침은 같은 조건이 아닙니다.
보안 페이지는 레이아웃의 지역 변수인 userInfo를 저절로 받지 않으므로, 그 페이지에서 필요한 데이터를 조회하거나 넘기는 방법을 따로 설계해야 합니다. 이 함수는 언제나 같은 이름을 돌려주므로, 화면에 이름이 같다는 것만으로는 호출 횟수나 캐시를 확인할 수 없으며, 함수의 호출과 레이아웃의 재사용, 데이터의 캐시를 각각 따로 살펴봅니다.
2. 레이아웃을 더할 위치
/dashboard/settings와 그 아래의 /dashboard/settings/notifications, /dashboard/settings/privacy에만 공통 탭을 보여 주고 싶습니다. 어디에 어떤 파일을 더해야 할까요?
해설 보기
src/app/dashboard/settings/layout.tsx를 만들어 탭 UI와 children을 렌더링합니다. 이 레이아웃은 settings 폴더와 그 하위 경로에만 적용되므로 세 페이지에 공통 탭이 보이고, /dashboard/profile 같은 다른 대시보드 페이지에는 보이지 않습니다.
3. 레이아웃 데이터를 페이지에서 쓰기
ProfileEditPage에서 DashboardLayout이 조회한 사용자의 이메일을 입력란의 초기값으로 쓰고 싶습니다. ProfileEditPage({ userInfo })처럼 props로 받으면 될까요?
해설 보기
되지 않습니다. 레이아웃은 children에 props를 더해 넘기지 않으므로 ProfileEditPage의 userInfo는 undefined입니다. 편집 페이지에서도 사용자 정보가 필요하다면 그 페이지에서 직접 조회하고, 같은 요청 안에서 중복 조회를 줄이려면 조회 함수를 React의 cache로 감싸는 방법 등을 검토합니다.
다음 글 템플릿 컴포넌트 활용에서는 레이아웃과 비슷하지만 이동할 때마다 새로 마운트되는 템플릿 컴포넌트를 알아봅니다.