Next.js를 실무 흐름으로 이해하기
React 기반 풀스택 프레임워크. App Router, 서버/클라이언트 컴포넌트, 데이터 캐싱, 서버 액션, Route Handlers, 미들웨어, 메타데이터·SEO, 스트리밍, 배포까지 — 현대 웹 개발의 표준을 전 과정으로 학습합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
React 기반 풀스택 프레임워크. App Router, 서버/클라이언트 컴포넌트, 데이터 캐싱, 서버 액션, Route Handlers, 미들웨어, 메타데이터·SEO, 스트리밍, 배포까지 — 현대 웹 개발의 표준을 전 과정으로 학습합니다.
React 기반 풀스택 프레임워크. App Router, 서버/클라이언트 컴포넌트, 데이터 캐싱, 서버 액션, Route Handlers, 미들웨어, 메타데이터·SEO, 스트리밍, 배포까지 — 현대 웹 개발의 표준을 전 과정으로 학습합니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
화면을 그리는 법에서 멈추지 않고, 상태, 데이터 요청, 라우팅, 접근성, 배포 단위까지 함께 봅니다.
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Next.js를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
Next.js를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
| 렌더링 | 설명 | 사용 시점 |
|---|---|---|
| SSG | 빌드 시 정적 HTML 생성 | 변경 적은 콘텐츠 |
| ISR | 정적 + 주기적 재검증 | 블로그, 상품 목록 |
| SSR | 요청마다 서버 렌더링 | 사용자별 맞춤 페이지 |
| CSR | 클라이언트 렌더링 | 대화형 대시보드 |
여기서는 App Router & 파일 기반 라우팅을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
app/
├── layout.tsx # 루트 레이아웃
├── page.tsx # /
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug (동적 세그먼트)
├── docs/
│ └── [...slug]/
│ └── page.tsx # /docs/a/b/c (catch-all)
├── (marketing)/ # route group — URL에 영향 없이 폴더만 그룹화
│ └── about/page.tsx # /about
└── api/
└── users/
└── route.ts # /api/users| 패턴 | 의미 |
|---|---|
| [id] | 동적 세그먼트 — params.id로 접근 |
| [...slug] | catch-all — 여러 세그먼트를 배열로 매칭 |
| [[...slug]] | optional catch-all — 상위 경로 자체도 매칭 |
| (marketing) | route group — URL 경로에는 나타나지 않는 폴더 그룹 |
| @modal | parallel route — 같은 레이아웃에 여러 페이지를 동시 렌더링 |
| (.)photo | intercepting route — 모달 등에서 다른 라우트를 가로채기 |
여기서는 서버 vs 클라이언트 컴포넌트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// 서버 컴포넌트 (기본) — DB 직접 접근 가능
async function ProductList() {
const products = await db.product.findMany({ take: 20 });
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
// 클라이언트 컴포넌트 — 인터랙션 필요 시
'use client';
import { useState } from 'react';
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n+1)}>{n}</button>;
}여기서는 데이터 페칭 & 캐싱을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
// 기본 — 무기한 캐시 (SSG처럼 동작)
const res = await fetch('https://api.example.com/products');
// ISR — N초마다 백그라운드에서 재검증
const res2 = await fetch(url, { next: { revalidate: 60 } });
// 태그 기반 재검증 — 특정 데이터만 선택적으로 무효화
const res3 = await fetch(url, { next: { tags: ['products'] } });
// 이후 서버 액션 등에서: revalidateTag('products');
// 매 요청마다 새로 페칭 (SSR)
const res4 = await fetch(url, { cache: 'no-store' });
// 라우트 전체를 동적 렌더링으로 강제
export const dynamic = 'force-dynamic';| 옵션 | 동작 |
|---|---|
| (기본, 옵션 없음) | 빌드 시 캐시, 재검증 전까지 재사용 |
| next: { revalidate: N } | N초 경과 후 백그라운드에서 재검증 (ISR) |
| next: { tags: [...] } | revalidateTag()로 특정 캐시만 선택적으로 무효화 |
| cache: 'no-store' | 매 요청마다 새로 페칭 (SSR과 동일) |
| export const dynamic = 'force-dynamic' | 페이지 전체를 요청마다 서버 렌더링 |
여기서는 서버 액션 & 폼을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const schema = z.object({ name: z.string().min(1, '이름을 입력하세요') });
export async function createUser(prevState: { error?: string }, formData: FormData) {
const parsed = schema.safeParse({ name: formData.get('name') });
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
await db.user.create({ data: parsed.data });
revalidatePath('/users');
return { error: undefined };
}'use client';
import { useActionState } from 'react';
import { createUser } from './actions';
export function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, {});
return (
<form action={formAction}>
<input name="name" />
{state.error && <p>{state.error}</p>}
<button disabled={isPending}>추가</button>
</form>
);
}여기서는 Route Handlers (API)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import { NextRequest, NextResponse } from 'next/server';
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) return NextResponse.json({ error: 'not found' }, { status: 404 });
return NextResponse.json(user);
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}| 상황 | 선택 |
|---|---|
| 같은 앱 안 폼 제출·mutation | Server Action |
| 외부 클라이언트(모바일 앱 등)가 호출할 REST API | Route Handler |
| webhook 수신 (결제, GitHub 등) | Route Handler |
| 서버 컴포넌트 밖에서 스트리밍 응답이 필요할 때 | Route Handler |
여기서는 미들웨어을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};여기서는 메타데이터 & SEO을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
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);
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, images: [post.coverImage] },
};
}export default async function sitemap() {
const posts = await getAllPosts();
return posts.map((p) => ({
url: 'https://example.com/blog/' + p.slug,
lastModified: p.updatedAt,
}));
}여기서는 Loading/Error UI & 스트리밍을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
app/dashboard/
├── layout.tsx
├── page.tsx
├── loading.tsx # page.tsx 준비되는 동안 자동 표시
├── error.tsx # 하위에서 던진 에러를 잡음 (use client 필요)
└── not-found.tsx # notFound() 호출 시 표시'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>문제가 발생했습니다: {error.message}</p>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}import { Suspense } from 'react';
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<p>통계 불러오는 중...</p>}>
<SlowStats />
</Suspense>
</>
);
}여기서는 이미지 & 폰트 최적화을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
import Image from 'next/image';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function Hero() {
return (
<main className={inter.className}>
<Image
src="/hero.png"
alt="제품 소개 이미지"
width={1200}
height={600}
priority // LCP 이미지에만 사용
/>
</main>
);
}여기서는 배포 (Vercel / Cloudflare)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
# Vercel
npm i -g vercel && vercel --prod
# Cloudflare Workers
npm install -D @opennextjs/cloudflare
npx opennextjs-cloudflare build
npx wrangler deploy| 배포 대상 | 특징 |
|---|---|
| Vercel | Next.js 제작사, ISR·Edge·이미지 최적화 등 모든 기능 완전 지원 |
| Cloudflare Workers/Pages | @opennextjs/cloudflare로 배포, 낮은 지연시간과 저렴한 비용 |
| Docker (자체 호스팅) | next.config.ts에 output: 'standalone' 설정 후 경량 이미지로 빌드 |
이 섹션은 다음 단계을 실무 관점에서 정리합니다. 개념을 외우기보다, 어떤 상황에서 이 기준을 꺼내 쓸지에 초점을 맞춰보세요.
Next.js 실무 설계은 선택지가 갈리는 지점입니다. 표를 기준으로 각 방법의 쓰임새와 운영상의 차이를 비교해두면 이후 판단이 훨씬 쉬워집니다.
| 결정 지점 | 확인 질문 | 실무 기준 |
|---|---|---|
| 경계 | Next.js 코드에서 바뀌기 쉬운 부분은 어디인가? | 입출력, 설정, 외부 연동, 핵심 규칙을 분리합니다. |
| 상태 | 상태가 어디서 생성되고 어디서 사라지는가? | 상태 소유자와 수명 주기를 코드로 드러냅니다. |
| 장애 | 실패했을 때 호출자는 무엇을 받는가? | timeout, fallback, error contract를 먼저 정합니다. |
이 섹션은 Next.js 운영 기준을 실무 관점에서 정리합니다. 개념을 외우기보다, 어떤 상황에서 이 기준을 꺼내 쓸지에 초점을 맞춰보세요.
Next.js 검증 전략은 선택지가 갈리는 지점입니다. 표를 기준으로 각 방법의 쓰임새와 운영상의 차이를 비교해두면 이후 판단이 훨씬 쉬워집니다.
| 품질 축 | 검증 방법 | 완료 기준 |
|---|---|---|
| 정확성 | 정상/실패 케이스를 자동화합니다. | 핵심 시나리오가 재현 가능하게 통과합니다. |
| 회귀 방지 | 버그 수정 시 동일 케이스를 테스트로 남깁니다. | 같은 장애가 다시 배포되지 않습니다. |
| 운영성 | 로그, 메트릭, 알림을 확인합니다. | 문제가 생겼을 때 원인 추적 경로가 있습니다. |