Next.js App Router란 무엇인가? 코드와 예제로 이해하기

Frontend

Next.js App Router를 처음 보면 파일이 꽤 많아 보입니다.

  • layout.tsx
  • page.tsx
  • loading.tsx
  • error.tsx
  • route.ts
  • generateMetadata

그래서 처음에는 "이게 라우터라는 건 알겠는데, 결국 뭐가 어떻게 연결되는 거지?"라는 느낌이 들기 쉽습니다.

실제로 App Router는 단순 라우팅 기능이 아닙니다. 페이지 구조, 레이아웃, 서버 렌더링, 데이터 패칭, 캐시, 에러 처리를 한 묶음으로 가져가는 방식에 가깝습니다.

이 글에서는 App Router가 무엇인지 개념만 설명하지 않고, 코드와 예제 중심으로 정리해보겠습니다. 기준은 다음 두 가지입니다.

  1. 각 파일이 무슨 역할을 하는지 바로 이해할 수 있을 것
  2. 실무에서 어디까지 서버에 두고, 어디부터 클라이언트로 내려야 하는지 감을 잡을 것

한눈에 보면

먼저 아주 짧게 정리하면 이렇습니다.

  • app/ 아래 폴더 구조가 URL 구조가 됩니다
  • page.tsx는 해당 경로의 실제 화면입니다
  • layout.tsx는 공통 레이아웃입니다
  • loading.tsx, error.tsx는 상태별 UI를 맡습니다
  • route.ts는 API 엔드포인트를 만듭니다
  • 기본은 Server Component이고, 상호작용이 필요할 때만 Client Component로 내립니다

가장 단순한 구조는 아래처럼 볼 수 있습니다.

app/
  layout.tsx
  page.tsx
  blog/
    page.tsx
    [slug]/
      page.tsx
  todos/
    page.tsx
    loading.tsx
  api/
    todos/
      route.ts

이 구조는 대략 아래 URL에 대응됩니다.

/            -> app/page.tsx
/blog        -> app/blog/page.tsx
/blog/hello  -> app/blog/[slug]/page.tsx
/todos       -> app/todos/page.tsx
/api/todos   -> app/api/todos/route.ts

App Router의 핵심은 "파일 구조로 화면과 상태를 설명한다"는 점이다

기존에 React SPA를 먼저 해봤다면 보통 라우팅은 이런 식이 익숙합니다.

import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/home-page';
import { BlogPage } from './pages/blog-page';
import { TodoPage } from './pages/todo-page';
 
export function AppRouter() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/blog" element={<BlogPage />} />
        <Route path="/todos" element={<TodoPage />} />
      </Routes>
    </BrowserRouter>
  );
}

App Router는 이 선언을 코드 한곳에 모아두지 않고, 폴더와 파일 자체로 나눠서 표현합니다.

즉:

  • 라우트 정의
  • 레이아웃
  • 로딩 UI
  • 에러 UI

를 경로 단위로 나누기 쉬워집니다.

가장 먼저 이해해야 할 파일들

1. page.tsx

page.tsx는 해당 URL의 실제 페이지입니다.

// app/page.tsx
export default function HomePage() {
  return <h1>Home</h1>;
}
// app/todos/page.tsx
export default function TodosPage() {
  return <h1>Todo List</h1>;
}

이 둘은 각각 /, /todos에 대응됩니다.

실무에서 중요한 점은 page.tsx가 기본적으로 Server Component라는 점입니다. 그래서 서버에서 데이터를 먼저 가져오기에 좋습니다.

