외주 제작 경험

Vite만 쓰다 Next App Router 쓰면 반드시 헷갈리는 것들

Frisbeen 2026. 1. 21. 22:11

0. 이 글에서 다루는 것

Next App Router로 프로젝트를 옮기다 보면 가장 먼저 막히는 지점이 있습니다.

app/layout.tsx에는 {children}밖에 없는데,

Next는 어떻게 “지금 어떤 페이지를 렌더링해야 하는지” 알고 있을까?


Vite + React Router에 익숙하다면 이 구조가 꽤 낯설게 느껴집니다.

아래 네 가지 주제를 토대로 정리합니다.

  • 라우팅이 무엇인가?
  • Vite 방식과 Next App Router 방식은 무엇이 다른가?
  • 실제 프로젝트에서 도메인(admin.)에 따라 레이아웃을 분기하는 법
  • 가드 로직은 어디에 두는 게 좋은지에 대한 개인적인 생각

1. 라우팅이란 무엇인가

라우팅은 결국 이 질문에 대한 답입니다.

“현재 URL 요청에 대해 어떤 화면(컴포넌트) 을 보여줄 것인가?”

브라우저가 /, /apply, /admin 같은 다양한 경로들로 들어왔을 때

각 경로마다 어떤 컴포넌트를 렌더링할지 선택하는 로직이 바로 라우터입니다.

프론트엔드 스택마다 이 질문에 답하는 방식이 다르고,

그 차이가 그대로 구조 차이로 이어집니다.


2. Vite + React Router 방식 (우리가 익숙한 구조)

먼저 Vite에서 자주 쓰는 라우팅 코드를 떠올려 보겠습니다.

// src/App.tsx (예시)

import { BrowserRouter, Routes, Route } from "react-router-dom";
import HomePage from "./pages/Home";
import ApplyPage from "./pages/Apply";
import AdminPage from "./pages/admin/AdminPage";
import AdminLayout from "./pages/admin/AdminLayout";

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* 메인 사이트 라우트 */}
        <Route path="/" element={<HomePage />} />
        <Route path="/apply" element={<ApplyPage />} />

        {/* 어드민 전용 라우트 */}
        <Route
          path="/admin/*"
          element={
            <AdminLayout>
              <AdminPage />
            </AdminPage>
          }
        />
      </Routes>
    </BrowserRouter>
  );
}

이 구조의 특징은 아주 분명합니다.

  • <Routes> 안에 있는 <Route>들이 라우팅 테이블 그 자체입니다.
  • /admin/*으로 요청이 들어오면 AdminLayout 안에 AdminPage가 렌더링된다는 것이 코드에 그대로 드러납니다.
  • “어디서 분기되는지”가 눈에 보이기 때문에 직관적입니다.

정리하면, 좀 더 라우팅에 개발자가 관여를 많이 한다고 볼 수 있겠습니다.

Vite + React Router에서는

개발자가 직접 path → 컴포넌트 매핑을 코드로 작성합니다.


3. Next.js App Router는 “파일 시스템 = 라우팅 테이블”

Next App Router에서는 <Route>를 쓰지 않습니다.

대신 app/ 폴더 구조 자체가 라우팅 규칙입니다.

예를 들어 아래와 같은 경우

app/
├ page.tsx              // '/'
├ apply/
│   └ page.tsx          // '/apply'
├ admin/
│   └ page.tsx          // '/admin'
├ not-found.tsx         // 404 페이지
└ layout.tsx            // 모든 페이지의 공통 레이아웃

Next는 이 폴더 구조를 스캔해서 빌드 시점에 내부적으로 이런 매핑을 만듭니다.

  • / → app/page.tsx
  • /apply → app/apply/page.tsx
  • /admin → app/admin/page.tsx

이 매핑을 우리가 코드로 쓰지 않는 것뿐입니다.

라우팅 로직이 없는 게 아니라, Next가 안에서 대신 들고 있는 것입니다.

Vite와 다른 큰 차이점 중 하나입니다.

 

그래서 if (pathname === '/admin') 같은 코드를 프로젝트에서 아무리 찾아도 보이지 않는 것입니다.


4. children의 정체: Next가 이미 골라서 넘겨주는 것

가장 헷갈리기 쉬운 부분이 바로 children입니다.

app/layout.tsx를 열어보면 우리 코드는 이렇게 생겼습니다.

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const headersList = await headers();
  const host = headersList.get("host") || "";
  const isAdminHost = host.startsWith("admin.");

  return (
    <html lang="ko" className={`${pretendard.variable}`}>
      <body className="font-pretendard antialiased">
        <Providers>
          <LayoutClient isAdminHost={isAdminHost}>{children}</LayoutClient>
        </Providers>
      </body>
    </html>
  );
}

자연스럽게 이런 생각이 듭니다.

“children에 뭐가 들어오는지 내가 정한 적이 없는데…?”

위처럼 생각하는 것이 어찌보면 당연합니다.

따라서 아래의 코드처럼 app Router의 라우팅 방식을 이해하면 쉽습니다.

Next App Router의 동작을 개념적으로 풀어 보면 다음과 같습니다.

// 실제 코드는 아니고, 개념을 설명하기 위한 의사 코드입니다.

function AppRouter(request) {
  const pathname = request.url.pathname; // '/', '/apply', '/admin' ...

  let PageComponent;

  if (pathname === "/") {
    PageComponent = require("app/page").default;          // Home
  } else if (pathname === "/admin") {
    PageComponent = require("app/admin/page").default;    // AdminPage
  } else if (pathname === "/apply") {
    PageComponent = require("app/apply/page").default;    // ApplyPage
  }

  return (
    <RootLayout>
      {/* 이 부분 전체가 RootLayout 의 children 으로 넘어갑니다 */}
      <PageComponent />
    </RootLayout>
  );
}

