Skip to content
Published on

Next.js 15 + React 19 완전 가이드: Server Components부터 Server Actions까지 2025 실전 정복

Authors

들어가며

React 19와 Next.js 15의 조합은 프론트엔드 개발의 패러다임을 근본적으로 바꾸고 있습니다. 서버 컴포넌트가 기본이 되고, 서버 액션으로 API 레이어가 사라지며, Turbopack이 빌드 속도를 혁신하고, PPR이 정적/동적 렌더링의 경계를 허물었습니다.

이 가이드는 Pages Router 시절의 Next.js를 사용하던 개발자, 또는 React 18까지만 경험한 개발자가 최신 스택으로 전환하기 위한 실전 완전 가이드입니다. 개념 설명에 그치지 않고, 실제 코드 패턴과 마이그레이션 전략까지 다룹니다.


1. React 19의 혁신

React 19는 2024년 12월에 안정 버전으로 출시되었으며, React의 철학 자체를 진화시키는 대규모 업데이트입니다.

1.1 React Compiler (자동 메모이제이션)

React Compiler(이전 명칭 React Forget)는 수동으로 useMemo, useCallback, memo를 작성할 필요를 없앱니다. 컴파일러가 빌드 타임에 컴포넌트를 분석해 자동으로 메모이제이션을 적용합니다.

Before (React 18):

import { useMemo, useCallback, memo } from 'react'

const ExpensiveList = memo(({ items, onSelect }: Props) => {
  const sortedItems = useMemo(() => items.sort((a, b) => a.name.localeCompare(b.name)), [items])

  const handleClick = useCallback(
    (id: string) => {
      onSelect(id)
    },
    [onSelect]
  )

  return (
    <ul>
      {sortedItems.map((item) => (
        <li key={item.id} onClick={() => handleClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  )
})

After (React 19 + Compiler):

// React Compiler가 자동으로 메모이제이션 적용
// useMemo, useCallback, memo 모두 불필요
function ExpensiveList({ items, onSelect }: Props) {
  const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name))

  return (
    <ul>
      {sortedItems.map((item) => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  )
}

Next.js 15에서 React Compiler를 활성화하려면 다음과 같이 설정합니다:

// next.config.js
module.exports = {
  experimental: {
    reactCompiler: true,
  },
}

설치도 필요합니다:

npm install babel-plugin-react-compiler

1.2 use() Hook - Promise와 Context 읽기

React 19의 use() 훅은 기존 훅과 다르게 조건문 안에서도 호출할 수 있습니다. Promise를 직접 읽어 Suspense와 연동하고, Context도 새로운 방식으로 소비합니다.

import { use, Suspense } from 'react'

// Promise를 직접 use()로 읽기
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // Suspense 경계에서 자동 대기

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

// 조건부로 Context 읽기
function ThemeButton({ showIcon }: { showIcon: boolean }) {
  if (showIcon) {
    const theme = use(ThemeContext)
    return <Icon color={theme.primary} />
  }
  return <button>Click me</button>
}

// 사용
function App() {
  const userPromise = fetchUser(userId) // Promise 생성

  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

1.3 Actions: useActionState, useFormStatus, useOptimistic

React 19는 폼과 비동기 작업을 위한 전용 훅 세트를 도입했습니다.

useActionState - 폼 액션의 상태를 관리합니다:

'use client'
import { useActionState } from 'react'
import { createTodo } from '@/actions/todo'

function TodoForm() {
  const [state, formAction, isPending] = useActionState(createTodo, {
    message: '',
    errors: {},
  })

  return (
    <form action={formAction}>
      <input name="title" placeholder="할 일 입력" />
      {state.errors?.title && <p className="error">{state.errors.title}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '추가 중...' : '추가'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

useFormStatus - 부모 폼의 제출 상태를 자식 컴포넌트에서 읽습니다:

'use client'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending, data, method } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : '제출하기'}
    </button>
  )
}

useOptimistic - 낙관적 업데이트를 선언적으로 처리합니다:

'use client'
import { useOptimistic } from 'react'

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
  )

  async function handleAdd(formData: FormData) {
    const title = formData.get('title') as string
    const tempTodo = { id: crypto.randomUUID(), title, completed: false }

    addOptimisticTodo(tempTodo) // 즉시 UI 업데이트
    await createTodoAction(formData) // 서버에 실제 저장
  }

  return (
    <div>
      <form action={handleAdd}>
        <input name="title" />
        <button type="submit">추가</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  )
}

1.4 Document Metadata와 Stylesheet 지원

React 19는 title, meta, link 태그를 컴포넌트 트리 어디서든 선언할 수 있습니다:

function BlogPost({ post }: { post: Post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <link rel="stylesheet" href="/styles/blog.css" precedence="default" />
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

stylesheet의 precedence 속성으로 CSS 로드 순서를 제어하고, Suspense와 연동하여 스타일시트가 로드될 때까지 콘텐츠 렌더링을 대기시킬 수 있습니다.

1.5 Ref as Prop (forwardRef 불필요)

React 19에서는 함수 컴포넌트가 ref를 일반 prop으로 받을 수 있습니다:

// Before (React 18) - forwardRef 필요
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})

// After (React 19) - ref를 그냥 prop으로
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}

2. Next.js 15 핵심 변경사항

Next.js 15는 2024년 10월에 릴리스되었으며, React 19를 공식 지원하는 최초의 메이저 프레임워크 버전입니다.

2.1 Turbopack 안정화

Turbopack이 next dev --turbopack으로 안정 버전이 되었습니다. Rust 기반의 증분 빌드 시스템으로:

  • 로컬 서버 시작: 최대 76.7% 빠름
  • Fast Refresh: 최대 96.3% 빠름
  • 초기 라우트 컴파일: 최대 45.8% 빠름 (캐시 없는 경우)
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

프로덕션 빌드(next build)에서 Turbopack 지원은 아직 실험적이며, --turbopack 플래그로 시도할 수 있습니다.

2.2 비동기 Request APIs

Next.js 15에서 가장 큰 **파괴적 변경(Breaking Change)**입니다. 런타임 Request 정보에 접근하는 API들이 모두 비동기가 되었습니다:

// Before (Next.js 14)
import { cookies, headers } from 'next/headers'

export default function Page({ params, searchParams }: PageProps) {
  const cookieStore = cookies()
  const headersList = headers()
  const slug = params.slug
  const query = searchParams.q
  // ...
}

// After (Next.js 15) - 모두 await 필요
import { cookies, headers } from 'next/headers'

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ q: string }>
}) {
  const cookieStore = await cookies()
  const headersList = await headers()
  const { slug } = await params
  const { q } = await searchParams
  // ...
}

