들어가며
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...