실제 빌드 결과물은 훨씬 복잡하지만, 위와 같은 방식으로 Next가 내부에서 “어떤 페이지를 children으로 쓸지 먼저 결정한 뒤, 그 결과를 layout.tsx에 넘겨준다고 이해하시면 됩니다.

조금 더 직관적으로 정리하면 다음과 같습니다.

  • Vite(React Router)에서는
  • “이 엔드포인트면 이 컴포넌트를 보여주세요”
  • 라는 규칙을 우리가 코드로 직접 썼고, 라우터가 그 코드를 그대로 실행했습니다.
  • Next App Router에서는
  • “어떤 엔드포인트로 들어왔는지”를 보고 Next가 먼저 page.tsx를 결정하고,
  • 그 결과를 최상위 layout.tsx의 children으로 넣어줍니다.

아래 처럼 말할수도 있겠습니다.

어떤 엔드포인트로 요청이 오면, Next가 그 경로에 맞는 폴더(/admin이면 app/admin)를 자동으로 찾고,그 안의 page.tsx를 layout.tsx의 children에 넣는다.

좀 더 비교를 해보자면,

  • *Vite(React Router)**에서는“이 경로에는 이 컴포넌트”라는 규칙을 코드로 써 주고, 라우터는 그 규칙을 그대로 실행합니다.
  • 라우팅과 렌더링을 우리가 직접 제어합니다.
  • Next App Router에서는어떤 page.tsx가 선택될지는 Next가 app/ 폴더 구조와 현재 URL을 보고 결정하고,
  • 우리는 그 결과(children)를 받아 레이아웃과 도메인(예: admin. 여부)에 따라 어떻게 감쌀지에만 집중합니다.
  • 라우팅은 프레임워크가 먼저 처리합니다.

그렇다면 아래의 두 질문에 대해 답을 할 수 있을 겁니다.

  • “왜 layout.tsx에는 {children}밖에 없는데 페이지가 잘 뜨는지”
  • “어떻게 admin. 도메인에서는 Footer 없는 레이아웃을, 일반 도메인에서는 Footer 있는 레이아웃을 쓰는지”

6. RootLayout은 매번 갈아끼우는 껍데기가 아니다

App Router를 처음 쓸 때 많이 생기는 오해가 있습니다.

즉 Vite를 하다가 오면 Layout.tsx에 대해 감을 잘 못잡습니다.

vite에도 layout.tsx가 있으며, 둘의 용법은 완전히 다르기 때문이죠.

또한 위 설명기준으로는

페이지 이동할 때마다 RootLayout의 children이

main/page.tsx → foot/page.tsx → admin/page.tsx 식으로

통째로 갈아끼워지는 것처럼 느껴지는 것 처럼 느낄수도 있습니다.


물론 이렇게 이해해도 큰 무리는 없으나..

결론부터 말하면

RootLayout은 “매번 새로 마운트되는 껍데기”가 아닙니다

App Router에서 app/layout.tsx(RootLayout)은 보통 앱 전역에서 지속됩니다.

  • / → /apply → /admin처럼 이동해도
  • RootLayout 자체는 유지되는 쪽이 기본 동작입니다.
  • 바뀌는 건 그 안에 들어가는 page(= children) + 중간 레이아웃들입니다.

