Next.js App Router.
masonfe-hyunsu
🐱 1. 개요 : 새로운 라우터보다 새로운 렌더링 모델.

App Router는 Next.js 13에서 도입되고 13.4에서 안정화되었다.
처음에는
pages/대신app/을 쓰는 새로운 파일 라우팅 정도로 생각했다.직접 구조를 따라가 보니 핵심은 폴더 이름보다 React Server Components(RSC), 중첩 레이아웃, 스트리밍, 데이터 캐시가 함께 움직이는 렌더링 모델에 있었다.
이 포스팅은 프로젝트에서 사용 중인 Next.js 15 기준으로 App Router의 구조와 자주 틀리는 지점을 정리해 본다.
App Router는 URL을 만드는 규칙만 바꾼 것이 아니라, 서버와 클라이언트가 UI를 나누는 기준까지 바꿨다.
🙂 2. Pages Router와 무엇이 다른가.
디렉터리와 라우트.
Pages Router
pages/about.tsx처럼 파일 하나가/about라우트가 된다._app.tsx,_document.tsx에서 앱 전체 공통 구조를 관리한다.getStaticProps,getServerSideProps같은 페이지 단위 데이터 함수를 사용한다.
App Router
app/about/page.tsx처럼 폴더가 URL segment를 만들고page.tsx가 공개 페이지를 만든다.폴더마다
layout.tsx,loading.tsx,error.tsx같은 파일을 함께 둘 수 있다.페이지와 레이아웃은 기본적으로 Server Component다.

