Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

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):**

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 (

{sortedItems.map((item) => (

{item.name}

))}

)

})

**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 (

{sortedItems.map((item) => (

{item.name}

))}

)

}

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도 새로운 방식으로 소비합니다.

// Promise를 직접 use()로 읽기

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {

const user = use(userPromise) // Suspense 경계에서 자동 대기

return (

)

}

// 조건부로 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 (

)

}

1.3 Actions: useActionState, useFormStatus, useOptimistic

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

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

'use client'

function TodoForm() {

const [state, formAction, isPending] = useActionState(createTodo, {

message: '',

errors: {},

})

return (

{state.errors?.title && <p className="error">{state.errors.title}</p>}

{isPending ? '추가 중...' : '추가'}

{state.message && <p>{state.message}</p>}

)

}

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

'use client'

function SubmitButton() {

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

return (

{pending ? '제출 중...' : '제출하기'}

)

}

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

'use client'

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 (

{optimisticTodos.map((todo) => (

))}

)

}

1.4 Document Metadata와 Stylesheet 지원

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

function BlogPost({ post }: { post: Post }) {

return (

)

}

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)

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 필요

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

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 (

{/* 동적 부분: 요청 시 스트리밍 */}

)

}

// 동적 컴포넌트 - 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입니다:

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 14 | Next.js 15 |

| ------------------- | ----------------------- | ------------------------------ |

| fetch 캐시 | force-cache (기본 캐시) | no-store (기본 미캐시) |

| GET Route Handler | 캐시됨 | 캐시 안됨 |

| Client Router Cache | 5분 | 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 - 기본)

export default async function DashboardPage() {

// 서버에서 직접 DB 접근 - API 레이어 불필요

const metrics = await db.metrics.findMany({

where: { date: { gte: thirtyDaysAgo() } },

orderBy: { date: 'asc' },

})

// 직렬화 가능한 데이터만 클라이언트 컴포넌트에 전달

return (

)

}

// app/dashboard/chart.tsx (Client Component)

'use client'

export function DashboardChart({ data }: { data: Metric[] }) {

// 클라이언트에서만 실행되는 인터랙티브 차트

return (

)

}

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

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

// app/layout.tsx (Server Component)

export default function Layout({ children }: { children: React.ReactNode }) {

return (

{/* 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 전달 */}

)

}

// app/sidebar.tsx (Client Component)

'use client'

export function Sidebar({ children }: { children: React.ReactNode }) {

const [isOpen, setIsOpen] = useState(true)

return (

{isOpen && children} {/* 서버에서 렌더링된 children */}

)

}

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

// 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 (

{posts.map((post) => (

))}

)

}

3.5 번들 크기 최적화

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

// 서버 컴포넌트 - 이 import들은 클라이언트 번들에 포함 안됨

// 총 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'

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'

export default function LoginPage() {

const [state, formAction, isPending] = useActionState(loginAction, {

message: '',

errors: {},

})

return (

{state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}

{state.errors?.password && <p className="text-red-500">{state.errors.password[0]}</p>}

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

{isPending ? '로그인 중...' : '로그인'}

)

}

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

4.2 낙관적 업데이트 실전

// app/comments/comment-section.tsx

'use client'

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 (

{optimisticComments.map((comment) => (

{comment.pending && <span>전송 중...</span>}

))}

)

}

4.3 파일 업로드

// actions/upload.ts

'use server'

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'

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 Actions | Route 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 (

)

}

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

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

export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {

const { id } = await params

const photo = await getPhoto(id)

return (

)

}

피드에서 사진을 클릭하면 모달로 열리지만, 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 (

)

}

// app/blog/error.tsx - Error Boundary 자동 적용

'use client' // Error 컴포넌트는 반드시 클라이언트 컴포넌트

export default function Error({

error,

reset,

}: {

error: Error & { digest?: string }

reset: () => void

}) {

return (

다시 시도

)

}

// app/blog/[slug]/not-found.tsx

export default function NotFound() {

return (

)

}

5.5 generateMetadata로 동적 SEO

// app/blog/[slug]/page.tsx

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 (프로젝트 루트)

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,

},

},

},

}

async function getProducts() {

'use cache'

cacheLife('blog')

cacheTag('products')

return db.product.findMany()

}

6.3 렌더링 전략 비교표

| 전략 | 빌드 시 | 요청 시 | 재검증 | 사용 사례 |

| ------------ | ------------ | ---------------------- | -------------- | --------------------- |

| SSG (Static) | HTML 생성 | 캐시에서 제공 | 빌드 시 | 블로그, 문서 |

