Next.js App Router 완전 가이드

Next.js 13+ App Router의 파일 시스템 라우팅, Server/Client Components, Server Actions, 데이터 페칭 패턴을 정리합니다.

· 13 min read · PALDYN Team

지난 글에서 CSR, SSR, SSG, ISR, Streaming SSR을 이론적으로 정리했습니다. 이번 글에서는 이 전략들을 실제로 구현하는 Next.js App Router를 깊이 살펴봅니다. Next.js 13에서 도입되어 15에서 안정화된 App Router는 React Server Components를 전면적으로 채택한 첫 번째 프로덕션 레디 프레임워크입니다.


App Router vs Pages Router

App Router(app/ 디렉터리)는 기존 Pages Router(pages/ 디렉터리)와 공존할 수 있습니다. 두 방식의 핵심 차이는 다음과 같습니다.

구분Pages RouterApp Router
기본 컴포넌트Client ComponentServer Component
데이터 페칭getServerSideProps, getStaticPropsasync/await + fetch
레이아웃_app.tsx (전역 하나)중첩 레이아웃 (세그먼트별)
Streaming미지원Suspense + 자동 스트리밍
메타데이터<Head> 컴포넌트metadata export

새 프로젝트라면 App Router를 사용하는 것이 좋습니다. 기존 Pages Router 프로젝트도 app/ 디렉터리를 추가해 점진적으로 마이그레이션할 수 있습니다.


파일 시스템 라우팅

Next.js App Router — 파일 시스템 라우팅

특수 파일 컨벤션

App Router는 파일 이름으로 역할을 결정합니다.

layout.tsx — 세그먼트와 자식 페이지를 감싸는 레이아웃입니다. 내비게이션 시 리렌더링되지 않고 상태를 유지합니다.

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: { template: '%s | My Site', default: 'My Site' },
  description: '기술 블로그',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ko">
      <body>
        <Header />
        <main>{children}</main>
        <Footer />
      </body>
    </html>
  )
}

page.tsx — 라우트의 실제 UI입니다. layoutchildren 자리에 렌더링됩니다.

loading.tsx — 해당 세그먼트의 page.tsx를 자동으로 <Suspense>로 감싸는 fallback UI입니다.

// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="skeleton">
      <div className="skeleton-title" />
      <div className="skeleton-body" />
    </div>
  )
}

error.tsx — 해당 세그먼트에서 발생한 런타임 에러를 잡는 Error Boundary입니다. 'use client'가 필수입니다.

// app/blog/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>문제가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  )
}

not-found.tsxnotFound() 함수 호출 시 표시되는 404 UI입니다.

라우트 그룹

폴더 이름을 (괄호)로 감싸면 URL에 포함되지 않는 그룹을 만들 수 있습니다. 레이아웃을 공유하는 페이지들을 논리적으로 묶을 때 유용합니다.

app/
├── (marketing)/
│   ├── layout.tsx      ← 마케팅 공통 레이아웃
│   ├── page.tsx        → /
│   └── about/page.tsx  → /about
└── (shop)/
    ├── layout.tsx      ← 쇼핑몰 공통 레이아웃
    ├── products/page.tsx → /products
    └── cart/page.tsx   → /cart

동적 세그먼트

[slug]처럼 대괄호로 감싸면 동적 경로를 만들 수 있습니다.

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    notFound() // not-found.tsx를 렌더링
  }

  return <article>{post.content}</article>
}

[[...slug]]처럼 이중 대괄호를 쓰면 선택적 catch-all 라우트가 됩니다. /docs, /docs/api, /docs/api/reference 모두 같은 페이지로 처리할 수 있습니다.


Server Components vs Client Components

Server Components vs Client Components

Server Component (기본값)

App Router에서 모든 컴포넌트는 기본적으로 Server Component입니다. 서버에서만 실행되고, 결과 HTML이 클라이언트로 전달됩니다. JS 번들에 포함되지 않습니다.

핵심 장점은 세 가지입니다.

첫째, 데이터 레이어 직접 접근입니다. ORM, 파일시스템, 내부 API를 컴포넌트 안에서 바로 호출합니다.

// 컴포넌트 안에서 DB 직접 접근
import { db } from '@/lib/db'

export default async function UserList() {
  const users = await db.user.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  )
}

둘째, 시크릿 안전 보호입니다. process.env.SECRET_KEY 같은 값이 클라이언트 번들로 노출되지 않습니다.

셋째, 제로 번들 크기입니다. 무거운 파싱 라이브러리(marked, highlight.js 등)를 서버에서만 실행하면 클라이언트 번들이 늘어나지 않습니다.

Client Component

'use client' 지시어를 파일 맨 위에 선언하면 해당 컴포넌트와 그 자식들은 Client Component 경계 안에 들어갑니다.

'use client'

import { useState, useEffect, useRef } from 'react'

export function SearchBar() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    inputRef.current?.focus()
  }, [])

  async function handleSearch(q: string) {
    setQuery(q)
    const res = await fetch(`/api/search?q=${q}`)
    const data = await res.json()
    setResults(data.results)
  }

  return (
    <div>
      <input
        ref={inputRef}
        value={query}
        onChange={e => handleSearch(e.target.value)}
        placeholder="검색..."
      />
      <ul>
        {results.map(r => <li key={r}>{r}</li>)}
      </ul>
    </div>
  )
}

컴포넌트 경계 규칙

SC는 CC를 children으로 받을 수 있습니다. SC가 CC를 import해 렌더링하면 CC가 클라이언트 번들에 포함됩니다.