두 라우터 모두 경로 단위 코드 분할을 지원한다. App Router에서 새롭게 체감되는 부분은 segment별 레이아웃과 로딩, 에러 경계, 그리고 서버와 클라이언트 모듈 그래프의 분리였다.
중첩 레이아웃.
app/
├── layout.tsx
├── page.tsx
└── dashboard/
├── layout.tsx
├── loading.tsx
└── page.tsxapp/layout.tsx는 필수 Root Layout이다.<html>과<body>를 직접 포함해야 한다.app/dashboard/layout.tsx는/dashboard아래에서만 유지되는 공통 UI다.같은 layout 안에서 이동하면 layout의 상태와 DOM은 유지되고 page 영역이 바뀐다.
layout을 다시 마운트해야 하는 화면에는
template.tsx를 사용할 수 있다.
🧭 3. 특수 파일 규칙.
App Router는 파일 이름으로 segment의 역할과 경계를 선언한다.
page.tsx해당 경로에서 공개되는 UI다.
page가 없는 폴더는 URL segment가 될 수 있어도 직접 접근할 페이지는 만들지 않는다.
layout.tsx여러 하위 route가 공유하는 UI다.
navigation 시 유지되므로 매번 새로 실행돼야 하는 로직을 무심코 두지 않는다.
loading.tsxsegment의 즉시 로딩 UI다.
내부적으로 Suspense 경계를 만들어 준비되지 않은 내용을 스트리밍한다.
error.tsxsegment의 Error Boundary 역할을 한다.
reset()과 이벤트 처리가 필요하므로 Client Component여야 한다.
not-found.tsxnotFound()호출이나 찾을 수 없는 경로의 UI를 처리한다.
route.tsGET,POST같은 Web Request/Response 기반 Route Handler를 만든다.
template.tsxlayout과 비슷하지만 navigation 때 자식 인스턴스를 새로 만들고 effect도 다시 실행한다.
// app/dashboard/error.tsx
'use client';
export default function Error({
reset,
}: {
reset: () => void;
}) {
return <button onClick={reset}>다시 시도</button>;
}Route Group.
app/(marketing)/about/page.tsx의 괄호 폴더는 URL에 포함되지 않는다.URL을 바꾸지 않고 route를 영역별로 묶거나 서로 다른 layout을 적용할 때 사용한다.
여러 Root Layout 사이를 이동하면 client-side navigation이 아니라 full page load가 일어날 수 있다.
🧩 4. Server Component와 Client Component의 경계.
App Router의 page와 layout은 기본적으로 Server Component다.
서버에서 데이터베이스와 API에 접근하고, secret을 노출하지 않으면서 결과를 UI에 가까이 둘 수 있다.
상태, 이벤트 핸들러, effect, 브라우저 API가 필요한 부분만 Client Component로 만든다.
// app/posts/[slug]/page.tsx — Server Component
import LikeButton from './LikeButton';
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <LikeButton initialLikes={post.likes} />;
}// app/posts/[slug]/LikeButton.tsx — Client Component
'use client';
import { useState } from 'react';
export default function LikeButton({
initialLikes,
}: {
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
return <button onClick={() => setLikes(likes + 1)}>{likes}</button>;
}
여기서 "use client"는 현재 파일만 표시하는 주석이 아니라 서버와 클라이언트 모듈 그래프의 경계다.
"use client"파일이 import한 모듈은 client bundle에 포함될 수 있다.Server Component에서 Client Component로 넘기는 props는 React가 직렬화할 수 있어야 한다.
큰 layout 전체에
"use client"를 붙이기보다 버튼이나 입력처럼 상호작용이 필요한 작은 섬으로 내린다.서버 전용 모듈에는
import 'server-only'를 넣어 Client Component에서 잘못 가져오는 일을 빌드 단계에서 막을 수 있다.
초기 요청에서는 HTML로 빠른 화면을 보여 주고, RSC Payload로 서버와 클라이언트 트리를 맞춘 뒤 Client Component에만 JavaScript를 연결해 hydration한다.
🗂️ 5. 동적 라우트와 비동기 params.
app/posts/[slug]/page.tsx는 /posts/hello, /posts/nextjs 같은 주소를 하나의 page로 처리한다.
Next.js 15부터 params는 Promise다. 이전 버전의 params.slug 예제를 그대로 쓰기보다 await params로 값을 꺼내는 편이 안전하다.
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <article>{slug}</article>;
}[slug]: 한 segment를 받는다.[...slug]: 뒤에 이어지는 segment를 배열로 모두 받는다.[[...slug]]: 값이 없는 기본 경로까지 포함한다.미리 알 수 있는 경로는
generateStaticParams()로 빌드 시 생성할 수 있다.Client Component에서는
use(params)또는useParams()로 현재 값을 읽을 수 있다.
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}route param은 사용자가 주소창에 직접 입력할 수 있는 런타임 값이다. union type만 믿지 않고 유효하지 않은 값은 검사한 뒤 notFound()로 처리해야 한다.
📦 6. 데이터 로딩, 캐시, 스트리밍.
Server Component는 컴포넌트 안에서 데이터를 직접 기다릴 수 있다.
export default async function Page() {
const response = await fetch('https://api.example.com/posts');
const posts = await response.json();
return <PostList posts={posts} />;
}Next.js의 캐시 기본값은 버전에 따라 달라졌다. Next.js 15의 fetch는 기본적으로 영구 캐시되지 않는다.
매 요청마다 최신 데이터:
cache: 'no-store'명시적인 영구 캐시:
cache: 'force-cache'시간 기반 갱신:
next: { revalidate: 60 }태그 기반 무효화:
next: { tags: ['posts'] }와revalidateTag()
const response = await fetch('https://api.example.com/posts', {
next: { revalidate: 60, tags: ['posts'] },
});동일한 GET 요청이 한 번의 React 렌더 트리에서 중복되면 memoization될 수 있지만, 이것은 요청을 넘어 유지되는 Data Cache와 같은 개념이 아니다.
로딩 UI와 스트리밍.
route 전체의 기본 fallback은
loading.tsx에 둔다.일부 느린 영역만 나누고 싶다면
<Suspense>를 직접 둔다.이 방식은 모든 데이터가 끝날 때까지 빈 화면으로 기다리지 않고, 준비된 UI부터 보낸다.
import { Suspense } from 'react';
export default function Page() {
return (
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
);
}cookies(), headers()처럼 요청 시점에 결정되는 API나 uncached data를 사용하면 route가 동적으로 렌더링될 수 있다. 정적 또는 동적이라는 이름보다 어떤 데이터가 언제 확정되는지를 기준으로 보는 편이 이해하기 쉬웠다.
🏷️ 7. Metadata와 Route Handler.
Metadata API.
고정 값은 metadata 객체로 선언한다.
동적 상세 페이지는 generateMetadata()에서
params와 데이터를 사용한다.title,description, Open Graph 같은 head 정보를 React 트리와 함께 관리할 수 있다.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
};
}Route Handler.
route.ts는 브라우저 페이지가 아니라 HTTP endpoint를 만든다.
// app/api/posts/route.ts
export async function GET() {
const posts = await getPosts();
return Response.json(posts);
}webhook, 외부 클라이언트용 API, 파일 응답처럼 HTTP 경계가 필요한 곳에 사용한다.
Server Component가 같은 애플리케이션의 데이터를 읽기 위해 무조건 내부 Route Handler를 거칠 필요는 없다.
서버에서만 쓰는 데이터 함수나 DB 호출은 직접 import하는 편이 불필요한 HTTP 왕복을 줄인다.
🤨 8. Pages Router에서 단계적으로 이전하기.
app/과 pages/ 병행은 가능하다. 다만 동일한 URL을 양쪽에서 동시에 만들 수는 없다.
새 기능이나 독립된 경로부터
app/에 만든다._app.tsx의 Provider는 필요한 범위의 Client Component Provider로 옮긴다.getServerSideProps는 async Server Component와 동적 데이터 접근으로 바꾼다.getStaticProps와getStaticPaths는 캐시 설정과generateStaticParams로 역할을 나눠 옮긴다.next/router는next/navigation의useRouter,usePathname,useSearchParams로 교체한다.API Routes는 유지하거나 필요한 경로만 Route Handler로 이전한다.
마이그레이션 중에는 다음을 특히 확인해야 했다.
"use client"범위를 너무 위로 끌어올리지 않았는가Server Component가 브라우저 API를 호출하지 않는가
Client Component props가 직렬화 가능한가
params,cookies(),headers()같은 비동기 API를await했는가기존 캐시 동작을 Next.js 15 기본값에 맞게 명시했는가
🙂 9. 정리.
App Router의 핵심은 segment 기반 UI 경계와 Server Component 기본 모델이다.
page,layout,loading,error,route같은 특수 파일이 라우팅과 렌더링 역할을 나눈다."use client"는 상호작용이 필요한 작은 경계에 두고 client bundle을 불필요하게 키우지 않는다.Next.js 15에서는
params가 Promise이며fetch도 기본적으로 영구 캐시되지 않는다.캐시, 스트리밍, 동적 렌더링은 데이터가 언제 확정되어야 하는지에 맞춰 선택한다.
처음에는 App Router를 폴더 규칙의 변화로 봤다. 정리하고 나니 더 중요한 것은 서버에서 끝낼 일과 브라우저로 넘길 일을 경계 짓는 방식이었다. 파일 규칙을 외우는 것보다 이 경계를 기준으로 보면 데이터 로딩과 UI 구조도 함께 이해하기 쉬웠다.
댓글