그래서 “통째로 갈아끼워진다”라는 말이 DOM 구조 관점에서는 맞는 느낌이지만,

컴포넌트 라이프사이클 관점에서는 다를 수 있습니다.

  • RootLayout은 앱 전체에서 단 한 번 존재하고,
  • URL이 바뀔 때마다 그 아래쪽 “레이아웃 트리”만 바뀝니다.

구조는 이런 모양을 유지합니다.

RootLayout
└── (선택된 하위 레이아웃들…)
    └── 최종 page.tsx

즉, RootLayout이 매번 새로 만들어진다기보다는

“RootLayout 아래 어떤 레이아웃/페이지 조합을 쓸지 Next가 다시 계산한다”고 보는 편이 가깝습니다.


7. 레이아웃 트리(Layout Tree)로 이해하면 6번이 더 쉽습니다.

App Router를 이해하는 가장 좋은 키워드는 레이아웃 트리(Layout Tree) 입니다.

Next는 URL마다 “레이아웃 트리”를 만든다.

예를 들어, 이런 구조를 생각해 보겠습니다.

app/
├ layout.tsx            // RootLayout
├ page.tsx              // '/'
├ main/
│   ├ layout.tsx        // MainLayout
│   └ page.tsx          // '/main'
├ foot/
│   ├ layout.tsx        // FootLayout
│   └ page.tsx          // '/foot'
└ admin/
    ├ layout.tsx        // AdminLayout
    └ page.tsx          // '/admin'

이때 URL별 레이아웃 트리는 다음과 같이 달라집니다.

  • /main
RootLayout
└── MainLayout
    └── main/page.tsx

  • /foot
RootLayout
└── FootLayout
    └── foot/page.tsx

  • /admin
RootLayout
└── AdminLayout
    └── admin/page.tsx

정리하면,

  • RootLayout은 항상 맨 위에 있고,
  • 그 아래에 어떤 layout.tsx 들이 중첩되는지만 URL에 따라 달라지는 구조입니다.

8. children은 언제 “대치”되는 걸까?

여기서 자연스럽게 이런 의문이 생깁니다.

“그럼 RootLayout의 children이

main/page.tsx → foot/page.tsx 식으로 계속 대치되는 거야?”


이 질문에 대한 답을 조금 구분해서 보면 이해하기 쉽습니다.

  • 개념적으로는그 트리의 최상단 결과가 RootLayout의 children으로 들어옵니다.
  • URL이 바뀌면 Next가 새 레이아웃 트리를 계산하고,
  • 구조적으로는React 입장에서는 “하위 트리 diff”가 일어나는 셈입니다.
  • RootLayout은 유지되고, 그 아래 하위 트리(subtree)만 교체됩니다.

그래서 전역 폰트 설정, 글로벌 Provider, Zustand 스토어 같은 것들이

페이지 이동 사이에서도 그대로 유지됩니다.


9. Vite에서 있던 중첩 라우팅은 없는 걸까?

결론부터 말하면, App Router는 중첩 라우팅을 아주 적극적으로 지원합니다.

다만 코드로 중첩을 선언”하는 대신 “폴더 구조로 중첩을 선언할 뿐입니다.

React Router에서 중첩 라우팅은 보통 이렇게 쓸 것입니다.

// React Router 예시
<Route path="/admin" element={<AdminLayout />}>
  <Route path="users" element={<UsersPage />} />
</Route>

Next App Router에서는 같은 개념을 이렇게 표현합니다.

app/
└ admin/
    ├ layout.tsx      // AdminLayout
    ├ page.tsx        // '/admin'
    └ users/
        └ page.tsx    // '/admin/users'

/admin/users 로 접근했을 때 레이아웃 트리는 다음과 같습니다.

RootLayout
└── AdminLayout
    └── users/page.tsx

즉,

  • React Router에서 <Outlet />이 하던 역할을
  • App Router에서는 각 layout.tsx 안의 {children}이 대신하고 있습니다.

중첩 라우팅이 “없는 것”이 아니라,

중첩 구조를 “코드”가 아니라 “폴더”로 표현하고 있을 뿐입니다.


10. Vite vs Next – 라우팅 트리를 누가 소유하느냐의 차이

여기까지 이해하고 나면, 마지막으로 한 가지를 더 정리할 수 있습니다.

 

이 부분은 “SPA냐 MPA이냐” 같은 거친 구분보다, 라우팅 모델 관점에서 보는 것이 훨씬 깔끔합니다.

