레이아웃 컴포넌트 생성 및 중첩
children을 받는 레이아웃을 여러 세그먼트에 중첩해 유지되는 UI 범위와 데이터 공유 경계를 설계합니다.
Next.js App Router에서 레이아웃 컴포넌트는 단순히 UI를 공유하는 것을 넘어, 애플리케이션의 구조와 성능을 최적화하는 데 핵심적인 역할을 합니다.
3장에서 레이아웃의 기본 개념과 최상위 레이아웃, 중첩 레이아웃 사용법을 간략히 살펴보았습니다.
이번 절에서는 레이아웃 컴포넌트를 더 깊이 이해하고, 실제 프로젝트에서 효율적으로 생성하고 중첩하는 고급 기법을 다룹니다.
레이아웃 컴포넌트의 본질과 역할
레이아웃 컴포넌트는 app 디렉터리 내의 특정 라우트 세그먼트 폴더 안에 위치한 layout.tsx 파일입니다.
이 파일은 해당 라우트와 그 모든 하위 라우트에 적용되는 공통 UI를 정의합니다.
레이아웃의 핵심 역할- UI 재사용 및 일관성 유지: 헤더, 푸터, 사이드바, 내비게이션 바 등 웹사이트의 여러 부분에서 반복되는 UI 요소를 한 곳에서 관리하여 코드 중복을 방지하고 디자인 일관성을 유지합니다.
- 상태 유지: 공유 레이아웃 아래의 클라이언트 이동에서는 레이아웃을 재사용해 그 안의 Client Component 상태를 유지합니다. 새로고침이나 해당 레이아웃을 벗어나는 이동까지 같은 수명을 보장하지는 않습니다.
- 서버 컴포넌트 기본값:
layout.tsx파일은 기본적으로 서버 컴포넌트로 동작합니다. 따라서 레이아웃에서 데이터를 페칭하거나, 서버 전용 로직을 안전하게 실행할 수 있습니다. - 성능 최적화: 서버 컴포넌트의 코드를 클라이언트 번들에 포함하지 않고 공통 구조를 재사용할 수 있습니다. 데이터 캐시 적용 여부와 실제 속도는 조회 방식·설정에 따라 판단합니다.
하위 라우트의 UI를 표시하려면 children prop을 받아 원하는 위치에 렌더링해야 합니다.
이 children prop을 통해 하위 레이아웃 또는 최종 페이지 컴포넌트의 콘텐츠가 렌더링됩니다.
// 모든 레이아웃 컴포넌트의 기본 형태
export default function MyLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/* 레이아웃에 고유한 공통 UI */}
<nav>공통 내비게이션</nav>
{children} {/* 여기에 하위 콘텐츠가 렌더링됩니다 */}
<footer>공통 푸터</footer>
</>
);
}레이아웃 중첩의 작동 방식
App Router에서 레이아웃은 폴더 구조에 따라 자동으로 중첩됩니다.
각 라우트 세그먼트(폴더)에 layout.tsx 파일이 있으면, 해당 폴더의 레이아웃은 그 부모 폴더의 레이아웃 안에 중첩되어 렌더링됩니다.
위 구조에서 /dashboard/settings 경로로 접근하면, 다음과 같은 순서로 레이아웃이 중첩되어 최종 페이지가 렌더링됩니다.
RootLayout: 최상위 HTML 구조(<html>, <body>)를 제공하고, 전역 스타일 등을 포함합니다.
이 레이아웃의 children으로 다음 레이아웃이 들어갑니다.
DashboardLayout: 대시보드 섹션에 특화된 UI(예: 사이드바)를 제공합니다.
이 레이아웃의 children으로 다음 레이아웃/페이지가 들어갑니다.
SettingsLayout (선택 사항): 만약 settings 폴더에 레이아웃이 있다면, 이 레이아웃이 추가로 중첩되어 특정 설정 페이지에만 적용되는 UI를 제공합니다.
이 레이아웃의 children으로 최종 페이지가 들어갑니다.
SettingsPage: 마지막으로 settings 폴더의 page.tsx가 렌더링되어 최종 콘텐츠를 표시합니다.
이러한 계층적 중첩은 애플리케이션의 복잡성에 따라 유연하게 UI를 구성할 수 있게 해줍니다.
레이아웃 컴포넌트 생성 실습
이전에 만들었던 대시보드 예제를 바탕으로, 특정 하위 페이지에만 적용되는 중첩 레이아웃을 추가해 보겠습니다. 라우트 그룹 실습에서 폴더를 옮겼다면 아래 src/app/dashboard 경로를 기존 src/app/(dashboard)/dashboard에 적용하고, 같은 URL의 폴더를 중복 생성하지 않습니다.
목표:
/dashboard/profile 경로에 사용자 프로필을 표시하는 페이지를 만들고, 이 페이지에만 적용되는 특정 레이아웃을 추가하여 사이드 메뉴를 더 세분화합니다.
src/app/dashboard/profile 폴더 생성:
src/app/dashboard 폴더 안에 profile이라는 새 폴더를 만듭니다.
src/app/dashboard/profile/layout.tsx 파일 생성:
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은 주황색 점선 테두리로 표시되어 시각적으로 구분됩니다. ProfileLayout은DashboardLayout안에 중첩되어 렌더링될 것입니다. 즉,RootLayout>DashboardLayout>ProfileLayout>page.tsx순으로 중첩됩니다.
src/app/dashboard/profile/page.tsx 파일 생성:
profile 폴더 안에 page.tsx 파일을 생성하고 기본 프로필 정보를 표시하는 내용을 작성합니다.
export default function ProfileHomePage() {
return (
<div>
<h3>내 프로필</h3>
<p>이곳은 사용자 프로필의 기본 정보를 표시하는 페이지입니다.</p>
<ul>
<li>이름: 홍길동</li>
<li>이메일: [email protected]</li>
</ul>
</div>
);
}src/app/dashboard/profile/edit/page.tsx 및 src/app/dashboard/profile/security/page.tsx 생성:
src/app/dashboard/profile 안에 각각 edit 폴더와 security 폴더를 만들고, 그 안에 page.tsx 파일을 생성합니다.
-
src/app/dashboard/profile/edit/page.tsxsrc/app/dashboard/profile/edit/page.tsx export default function ProfileEditPage() { return ( <div> <h3>프로필 편집</h3> <p>여기서 사용자 정보를 편집할 수 있습니다.</p> {/* 실제로는 폼 컴포넌트가 들어갈 자리 */} </div> ); } -
src/app/dashboard/profile/security/page.tsxsrc/app/dashboard/profile/security/page.tsx // src/app/dashboard/profile/security/page.tsx export default function ProfileSecurityPage() { return ( <div> <h3>보안 설정</h3> <p>비밀번호 변경, 2단계 인증 등의 보안 설정을 할 수 있습니다.</p> </div> ); }
DashboardLayout에 프로필 링크 추가 (선택 사항):
src/app/dashboard/layout.tsx 파일의 사이드바에 프로필 링크를 추가하여 접근성을 높일 수 있습니다.
// ...
<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(루트 + 대시보드 레이아웃)http://localhost:3000/dashboard/profile(루트 + 대시보드 + 프로필 레이아웃 + 프로필 홈 페이지)http://localhost:3000/dashboard/profile/edit(루트 + 대시보드 + 프로필 레이아웃 + 프로필 편집 페이지)
프로필 관련 페이지들에서만 ProfileLayout의 주황색 점선 테두리와 내부 메뉴가 보이는 것을 확인할 수 있습니다.
/dashboard/profile/edit에서는 세 레이아웃과 편집 페이지가 중첩됩니다. 대시보드 홈이나 프로필 홈의 page.tsx가 함께 렌더링되는 구조는 아닙니다.
프로필 편집 화면의 네 계층
레이아웃에서 데이터 공유
서버 레이아웃은 자신이 표시하는 공통 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에 자동으로 전달되지 않습니다. 하위 페이지에서도 필요하면 데이터를 전달하는 별도 경계를 설계하거나 그 페이지에서 조회합니다.
현재 getUserInfo는 일반 비동기 함수이므로 다시 호출하면 지연 코드도 다시 실행됩니다. 공유 레이아웃의 클라이언트 이동 중 재사용과 데이터 함수의 캐시는 서로 다른 개념입니다. 같은 서버 렌더에서 동일한 fetch 요청의 중복 제거를 이용하거나 일반 조회 함수를 React cache로 감싸는 방식은 데이터 페칭 장에서 다룹니다.