이 변경은 서버 렌더링 최적화를 위한 것입니다. Next.js는 이제 요청 데이터를 실제로 사용하는 시점까지 지연(defer)할 수 있어, 정적 부분은 미리 렌더링하고 동적 부분만 요청 시 처리합니다.

자동 마이그레이션 코드모드:

npx @next/codemod@canary next-async-request-api .

2.3 PPR (Partial Pre-Rendering)

PPR은 하나의 페이지에서 정적 부분과 동적 부분을 동시에 처리하는 혁신적 렌더링 전략입니다:

// next.config.js
module.exports = {
  experimental: {
    ppr: 'incremental', // 라우트별로 점진 적용
  },
}
// app/product/[id]/page.tsx
import { Suspense } from 'react'

export const experimental_ppr = true // 이 라우트에 PPR 활성화

// 정적 쉘: 빌드 타임에 사전 렌더링
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id) // 빌드 타임에 가져옴

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <StaticImage src={product.image} />

      {/* 동적 부분: 요청 시 스트리밍 */}
      <Suspense fallback={<PriceSkeleton />}>
        <DynamicPrice productId={id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <DynamicReviews productId={id} />
      </Suspense>
    </div>
  )
}

// 동적 컴포넌트 - cookies/headers 사용으로 자동 dynamic
async function DynamicPrice({ productId }: { productId: string }) {
  const cookieStore = await cookies()
  const region = cookieStore.get('region')?.value ?? 'US'
  const price = await getPrice(productId, region)
  return <PriceDisplay price={price} currency={region} />
}

PPR의 동작 원리:

  1. 빌드 타임에 정적 쉘(HTML)을 생성
  2. 동적 부분은 Suspense 경계로 표시하여 폴백 포함
  3. 요청이 오면 정적 쉘을 즉시 전송하고, 동적 부분은 스트리밍

2.4 next/after API

응답을 보낸 후 비동기 작업을 실행할 수 있는 새 API입니다:

import { after } from 'next/server'
import { log } from '@/lib/logger'

export default async function Page() {
  const data = await fetchData()

  // 응답 후 실행 - 사용자 응답 지연 없음
  after(() => {
    log('page-view', { data: data.id })
  })

  return <Dashboard data={data} />
}

로깅, 분석, 캐시 워밍 등 사용자 응답에 영향을 주지 않아야 하는 작업에 이상적입니다.

2.5 캐싱 기본값 변경

Next.js 15에서 가장 중요한 철학적 변경입니다:

항목Next.js 14Next.js 15
fetch 캐시force-cache (기본 캐시)no-store (기본 미캐시)
GET Route Handler캐시됨캐시 안됨
Client Router Cache5분0초 (페이지 탐색 시 항상 최신)
// Next.js 15 - 명시적 캐시 설정 권장
const data = await fetch('https://api.example.com/posts', {
  cache: 'force-cache', // 명시적으로 캐시 활성화
  next: { revalidate: 3600 }, // 1시간 재검증
})

3. Server Components 심화

3.1 RSC의 동작 원리

React Server Components의 핵심은 RSC Payload(일명 Flight Protocol)입니다. 서버에서 생성된 이 직렬화 형식은 컴포넌트 트리를 클라이언트에 전달합니다.

렌더링 흐름은 다음과 같습니다:

  1. 서버에서 RSC 트리를 렌더링하여 RSC Payload 생성
  2. 클라이언트 컴포넌트를 위한 참조(번들 경로)를 포함
  3. 클라이언트에서 RSC Payload를 받아 DOM 트리 구성
  4. 클라이언트 컴포넌트만 하이드레이션(hydration)
[Server]                    [Client]
   |                           |
   |-- RSC Payload (stream) -->|
   |   - 직렬화된 서버 컴포넌트  |
   |   - 클라이언트 참조        |-- DOM 구성
   |   - Suspense 경계         |-- 클라이언트 하이드레이션
   |                           |

3.2 서버 vs 클라이언트 컴포넌트 경계

서버 컴포넌트에서 가능한 것:

  • DB 직접 접근
  • 파일 시스템 읽기
  • 서버 전용 라이브러리 사용 (fs, crypto, ORM 등)
  • 환경 변수(서버 전용) 접근
  • 번들 크기에 영향 없음

클라이언트 컴포넌트가 필요한 경우:

  • useState, useEffect 등 React 훅
  • 브라우저 API (localStorage, window 등)
  • 이벤트 리스너 (onClick, onChange 등)
  • 클래스 컴포넌트
  • 서드파티 라이브러리 (대부분 클라이언트 전용)
// app/dashboard/page.tsx (Server Component - 기본)
import { db } from '@/lib/db'
import { DashboardChart } from './chart' // Client Component

export default async function DashboardPage() {
  // 서버에서 직접 DB 접근 - API 레이어 불필요
  const metrics = await db.metrics.findMany({
    where: { date: { gte: thirtyDaysAgo() } },
    orderBy: { date: 'asc' },
  })

  // 직렬화 가능한 데이터만 클라이언트 컴포넌트에 전달
  return (
    <div>
      <h1>대시보드</h1>
      <DashboardChart data={metrics} /> {/* Client Component */}
    </div>
  )
}
// app/dashboard/chart.tsx (Client Component)
'use client'

