Next.js
비유로 시작하기
일반 식당(CSR)은 손님이 오면 그때 요리를 시작합니다. 뷔페(SSG)는 미리 음식을 만들어 진열합니다. 정기배송 도시락(ISR)은 주기적으로 새로 만듭니다. 주문 즉시 조리(SSR)는 손님 정보에 맞게 즉석에서 만듭니다.
Next.js는 React 기반의 풀스택 프레임워크입니다. 파일 기반 라우팅, 다양한 렌더링 전략(SSR/SSG/ISR), API Routes, 이미지 최적화 등을 제공합니다.
렌더링 전략 비교
graph TD
subgraph CSR Client-Side Rendering
C1[브라우저 요청] --> C2[빈 HTML 반환]
C2 --> C3[JS 번들 다운로드]
C3 --> C4[React가 DOM 생성]
end
subgraph SSG Static Site Generation
S1[빌드 시 HTML 생성] --> S2[CDN 배포]
S2 --> S3[브라우저 요청]
S3 --> S4[완성된 HTML 즉시 반환]
end
subgraph SSR Server-Side Rendering
R1[브라우저 요청] --> R2[서버에서 데이터 조회]
R2 --> R3[완성된 HTML 생성]
R3 --> R4[클라이언트로 전송]
end
subgraph ISR Incremental Static Regeneration
I1[첫 요청: SSG처럼 빠름]
I2[revalidate 시간 경과]
I3[백그라운드에서 재생성]
I1 --> I2 --> I3 --> I1
end
| 전략 | 빌드 시 생성 | 요청 시 생성 | SEO | 실시간 데이터 | 사용 예 |
|---|---|---|---|---|---|
| CSR | X | O (클라이언트) | 나쁨 | 가능 | 대시보드, 관리자 |
| SSG | O | X | 좋음 | 불가 | 블로그, 문서 |
| ISR | O | X (주기 갱신) | 좋음 | 준실시간 | 뉴스, 상품 목록 |
| SSR | X | O (서버) | 좋음 | 가능 | 개인화 피드, 실시간 |
App Router (Next.js 13+)
app/ 디렉토리 기반의 새 라우팅 시스템입니다.
파일 구조
app/
├── layout.tsx # 루트 레이아웃 (모든 페이지 공유)
├── page.tsx # / 루트 페이지
├── loading.tsx # 로딩 UI (Suspense 자동 적용)
├── error.tsx # 에러 UI
├── not-found.tsx # 404 UI
├── products/
│ ├── layout.tsx # /products 레이아웃
│ ├── page.tsx # /products 페이지
│ └── [id]/
│ └── page.tsx # /products/123 동적 페이지
├── api/
│ └── orders/
│ └── route.ts # /api/orders API Route
└── (auth)/ # 괄호: URL에 포함 안 됨 (레이아웃 그룹)
├── login/
└── register/
기본 페이지
// app/products/page.tsx
export default async function ProductsPage() {
// 서버 컴포넌트: fetch는 서버에서 실행됨
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 } // ISR: 60초마다 재검증
}).then(r => r.json());
return (
<div>
<h1>상품 목록</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
Server Components vs Client Components
Next.js 13+의 핵심 개념입니다.
Server Components (기본)
// app/products/[id]/page.tsx
// 'use client' 없으면 기본적으로 Server Component
async function getProduct(id: string) {
// 서버에서 직접 DB 접근 가능
const product = await db.product.findUnique({ where: { id } });
return product;
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>₩{product.price.toLocaleString()}</p>
{/* Client Component 중첩 가능 */}
<AddToCartButton productId={product.id} />
</div>
);
}
// 메타데이터 (SEO)
export async function generateMetadata({ params }) {
const product = await getProduct(params.id);
return {
title: product.name,
description: product.description,
openGraph: { images: [product.imageUrl] }
};
}
Server Component의 장점:
- 번들 크기에 포함 안 됨 (JS 0byte 클라이언트 전달)
- 서버 리소스 직접 접근 (DB, 파일시스템)
- API key가 클라이언트에 노출 안 됨
Client Components
// components/AddToCartButton.tsx
'use client' // 클라이언트 컴포넌트 선언
import { useState } from 'react';
import { useCartStore } from '@/store/cart';
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
const addToCart = useCartStore(state => state.addItem);
const handleClick = async () => {
await addToCart(productId);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
};
return (
<button onClick={handleClick} disabled={added}>
{added ? '담겼습니다!' : '장바구니 담기'}
</button>
);
}
Client Component가 필요한 경우:
useState,useEffect등 Hook 사용- 브라우저 API (window, document)
- 이벤트 핸들러
API Routes
// app/api/orders/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('userId');
const orders = await db.order.findMany({
where: { customerId: userId },
include: { items: true }
});
return NextResponse.json(orders);
}
export async function POST(request: NextRequest) {
const body = await request.json();
// 인증 확인
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const order = await db.order.create({ data: body });
return NextResponse.json(order, { status: 201 });
}
// app/api/orders/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const order = await db.order.findUnique({ where: { id: params.id } });
if (!order) {
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
}
return NextResponse.json(order);
}
미들웨어
요청이 완료되기 전에 실행되는 코드입니다. 인증, 리다이렉트, 로깅에 활용합니다.
// middleware.ts (루트에 위치)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
const { pathname } = request.nextUrl;
// 인증 필요 경로
const protectedPaths = ['/dashboard', '/orders', '/profile'];
const isProtected = protectedPaths.some(p => pathname.startsWith(p));
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 로그인 상태에서 로그인 페이지 접근 시 리다이렉트
if (pathname === '/login' && token) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 요청 헤더 추가
const response = NextResponse.next();
response.headers.set('X-Request-Id', crypto.randomUUID());
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
성능 최적화
Image 최적화
import Image from 'next/image';
// next/image: 자동 최적화 (WebP 변환, 레이지 로딩, 크기 최적화)
export function ProductImage({ product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
priority={true} // LCP 이미지는 priority 설정
placeholder="blur" // 블러 플레이스홀더
blurDataURL={product.blurHash}
/>
);
}
Font 최적화
// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={notoSansKr.className}>
<body>{children}</body>
</html>
);
}
// 폰트 파일을 자체 호스팅 → 외부 요청 없음 → 빠름
Suspense와 Streaming
// app/products/page.tsx
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
<h1>상품 목록</h1>
{/* 각 Suspense 구역이 독립적으로 스트리밍 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations />
</Suspense>
</div>
);
}
극한 시나리오
시나리오: 실시간 데이터가 필요한 SSG 페이지
요구사항: 상품 상세 페이지 - SEO 필요 + 재고는 실시간
// 해결: SSG + Client-side Fetch 혼합
export default async function ProductPage({ params }) {
// 정적 데이터: SSG (상품명, 설명, 이미지)
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 실시간 데이터: Client Component에서 별도 fetch */}
<StockBadge productId={params.id} />
</div>
);
}
'use client'
function StockBadge({ productId }) {
const { data: stock } = useQuery({
queryKey: ['stock', productId],
queryFn: () => fetch(`/api/stock/${productId}`).then(r => r.json()),
refetchInterval: 30000, // 30초마다 재조회
});
return <span>{stock?.available ? '재고 있음' : '품절'}</span>;
}
// generateStaticParams: 빌드 시 생성할 페이지 목록
export async function generateStaticParams() {
const products = await getTopProducts(1000); // 인기 상품 1000개 SSG
return products.map(p => ({ id: p.id }));
}