| ISR | HTML 생성 | 캐시 + 백그라운드 갱신 | 시간/태그 기반 | 제품 목록, 뉴스 |

| SSR | - | 매 요청 렌더링 | 없음 | 개인화 페이지 |

| PPR | 정적 쉘 생성 | 동적 부분만 렌더링 | 혼합 | 제품 상세 (가격+설명) |

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

// app/products/[id]/page.tsx

// 제품 기본 정보: 자주 변하지 않으므로 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 (

)

}

async function PriceSection({ productId }: { productId: string }) {

const pricing = await getProductPricing(productId)

return (

{pricing?.discount && <span className="text-red-500">{pricing.discount}% 할인</span>}

)

}

async function ReviewSection({ productId }: { productId: string }) {

const reviews = await getProductReviews(productId)

return (

{reviews.map((review) => (

))}

)

}

7. 성능 최적화

7.1 Turbopack vs Webpack 벤치마크

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

| 측정 항목 | Webpack | Turbopack | 개선율 |

| -------------------------- | ------- | --------- | ------ |

| Cold Start (dev) | 8.2초 | 1.9초 | 76.7% |

| Hot Module Replacement | 520ms | 19ms | 96.3% |

| 라우트 컴파일 (첫 접근) | 1.8초 | 0.97초 | 45.8% |

| 메모리 사용량 (2000+ 모듈) | 1.2GB | 650MB | 45.8% |

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

// Image 최적화

function ProductCard({ product }: { product: Product }) {

return (

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 최적화 - 레이아웃 시프트 방지

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 (

)

}

// Script 최적화

export default function Layout({ children }: { children: React.ReactNode }) {

return (

<>

{children}

{/* afterInteractive: 페이지 하이드레이션 후 로드 (기본) */}

{/* lazyOnload: 브라우저 유휴 시 로드 */}

{/* worker: Web 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

const sorted = _.sortBy(items, 'name')

// Good - 필요한 함수만 import

const sorted = sortBy(items, 'name')

// Best - 서버 컴포넌트에서 사용 (번들에 포함 안됨)

// 서버 컴포넌트라면 전체 import 해도 무관

7.4 Dynamic Imports와 Lazy Loading

// 클라이언트 전용 컴포넌트 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 (

{showModal && <DynamicModal onClose={() => setShowModal(false)} />}

)

}

7.5 Core Web Vitals 최적화

**LCP (Largest Contentful Paint) 최적화:**

// 가장 큰 콘텐츠 요소에 priority 설정

// 중요 리소스 프리로드

**CLS (Cumulative Layout Shift) 최적화:**

// 이미지 크기 명시 (width/height)

// 폰트 swap으로 FOIT 방지

const font = Inter({ display: 'swap' })

// 동적 콘텐츠에 최소 높이 설정

**INP (Interaction to Next Paint) 최적화:**

// 무거운 작업은 useTransition으로 우선순위 낮추기

'use client'

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 (

{isPending ? <Spinner /> : <ResultList results={results} />}

)

}

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

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

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)

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'

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 (

{dashboard?.widgets.map((widget) => (

))}

)

}

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 (

)

}

// After: app/blog/[slug]/page.tsx

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 (

)

}

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

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 (

)

}

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

export async function GET() {

const posts = await prisma.post.findMany()

return NextResponse.json(posts)

}

// 내부 데이터 변경은 Server Action으로 대체

// actions/posts.ts

;('use server')

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 객체는 직렬화 불가

// 해결: 문자열로 변환

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

// 오류: 서버 컴포넌트에서 useContext 사용 불가

// 해결: 클라이언트 컴포넌트로 Provider 분리

// app/providers.tsx

'use client'

export function Providers({ children }: { children: React.ReactNode }) {

return (

)

}

// app/layout.tsx (Server Component)

export default function RootLayout({ children }: { children: React.ReactNode }) {

return (

)

}

**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'

export function ClientOnlyWrapper(props: any) {

return <SomeClientLib {...props} />

}

// 서버 컴포넌트에서 사용

export default function Page() {

return <ClientOnlyWrapper data={serverData} />

}

10. 퀴즈

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

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

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

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

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

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

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

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초(항상 서버에서 최신 데이터 가져옴)입니다.

**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

현재 단락 (1/1141)

React 19와 Next.js 15의 조합은 프론트엔드 개발의 패러다임을 근본적으로 바꾸고 있습니다. 서버 컴포넌트가 기본이 되고, 서버 액션으로 API 레이어가 사라지며, Tu...

작성 글자: 0원문 글자: 28,792작성 단락: 0/1141