import { LineChart, Line, XAxis, YAxis } from 'recharts'

export function DashboardChart({ data }: { data: Metric[] }) {
  // 클라이언트에서만 실행되는 인터랙티브 차트
  return (
    <LineChart width={800} height={400} data={data}>
      <XAxis dataKey="date" />
      <YAxis />
      <Line type="monotone" dataKey="value" stroke="#8884d8" />
    </LineChart>
  )
}

3.3 Composition 패턴: 서버가 클라이언트를 감싸기

서버 컴포넌트가 클라이언트 컴포넌트의 부모가 되는 것이 RSC의 핵심 패턴입니다. 반대로, 클라이언트 컴포넌트 안에서 서버 컴포넌트를 import하면 안 됩니다 (자동으로 클라이언트가 됨). 대신 children 패턴을 사용합니다:

// app/layout.tsx (Server Component)
import { Sidebar } from './sidebar' // Client Component
import { UserInfo } from './user-info' // Server Component

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex">
      {/* 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달 */}
      <Sidebar>
        <UserInfo /> {/* 이렇게 하면 서버 컴포넌트로 유지됨 */}
      </Sidebar>
      <main>{children}</main>
    </div>
  )
}
// app/sidebar.tsx (Client Component)
'use client'

import { useState } from 'react'

export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true)

  return (
    <aside className={isOpen ? 'w-64' : 'w-16'}>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {isOpen && children} {/* 서버에서 렌더링된 children */}
    </aside>
  )
}

3.4 DB 직접 접근과 API 레이어 제거

서버 컴포넌트의 가장 강력한 이점 중 하나는 별도의 API 엔드포인트 없이 데이터베이스에 직접 접근할 수 있다는 것입니다:

// Before (Pages Router + API Routes)
// 1. pages/api/posts.ts - API 엔드포인트
// 2. lib/fetcher.ts - fetch 유틸
// 3. pages/blog.tsx - getServerSideProps + 클라이언트 컴포넌트

// After (App Router + Server Components)
// 단 하나의 파일로 충분
// app/blog/page.tsx
import { prisma } from '@/lib/prisma'
import { cache } from 'react'

// React cache로 같은 요청 내 중복 방지
const getPosts = cache(async () => {
  return prisma.post.findMany({
    include: { author: true, tags: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
  })
})

export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>블로그</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.author.name}</p>
          <time>{post.createdAt.toLocaleDateString()}</time>
        </article>
      ))}
    </div>
  )
}

3.5 번들 크기 최적화

서버 컴포넌트에서 사용하는 라이브러리는 클라이언트 번들에 포함되지 않습니다:

// 서버 컴포넌트 - 이 import들은 클라이언트 번들에 포함 안됨
import { marked } from 'marked' // 35KB
import hljs from 'highlight.js' // 180KB
import { parse } from 'yaml' // 25KB
import sanitizeHtml from 'sanitize-html' // 60KB

// 총 300KB의 라이브러리가 클라이언트에 전혀 전송되지 않음!
export default async function MarkdownPage({ slug }: { slug: string }) {
  const raw = await fs.readFile(`content/${slug}.md`, 'utf-8')
  const html = marked(raw, {
    highlight: (code, lang) => hljs.highlight(code, { language: lang }).value,
  })
  const safe = sanitizeHtml(html)

  return <div dangerouslySetInnerHTML={{ __html: safe }} />
}

4. Server Actions 실전

Server Actions는 'use server' 지시어로 정의하며, 클라이언트에서 직접 서버 함수를 호출하는 RPC 패턴입니다.

4.1 Form 처리: useActionState + Progressive Enhancement

// actions/auth.ts
'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { createSession } from '@/lib/session'

const loginSchema = z.object({
  email: z.string().email('유효한 이메일을 입력하세요'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다'),
})

type LoginState = {
  message: string
  errors: Record<string, string[]>
}

export async function loginAction(prevState: LoginState, formData: FormData): Promise<LoginState> {
  const rawData = {
    email: formData.get('email'),
    password: formData.get('password'),
  }

  const validated = loginSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      message: '',
      errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
    }
  }

  const user = await authenticateUser(validated.data)

  if (!user) {
    return {
      message: '이메일 또는 비밀번호가 잘못되었습니다',
      errors: {},
    }
  }

  await createSession(user.id)
  redirect('/dashboard')
}
// app/login/page.tsx
'use client'

import { useActionState } from 'react'
import { loginAction } from '@/actions/auth'

