1. Next.js란?
Next.js는 Vercel이 개발한 React 기반 풀스택 프레임워크입니다. 파일 기반 라우팅, 서버 컴포넌트, ISR(증분 정적 재생성) 등으로 성능과 개발 생산성을 극대화합니다.
| 렌더링 방식 | 설명 | 사용 시점 |
|---|---|---|
| SSG | 빌드 시 정적 HTML 생성 | 변경이 적은 마케팅 페이지 |
| ISR | 정적 + 주기적 재검증 | 블로그, 상품 목록 |
| SSR | 요청마다 서버에서 렌더링 | 사용자별 맞춤 페이지 |
| CSR | 클라이언트에서 렌더링 | 대화형 대시보드 |
2. 프로젝트 생성
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--eslint \
--app # App Router 사용
--src-dir # src/ 디렉토리 구조
cd my-app
npm run dev # http://localhost:3000bash
# 프로젝트 구조 (App Router)
my-app/
├── app/
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # / 경로
│ ├── globals.css
│ ├── blog/
│ │ ├── page.tsx # /blog
│ │ └── [slug]/
│ │ └── page.tsx # /blog/:slug
│ └── api/
│ └── users/
│ └── route.ts # /api/users
├── components/
├── lib/
└── public/text
3. App Router & 파일 기반 라우팅
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ lang?: string }>;
}
// generateStaticParams: 정적 경로 사전 생성
export async function generateStaticParams() {
const posts = await fetchPosts();
return posts.map(p => ({ slug: p.slug }));
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await fetchPost(slug);
if (!post) notFound(); // 404 페이지
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// 라우팅 파일 규칙
// page.tsx — 라우트 UI
// layout.tsx — 공유 레이아웃 (중첩 가능)
// loading.tsx — Suspense 폴백
// error.tsx — 에러 바운더리 ('use client' 필수)
// not-found.tsx — 404 페이지
// route.ts — API 엔드포인트
// middleware.ts — 요청 인터셉터 (루트)tsx
4. 서버 vs 클라이언트 컴포넌트
기본값은 서버 컴포넌트입니다. 클라이언트 API(useState, useEffect, 이벤트 핸들러)가 필요할 때만
'use client'를 선언하세요. 컴포넌트 트리의 최대한 잎 노드에 배치하면 번들 크기를 최소화할 수 있습니다.
// ── 서버 컴포넌트 (기본) ─────────────────────────
// app/products/page.tsx
async function ProductList() {
// 서버에서 직접 DB/API 접근 (비밀키 노출 없음)
const products = await db.product.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<ul>
{products.map(p => (
<li key={p.id}>
<h3>{p.name}</h3>
<AddToCart productId={p.id} /> {/* 클라이언트 컴포넌트 */}
</li>
))}
</ul>
);
}
// ── 클라이언트 컴포넌트 ───────────────────────────
// components/AddToCart.tsx
'use client';
import { useState } from 'react';
export function AddToCart({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
};
return (
<button onClick={handleClick} disabled={loading}>
{loading ? '추가 중...' : '장바구니 담기'}
</button>
);
}tsx
5. 레이아웃 & 템플릿
// app/layout.tsx — 루트 레이아웃
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: { default: 'My App', template: '%s | My App' },
description: '...',
metadataBase: new URL('https://example.com'),
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body className={inter.className}>
<header><nav>{/* 네비게이션 */}</nav></header>
<main>{children}</main>
<footer>{/* 푸터 */}</footer>
</body>
</html>
);
}
// app/blog/layout.tsx — 중첩 레이아웃
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-8">
<aside><BlogSidebar /></aside>
<div className="flex-1">{children}</div>
</div>
);
}tsx
6. 데이터 페칭 & 캐싱
// ── fetch 캐싱 제어 ──────────────────────────────
// ISR: 300초마다 재검증
const data = await fetch('/api/posts', {
next: { revalidate: 300 }
});
// 동적 렌더링 (캐시 없음)
const data = await fetch('/api/user', {
cache: 'no-store'
});
// 빌드 시 한 번만 (SSG)
const data = await fetch('/api/config', {
cache: 'force-cache'
});
// ── React cache()로 요청 중복 제거 ────────────────
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
// 같은 렌더 트리에서 getUser('1') 여러 번 호출해도 1번만 실행
// ── Parallel data fetching ────────────────────────
async function Page() {
// 직렬 (비효율): await 후 다음 await
// const user = await getUser(id);
// const posts = await getPosts(id);
// 병렬 (효율적): 동시 실행
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);
return <div>{user.name}</div>;
}
// ── Loading UI (Suspense) ─────────────────────────
// app/blog/loading.tsx
export default function Loading() {
return <div className="skeleton">로딩 중...</div>;
}
// app/blog/page.tsx
import { Suspense } from 'react';
export default function Blog() {
return (
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
);
}tsx
7. 서버 액션 (Server Actions)
서버에서 실행되는 비동기 함수입니다. 폼 제출, 데이터 변경에 사용합니다. API Route 없이 클라이언트에서 직접 호출 가능합니다.
// app/actions/user.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export async function createUser(formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = CreateUserSchema.safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.user.create({ data: parsed.data });
revalidatePath('/users'); // 캐시 무효화
redirect('/users'); // 리디렉션
}
// app/users/new/page.tsx — 폼에서 직접 사용
'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions/user';
export default function NewUserForm() {
const [state, action, isPending] = useActionState(createUser, null);
return (
<form action={action}>
{state?.error?.name && <p className="error">{state.error.name}</p>}
<input name="name" placeholder="이름" />
<input name="email" type="email" placeholder="이메일" />
<button disabled={isPending}>
{isPending ? '생성 중...' : '사용자 생성'}
</button>
</form>
);
}tsx
8. 이미지 & 폰트 최적화
// next/image — 자동 WebP 변환, 지연 로딩, 크기 최적화
import Image from 'next/image';
// 로컬 이미지 (크기 자동 감지)
import heroImg from '@/public/hero.png';
<Image src={heroImg} alt="히어로 이미지" priority />
// 원격 이미지 (next.config.ts에 도메인 허용 필요)
<Image
src="https://cdn.example.com/photo.jpg"
alt="사진"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
quality={80}
/>
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [{ hostname: 'cdn.example.com' }],
},
};
// next/font — 서브셋, 자가 호스팅 자동 처리
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const notoKR = Noto_Sans_KR({ weight: ['400','700'], subsets: ['latin'] });tsx
9. 미들웨어
// middleware.ts (루트에 위치)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 인증 체크
const token = request.cookies.get('auth-token');
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 헤더 추가
const response = NextResponse.next();
response.headers.set('x-pathname', pathname);
return response;
}
// 미들웨어가 실행될 경로 지정
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};tsx
10. SEO & 메타데이터
// 정적 메타데이터
export const metadata = {
title: '블로그 — My App',
description: '...',
openGraph: {
title: '블로그',
images: [{ url: '/og-blog.png' }],
},
};
// 동적 메타데이터 (generateMetadata)
export async function generateMetadata({ params }: Props) {
const post = await fetchPost((await params).slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [{ url: post.coverImage }],
},
alternates: {
canonical: `https://example.com/blog/${post.slug}`,
},
};
}tsx
11. 배포
# ── Vercel (기본) ────────────────────────────────
npm i -g vercel
vercel # 최초 배포 (대화형)
vercel --prod # 프로덕션 배포
# ── Cloudflare Workers (@opennextjs/cloudflare) ───
npm install -D @opennextjs/cloudflare esbuild wrangler
npx opennextjs-cloudflare build
npx wrangler deploybash
# wrangler.jsonc (Cloudflare Workers)
{
"name": "my-nextjs-app",
"compatibility_date": "2025-05-18",
"compatibility_flags": ["nodejs_compat"],
"main": ".open-next/worker.js",
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
}
}json
# Docker 배포
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]dockerfile
Docker standalone 출력을 사용하려면
next.config.ts에 output: 'standalone'을 추가하세요.
12. 다음 단계
Next.js 심화 로드맵
• Prisma / Drizzle ORM: 타입 안전 데이터베이스 쿼리
• NextAuth.js (Auth.js): OAuth, JWT, 세션 인증
• tRPC: 타입 안전 API (서버·클라이언트 타입 공유)
• Zustand / Jotai: 경량 클라이언트 상태 관리
• React Query (TanStack): 서버 상태 동기화
• Storybook: 컴포넌트 문서화 및 테스트
연계 가이드: React 가이드 · TypeScript 가이드 · Docker 가이드
• Prisma / Drizzle ORM: 타입 안전 데이터베이스 쿼리
• NextAuth.js (Auth.js): OAuth, JWT, 세션 인증
• tRPC: 타입 안전 API (서버·클라이언트 타입 공유)
• Zustand / Jotai: 경량 클라이언트 상태 관리
• React Query (TanStack): 서버 상태 동기화
• Storybook: 컴포넌트 문서화 및 테스트
연계 가이드: React 가이드 · TypeScript 가이드 · Docker 가이드