Archive.sakamoto
RoutingLayoutNext.js

App Router Layout 중첩 구조

RootLayout · Layout · Page가 각각 언제 렌더되고 무엇을 담당하는지 역할을 알아보자!

Sakamoto·

1. 핵심 개념

한 줄 정의

App Router는 요청이 들어오면 바깥 Layout부터 안쪽 Page까지 겹겹이 쌓아서 하나의 화면을 완성한다.

실생활 비유 — 러시아 마트료시카 인형

레이아웃 중첩 구조는 마트료시카 인형과 똑같다.

가장 큰 인형(RootLayout)이 앱 전체를 감싸고, 그 안에 중간 인형(Segment Layout)이 특정 구역을 감싸고, 가장 안쪽 인형(Page)이 실제 화면 내용을 담는다.

중요한 점: 바깥 인형은 안쪽 인형이 바뀌어도 그대로 유지된다. 페이지 이동 시 Layout은 유지되고 Page만 교체되는 이유가 바로 이 구조 때문이다.

파일역할비유
app/layout.tsx앱 전체 공통 틀가장 바깥 인형
app/dashboard/layout.tsx대시보드 구역 공통 틀중간 인형
app/dashboard/user/page.tsx/dashboard/user URL의 실제 화면가장 안쪽 인형

2. 왜 이렇게 설계하는가

문제 상황: 모든 페이지마다 Header, Sidebar, Provider를 따로 넣으면?

Layout 중첩 구조가 해결하는 것:

  1. 공통 UI 재사용 — Sidebar, Header 한 번만 선언, 전 페이지에 적용
  2. 불필요한 리렌더링 방지 — 페이지 이동 시 Layout은 유지, Page만 교체
  3. 구역별 책임 분리 — 전역 설정은 RootLayout, 구역 규칙은 Segment Layout, 화면 내용은 Page

💡 면접 tip
"왜 Layout을 분리하나요?", "페이지 이동 시 왜 리렌더링이 없나요?" 라는 질문이 나오면 위 3가지 이유가 바로 답이 된다.


3. 사용 원리 (코드 실행 흐름)

RootLayout

// app/layout.tsx

// [역할] 앱 전체의 환경을 세팅하는 최상위 틀
// [주의] 반드시 <html>과 <body> 태그를 포함해야 함 (Next.js 규칙)
export default function RootLayout({
  children, // [입력] 하위 Layout 또는 Page가 여기 들어옴
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        {/* [처리] 전역 Provider, 전역 스타일, 공통 리소스 세팅 */}
        <QueryClientProvider client={queryClient}>
          <ThemeProvider>
            {children} {/* [출력] 모든 페이지의 기본 프레임으로 감싸서 반환 */}
          </ThemeProvider>
        </QueryClientProvider>
      </body>
    </html>
  );
}

Segment Layout (구역 레이아웃)

// app/dashboard/layout.tsx

// [역할] /dashboard 이하 모든 URL에 공통으로 적용되는 틀
export default function DashboardLayout({
  children, // [입력] /dashboard/user, /dashboard/settings 등 하위 Page가 들어옴
}: {
  children: React.ReactNode;
}) {
  return (
    <section className="dashboard-layout">
      {/* [처리] 대시보드 전용 사이드바, 권한 체크 등 구역 규칙 적용 */}
      <Sidebar />
      <main>
        {children} {/* [출력] 대시보드 틀 안에 각 Page 콘텐츠 삽입 */}
      </main>
    </section>
  );
}

Page (화면 단위)

// app/dashboard/user/page.tsx

// [역할] /dashboard/user URL에서만 보이는 실제 화면 콘텐츠
export default function UserPage() {
  // [입력] 이 URL에서만 필요한 데이터 fetch, params 처리
  // [처리] 화면 전용 UI 구성
  // [출력] 유저 목록 화면 콘텐츠 반환
  return <h1>User List</h1>;
}

4. 데이터 흐름 (입력 → 처리 → 출력)

/dashboard/user 요청 시 실행 순서

1. 브라우저 → /dashboard/user 요청
         ↓
2. app/layout.tsx (RootLayout) 실행
   → <html>, <body>, 전역 Provider 세팅
         ↓
