Next.js App Router란 무엇인가? 코드와 예제로 이해하기
Next.js App Router를 처음 보면 파일이 꽤 많아 보입니다.
layout.tsxpage.tsxloading.tsxerror.tsxroute.tsgenerateMetadata
그래서 처음에는 "이게 라우터라는 건 알겠는데, 결국 뭐가 어떻게 연결되는 거지?"라는 느낌이 들기 쉽습니다.
실제로 App Router는 단순 라우팅 기능이 아닙니다. 페이지 구조, 레이아웃, 서버 렌더링, 데이터 패칭, 캐시, 에러 처리를 한 묶음으로 가져가는 방식에 가깝습니다.
이 글에서는 App Router가 무엇인지 개념만 설명하지 않고, 코드와 예제 중심으로 정리해보겠습니다. 기준은 다음 두 가지입니다.
- 각 파일이 무슨 역할을 하는지 바로 이해할 수 있을 것
- 실무에서 어디까지 서버에 두고, 어디부터 클라이언트로 내려야 하는지 감을 잡을 것
한눈에 보면
먼저 아주 짧게 정리하면 이렇습니다.
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.tsApp 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 페이지 로딩 UIapp/todos/error.tsx: Todo 페이지 에러 UIapp/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를 처음 익힐 때는 아래 순서가 가장 부담이 적습니다.
page.tsx와 폴더 기반 라우팅 이해layout.tsx로 공통 레이아웃 분리loading.tsx,error.tsx추가- 서버 컴포넌트와 클라이언트 컴포넌트 경계 구분
route.ts와 캐시 정책 이해
즉, 처음부터 모든 기능을 한 번에 이해하려고 하기보다, 화면 구조 -> 상태 파일 -> 서버/클라이언트 경계 -> 데이터 흐름 순서로 보는 편이 더 잘 들어옵니다.
정리하면
Next.js App Router를 한 줄로 설명하면 이렇습니다.
URL 구조, 레이아웃, 로딩/에러 상태, 서버 데이터 패칭, API 엔드포인트를 파일 구조 기준으로 같이 정리하는 방식입니다.
그래서 App Router를 이해하려면 라우팅만 보면 안 되고, 아래를 같이 봐야 합니다.
- 어떤 파일이 화면인가
- 어떤 파일이 공통 레이아웃인가
- 어떤 부분이 서버에서 먼저 실행되는가
- 어떤 부분이 브라우저 상호작용을 담당하는가
- 데이터와 캐시 정책이 어디에 붙는가
이 감각만 잡히면 App Router는 생각보다 훨씬 읽기 쉬워집니다.