export default function LoginPage() {
  const [state, formAction, isPending] = useActionState(loginAction, {
    message: '',
    errors: {},
  })

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" name="email" type="email" required />
        {state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
      </div>

      <div>
        <label htmlFor="password">비밀번호</label>
        <input id="password" name="password" type="password" required />
        {state.errors?.password && <p className="text-red-500">{state.errors.password[0]}</p>}
      </div>

      {state.message && <p className="text-red-500">{state.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? '로그인 중...' : '로그인'}
      </button>
    </form>
  )
}

이 폼은 Progressive Enhancement를 지원합니다. JavaScript가 비활성화된 환경에서도 HTML form 자체가 동작합니다.

4.2 낙관적 업데이트 실전

// app/comments/comment-section.tsx
'use client'

import { useOptimistic, useRef } from 'react'
import { useActionState } from 'react'
import { addComment } from '@/actions/comments'

type Comment = {
  id: string
  text: string
  author: string
  createdAt: string
  pending?: boolean
}

export function CommentSection({
  postId,
  initialComments,
}: {
  postId: string
  initialComments: Comment[]
}) {
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state, newComment: Comment) => [...state, newComment]
  )

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string

    // 즉시 UI 반영 (낙관적)
    addOptimisticComment({
      id: `temp-${Date.now()}`,
      text,
      author: '나',
      createdAt: new Date().toISOString(),
      pending: true,
    })

    formRef.current?.reset()

    // 서버에 실제 저장
    await addComment(postId, formData)
  }

  return (
    <div>
      <ul className="space-y-4">
        {optimisticComments.map((comment) => (
          <li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
            <strong>{comment.author}</strong>
            <p>{comment.text}</p>
            {comment.pending && <span>전송 중...</span>}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={handleSubmit}>
        <textarea name="text" placeholder="댓글을 입력하세요" required />
        <button type="submit">댓글 작성</button>
      </form>
    </div>
  )
}

4.3 파일 업로드

// actions/upload.ts
'use server'

import { writeFile } from 'fs/promises'
import { join } from 'path'
import { revalidatePath } from 'next/cache'

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File

  if (!file || file.size === 0) {
    return { error: '파일을 선택해주세요' }
  }

  // 파일 크기 제한 (5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: '파일 크기는 5MB 이하여야 합니다' }
  }

  // 허용된 타입 확인
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return { error: 'JPEG, PNG, WebP 이미지만 업로드 가능합니다' }
  }

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const uniqueName = `${Date.now()}-${file.name}`
  const path = join(process.cwd(), 'public/uploads', uniqueName)

  await writeFile(path, buffer)
  revalidatePath('/gallery')

  return { success: true, path: `/uploads/${uniqueName}` }
}

4.4 revalidatePath와 revalidateTag

// actions/post.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, formData: FormData) {
  await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  // 특정 경로 재검증
  revalidatePath(`/blog/${id}`)

  // 태그 기반 재검증 (더 세밀한 제어)
  revalidateTag('posts')
  revalidateTag(`post-${id}`)
}

// 데이터 페칭 시 태그 지정
async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: [`post-${id}`, 'posts'] },
  })
  return res.json()
}

4.5 Server Actions vs API Routes 비교

기능Server ActionsRoute Handlers (API Routes)
사용 패턴폼 제출, 데이터 변경외부 API, 웹훅, 제3자 연동
Progressive Enhancement지원 (JS 없이 동작)미지원
타입 안전성함수 시그니처로 보장수동 타입 정의 필요
호출 방식form action 또는 직접 호출fetch/HTTP 요청
캐싱자동 revalidation 통합수동 캐시 설정
보안자동 CSRF 보호수동 구현 필요
사용처내부 데이터 변경외부 시스템 연동

5. App Router 심화 패턴

5.1 Parallel Routes (@slot)

하나의 레이아웃에서 여러 페이지를 동시에 렌더링합니다:

app/
  dashboard/
    @analytics/
      page.tsx      ← 분석 슬롯
      loading.tsx   ← 분석 로딩 상태
    @team/
      page.tsx      ← 팀 슬롯
      loading.tsx   ← 팀 로딩 상태
    layout.tsx      ← 두 슬롯 조합
    page.tsx        ← 메인 콘텐츠
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="grid grid-cols-2 gap-4">
      <div className="col-span-2">{children}</div>
      <div>{analytics}</div>
      <div>{team}</div>
    </div>
  )
}

각 슬롯은 독립적으로 로딩되며, 하나가 느려도 나머지는 먼저 표시됩니다.

5.2 Intercepting Routes

모달 패턴에서 특히 유용합니다. URL을 유지하면서 현재 레이아웃 내에서 다른 라우트를 보여줍니다:

app/
  feed/
    page.tsx           ← 피드 목록
    @modal/
      (..)photo/[id]/
        page.tsx       ← 모달로 사진 보기
      default.tsx
    layout.tsx
  photo/[id]/
    page.tsx           ← 전체 페이지 사진 보기 (직접 접근/새로고침 시)
// app/feed/@modal/(..)photo/[id]/page.tsx
import { Modal } from '@/components/modal'

export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const photo = await getPhoto(id)

  return (
    <Modal>
      <img src={photo.url} alt={photo.alt} />
      <p>{photo.description}</p>
    </Modal>
  )
}

피드에서 사진을 클릭하면 모달로 열리지만, URL은 /photo/123으로 변경됩니다. 이 URL을 직접 방문하거나 새로고침하면 전체 페이지 버전이 렌더링됩니다.

5.3 Route Groups

URL 구조에 영향을 주지 않고 라우트를 논리적으로 그룹화합니다:

app/
  (marketing)/URL에 포함 안됨
    layout.tsx         ← 마케팅 전용 레이아웃
    page.tsx/ 경로
    about/
      page.tsx/about
    pricing/
      page.tsx/pricing
  (dashboard)/URL에 포함 안됨
    layout.tsx         ← 대시보드 전용 레이아웃 (사이드바 등)
    dashboard/
      page.tsx/dashboard
    settings/
      page.tsx/settings

5.4 Loading, Error, Not-Found 바운더리

App Router의 파일 컨벤션은 자동으로 Suspense와 Error Boundary를 설정합니다:

// app/blog/loading.tsx - Suspense fallback 자동 적용
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 w-3/4 rounded bg-gray-200" />
      <div className="h-4 w-full rounded bg-gray-200" />
      <div className="h-4 w-5/6 rounded bg-gray-200" />
    </div>
  )
}
// app/blog/error.tsx - Error Boundary 자동 적용
'use client' // Error 컴포넌트는 반드시 클라이언트 컴포넌트

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="py-10 text-center">
      <h2>문제가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={reset} className="mt-4 bg-blue-500 px-4 py-2 text-white">
        다시 시도
      </button>
    </div>
  )
}
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div className="py-20 text-center">
      <h2 className="text-2xl font-bold">게시글을 찾을 수 없습니다</h2>
      <p className="mt-2 text-gray-600">요청하신 게시글이 존재하지 않습니다.</p>
    </div>
  )
}