3. app/dashboard/layout.tsx (DashboardLayout) 실행
   → Sidebar, 권한 체크 등 대시보드 구역 규칙 적용
         ↓
4. app/dashboard/user/page.tsx (UserPage) 실행
   → 유저 목록 데이터 fetch + UI 렌더링
         ↓
5. 모든 Layout + Page가 합쳐진 완성된 HTML 반환

페이지 이동 시 (/dashboard/user/dashboard/settings):


5. 주요 옵션 (실무 기준)

위치주 역할실무에서 주로 넣는 것
RootLayout앱 전체 환경QueryClientProvider, ThemeProvider, 전역 CSS, 폰트, Analytics
Segment Layout구역 규칙 + 공통 UISidebar, 로그인 필수 체크, 구역 전용 Context, 레이아웃 그리드
PageURL 전용 화면화면 전용 data fetch, params/searchParams 처리, UI 컴포넌트 조립

6. 실무 사용 패턴

✅ 권장 패턴 — 구역별 인증 처리

// app/dashboard/layout.tsx
// [실무 패턴] 대시보드 전체에 로그인 체크를 한 번만 적용

import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession(); // [입력] 세션 정보 조회

  if (!session) {
    redirect("/login"); // [처리] 비로그인이면 로그인 페이지로 이동
  }

  return (
    <div>
      <Sidebar user={session.user} /> {/* [출력] 로그인된 유저 정보를 Sidebar에 전달 */}
      <main>{children}</main>
    </div>
  );
}

dashboard 하위 페이지 어디에서도 인증 코드를 따로 작성할 필요 없음

🔧 실무 point
실무에서는 인증이 필요한 구역에 Segment Layout을 두고 여기서 세션을 체크한다.
page.tsx마다 인증 코드를 반복하는 건 누락 위험이 있고 유지보수도 어렵다.

✅ 권장 패턴 — 전역 Provider는 RootLayout에

// app/layout.tsx
// [실무 패턴] QueryClient는 앱 전체에서 하나만 존재해야 하므로 RootLayout에 위치

import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
      </body>
    </html>
  );
}

🔧 실무 point
QueryClientProvider, ThemeProvider처럼 앱 전체에서 하나만 존재해야 하는 Provider는 반드시 RootLayout에 배치한다. Segment Layout에 넣으면 구역 진입마다 인스턴스가 새로 생성된다.


7. 자주 하는 실수 ⚠️

❌ RootLayout에서 페이지별 데이터 fetch

// app/layout.tsx — 잘못된 예
export default async function RootLayout({ children }) {
  const userData = await fetchUser(); // 모든 요청마다 실행됨
  // ...
}

✅ 올바른 방법: 특정 페이지에서만 필요한 데이터는 해당 page.tsx에서 fetch

🔎 이유: RootLayout은 앱의 모든 요청에서 실행됨. 여기서 불필요한 fetch를 하면 관계없는 페이지 접근 시에도 비용이 발생한다.


❌ 구역 전용 UI를 RootLayout에 배치

// app/layout.tsx — 잘못된 예
export default function RootLayout({ children }) {
  return (
    <html><body>
      <DashboardSidebar /> {/* 대시보드 전용인데 모든 페이지에 노출됨 */}
      {children}
    </body></html>
  );
}

✅ 올바른 방법: app/dashboard/layout.tsx에 배치

🔎 이유: RootLayout은 로그인 페이지, 404 페이지 등 모든 곳에 적용됨. 구역 전용 UI가 의도치 않은 곳에 노출된다.


❌ 인증 로직을 page마다 중복 작성

// app/dashboard/user/page.tsx — 잘못된 예
export default async function UserPage() {
  const session = await getSession();
  if (!session) redirect("/login"); // 모든 dashboard 페이지마다 이 코드가 반복됨
  // ...
}

✅ 올바른 방법: app/dashboard/layout.tsx에서 한 번만 처리

🔎 이유: 인증 로직이 각 page에 흩어지면 누락 가능성이 생기고, 로직 변경 시 모든 파일을 수정해야 한다.


8. 한 줄 요약

RootLayout은 앱의 "환경",
Segment Layout은 구역의 "틀과 규칙",
Page는 URL 하나의 "본문 화면" 이다.