mason
mason-log.

Next.js App Router.

mason

masonfe-hyunsu

🐱 1. 개요 : 새로운 라우터보다 새로운 렌더링 모델.

기존 구조를 정리하고 App Router를 살펴보는 과정
  • 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다.

Pages Router와 App Router의 라우팅 구조 비교

두 라우터 모두 경로 단위 코드 분할을 지원한다. App Router에서 새롭게 체감되는 부분은 segment별 레이아웃과 로딩, 에러 경계, 그리고 서버와 클라이언트 모듈 그래프의 분리였다.

중첩 레이아웃.

app/
├── layout.tsx
├── page.tsx
└── dashboard/
    ├── layout.tsx
    ├── loading.tsx
    └── page.tsx
  • app/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.tsx

    • segment의 즉시 로딩 UI다.

    • 내부적으로 Suspense 경계를 만들어 준비되지 않은 내용을 스트리밍한다.

  • error.tsx

    • segment의 Error Boundary 역할을 한다.

    • reset()과 이벤트 처리가 필요하므로 Client Component여야 한다.

  • not-found.tsx

    • notFound() 호출이나 찾을 수 없는 경로의 UI를 처리한다.

  • route.ts

    • GET, POST 같은 Web Request/Response 기반 Route Handler를 만든다.

  • template.tsx

    • layout과 비슷하지만 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>;
}
App Router의 서버와 클라이언트 렌더링 경계

여기서 "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부터 paramsPromise다. 이전 버전의 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와 동적 데이터 접근으로 바꾼다.

  • getStaticPropsgetStaticPaths는 캐시 설정과 generateStaticParams로 역할을 나눠 옮긴다.

  • next/routernext/navigationuseRouter, 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 구조도 함께 이해하기 쉬웠다.


📎 10. 참고 URL.

이전 글
E2E 테스트 (Playwright).

댓글

불러오는 중...
목록으로