5.5 generateMetadata로 동적 SEO

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

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    return { title: 'Post Not Found' }
  }

  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: [post.coverImage],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.summary,
      images: [post.coverImage],
    },
  }
}

5.6 Middleware 활용

// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // 인증 체크
  const token = request.cookies.get('session-token')

  if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // 국제화 - Accept-Language 기반 리다이렉트
  const locale = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
  if (request.nextUrl.pathname === '/' && locale === 'ko') {
    return NextResponse.rewrite(new URL('/ko', request.url))
  }

  // 보안 헤더 추가
  const response = NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')

  return response
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

6. 캐싱 전략 완전 정복

Next.js의 캐싱은 4가지 레이어로 구성되며, 각각의 역할을 이해하는 것이 중요합니다.

6.1 4가지 캐싱 레이어

1. Request Memoization (요청 메모이제이션)

같은 렌더링 사이클 내에서 동일한 fetch 요청을 자동 중복 제거합니다:

// 같은 렌더링 내 두 컴포넌트에서 동일 호출 → 실제 요청은 1번만
async function Header() {
  const user = await fetch('/api/user') // 요청 1
  return <h1>{user.name}</h1>
}

async function Sidebar() {
  const user = await fetch('/api/user') // 메모이제이션 - 실제 요청 안됨
  return <nav>{user.role}</nav>
}

2. Data Cache (데이터 캐시)

fetch 응답을 서버에 영구 저장합니다 (Next.js 15에서는 기본 비활성):

// 명시적 캐시 설정
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // 영구 캐시
  next: { revalidate: 3600 }, // 1시간 후 재검증
  next: { tags: ['products'] }, // 태그 기반 재검증
})

3. Full Route Cache (전체 라우트 캐시)

빌드 타임에 생성된 정적 라우트의 HTML과 RSC Payload를 캐시합니다:

// 이 페이지는 빌드 타임에 캐시됨 (정적 라우트)
export default async function AboutPage() {
  return <div>회사 소개</div>
}

// generateStaticParams로 동적 경로도 정적 생성
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

4. Router Cache (라우터 캐시)

클라이언트에서 방문한 라우트의 RSC Payload를 메모리에 캐시합니다. Next.js 15에서는 기본 0초 (항상 최신 데이터):

// next.config.js에서 Router Cache 설정 가능
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30, // 동적 라우트: 30초
      static: 180, // 정적 라우트: 180초
    },
  },
}

6.2 cacheLife와 cacheTag (Next.js 15)

이전의 unstable_cache를 대체하는 새로운 캐싱 API입니다:

// next.config.js
module.exports = {
  experimental: {
    dynamicIO: true,
    cacheLife: {
      blog: {
        stale: 300, // 5분간 캐시 사용
        revalidate: 900, // 15분마다 백그라운드 재검증
        expire: 86400, // 24시간 후 만료
      },
      frequent: {
        stale: 0,
        revalidate: 60,
        expire: 300,
      },
    },
  },
}
import { cacheLife, cacheTag } from 'next/cache'

async function getProducts() {
  'use cache'
  cacheLife('blog')
  cacheTag('products')

  return db.product.findMany()
}

6.3 렌더링 전략 비교표

전략빌드 시요청 시재검증사용 사례
SSG (Static)HTML 생성캐시에서 제공빌드 시블로그, 문서
ISRHTML 생성캐시 + 백그라운드 갱신시간/태그 기반제품 목록, 뉴스
SSR-매 요청 렌더링없음개인화 페이지
PPR정적 쉘 생성동적 부분만 렌더링혼합제품 상세 (가격+설명)

6.4 실전: E-commerce 제품 페이지 캐싱 전략

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { unstable_cache } from 'next/cache'

// 제품 기본 정보: 자주 변하지 않으므로 ISR (1시간)
const getProductInfo = unstable_cache(
  async (id: string) => {
    return db.product.findUnique({
      where: { id },
      select: { name: true, description: true, images: true },
    })
  },
  ['product-info'],
  { revalidate: 3600, tags: ['products'] }
)

// 가격/재고: 자주 변하므로 짧은 캐시 (1분)
const getProductPricing = unstable_cache(
  async (id: string) => {
    return db.product.findUnique({
      where: { id },
      select: { price: true, stock: true, discount: true },
    })
  },
  ['product-pricing'],
  { revalidate: 60, tags: ['pricing'] }
)

// 리뷰: 사용자별 데이터 포함으로 동적
async function getProductReviews(id: string) {
  return db.review.findMany({
    where: { productId: id },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProductInfo(id)

  return (
    <div>
      <h1>{product?.name}</h1>
      <p>{product?.description}</p>

      <Suspense fallback={<div>가격 로딩...</div>}>
        <PriceSection productId={id} />
      </Suspense>

      <Suspense fallback={<div>리뷰 로딩...</div>}>
        <ReviewSection productId={id} />
      </Suspense>
    </div>
  )
}

async function PriceSection({ productId }: { productId: string }) {
  const pricing = await getProductPricing(productId)
  return (
    <div>
      <span className="text-2xl font-bold">{pricing?.price}</span>
      {pricing?.discount && <span className="text-red-500">{pricing.discount}% 할인</span>}
      <p>{pricing?.stock ? `재고: ${pricing.stock}` : '품절'}</p>
    </div>
  )
}

async function ReviewSection({ productId }: { productId: string }) {
  const reviews = await getProductReviews(productId)
  return (
    <div>
      <h2>리뷰 ({reviews.length})</h2>
      {reviews.map((review) => (
        <div key={review.id}>
          <strong>{review.author}</strong>
          <p>{review.content}</p>
        </div>
      ))}
    </div>
  )
}

7. 성능 최적화

7.1 Turbopack vs Webpack 벤치마크

Next.js 15에서의 실제 프로젝트 기준 비교:

측정 항목WebpackTurbopack개선율
Cold Start (dev)8.2초1.9초76.7%
Hot Module Replacement520ms19ms96.3%
라우트 컴파일 (첫 접근)1.8초0.97초45.8%
메모리 사용량 (2000+ 모듈)1.2GB650MB45.8%

7.2 next/image, next/font, next/script

// Image 최적화
import Image from 'next/image'

function ProductCard({ product }: { product: Product }) {
  return (
    <Image
      src={product.image}
      alt={product.name}
      width={400}
      height={300}
      placeholder="blur"
      blurDataURL={product.blurHash}
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      priority={false} // 스크롤 아래 이미지는 lazy load
    />
  )
}
// Font 최적화 - 레이아웃 시프트 방지
import { Inter, Noto_Sans_KR } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

const notoSansKR = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  variable: '--font-noto',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
      <body>{children}</body>
    </html>
  )
}
// Script 최적화
import Script from 'next/script'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      {/* afterInteractive: 페이지 하이드레이션 후 로드 (기본) */}
      <Script src="https://analytics.example.com/script.js" strategy="afterInteractive" />
      {/* lazyOnload: 브라우저 유휴 시 로드 */}
      <Script src="https://chatbot.example.com/widget.js" strategy="lazyOnload" />
      {/* worker: Web Worker에서 실행 (실험적) */}
      <Script src="https://heavy-lib.example.com/main.js" strategy="worker" />
    </>
  )
}