10-1. Vite(React Router): 라우팅 트리가 앱 안에 고정돼 있다

Vite + React Router에서 핵심은 이 패턴입니다.

<BrowserRouter>
  <Routes>
    <Route path="/" element={<MainLayout><Home /></MainLayout>} />
    <Route path="/foot" element={<FootLayout><Foot /></FootLayout>} />
    <Route path="/admin" element={<AdminLayout><Admin /></AdminLayout>} />
  </Routes>
</BrowserRouter>

이 코드가 의미하는 바는 명확합니다.

  • 라우팅 테이블 전체가 이미 JS 번들 안에 들어 있습니다.
  • 앱이 시작되는 시점에“이 앱은 이런 라우팅 구조를 가진다”가 확정됩니다.

그 이후 사용자가 인터랙션을 할 때는:

  1. URL이 바뀌고
  2. React Router가 location만 변경하고
  3. 이미 존재하는 트리 안에서 어떤 컴포넌트를 활성화할지만 결정합니다.

그래서 Vite 쪽은 이렇게 정리하는 것이 정확합니다.

Vite(React Router)는

라우팅 트리가 클라이언트에 고정되어 있고,

URL 변경은 “이미 만들어진 트리에서 어떤 노드를 보여줄지만 바꾸는 것”이다? 

“통으로 다 받아와서 보여준다 / 말까”라는 표현은 절반만 맞습니다.

라우팅 구조는 이미 다 받아와 있지만, 렌더 자체는 React의 diff로 최소화되기 때문입니다.

10-2. Next App Router: 요청마다 레이아웃 트리를 계산한다

Next는 접근 방식이 다릅니다.

가장 중요한 차이를 한 줄로 정리하면 이렇습니다.

Next는 “앱 전체 라우팅 트리”를 들고 있지 않고,

요청 URL 기준으로 “필요한 레이아웃 트리만 계산해서” React에 넘긴다.


예를 들어 /admin으로 들어왔을 때, 개념적으로는 이렇게 동작합니다.

  1. 현재 요청 URL이 /admin 인지 확인합니다.
  2. app/ 폴더를 기준으로, 다음과 같은 레이아웃 트리를 조립합니다.
  3. RootLayout └── admin/layout.tsx └── admin/page.tsx
  4. 이 트리의 결과를 RootLayout의 children으로 넘겨서 렌더링합니다.

/foot 으로 이동하면 다시:

RootLayout
└── foot/layout.tsx
    └── foot/page.tsx

이전 트리를 “조금 수정”하는 개념이 아니라,

해당 URL에 맞는 레이아웃 트리를 다시 계산해서 넘기는 것에 가깝습니다.

그래서 앞에서 정리했던 표현이 여기서 딱 들어맞습니다.

RootLayout이 다시 렌더링된다기보다는


“어떤 하위 레이아웃 트리를 쓸지 Next가 다시 계산한다”**고 보는 것이 더 정확하다.

10-3. 이걸 SPA vs SSR로만 설명하면 애매해지는 이유

여기서 흔히 나오는 설명이 있습니다.

“Vite는 SPA고, Next는 MPA이라서 라우팅이 다르다?"

하지만 이 설명은 반만 맞습니다.

  • Next도 클라이언트 내비게이션에서는 SPA처럼 동작하고,
  • Vite도 SSR을 붙이면 서버 렌더링이 가능합니다.

따라서,

  • “Vite는 SPA라서, Next는 SSR이라서…”라고 설명하면 라우팅 모델의 진짜 차이를 놓치게 됩니다.

대신 이렇게 정리하는 편이 더 정확합니다.

Vite(React Router)는 라우팅 트리가 클라이언트 애플리케이션 안에 고정돼 있고,

URL 변경은 그 트리 안에서 “어떤 노드를 보여줄지”만 바꾸는 것.

Next App Router는 URL이 들어올 때마다 프레임워크가 해당 경로에 맞는 레이아웃 트리를 새로 조립하고,

그 결과를 RootLayout의 children으로 넘겨주는 것.


결국 차이는 “누가 라우팅 트리를 소유하느냐”에 있습니다.

  • Vite: 클라이언트 앱이 라우팅 트리를 소유합니다.
  • Next App Router: 프레임워크가 레이아웃/라우팅 트리를 계산해서 제공합니다.

Vite만 하시다가 Next.js로 넘어온 프론트 개발자분들에게 이 문서가 도움이 됐기를 바랍니다.