CC는 SC를 직접 import할 수 없습니다. CC는 클라이언트 번들에 포함되어야 하는데, SC는 서버 전용이므로 모순이 생깁니다. 대신 SC를 children prop으로 CC에 전달하는 패턴을 씁니다.

// 올바른 패턴: SC를 children으로 전달
// app/page.tsx (SC)
import { ClientShell } from './ClientShell' // CC
import { ServerData } from './ServerData'   // SC

export default function Page() {
  return (
    <ClientShell>
      {/* ServerData는 SC — children으로 전달됨 */}
      <ServerData />
    </ClientShell>
  )
}

// ClientShell.tsx (CC)
'use client'
export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(!open)}>토글</button>
      {open && children}
    </div>
  )
}

데이터 페칭

fetch + 캐시 옵션

Next.js는 fetch를 확장해 캐싱을 세밀하게 제어합니다.

// SSG: 빌드 시 한 번 fetch (기본값)
const data = await fetch('https://api.example.com/static')

// SSR: 매 요청마다 fetch
const data = await fetch('https://api.example.com/dynamic', {
  cache: 'no-store',
})

// ISR: 60초마다 재검증
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
})

// 태그 기반 재검증
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
})

요청 메모이제이션

같은 URL로 여러 컴포넌트가 동일한 fetch를 호출하면, 하나의 요청 내에서 자동으로 중복 제거(deduplication)됩니다. 컴포넌트 트리 어디서나 필요한 데이터를 fetch해도 네트워크 요청이 하나만 발생합니다.

// Header, Footer, Page 모두에서 호출해도 요청은 한 번
async function getCurrentUser() {
  return fetch('/api/me', { cache: 'no-store' }).then(r => r.json())
}

parallel fetch로 워터폴 방지

export default async function Dashboard() {
  // 순차 실행 (나쁜 예) — 각 fetch가 완료될 때까지 다음이 시작 안 됨
  // const user = await getUser()
  // const posts = await getPosts()
  // const stats = await getStats()

  // 병렬 실행 (좋은 예) — 모두 동시에 시작
  const [user, posts, stats] = await Promise.all([
    getUser(),
    getPosts(),
    getStats(),
  ])

  return (
    <main>
      <UserCard user={user} />
      <PostList posts={posts} />
      <StatsPanel stats={stats} />
    </main>
  )
}

Server Actions

Server Actions는 클라이언트에서 서버 함수를 직접 호출하는 방법입니다. API 라우트 없이 폼 처리나 데이터 변이를 처리할 수 있습니다.

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // 서버에서 직접 DB 접근
  const post = await db.post.create({
    data: { title, content },
  })

  // 특정 경로 캐시 무효화
  revalidatePath('/blog')

  // 생성된 포스트 페이지로 이동
  redirect(`/blog/${post.slug}`)
}

폼에서 Server Action 사용:

// app/new-post/page.tsx
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" required />
      <textarea name="content" placeholder="내용" required />
      <button type="submit">게시</button>
    </form>
  )
}

클라이언트 컴포넌트에서도 Server Action을 호출할 수 있습니다.

'use client'

import { createPost } from '../actions'
import { useTransition } from 'react'

export function PostForm() {
  const [isPending, startTransition] = useTransition()

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    startTransition(async () => {
      await createPost(formData)
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
    </form>
  )
}

Streaming과 Suspense

App Router는 loading.tsx가 없어도 <Suspense>를 직접 써서 스트리밍을 제어할 수 있습니다.

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      {/* 빠른 섹션 — 즉시 렌더링 */}
      <QuickStats />

      {/* 느린 섹션들 — 각자 독립적으로 스트리밍 */}
      <div className="grid">
        <Suspense fallback={<CardSkeleton />}>
          <RecentOrders />
        </Suspense>

        <Suspense fallback={<ChartSkeleton />}>
          <SalesChart />
        </Suspense>

        <Suspense fallback={<TableSkeleton />}>
          <TopProducts />
        </Suspense>
      </div>
    </div>
  )
}

RecentOrders, SalesChart, TopProducts 각각의 데이터 fetch가 완료되는 순서대로 화면에 나타납니다. 가장 빠른 것이 먼저 보이고, 나머지는 차례로 스켈레톤 UI를 교체합니다.


메타데이터 API

App Router는 metadata export로 SEO 메타데이터를 선언적으로 관리합니다.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

// 정적 메타데이터
export const metadata: Metadata = {
  title: '블로그',
}

// 동적 메타데이터 — 라우트 파라미터 기반
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
    },
  }
}

API Routes

App Router의 API 라우트는 route.ts 파일로 정의합니다. HTTP 메서드별 핸들러를 named export로 작성합니다.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = Number(searchParams.get('page') ?? '1')

  const posts = await db.post.findMany({
    skip: (page - 1) * 10,
    take: 10,
    orderBy: { createdAt: 'desc' },
  })

  return NextResponse.json({ posts })
}

export async function POST(request: NextRequest) {
  const body = await request.json()

  // 인증 확인
  const session = await getSession()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const post = await db.post.create({ data: body })
  return NextResponse.json(post, { status: 201 })
}

App Router는 처음에는 낯설게 느껴지지만, Server Components와 Streaming을 익히고 나면 기존 방식보다 훨씬 자연스럽게 성능 좋은 앱을 만들 수 있습니다. 다음 글에서는 Next.js 외에 Remix, Nuxt, SvelteKit, Astro 같은 다른 메타 프레임워크들을 비교합니다.