7.3 Bundle Analyzer + Tree Shaking

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  // 기존 설정
})
ANALYZE=true npm run build

Tree Shaking 최적화 팁:

// Bad - 전체 라이브러리 import
import _ from 'lodash'
const sorted = _.sortBy(items, 'name')

// Good - 필요한 함수만 import
import sortBy from 'lodash/sortBy'
const sorted = sortBy(items, 'name')

// Best - 서버 컴포넌트에서 사용 (번들에 포함 안됨)
// 서버 컴포넌트라면 전체 import 해도 무관
import _ from 'lodash'

7.4 Dynamic Imports와 Lazy Loading

import dynamic from 'next/dynamic'

// 클라이언트 전용 컴포넌트 lazy load
const DynamicChart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // 서버 렌더링 건너뛰기
})

// 조건부 로딩
const DynamicModal = dynamic(() => import('@/components/modal'))

export default function Page() {
  const [showModal, setShowModal] = useState(false)

  return (
    <div>
      <DynamicChart data={data} />
      <button onClick={() => setShowModal(true)}>모달 열기</button>
      {showModal && <DynamicModal onClose={() => setShowModal(false)} />}
    </div>
  )
}

7.5 Core Web Vitals 최적화

LCP (Largest Contentful Paint) 최적화:

// 가장 큰 콘텐츠 요소에 priority 설정
<Image src="/hero.jpg" alt="Hero" priority sizes="100vw" />

// 중요 리소스 프리로드
<link rel="preload" href="/api/critical-data" as="fetch" crossOrigin="anonymous" />

CLS (Cumulative Layout Shift) 최적화:

// 이미지 크기 명시 (width/height)
<Image src="/photo.jpg" width={800} height={600} alt="Photo" />

// 폰트 swap으로 FOIT 방지
const font = Inter({ display: 'swap' })

// 동적 콘텐츠에 최소 높이 설정
<div style={{ minHeight: '200px' }}>
  <Suspense fallback={<Skeleton height={200} />}>
    <DynamicContent />
  </Suspense>
</div>

INP (Interaction to Next Paint) 최적화:

// 무거운 작업은 useTransition으로 우선순위 낮추기
'use client'
import { useTransition } from 'react'

function SearchResults() {
  const [isPending, startTransition] = useTransition()
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value) // 즉각 반영 (높은 우선순위)

    startTransition(() => {
      // 결과 필터링은 낮은 우선순위
      setResults(filterLargeDataset(e.target.value))
    })
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <ResultList results={results} />}
    </div>
  )
}

8. 실전 프로젝트: 풀스택 대시보드

8.1 프로젝트 구조

my-dashboard/
  app/
    (auth)/
      login/page.tsx
      register/page.tsx
      layout.tsx
    (dashboard)/
      dashboard/
        page.tsx
        loading.tsx
        error.tsx
      settings/
        page.tsx
      layout.tsx
    api/
      webhooks/
        route.ts
    layout.tsx
  actions/
    auth.ts
    dashboard.ts
  lib/
    prisma.ts
    auth.ts
    utils.ts
  components/
    ui/
    charts/
    forms/
  prisma/
    schema.prisma

8.2 인증: Auth.js v5 (NextAuth.js v5)

// lib/auth.ts
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google,
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async (credentials) => {
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        })
        if (!user || !user.password) return null

        const valid = await bcrypt.compare(credentials.password as string, user.password)
        return valid ? user : null
      },
    }),
  ],
  callbacks: {
    authorized: async ({ auth: session }) => {
      return !!session
    },
    session: ({ session, token }) => ({
      ...session,
      user: { ...session.user, id: token.sub },
    }),
  },
  pages: {
    signIn: '/login',
  },
})
// middleware.ts
export { auth as middleware } from '@/lib/auth'

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
}
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