// app/blog/page.tsx
export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  return (
    <ul>
      {posts.map((post: { id: string; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

즉, App Router에서는 "페이지에 들어오기 전에 서버에서 데이터를 준비한다"는 감각이 자연스럽습니다.

2. layout.tsx

layout.tsx는 해당 경로 아래에서 공통으로 쓰는 레이아웃입니다.

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <header>Marco Log</header>
        <main>{children}</main>
      </body>
    </html>
  );
}

이제 모든 페이지는 이 레이아웃 안에서 렌더링됩니다.

특정 영역만 별도 레이아웃을 두고 싶으면 하위 폴더에 추가하면 됩니다.

// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard-shell">
      <aside>Sidebar</aside>
      <section>{children}</section>
    </div>
  );
}

그러면 /dashboard/* 아래 페이지는 이 레이아웃을 공유합니다.

즉, App Router의 레이아웃은 "공통 UI를 어디까지 묶을 것인가"를 URL 계층과 맞춰서 설계하기 좋습니다.

3. loading.tsx

loading.tsx는 해당 세그먼트가 로딩 중일 때 보여줄 UI입니다.

// app/todos/loading.tsx
export default function Loading() {
  return <p>할 일 목록을 불러오는 중입니다...</p>;
}

이 파일이 좋은 이유는 로딩 UI를 컴포넌트 내부 조건문으로 여기저기 흩뿌리지 않아도 된다는 점입니다.

즉, "이 경로가 로딩 중일 때 보여줄 기본 UI"를 파일 하나로 설명할 수 있습니다.

4. error.tsx

error.tsx는 해당 세그먼트에서 에러가 발생했을 때 보여줄 UI입니다.

이 파일은 클라이언트 컴포넌트여야 합니다.

'use client';
 
// app/todos/error.tsx
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>문제가 발생했습니다: {error.message}</p>
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  );
}

즉, App Router는 에러 처리도 라우트 단위로 분리해서 관리하기 좋습니다.

동적 라우팅은 어떻게 할까?

블로그 상세나 상품 상세처럼 URL 파라미터가 필요한 경우가 많습니다.

예를 들어 /blog/react-19 같은 경로는 아래처럼 만듭니다.

app/
  blog/
    [slug]/
      page.tsx
// app/blog/[slug]/page.tsx
export default async function BlogDetailPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

이렇게 하면 params.slug로 URL 값을 받을 수 있습니다.

실무에서는 이 패턴이 자주 나옵니다.

  • /products/[id]
  • /users/[userId]
  • /categories/[category]/[subcategory]

즉, 폴더 구조가 곧 라우팅 규칙이 됩니다.

메타데이터는 어디서 관리할까?

페이지마다 title, description이 다르다면 generateMetadata를 같이 둘 수 있습니다.

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
 
  return {
    title: post.title,
    description: post.description,
  };
}
 
export default async function BlogDetailPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
 
  return <article>{post.title}</article>;
}

이 방식이 좋은 이유는 "화면"과 "메타데이터"가 같은 문맥 안에 있기 때문입니다.

즉, SEO가 중요한 페이지에서는 꽤 자연스럽습니다.

서버 컴포넌트와 클라이언트 컴포넌트는 어떻게 나눌까?

이 부분이 App Router에서 가장 많이 헷갈리는 지점입니다.

기본 원칙은 단순합니다.

  • 기본은 Server Component
  • 브라우저 API, 이벤트, 상태가 필요하면 Client Component

예를 들어 Todo 페이지를 보겠습니다.

// app/todos/page.tsx
import { TodoForm } from './todo-form';
 
export default async function TodosPage() {
  const todos = await getTodos();
 
  return (
    <section>
      <h1>Todos</h1>
      <TodoForm />
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </section>
  );
}

이 페이지는 서버에서 목록을 가져와서 그립니다.

반면 입력 폼은 상태와 이벤트가 필요하므로 클라이언트로 둡니다.

'use client';
 
import { useState } from 'react';
 
export function TodoForm() {
  const [title, setTitle] = useState('');
 
  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
 
    await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title }),
    });
 
    setTitle('');
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(event) => setTitle(event.target.value)} />
      <button type="submit">추가</button>
    </form>
  );
}

이렇게 보면 감이 옵니다.

  • 목록 조회: 서버에 두기 좋음
  • 사용자 입력: 클라이언트에 두기 좋음

즉, App Router는 "기본은 서버, 상호작용만 클라이언트"라는 분리가 핵심입니다.

route.ts는 언제 쓸까?

App Router에서는 API 엔드포인트도 app 아래에서 만들 수 있습니다.

// app/api/todos/route.ts
import { NextResponse } from 'next/server';
 
const todos = [
  { id: '1', title: 'App Router 글 쓰기', completed: false },
  { id: '2', title: '예제 코드 정리하기', completed: true },
];
 
export async function GET() {
  return NextResponse.json(todos);
}
 
export async function POST(request: Request) {
  const body = await request.json();
 
  const newTodo = {
    id: crypto.randomUUID(),
    title: body.title,
    completed: false,
  };
 
  todos.unshift(newTodo);
 
  return NextResponse.json(newTodo, { status: 201 });
}

그러면 /api/todos 경로가 만들어집니다.

이 패턴은 아래 상황에서 자주 씁니다.

  • 간단한 BFF 역할이 필요할 때
  • 외부 API 응답을 가공해서 프론트에 맞게 내리고 싶을 때
  • 인증 쿠키를 이용한 서버 측 요청이 필요할 때

즉, route.ts는 "페이지가 아닌 서버 엔드포인트"라고 보면 됩니다.

캐시와 재검증은 왜 같이 이해해야 할까?

App Router는 데이터 패칭을 할 때 캐시 정책도 같이 생각하게 만듭니다.

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 600 },
  }).then((res) => res.json());
 
  return <PostList posts={posts} />;
}

이 코드는 단순 fetch가 아니라 아래 의미도 가집니다.

  • 10분 동안은 캐시를 재사용할 수 있고
  • 그 이후에는 재검증할 수 있습니다

즉, App Router에서는 데이터 조회 코드가 곧 운영 정책이 되기도 합니다.

실무에서 이 지점이 중요한 이유는 다음과 같습니다.

  • 모든 데이터를 매 요청마다 새로 가져오면 비용이 커지고
  • 반대로 전부 캐시하면 최신성이 떨어질 수 있기 때문입니다

그래서 App Router를 쓸 때는 "어디서 데이터를 가져오는가"와 함께 "얼마나 신선해야 하는가"도 같이 봐야 합니다.

예제로 한 번에 정리해보자

아래는 블로그 + Todo가 섞인 간단한 구조입니다.

app/
  layout.tsx
  page.tsx
  blog/
    page.tsx
    [slug]/
      page.tsx
  todos/
    page.tsx
    loading.tsx
    error.tsx
    todo-form.tsx
  api/
    todos/
      route.ts

역할을 다시 정리하면 이렇습니다.

  • app/layout.tsx: 전체 공통 레이아웃
  • app/page.tsx: 메인 페이지
  • app/blog/page.tsx: 블로그 목록
  • app/blog/[slug]/page.tsx: 블로그 상세
  • app/todos/page.tsx: Todo 목록 페이지
  • app/todos/loading.tsx: Todo 페이지 로딩 UI
  • app/todos/error.tsx: Todo 페이지 에러 UI
  • app/todos/todo-form.tsx: 사용자 입력용 클라이언트 컴포넌트
  • app/api/todos/route.ts: Todo API 엔드포인트

즉, App Router는 라우팅만 정리하는 도구가 아니라, 화면 구조와 서버 책임을 경로 단위로 정리하게 해주는 방식입니다.

실무에서 자주 하는 실수

1. 모든 것을 클라이언트 컴포넌트로 만드는 것

처음에는 편해서 거의 모든 파일에 'use client'를 붙이기 쉽습니다.

하지만 이렇게 되면 App Router의 장점이 줄어듭니다.

  • 서버에서 먼저 렌더링하는 이점이 약해지고
  • 클라이언트 번들이 커지고
  • 경계 설계도 흐려집니다

2. 반대로 모든 것을 서버에 두려는 것

상호작용이 필요한 컴포넌트는 결국 클라이언트로 내려야 합니다.

  • 입력 폼
  • 모달 열기/닫기
  • 탭 전환
  • 브라우저 이벤트

즉, 중요한 것은 "무조건 서버"가 아니라 서버에 둘 이유가 있는 것은 서버에 두고, 상호작용만 클라이언트로 내리는 것입니다.

3. route.ts와 페이지 책임을 섞는 것

페이지는 화면입니다. route.ts는 API입니다.

이 둘을 한 파일처럼 생각하면 구조가 금방 헷갈립니다.

어떤 순서로 익히면 좋을까?

App Router를 처음 익힐 때는 아래 순서가 가장 부담이 적습니다.

  1. page.tsx와 폴더 기반 라우팅 이해
  2. layout.tsx로 공통 레이아웃 분리
  3. loading.tsx, error.tsx 추가
  4. 서버 컴포넌트와 클라이언트 컴포넌트 경계 구분
  5. route.ts와 캐시 정책 이해

즉, 처음부터 모든 기능을 한 번에 이해하려고 하기보다, 화면 구조 -> 상태 파일 -> 서버/클라이언트 경계 -> 데이터 흐름 순서로 보는 편이 더 잘 들어옵니다.

정리하면

Next.js App Router를 한 줄로 설명하면 이렇습니다.

URL 구조, 레이아웃, 로딩/에러 상태, 서버 데이터 패칭, API 엔드포인트를 파일 구조 기준으로 같이 정리하는 방식입니다.

그래서 App Router를 이해하려면 라우팅만 보면 안 되고, 아래를 같이 봐야 합니다.

  • 어떤 파일이 화면인가
  • 어떤 파일이 공통 레이아웃인가
  • 어떤 부분이 서버에서 먼저 실행되는가
  • 어떤 부분이 브라우저 상호작용을 담당하는가
  • 데이터와 캐시 정책이 어디에 붙는가

이 감각만 잡히면 App Router는 생각보다 훨씬 읽기 쉬워집니다.

같이 보면 좋은 글