레이아웃 컴포넌트 생성 및 중첩
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를 구성할 수 있게 해줍니다.
레이아웃 컴포넌트 생성 실습
이 실습은 children을 렌더링하는 일반 대시보드 레이아웃을 기준으로 합니다. 앞 장의 병렬 라우트 @analytics, @team까지 같은 프로젝트에 남겨 두었다면, 뒤의 DashboardLayout 전체 예시로 덮어쓰기 전에 기존 슬롯 props와 슬롯을 표시하는 JSX도 유지해야 합니다. children만 받는 코드로 바꾸면 슬롯을 화면에 배치하던 부분이 사라집니다. 병렬 라우트 실습을 별도 프로젝트로 나누었다면 일반 레이아웃 예시를 그대로 적용할 수 있습니다.
이전에 만들었던 대시보드 예제를 바탕으로, 특정 하위 페이지에만 적용되는 중첩 레이아웃을 추가해 보겠습니다. 라우트 그룹 실습에서 폴더를 옮겼다면 아래 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가 함께 렌더링되는 구조는 아닙니다.
NEXT · CH5-2
편집 화면 하나를 세 layout이 감쌉니다
/dashboard/profile/edit에서는 RootLayout → DashboardLayout → ProfileLayout의 children 안에 편집 페이지 하나가 들어갑니다.
대시보드 홈과 프로필 홈 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가 추가되지는 않습니다. 또한 편집·보안 예제의 문장은 표시용 안내이며 실제 저장 요청이나 비밀번호 변경을 구현한 코드는 아닙니다.
스스로 확인하기
/dashboard/profile/edit에서 프로필 메뉴의 Link로 /dashboard/profile/security에 이동한다고 가정합니다. 어느 레이아웃들이 두 화면을 공통으로 감싸고, 어떤 최종 페이지가 바뀌나요? 보안 페이지에서 사이드바의 userInfo가 필요하다면 포함 관계만으로 읽을 수 있나요? 같은 이름이 두 번 보인다는 사실만으로 데이터 함수가 캐시되었다고 결론 내려도 되나요?
풀이와 설계 기준
두 경로 모두 RootLayout, DashboardLayout, ProfileLayout 아래에 있고 마지막 edit/page.tsx가 security/page.tsx로 바뀝니다. 대시보드 홈이나 프로필 홈의 page.tsx가 부모 페이지로 함께 쌓이는 구조는 아닙니다. 같은 레이아웃을 공유하는 클라이언트 탐색에서는 그 레이아웃의 유지 범위를 고려할 수 있지만, 새로고침은 같은 조건이 아닙니다.
보안 페이지는 레이아웃 지역 변수인 userInfo를 자동으로 받지 않습니다. 그 페이지에서 필요한 데이터를 조회하거나, 명시적인 전달 경계를 설계해야 합니다. 이 함수는 항상 같은 이름을 반환하므로 화면에 이름이 같다는 관찰만으로 호출 횟수나 캐시를 증명할 수 없습니다. 함수의 호출, 레이아웃 재사용, 데이터 캐시를 각각 조사합니다.
현재 getUserInfo는 일반 비동기 함수이므로 다시 호출하면 지연 코드도 다시 실행됩니다. 공유 레이아웃의 클라이언트 이동 중 재사용과 데이터 함수의 캐시는 서로 다른 개념입니다. 같은 서버 렌더에서 동일한 fetch 요청의 중복 제거를 이용하거나 일반 조회 함수를 React cache로 감싸는 방식은 데이터 페칭 장에서 다룹니다.