8.3 DB: Prisma + PostgreSQL

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String?
  image     String?
  accounts  Account[]
  sessions  Session[]
  dashboards Dashboard[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Dashboard {
  id        String   @id @default(cuid())
  name      String
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  widgets   Widget[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Widget {
  id          String    @id @default(cuid())
  type        String
  title       String
  config      Json
  dashboardId String
  dashboard   Dashboard @relation(fields: [dashboardId], references: [id])
  position    Int
}

8.4 상태 관리: TanStack Query + Server Components

// app/(dashboard)/dashboard/page.tsx (Server Component)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { DashboardClient } from './dashboard-client'

export default async function DashboardPage() {
  const session = await auth()
  if (!session?.user?.id) return null

  // 서버에서 초기 데이터 fetch
  const dashboard = await prisma.dashboard.findFirst({
    where: { userId: session.user.id },
    include: { widgets: { orderBy: { position: 'asc' } } },
  })

  // 초기 데이터를 클라이언트 컴포넌트에 전달
  return <DashboardClient initialData={dashboard} />
}
// app/(dashboard)/dashboard/dashboard-client.tsx
'use client'

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { updateWidget } from '@/actions/dashboard'

export function DashboardClient({ initialData }: { initialData: Dashboard | null }) {
  const queryClient = useQueryClient()

  // 서버 데이터를 초기값으로, 이후 클라이언트에서 갱신
  const { data: dashboard } = useQuery({
    queryKey: ['dashboard'],
    queryFn: () => fetch('/api/dashboard').then((r) => r.json()),
    initialData,
    staleTime: 60_000, // 1분
  })

  const mutation = useMutation({
    mutationFn: updateWidget,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['dashboard'] })
    },
  })

  return (
    <div className="grid grid-cols-3 gap-4">
      {dashboard?.widgets.map((widget) => (
        <WidgetCard key={widget.id} widget={widget} onUpdate={(data) => mutation.mutate(data)} />
      ))}
    </div>
  )
}

8.5 배포: Vercel Edge Runtime

// app/api/fast-api/route.ts
export const runtime = 'edge' // Edge Runtime 사용

export async function GET(request: Request) {
  const url = new URL(request.url)
  const query = url.searchParams.get('q')

  // Edge에서 가까운 리전에서 실행 - 낮은 지연시간
  const data = await fetch(`https://api.example.com/search?q=${query}`, {
    next: { revalidate: 60 },
  })

  return Response.json(await data.json())
}
// vercel.json - 리전 설정
{
  "regions": ["icn1", "nrt1"], // 서울, 도쿄
  "functions": {
    "app/api/**": {
      "memory": 1024,
      "maxDuration": 30
    }
  }
}

9. Pages Router에서 App Router 마이그레이션

9.1 단계별 점진적 마이그레이션 전략

App Router와 Pages Router는 동시에 존재할 수 있습니다. 이를 활용한 점진적 마이그레이션 전략:

Phase 1: 레이아웃 마이그레이션
  app/layout.tsx 생성 (루트 레이아웃)
  pages/_app.tsx 로직을 app/layout.tsx로

Phase 2: 정적 페이지부터 이동
  pages/about.tsx → app/about/page.tsx
  pages/pricing.tsx → app/pricing/page.tsx

Phase 3: 동적 라우트 마이그레이션
  pages/blog/[slug].tsx → app/blog/[slug]/page.tsx
  getServerSideProps → async Server Component

Phase 4: API Routes 정리
  내부 호출 → Server Actions로 교체
  외부 연동 → app/api/route.ts (Route Handlers)

Phase 5: 최적화
  캐싱 전략 설정
  Parallel Routes, Intercepting Routes 적용

9.2 getServerSideProps를 Server Components로

// Before: pages/blog/[slug].tsx
export async function getServerSideProps({ params }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug },
  })

  if (!post) {
    return { notFound: true }
  }

  return {
    props: { post: JSON.parse(JSON.stringify(post)) },
  }
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}
// After: app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await prisma.post.findUnique({
    where: { slug },
  })

  if (!post) {
    notFound() // 자동으로 not-found.tsx 렌더링
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

9.3 getStaticProps를 generateStaticParams로

// Before: pages/products/[id].tsx
export async function getStaticPaths() {
  const products = await prisma.product.findMany({ select: { id: true } })
  return {
    paths: products.map((p) => ({ params: { id: p.id } })),
    fallback: 'blocking',
  }
}

export async function getStaticProps({ params }) {
  const product = await prisma.product.findUnique({
    where: { id: params.id },
  })
  return {
    props: { product: JSON.parse(JSON.stringify(product)) },
    revalidate: 3600,
  }
}
// After: app/products/[id]/page.tsx
import { prisma } from '@/lib/prisma'

export async function generateStaticParams() {
  const products = await prisma.product.findMany({ select: { id: true } })
  return products.map((p) => ({ id: p.id }))
}

// dynamicParams = true가 기본 (fallback: 'blocking'과 동일)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await prisma.product.findUnique({ where: { id } })

  return (
    <div>
      <h1>{product?.name}</h1>
      <p>{product?.description}</p>
    </div>
  )
}

9.4 API Routes를 Route Handlers와 Server Actions로

// Before: pages/api/posts.ts
export default async function handler(req, res) {
  if (req.method === 'GET') {
    const posts = await prisma.post.findMany()
    return res.json(posts)
  }

  if (req.method === 'POST') {
    const post = await prisma.post.create({ data: req.body })
    return res.status(201).json(post)
  }
}
// After (외부 연동용): app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const posts = await prisma.post.findMany()
  return NextResponse.json(posts)
}

// 내부 데이터 변경은 Server Action으로 대체
// actions/posts.ts
;('use server')

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await prisma.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  revalidatePath('/blog')
  return post
}

9.5 주의사항과 함정

1. 클라이언트 컴포넌트에 직렬화 불가능한 데이터 전달 금지:

// 오류 발생: Date 객체는 직렬화 불가
<ClientComponent date={new Date()} />

// 해결: 문자열로 변환
<ClientComponent date={new Date().toISOString()} />

2. 서버 컴포넌트에서 Context 사용 불가:

// 오류: 서버 컴포넌트에서 useContext 사용 불가
// 해결: 클라이언트 컴포넌트로 Provider 분리

// app/providers.tsx
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </ThemeProvider>
  )
}

// app/layout.tsx (Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

3. 환경 변수 접근:

// NEXT_PUBLIC_ 접두사가 없는 환경 변수는 서버에서만 접근 가능
// 서버 컴포넌트에서는 자유롭게 사용
const dbUrl = process.env.DATABASE_URL // 서버 전용

