안동민 개발노트

안동민 개발노트

App Router 구조페이지 생성레이아웃 컴포넌트페이지 간 링크 생성
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 3장 : App Router 기초
  5. 레이아웃 컴포넌트
  1. Next.js
  2. 레이아웃 컴포넌트

레이아웃 컴포넌트

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
// 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>&copy; 2024 나 혼자 Next.js. All rights reserved.</p>
        </footer>
      </body>
    </html>
  );
}
설명
  • children prop: 이 레이아웃 내부에 렌더링될 하위 콘텐츠(다른 레이아웃 또는 최종 page.tsx 파일)를 나타냅니다. 모든 레이아웃 컴포넌트는 이 children prop을 받아야 합니다.
  • <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 파일을 생성합니다.

layout.tsx
page.tsx
layout.tsx
page.tsx
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 태그 내부에 렌더링되도록 합니다.

실습 src/app/dashboard/layout.tsx 파일을 생성하고 위 코드를 붙여넣은 후, 개발 서버를 확인해 보세요.

  • 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 내용이 렌더링됩니다.
설정 페이지를 감싸는 두 레이아웃

dashboard/settings 페이지에서는 RootLayout의 children 안에 DashboardLayout이, 그 children 안에 설정 페이지가 들어갑니다.

설정 페이지를 감싸는 두 레이아웃dashboard/settings 페이지에서는 RootLayout의 children 안에 DashboardLayout이, 그 children 안에 설정 페이지가 들어갑니다.RootLayout공통 headerDashboardLayout사이드바 · children설정 페이지settings/page.tsx공통 footer설정 페이지를 감싸는 두 레이아웃dashboard/settings 페이지에서는 RootLayout의 children 안에 DashboardLayout이, 그 children 안에 설정 페이지가 들어갑니다.RootLayout공통 headerDashboardLayout사이드바 · children설정 페이지settings/page.tsx공통 footer

상자는 컴포넌트가 children을 감싸는 포함관계입니다. 실제 DOM의 모든 노드를 나타내지는 않으며, Sidebar는 DashboardLayout 안에서 페이지와 나란히 배치됩니다.


레이아웃의 데이터 페칭

레이아웃은 기본적으로 서버 컴포넌트이므로, 서버에서 데이터를 미리 가져와 UI를 구성하는 것이 가능합니다.

이는 해당 레이아웃이 적용되는 모든 페이지에서 공통적으로 필요한 데이터를 효율적으로 로드하는 데 유용합니다.

아래 데이터 페칭 예시는 http://localhost:4000 로컬 Mock API가 실행 중인 상황을 전제로 합니다.

src/app/dashboard/layout.tsx (데이터 페칭 예시)
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를 실행한다는 뜻은 아닙니다.

이동 방식에 따른 레이아웃의 유지 범위

이동 방식에 따른 레이아웃의 유지 범위의 비교 기준입니다.

이동 방식에 따른 레이아웃의 유지 범위
이동 조건유지되는 범위바뀌는 범위
대시보드 안 Link 이동RootLayout과 DashboardLayout의 공유 UI·클라이언트 상태현재 page 콘텐츠; 예: 홈에서 settings로 이동
소개에서 대시보드로 Link 이동같은 RootLayoutAbout 페이지 대신 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를 다시 만드는 것은 아닙니다.

페이지 생성

이전 페이지

페이지 간 링크 생성

다음 페이지

이 페이지의 목차

레이아웃이란 무엇인가요?최상위 레이아웃 (RootLayout) 이해하기중첩 레이아웃 (Nested Layouts) 사용하기레이아웃의 데이터 페칭