// 클라이언트 컴포넌트에서는 NEXT_PUBLIC_ 접두사 필요
const apiUrl = process.env.NEXT_PUBLIC_API_URL // 클라이언트에서도 접근 가능

4. 서드파티 라이브러리 호환성:

많은 라이브러리가 아직 서버 컴포넌트를 지원하지 않습니다. 해결 방법:

// 래퍼 클라이언트 컴포넌트 생성
// components/client-only-lib.tsx
'use client'

import { SomeClientLib } from 'some-client-lib'

export function ClientOnlyWrapper(props: any) {
  return <SomeClientLib {...props} />
}

// 서버 컴포넌트에서 사용
import { ClientOnlyWrapper } from '@/components/client-only-lib'

export default function Page() {
  return <ClientOnlyWrapper data={serverData} />
}

10. 퀴즈

Q1. React 19에서 useMemo, useCallback, memo를 대체하는 기능은 무엇인가요?

React Compiler(이전 명칭 React Forget)입니다. 빌드 타임에 컴포넌트를 분석하여 자동으로 메모이제이션을 적용합니다. 개발자가 수동으로 메모이제이션 훅을 작성할 필요가 없어지며, 코드가 간결해지고 실수를 줄일 수 있습니다.

Next.js 15에서는 next.config.jsexperimental.reactCompiler: true로 활성화할 수 있습니다.

Q2. Next.js 15에서 params, searchParams, cookies(), headers()가 모두 비동기로 변경된 이유는 무엇인가요?

PPR(Partial Pre-Rendering) 최적화를 위해서입니다. 요청 데이터를 실제 사용 시점까지 지연(defer)할 수 있게 되어, 정적 부분은 빌드 타임에 미리 렌더링하고 동적 부분(요청 데이터 의존)만 런타임에 처리할 수 있습니다. 이를 통해 하나의 페이지에서 정적 쉘을 즉시 제공하고 동적 콘텐츠를 스트리밍하는 것이 가능해졌습니다.

마이그레이션을 위해 npx @next/codemod@canary next-async-request-api . 코드모드를 사용할 수 있습니다.

Q3. 서버 컴포넌트 안에서 클라이언트 컴포넌트를 사용하면서도, 그 클라이언트 컴포넌트의 자식이 서버 컴포넌트로 남도록 하는 패턴은 무엇인가요?

Composition(합성) 패턴 또는 children 패턴입니다.

서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하면서, 서버에서 미리 렌더링된 콘텐츠를 children prop으로 전달합니다. 클라이언트 컴포넌트는 children을 그대로 렌더링만 하므로, 전달된 서버 컴포넌트는 서버에서 렌더링된 상태를 유지합니다.

주의: 클라이언트 컴포넌트에서 서버 컴포넌트를 직접 import하면, 해당 서버 컴포넌트가 자동으로 클라이언트 컴포넌트로 변환됩니다.

Q4. Next.js 15의 4가지 캐싱 레이어를 설명하고, 각각의 위치(서버/클라이언트)를 구분해주세요.
  1. Request Memoization (서버): 같은 렌더링 사이클 내에서 동일한 fetch 요청을 자동 중복 제거합니다. React의 자체 기능입니다.

  2. Data Cache (서버): fetch 응답을 서버에 영구 저장합니다. Next.js 15에서는 기본 비활성(no-store)이며, 명시적으로 활성화해야 합니다.

  3. Full Route Cache (서버): 빌드 타임에 생성된 정적 라우트의 HTML과 RSC Payload를 저장합니다. SSG/ISR 라우트에 적용됩니다.

  4. Router Cache (클라이언트): 방문한 라우트의 RSC Payload를 브라우저 메모리에 캐시합니다. Next.js 15에서는 기본 0초(항상 서버에서 최신 데이터 가져옴)입니다.

Q5. Server Actions과 Route Handlers(API Routes)는 각각 어떤 상황에서 사용하는 것이 적절한가요?

Server Actions:

  • 폼 제출 및 데이터 변경(CRUD) 작업
  • Progressive Enhancement가 필요한 경우 (JS 비활성 환경 대응)
  • 타입 안전한 서버 함수 호출이 필요한 경우
  • revalidatePath/revalidateTag와의 자연스러운 통합
  • 내부 데이터 변경 (DB 업데이트, 파일 저장 등)

Route Handlers:

  • 외부 시스템과의 연동 (웹훅 수신, OAuth 콜백 등)
  • 제3자 서비스가 호출하는 API 엔드포인트
  • 파일 다운로드, 이미지 생성 등 커스텀 응답 필요 시
  • REST API를 외부에 노출해야 하는 경우

일반적인 원칙: 내부 데이터 변경에는 Server Actions, 외부 시스템 연동에는 Route Handlers를 사용합니다.


11. 참고 자료

  1. React 19 공식 블로그 - React v19 릴리스 노트
  2. Next.js 15 공식 블로그 - Next.js 15 릴리스 노트
  3. Next.js 공식 문서 - App Router
  4. Next.js 공식 문서 - Server Components
  5. Next.js 공식 문서 - Server Actions and Mutations
  6. Next.js 공식 문서 - Caching
  7. React 공식 문서 - use() Hook
  8. React 공식 문서 - React Compiler
  9. Vercel 블로그 - Partial Pre-Rendering
  10. Next.js 공식 문서 - Turbopack
  11. Auth.js (NextAuth.js v5) 공식 문서
  12. Prisma 공식 문서 - Next.js 통합
  13. TanStack Query 공식 문서 - Next.js Integration
  14. Next.js 공식 문서 - Migrating from Pages Router
  15. Web.dev - Core Web Vitals
  16. Vercel 블로그 - How React Server Components Work
  17. Next.js GitHub - next/after RFC
  18. Next.js 공식 문서 - Middleware