Next.js Best Practices - Vercel이 공개한 Next.js 개발 가이드 (전문 번역)
· nextjs, react, vercel, best-practices
목차
참고: 이 문서는 Vercel Labs의 next-best-practices 스킬 전문을 번역한 것입니다. 원문은 인덱스(SKILL.md)와 19개의 참조 문서로 구성된 AI 에이전트용 스킬이며, 이 글은 19개 참조 문서를 원문 구조 그대로 순서대로 옮겼습니다. 이전에 번역한 React Best Practices가 성능 최적화 규칙 중심이었다면, 이 문서는 Next.js를 올바르게 쓰는 방법 — 파일 컨벤션, 서버/클라이언트 경계, 에러 처리 같은 정확성 규칙 중심입니다.
1. 파일 컨벤션 (File Conventions)
Next.js App Router는 파일 기반 라우팅과 특수 파일 컨벤션을 사용해요.
프로젝트 구조
참고: https://nextjs.org/docs/app/getting-started/project-structure
app/
├── layout.tsx # 루트 레이아웃 (필수)
├── page.tsx # 홈 페이지 (/)
├── loading.tsx # 로딩 UI
├── error.tsx # 에러 UI
├── not-found.tsx # 404 UI
├── global-error.tsx # 전역 에러 UI
├── route.ts # API 엔드포인트
├── template.tsx # 재렌더링되는 레이아웃
├── default.tsx # 병렬 라우트 폴백
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
└── (group)/ # 라우트 그룹 (URL에 영향 없음)
└── page.tsx
특수 파일
| 파일 | 용도 |
|---|---|
page.tsx | 라우트 세그먼트의 UI |
layout.tsx | 세그먼트와 자식들의 공유 UI |
loading.tsx | 로딩 UI (Suspense 경계) |
error.tsx | 에러 UI (Error 경계) |
not-found.tsx | 404 UI |
route.ts | API 엔드포인트 |
template.tsx | layout과 비슷하지만 내비게이션마다 재렌더링 |
default.tsx | 병렬 라우트의 폴백 |
라우트 세그먼트
app/
├── blog/ # 정적 세그먼트: /blog
├── [slug]/ # 동적 세그먼트: /:slug
├── [...slug]/ # Catch-all: /a/b/c
├── [[...slug]]/ # 선택적 catch-all: / 또는 /a/b/c
└── (marketing)/ # 라우트 그룹 (URL에서 무시됨)
병렬 라우트
app/
├── @analytics/
│ └── page.tsx
├── @sidebar/
│ └── page.tsx
└── layout.tsx # { analytics, sidebar }를 props로 받음
인터셉팅 라우트
app/
├── feed/
│ └── page.tsx
├── @modal/
│ └── (.)photo/[id]/ # /feed에서 /photo/[id]를 인터셉트
│ └── page.tsx
└── photo/[id]/
└── page.tsx
컨벤션:
(.)- 같은 레벨(..)- 한 레벨 위(..)(..)- 두 레벨 위(...)- 루트부터
프라이빗 폴더
app/
├── _components/ # 프라이빗 폴더 (라우트 아님)
│ └── Button.tsx
└── page.tsx
_ 접두사를 붙이면 라우팅에서 제외돼요.
Middleware / Proxy
Next.js 14-15: middleware.ts
// middleware.ts (프로젝트 루트)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// 인증, 리다이렉트, 리라이트 등
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
Next.js 16+: proxy.ts
명확성을 위해 이름이 변경됐어요 — 기능은 같고 이름만 달라요:
// proxy.ts (프로젝트 루트)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
// middleware와 같은 로직
return NextResponse.next();
}
export const proxyConfig = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
| 버전 | 파일 | Export | 설정 |
|---|---|---|---|
| v14-15 | middleware.ts | middleware() | config |
| v16+ | proxy.ts | proxy() | proxyConfig |
마이그레이션: npx @next/codemod@latest upgrade를 실행하면 자동으로 이름을 바꿔줘요.
파일 컨벤션 레퍼런스
참고: https://nextjs.org/docs/app/api-reference/file-conventions
2. RSC 경계 (RSC Boundaries)
Server/Client 컴포넌트 경계를 넘을 때 발생하는 유효하지 않은 패턴을 감지하고 방지해요.
감지 규칙 1: async 클라이언트 컴포넌트는 유효하지 않음
클라이언트 컴포넌트는 async 함수가 될 수 없어요. Server Components만 async가 될 수 있어요.
감지 조건: 파일에 'use client'가 있고 컴포넌트가 async function이거나 Promise를 반환
// 잘못된 예: async 클라이언트 컴포넌트
"use client";
export default async function UserProfile() {
const user = await getUser(); // 클라이언트 컴포넌트에서 await 불가
return <div>{user.name}</div>;
}
// 올바른 예: async 제거, 부모 서버 컴포넌트에서 데이터 페칭
// page.tsx (서버 컴포넌트 - 'use client' 없음)
export default async function Page() {
const user = await getUser();
return <UserProfile user={user} />;
}
// UserProfile.tsx (클라이언트 컴포넌트)
("use client");
export function UserProfile({ user }: { user: User }) {
return <div>{user.name}</div>;
}
// 잘못된 예: async 화살표 함수 클라이언트 컴포넌트
"use client";
const Dashboard = async () => {
const data = await fetchDashboard();
return <div>{data}</div>;
};
// 올바른 예: 서버 컴포넌트에서 페칭 후 데이터를 아래로 전달
감지 규칙 2: 직렬화 불가능한 props를 클라이언트 컴포넌트에 전달
Server → Client로 전달되는 props는 JSON 직렬화가 가능해야 해요.
감지 조건: 서버 컴포넌트가 클라이언트 컴포넌트에 다음을 전달:
- 함수 (
'use server'가 붙은 Server Action은 예외) Date객체Map,Set,WeakMap,WeakSet- 클래스 인스턴스
Symbol(전역 등록된 경우 제외)- 순환 참조
// 잘못된 예: 함수 prop
// page.tsx (서버)
export default function Page() {
const handleClick = () => console.log("clicked");
return <ClientButton onClick={handleClick} />;
}
// 올바른 예: 클라이언트 컴포넌트 내부에서 함수 정의
// ClientButton.tsx
("use client");
export function ClientButton() {
const handleClick = () => console.log("clicked");
return <button onClick={handleClick}>Click</button>;
}
// 잘못된 예: Date 객체 (조용히 문자열이 된 뒤 크래시)
// page.tsx (서버)
export default async function Page() {
const post = await getPost();
return <PostCard createdAt={post.createdAt} />; // Date 객체
}
// PostCard.tsx (클라이언트) - .getFullYear()에서 크래시
("use client");
export function PostCard({ createdAt }: { createdAt: Date }) {
return <span>{createdAt.getFullYear()}</span>; // 런타임 에러!
}
// 올바른 예: 서버에서 문자열로 직렬화
// page.tsx (서버)
export default async function Page() {
const post = await getPost();
return <PostCard createdAt={post.createdAt.toISOString()} />;
}
// PostCard.tsx (클라이언트)
("use client");
export function PostCard({ createdAt }: { createdAt: string }) {
const date = new Date(createdAt);
return <span>{date.getFullYear()}</span>;
}
// 잘못된 예: 클래스 인스턴스
const user = new UserModel(data)
<ClientProfile user={user} /> // 메서드가 제거됨
// 올바른 예: 평범한 객체 전달
const user = await getUser()
<ClientProfile user={{ id: user.id, name: user.name }} />
// 잘못된 예: Map/Set
<ClientComponent items={new Map([['a', 1]])} />
// 올바른 예: 배열/객체로 변환
<ClientComponent items={Object.fromEntries(map)} />
<ClientComponent items={Array.from(set)} />
감지 규칙 3: Server Actions는 예외
'use server'가 붙은 함수는 클라이언트 컴포넌트에 전달할 수 있어요.
// 유효함: Server Action은 전달 가능
// actions.ts
"use server";
export async function submitForm(formData: FormData) {
// 서버 사이드 로직
}
// page.tsx (서버)
import { submitForm } from "./actions";
export default function Page() {
return <ClientForm onSubmit={submitForm} />; // OK!
}
// ClientForm.tsx (클라이언트)
("use client");
export function ClientForm({
onSubmit,
}: {
onSubmit: (data: FormData) => Promise<void>;
}) {
return <form action={onSubmit}>...</form>;
}
빠른 참조
| 패턴 | 유효? | 해결책 |
|---|---|---|
'use client' + async function | 아니오 | 서버 부모에서 페칭 후 데이터 전달 |
() => {} 를 클라이언트에 전달 | 아니오 | 클라이언트에서 정의하거나 Server Action 사용 |
new Date()를 클라이언트에 전달 | 아니오 | .toISOString() 사용 |
new Map()을 클라이언트에 전달 | 아니오 | 객체/배열로 변환 |
| 클래스 인스턴스를 클라이언트에 전달 | 아니오 | 평범한 객체 전달 |
| Server Action을 클라이언트에 전달 | 예 | - |
string/number/boolean 전달 | 예 | - |
| 평범한 객체/배열 전달 | 예 | - |
3. 비동기 패턴 (Async Patterns)
Next.js 15+에서 params, searchParams, cookies(), headers()는 비동기예요.
비동기 params와 searchParams
항상 Promise<...>로 타입을 지정하고 await하세요.
페이지와 레이아웃:
type Props = { params: Promise<{ slug: string }> };
export default async function Page({ params }: Props) {
const { slug } = await params;
}
Route Handlers:
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
}
searchParams:
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ query?: string }>;
};
export default async function Page({ params, searchParams }: Props) {
const { slug } = await params;
const { query } = await searchParams;
}
동기 컴포넌트:
async가 아닌 컴포넌트에서는 React.use()를 사용하세요:
import { use } from "react";
type Props = { params: Promise<{ slug: string }> };
export default function Page({ params }: Props) {
const { slug } = use(params);
}
generateMetadata:
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
return { title: slug };
}
비동기 cookies와 headers
import { cookies, headers } from "next/headers";
export default async function Page() {
const cookieStore = await cookies();
const headersList = await headers();
const theme = cookieStore.get("theme");
const userAgent = headersList.get("user-agent");
}
마이그레이션 codemod
npx @next/codemod@latest next-async-request-api .
4. 런타임 선택 (Runtime Selection)
기본으로 Node.js 런타임 사용
새 라우트와 페이지에는 기본 Node.js 런타임을 사용하세요. Edge 런타임은 프로젝트에서 이미 사용 중이거나 특별한 요구사항이 있을 때만 사용해요.
// 올바른 예: 기본값 - 런타임 설정 불필요 (Node.js 사용)
export default function Page() { ... }
// 주의: 프로젝트에서 이미 사용 중이거나 특별히 필요한 경우에만
export const runtime = 'edge'
각 런타임을 사용해야 할 때
Node.js 런타임 (기본값):
- 전체 Node.js API 지원
- 파일 시스템 접근 (
fs) - 완전한
crypto지원 - 데이터베이스 연결
- 대부분의 npm 패키지 동작
Edge 런타임:
- 특정 edge 위치의 지연 시간 요구사항이 있을 때만
- 제한된 API (
fs없음, 제한된crypto) - 더 작은 콜드 스타트
- 지리적 분산 필요 시
감지
**runtime = 'edge'를 추가하기 전에** 확인하세요:
- 프로젝트가 이미 Edge 런타임을 사용하는가?
- 특별한 지연 시간 요구사항이 있는가?
- 모든 의존성이 Edge 호환인가?
확신이 없으면 Node.js 런타임을 사용하세요.
5. 지시어 (Directives)
React 지시어
'use client'와 'use server'는 Next.js가 아닌 React의 지시어예요.
**'use client'**
컴포넌트를 Client Component로 표시해요. 다음에 필요해요:
- React 훅 (
useState,useEffect등) - 이벤트 핸들러 (
onClick,onChange) - 브라우저 API (
window,localStorage)
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
참고: https://react.dev/reference/rsc/use-client
**'use server'**
함수를 Server Action으로 표시해요. Client Components에 전달할 수 있어요.
"use server";
export async function submitForm(formData: FormData) {
// 서버에서 실행
}
또는 Server Component 안에서 인라인으로:
export default function Page() {
async function submit() {
"use server";
// 서버에서 실행
}
return <form action={submit}>...</form>;
}
참고: https://react.dev/reference/rsc/use-server
Next.js 지시어
**'use cache'**
함수나 컴포넌트를 캐싱 대상으로 표시해요. Next.js Cache Components의 일부예요.
"use cache";
export async function getCachedData() {
return await fetchData();
}
next.config.ts에 cacheComponents: true가 필요해요.
캐시 프로필, cacheLife(), cacheTag(), updateTag()를 포함한 자세한 사용법은 원문에서 별도의 next-cache-components 스킬로 분리되어 있어요.
참고: https://nextjs.org/docs/app/api-reference/directives/use-cache
6. 함수 (Functions)
Next.js 함수 API예요.
참고: https://nextjs.org/docs/app/api-reference/functions
내비게이션 훅 (클라이언트)
| 훅 | 용도 | 레퍼런스 |
|---|---|---|
useRouter | 프로그래밍 방식 내비게이션 (push, replace, back, refresh) | Docs |
usePathname | 현재 pathname 조회 | Docs |
useSearchParams | URL 검색 파라미터 읽기 | Docs |
useParams | 동적 라우트 파라미터 접근 | Docs |
useSelectedLayoutSegment | 활성 자식 세그먼트 (한 레벨) | Docs |
useSelectedLayoutSegments | 레이아웃 아래 모든 활성 세그먼트 | Docs |
useLinkStatus | 링크 프리페치 상태 확인 | Docs |
useReportWebVitals | Core Web Vitals 지표 리포트 | Docs |
서버 함수
| 함수 | 용도 | 레퍼런스 |
|---|---|---|
cookies | 쿠키 읽기/쓰기 | Docs |
headers | 요청 헤더 읽기 | Docs |
draftMode | 미발행 CMS 콘텐츠 미리보기 활성화 | Docs |
after | 응답 스트리밍 완료 후 코드 실행 | Docs |
connection | 동적 렌더링 전 연결 대기 | Docs |
userAgent | User-Agent 헤더 파싱 | Docs |
Generate 함수
| 함수 | 용도 | 레퍼런스 |
|---|---|---|
generateStaticParams | 빌드 타임에 동적 라우트 사전 렌더링 | Docs |
generateMetadata | 동적 메타데이터 | Docs |
generateViewport | 동적 viewport 설정 | Docs |
generateSitemaps | 대형 사이트용 다중 사이트맵 | Docs |
generateImageMetadata | 라우트당 다중 OG 이미지 | Docs |
Request/Response
| 함수 | 용도 | 레퍼런스 |
|---|---|---|
NextRequest | 헬퍼가 추가된 확장 Request | Docs |
NextResponse | 헬퍼가 추가된 확장 Response | Docs |
ImageResponse | OG 이미지 생성 | Docs |
자주 쓰는 예제
내비게이션
내부 내비게이션에는 <a> 태그 대신 next/link를 사용하세요.
// 잘못된 예: 일반 앵커 태그
<a href="/about">About</a>;
// 올바른 예: Next.js Link
import Link from "next/link";
<Link href="/about">About</Link>;
활성 링크 스타일링:
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({ href, children }) {
const pathname = usePathname();
return (
<Link href={href} className={pathname === href ? "active" : ""}>
{children}
</Link>
);
}
정적 생성
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
응답 후 실행
import { after } from "next/server";
export async function POST(request: Request) {
const data = await processRequest(request);
after(async () => {
await logAnalytics(data);
});
return Response.json({ success: true });
}
7. 에러 처리 (Error Handling)
Next.js 애플리케이션에서 에러를 우아하게 처리해요.
참고: https://nextjs.org/docs/app/getting-started/error-handling
에러 경계
**error.tsx**
라우트 세그먼트와 자식들의 에러를 잡아요:
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
중요: error.tsx는 반드시 Client Component여야 해요.
**global-error.tsx**
루트 레이아웃의 에러를 잡아요:
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
중요: 반드시 <html>과 <body> 태그를 포함해야 해요.
Server Actions: 내비게이션 API 함정
내비게이션 API를 try-catch로 감싸지 마세요. 이 함수들은 Next.js가 내부적으로 처리하는 특수한 에러를 throw해요.
참고: https://nextjs.org/docs/app/api-reference/functions/redirect#behavior
'use server'
import { redirect } from 'next/navigation'
import { notFound } from 'next/navigation'
// 잘못된 예: try-catch가 내비게이션 "에러"를 잡아버림
async function createPost(formData: FormData) {
try {
const post = await db.post.create({ ... })
redirect(`/posts/${post.id}`) // throw가 발생!
} catch (error) {
// redirect()의 throw가 여기서 잡힘 - 내비게이션 실패!
return { error: 'Failed to create post' }
}
}
// 올바른 예: 내비게이션 API를 try-catch 밖에서 호출
async function createPost(formData: FormData) {
let post
try {
post = await db.post.create({ ... })
} catch (error) {
return { error: 'Failed to create post' }
}
redirect(`/posts/${post.id}`) // try-catch 밖
}
// 올바른 예: 내비게이션 에러를 다시 throw
async function createPost(formData: FormData) {
try {
const post = await db.post.create({ ... })
redirect(`/posts/${post.id}`)
} catch (error) {
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
throw error // 내비게이션 에러는 다시 throw
}
return { error: 'Failed to create post' }
}
}
같은 원칙이 다음에 모두 적용돼요:
redirect()- 307 임시 리다이렉트permanentRedirect()- 308 영구 리다이렉트notFound()- 404 not foundforbidden()- 403 forbiddenunauthorized()- 401 unauthorized
catch 블록에서 이 에러들을 다시 throw하려면 unstable_rethrow()를 사용해요:
import { unstable_rethrow } from "next/navigation";
async function action() {
try {
// ...
redirect("/success");
} catch (error) {
unstable_rethrow(error); // Next.js 내부 에러를 다시 throw
return { error: "Something went wrong" };
}
}
리다이렉트
import { redirect, permanentRedirect } from "next/navigation";
// 307 임시 - 대부분의 경우에 사용
redirect("/new-path");
// 308 영구 - URL 마이그레이션에 사용 (브라우저가 캐싱)
permanentRedirect("/new-url");
인증 에러
인증 관련 에러 페이지를 트리거해요:
import { forbidden, unauthorized } from "next/navigation";
async function Page() {
const session = await getSession();
if (!session) {
unauthorized(); // unauthorized.tsx 렌더링 (401)
}
if (!session.hasAccess) {
forbidden(); // forbidden.tsx 렌더링 (403)
}
return <Dashboard />;
}
대응하는 에러 페이지를 만들어요:
// app/forbidden.tsx
export default function Forbidden() {
return <div>You don't have access to this resource</div>;
}
// app/unauthorized.tsx
export default function Unauthorized() {
return <div>Please log in to continue</div>;
}
Not Found
**not-found.tsx**
라우트 세그먼트의 커스텀 404 페이지:
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find the requested resource</p>
</div>
);
}
Not Found 트리거하기
import { notFound } from "next/navigation";
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
if (!post) {
notFound(); // 가장 가까운 not-found.tsx 렌더링
}
return <div>{post.title}</div>;
}
에러 계층
에러는 가장 가까운 에러 경계로 버블링돼요:
app/
├── error.tsx # 모든 자식의 에러를 잡음
├── blog/
│ ├── error.tsx # /blog/* 의 에러를 잡음
│ └── [slug]/
│ ├── error.tsx # /blog/[slug] 의 에러를 잡음
│ └── page.tsx
└── layout.tsx # 여기의 에러는 global-error.tsx로
8. 데이터 패턴 (Data Patterns)
각 사용 사례에 맞는 데이터 페칭 패턴을 선택하세요.
의사결정 트리
데이터를 페칭해야 하나요?
├── Server Component에서?
│ └── 사용: 직접 페칭 (API 불필요)
│
├── Client Component에서?
│ ├── 뮤테이션 (POST/PUT/DELETE)인가?
│ │ └── 사용: Server Action
│ └── 읽기 (GET)인가?
│ └── 사용: Route Handler 또는 Server Component에서 전달
│
├── 외부 API 접근이 필요? (웹훅, 서드파티)
│ └── 사용: Route Handler
│
└── 모바일 앱/외부 클라이언트용 REST API가 필요?
└── 사용: Route Handler
패턴 1: Server Components (읽기에 권장)
Server Components에서 직접 데이터를 페칭해요 — API 레이어가 필요 없어요.
// app/users/page.tsx
async function UsersPage() {
// 직접 데이터베이스 접근 - API 라운드트립 없음
const users = await db.user.findMany();
// 또는 외부 API에서 페칭
const posts = await fetch("https://api.example.com/posts").then((r) =>
r.json(),
);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
이점:
- 유지보수할 API 없음
- 클라이언트-서버 워터폴 없음
- 시크릿이 서버에 유지됨
- 직접 데이터베이스 접근
패턴 2: Server Actions (뮤테이션에 권장)
Server Actions는 뮤테이션을 처리하는 권장 방법이에요.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/posts");
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidateTag("posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
이점:
- 엔드투엔드 타입 안전성
- 점진적 향상 (JS 없이도 동작)
- 자동 요청 처리
- React transitions와 통합
제약:
- POST만 가능 (GET 캐싱 시맨틱 없음)
- 내부 전용 (외부 접근 불가)
- 직렬화 불가능한 데이터 반환 불가
패턴 3: Route Handlers (API)
REST API가 필요할 때 Route Handlers를 사용해요.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
// GET은 캐싱 가능
export async function GET(request: NextRequest) {
const posts = await db.post.findMany();
return NextResponse.json(posts);
}
// POST는 뮤테이션용
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
사용해야 할 때:
- 외부 API 접근 (모바일 앱, 서드파티)
- 외부 서비스의 웹훅
- HTTP 캐싱이 필요한 GET 엔드포인트
- OpenAPI/Swagger 문서가 필요할 때
사용하지 말아야 할 때:
- 내부 데이터 페칭 (Server Components 사용)
- UI에서의 뮤테이션 (Server Actions 사용)
데이터 워터폴 피하기
문제: 순차 페칭
// 잘못된 예: 순차 워터폴
async function Dashboard() {
const user = await getUser(); // 대기...
const posts = await getPosts(); // 그다음 대기...
const comments = await getComments(); // 그다음 대기...
return <div>...</div>;
}
해결책 1: Promise.all로 병렬 페칭
// 올바른 예: 병렬 페칭
async function Dashboard() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return <div>...</div>;
}
해결책 2: Suspense로 스트리밍
// 올바른 예: 콘텐츠를 점진적으로 표시
import { Suspense } from "react";
async function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
</div>
);
}
async function UserSection() {
const user = await getUser(); // 독립적으로 페칭
return <div>{user.name}</div>;
}
async function PostsSection() {
const posts = await getPosts(); // 독립적으로 페칭
return <PostList posts={posts} />;
}
해결책 3: Preload 패턴
// lib/data.ts
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
export const preloadUser = (id: string) => {
void getUser(id); // Fire and forget
};
// app/user/[id]/page.tsx
import { getUser, preloadUser } from "@/lib/data";
export default async function UserPage({ params }) {
const { id } = await params;
// 일찍 페칭 시작
preloadUser(id);
// 다른 작업 수행...
// 이 시점엔 데이터가 준비됐을 가능성이 높음
const user = await getUser(id);
return <div>{user.name}</div>;
}
Client Component 데이터 페칭
Client Components에 데이터가 필요할 때:
옵션 1: Server Component에서 전달 (권장)
// Server Component
async function Page() {
const data = await fetchData();
return <ClientComponent initialData={data} />;
}
// Client Component
("use client");
function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData);
// ...
}
옵션 2: 마운트 시 페칭 (필요할 때)
"use client";
import { useEffect, useState } from "react";
function ClientComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/data")
.then((r) => r.json())
.then(setData);
}, []);
if (!data) return <Loading />;
return <div>{data.value}</div>;
}
옵션 3: 읽기용 Server Action (동작은 하지만 이상적이지 않음)
클라이언트 컴포넌트에서 읽기용으로 Server Action을 호출할 수 있지만, 원래 의도된 용도는 아니에요:
"use client";
import { getData } from "./actions";
import { useEffect, useState } from "react";
function ClientComponent() {
const [data, setData] = useState(null);
useEffect(() => {
getData().then(setData);
}, []);
return <div>{data?.value}</div>;
}
참고: Server Actions는 항상 POST를 사용하므로 HTTP 캐싱이 없어요. 캐싱 가능한 읽기에는 Route Handlers를 선호하세요.
빠른 참조
| 패턴 | 사용 사례 | HTTP 메서드 | 캐싱 |
|---|---|---|---|
| Server Component fetch | 내부 읽기 | 모두 | 완전한 Next.js 캐싱 |
| Server Action | 뮤테이션, 폼 제출 | POST만 | 없음 |
| Route Handler | 외부 API, 웹훅 | 모두 | GET 캐싱 가능 |
| 클라이언트 fetch → API | 클라이언트 사이드 읽기 | 모두 | HTTP 캐시 헤더 |
9. Route Handlers
route.ts 파일로 API 엔드포인트를 만들어요.
기본 사용법
// app/api/users/route.ts
export async function GET() {
const users = await getUsers();
return Response.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await createUser(body);
return Response.json(user, { status: 201 });
}
지원 메서드
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
GET 핸들러와 page.tsx의 충돌
**route.ts와 page.tsx는 같은 폴더에 공존할 수 없어요.**
app/
├── api/
│ └── users/
│ └── route.ts # /api/users
└── users/
├── page.tsx # /users (페이지)
└── route.ts # 경고: page.tsx와 충돌!
같은 경로에 페이지와 API가 모두 필요하면 다른 경로를 사용하세요:
app/
├── users/
│ └── page.tsx # /users (페이지)
└── api/
└── users/
└── route.ts # /api/users (API)
환경 동작
Route handlers는 Server Component와 유사한 환경에서 실행돼요:
- 가능:
async/await사용 - 가능:
cookies(),headers()접근 - 가능: Node.js API 사용
- 불가능: React 훅 사용
- 불가능: React DOM API 사용
- 불가능: 브라우저 API 사용
// 잘못된 예: 동작하지 않음 - route handler에는 React DOM이 없어요
import { renderToString } from "react-dom/server";
export async function GET() {
const html = renderToString(<Component />); // 에러!
return new Response(html);
}
동적 Route Handlers
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const user = await getUser(id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);
}
Request 헬퍼
export async function GET(request: Request) {
// URL과 검색 파라미터
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
// 헤더
const authHeader = request.headers.get("authorization");
// 쿠키 (Next.js 헬퍼)
const cookieStore = await cookies();
const token = cookieStore.get("token");
return Response.json({ query, token });
}
Response 헬퍼
// JSON 응답
return Response.json({ data });
// 상태 코드와 함께
return Response.json({ error: "Not found" }, { status: 404 });
// 헤더와 함께
return Response.json(data, {
headers: {
"Cache-Control": "max-age=3600",
},
});
// 리다이렉트
return Response.redirect(new URL("/login", request.url));
// 스트림
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
});
Route Handlers vs Server Actions를 언제 쓸까
| 사용 사례 | Route Handlers | Server Actions |
|---|---|---|
| 폼 제출 | 아니오 | 예 |
| UI에서의 데이터 뮤테이션 | 아니오 | 예 |
| 서드파티 웹훅 | 예 | 아니오 |
| 외부 API 소비 | 예 | 아니오 |
| 공개 REST API | 예 | 아니오 |
| 파일 업로드 | 둘 다 가능 | 둘 다 가능 |
UI에서 트리거되는 뮤테이션에는 Server Actions를 선호하세요. 외부 연동과 공개 API에는 Route Handlers를 사용하세요.
10. 메타데이터 (Metadata)
Metadata API로 Next.js 페이지에 SEO 메타데이터를 추가해요.
중요: Server Components 전용
metadata 객체와 generateMetadata 함수는 Server Components에서만 지원돼요. Client Components에서는 사용할 수 없어요.
대상 페이지에 'use client'가 있다면:
- 가능하면
'use client'를 제거하고 클라이언트 로직을 자식 컴포넌트로 이동 - 또는 메타데이터를 부모 Server Component 레이아웃으로 추출
- 또는 파일 분리: 메타데이터를 가진 Server Component가 Client Components를 import
정적 메타데이터
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Page Title",
description: "Page description for search engines",
};
동적 메타데이터
import type { Metadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title, description: post.description };
}
중복 페칭 방지
메타데이터와 페이지 모두에 같은 데이터가 필요하면 React cache()를 사용하세요:
import { cache } from "react";
export const getPost = cache(async (slug: string) => {
return await db.posts.findFirst({ where: { slug } });
});
Viewport
스트리밍 지원을 위해 메타데이터와 분리되어 있어요:
import type { Viewport } from "next";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#000000",
};
// 또는 동적으로
export function generateViewport({ params }): Viewport {
return { themeColor: getThemeColor(params) };
}
타이틀 템플릿
루트 레이아웃에서 일관된 네이밍:
export const metadata: Metadata = {
title: { default: "Site Name", template: "%s | Site Name" },
};
메타데이터 파일 컨벤션
참고: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions
이 파일들을 app/ 디렉토리(또는 라우트 세그먼트)에 배치해요:
| 파일 | 용도 |
|---|---|
favicon.ico | 파비콘 |
icon.png / icon.svg | 앱 아이콘 |
apple-icon.png | Apple 앱 아이콘 |
opengraph-image.png | OG 이미지 |
twitter-image.png | 트위터 카드 이미지 |
sitemap.ts / sitemap.xml | 사이트맵 (여러 개는 generateSitemaps 사용) |
robots.ts / robots.txt | Robots 지시문 |
manifest.ts / manifest.json | 웹 앱 매니페스트 |
SEO 베스트 프랙티스: 정적 파일이면 충분한 경우가 많아요
대부분의 사이트에서 정적 메타데이터 파일이 훌륭한 SEO 커버리지를 제공해요:
app/
├── favicon.ico
├── opengraph-image.png # OG와 트위터 모두 커버
├── sitemap.ts
├── robots.ts
└── layout.tsx # title/description 메타데이터 포함
팁:
opengraph-image.png하나로 Open Graph와 트위터를 모두 커버해요 (트위터는 OG로 폴백)- 레이아웃 메타데이터의 정적
title,description이면 대부분의 페이지에 충분해요 - 페이지마다 콘텐츠가 달라질 때만 동적
generateMetadata를 사용하세요
OG 이미지 생성
next/og로 동적 Open Graph 이미지를 생성해요.
중요 규칙:
**next/og를 사용하세요** —@vercel/og가 아니에요 (Next.js에 내장돼 있어요)- searchParams 사용 불가 — OG 이미지는 검색 파라미터에 접근할 수 없어요. 라우트 파라미터를 사용하세요
- Edge 런타임 피하기 — 기본 Node.js 런타임을 사용하세요
// 올바른 예
import { ImageResponse } from "next/og";
// 잘못된 예
// import { ImageResponse } from '@vercel/og'
// export const runtime = 'edge'
기본 OG 이미지:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const alt = "Site Name";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
<div
style={{
fontSize: 128,
background: "white",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
Hello World
</div>,
{ ...size },
);
}
동적 OG 이미지:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const alt = "Blog Post";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
type Props = { params: Promise<{ slug: string }> };
export default async function Image({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
<div
style={{
fontSize: 48,
background: "linear-gradient(to bottom, #1a1a1a, #333)",
color: "white",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 48,
}}
>
<div style={{ fontSize: 64, fontWeight: "bold" }}>{post.title}</div>
<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>
</div>,
{ ...size },
);
}
커스텀 폰트:
import { ImageResponse } from "next/og";
import { join } from "path";
import { readFile } from "fs/promises";
export default async function Image() {
const fontPath = join(process.cwd(), "assets/fonts/Inter-Bold.ttf");
const fontData = await readFile(fontPath);
return new ImageResponse(
<div style={{ fontFamily: "Inter", fontSize: 64 }}>Custom Font Text</div>,
{
width: 1200,
height: 630,
fonts: [{ name: "Inter", data: fontData, style: "normal" }],
},
);
}
파일 네이밍:
opengraph-image.tsx- Open Graph (Facebook, LinkedIn)twitter-image.tsx- Twitter/X 카드 (선택 사항, OG로 폴백)
스타일링 참고:
ImageResponse는 Flexbox 레이아웃을 사용해요:
display: 'flex'를 사용하세요- CSS Grid는 지원하지 않아요
- 스타일은 인라인 객체여야 해요
다중 OG 이미지:
라우트당 여러 이미지가 필요하면 generateImageMetadata를 사용해요:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export async function generateImageMetadata({ params }) {
const images = await getPostImages(params.slug);
return images.map((img, idx) => ({
id: idx,
alt: img.alt,
size: { width: 1200, height: 630 },
contentType: "image/png",
}));
}
export default async function Image({ params, id }) {
const images = await getPostImages(params.slug);
const image = images[id];
return new ImageResponse(/* ... */);
}
다중 사이트맵:
대형 사이트에는 generateSitemaps를 사용해요:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export async function generateSitemaps() {
// 사이트맵 ID 배열 반환
return [{ id: 0 }, { id: 1 }, { id: 2 }];
}
export default async function sitemap({
id,
}: {
id: number;
}): Promise<MetadataRoute.Sitemap> {
const start = id * 50000;
const end = start + 50000;
const products = await getProducts(start, end);
return products.map((product) => ({
url: `https://example.com/product/${product.id}`,
lastModified: product.updatedAt,
}));
}
/sitemap/0.xml, /sitemap/1.xml 등이 생성돼요.
11. 이미지 최적화 (Image Optimization)
자동 이미지 최적화를 위해 next/image를 사용해요.
항상 next/image 사용
// 잘못된 예: 네이티브 img 피하기
<img src="/hero.png" alt="Hero" />;
// 올바른 예: next/image 사용
import Image from "next/image";
<Image src="/hero.png" alt="Hero" width={800} height={400} />;
필수 props
레이아웃 시프트를 방지하려면 이미지에 명시적 크기가 필요해요:
// 로컬 이미지 - 크기 자동 추론
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" />
// 원격 이미지 - width/height 명시 필수
<Image src="https://example.com/image.jpg" alt="Hero" width={800} height={400} />
// 또는 부모 기준 크기 조정에 fill 사용
<div style={{ position: 'relative', width: '100%', height: 400 }}>
<Image src="/hero.png" alt="Hero" fill style={{ objectFit: 'cover' }} />
</div>
원격 이미지 설정
원격 도메인은 next.config.js에 설정해야 해요:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "example.com",
pathname: "/images/**",
},
{
protocol: "https",
hostname: "*.cdn.com", // 와일드카드 서브도메인
},
],
},
};
반응형 이미지
sizes로 브라우저가 다운로드할 크기를 알려주세요:
// 전체 너비 히어로
<Image
src="/hero.png"
alt="Hero"
fill
sizes="100vw"
/>
// 반응형 그리드 (데스크톱 3열, 모바일 1열)
<Image
src="/card.png"
alt="Card"
fill
sizes="(max-width: 768px) 100vw, 33vw"
/>
// 고정 크기 사이드바 이미지
<Image
src="/avatar.png"
alt="Avatar"
width={200}
height={200}
sizes="200px"
/>
Blur 플레이스홀더
플레이스홀더로 레이아웃 시프트를 방지해요:
// 로컬 이미지 - 자동 blur 해시
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" placeholder="blur" />
// 원격 이미지 - blurDataURL 제공
<Image
src="https://example.com/image.jpg"
alt="Hero"
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
// 또는 색상 플레이스홀더 사용
<Image
src="https://example.com/image.jpg"
alt="Hero"
width={800}
height={400}
placeholder="empty"
style={{ backgroundColor: '#e0e0e0' }}
/>
Priority 로딩
스크롤 없이 보이는 이미지(LCP)에는 priority를 사용하세요:
// 히어로 이미지 - 즉시 로드
<Image src="/hero.png" alt="Hero" fill priority />
// 스크롤 아래 이미지 - 기본으로 지연 로드 (priority 불필요)
<Image src="/card.png" alt="Card" width={400} height={300} />
흔한 실수
// 잘못된 예: fill에 sizes 누락 - 가장 큰 이미지를 다운로드
<Image src="/hero.png" alt="Hero" fill />
// 올바른 예: 올바른 반응형 동작을 위해 sizes 추가
<Image src="/hero.png" alt="Hero" fill sizes="100vw" />
// 잘못된 예: 종횡비 용도로만 width/height 사용
<Image src="/hero.png" alt="Hero" width={16} height={9} />
// 올바른 예: 실제 표시 크기를 쓰거나 fill + sizes 사용
<Image src="/hero.png" alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
// 잘못된 예: 설정 없는 원격 이미지
<Image src="https://untrusted.com/image.jpg" alt="Image" width={400} height={300} />
// 에러: Invalid src prop, hostname not configured
// 올바른 예: next.config.js remotePatterns에 hostname 추가
정적 내보내기 (Static Export)
output: 'export'를 사용할 때는 unoptimized나 커스텀 로더를 사용해요:
// 옵션 1: 최적화 비활성화
<Image src="/hero.png" alt="Hero" width={800} height={400} unoptimized />;
// 옵션 2: 전역 설정
// next.config.js
module.exports = {
output: "export",
images: { unoptimized: true },
};
// 옵션 3: 커스텀 로더 (Cloudinary, Imgix 등)
const cloudinaryLoader = ({ src, width, quality }) => {
return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`;
};
<Image
loader={cloudinaryLoader}
src="sample.jpg"
alt="Sample"
width={800}
height={400}
/>;
12. 폰트 최적화 (Font Optimization)
레이아웃 시프트 제로의 자동 폰트 최적화를 위해 next/font를 사용해요.
Google Fonts
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
다중 폰트
import { Inter, Roboto_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const robotoMono = Roboto_Mono({
subsets: ["latin"],
variable: "--font-roboto-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
);
}
CSS에서 사용:
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-roboto-mono);
}
폰트 두께와 스타일
// 단일 두께
const inter = Inter({
subsets: ["latin"],
weight: "400",
});
// 다중 두께
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "700"],
});
// 가변 폰트 (권장) - 모든 두께 포함
const inter = Inter({
subsets: ["latin"],
// weight 불필요 - 가변 폰트는 모든 두께를 지원
});
// 이탤릭 포함
const inter = Inter({
subsets: ["latin"],
style: ["normal", "italic"],
});
로컬 폰트
import localFont from "next/font/local";
const myFont = localFont({
src: "./fonts/MyFont.woff2",
});
// 두께별 여러 파일
const myFont = localFont({
src: [
{
path: "./fonts/MyFont-Regular.woff2",
weight: "400",
style: "normal",
},
{
path: "./fonts/MyFont-Bold.woff2",
weight: "700",
style: "normal",
},
],
});
// 가변 폰트
const myFont = localFont({
src: "./fonts/MyFont-Variable.woff2",
variable: "--font-my-font",
});
Tailwind CSS 통합
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ["var(--font-inter)"],
},
},
},
};
서브셋 프리로딩
필요한 문자 서브셋만 로드하세요:
// Latin만 (가장 흔한 경우)
const inter = Inter({ subsets: ["latin"] });
// 다중 서브셋
const inter = Inter({ subsets: ["latin", "latin-ext", "cyrillic"] });
Display 전략
폰트 로딩 동작을 제어해요:
const inter = Inter({
subsets: ["latin"],
display: "swap", // 기본값 - 폴백을 보여주고 로드되면 교체
});
// 옵션:
// 'auto' - 브라우저가 결정
// 'block' - 짧은 블록 기간 후 교체
// 'swap' - 즉시 폴백, 준비되면 교체 (권장)
// 'fallback' - 짧은 블록, 짧은 교체, 이후 폴백
// 'optional' - 짧은 블록, 교체 없음 (폰트가 선택적일 때 사용)
수동 폰트 링크를 사용하지 마세요
Google Fonts에는 <link> 태그 대신 항상 next/font를 사용하세요.
// 잘못된 예: 수동 link 태그 (렌더링 차단, 최적화 없음)
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
// 잘못된 예: display와 preconnect 누락
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
// 올바른 예: next/font 사용 (셀프 호스팅, 레이아웃 시프트 제로)
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
흔한 실수
// 잘못된 예: 모든 컴포넌트에서 폰트 import
// components/Button.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] }); // 매번 새 인스턴스 생성!
// 올바른 예: 레이아웃에서 한 번 import 후 CSS 변수 사용
// app/layout.tsx
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
/* 잘못된 예: CSS에서 @import 사용 (렌더링 차단) */
/* globals.css */
@import url("https://fonts.googleapis.com/css2?family=Inter");
// 올바른 예: next/font 사용 (셀프 호스팅, 네트워크 요청 없음)
import { Inter } from "next/font/google";
// 잘못된 예: 일부만 쓰는데 모든 두께를 로드
const inter = Inter({ subsets: ["latin"] }); // 모든 두께 로드
// 올바른 예: 필요한 두께만 지정 (가변 폰트가 아닌 경우)
const inter = Inter({ subsets: ["latin"], weight: ["400", "700"] });
// 잘못된 예: subset 누락 - 모든 문자를 로드
const inter = Inter({});
// 올바른 예: 항상 subset 지정
const inter = Inter({ subsets: ["latin"] });
특정 컴포넌트의 폰트
// 컴포넌트별 폰트는 공유 파일에서 export
// lib/fonts.ts
import { Inter, Playfair_Display } from "next/font/google";
export const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair",
});
// components/Heading.tsx
import { playfair } from "@/lib/fonts";
export function Heading({ children }) {
return <h1 className={playfair.className}>{children}</h1>;
}
13. 번들링 (Bundling)
서드파티 패키지의 흔한 번들링 문제를 해결해요.
서버 비호환 패키지
일부 패키지는 브라우저 API(window, document, localStorage)를 사용해 Server Components에서 실패해요.
에러 신호:
ReferenceError: window is not defined
ReferenceError: document is not defined
ReferenceError: localStorage is not defined
Module not found: Can't resolve 'fs'
해결책 1: 클라이언트 전용으로 표시
패키지가 클라이언트에서만 필요하다면:
// 잘못된 예: 실패 - 패키지가 window 사용
import SomeChart from "some-chart-library";
export default function Page() {
return <SomeChart />;
}
// 올바른 예: ssr: false로 dynamic import 사용
import dynamic from "next/dynamic";
const SomeChart = dynamic(() => import("some-chart-library"), {
ssr: false,
});
export default function Page() {
return <SomeChart />;
}
해결책 2: 서버 번들에서 외부화
서버에서 실행돼야 하지만 번들링 문제가 있는 패키지:
// next.config.js
module.exports = {
serverExternalPackages: ["problematic-package"],
};
다음에 사용하세요:
- 네이티브 바인딩이 있는 패키지 (sharp, bcrypt)
- 번들이 잘 안 되는 패키지 (일부 ORM)
- 순환 의존성이 있는 패키지
해결책 3: 클라이언트 컴포넌트 래퍼
사용처 전체를 클라이언트 컴포넌트로 감싸요:
// components/ChartWrapper.tsx
"use client";
import { Chart } from "chart-library";
export function ChartWrapper(props) {
return <Chart {...props} />;
}
// app/page.tsx (서버 컴포넌트)
import { ChartWrapper } from "@/components/ChartWrapper";
export default function Page() {
return <ChartWrapper data={data} />;
}
CSS import
<link> 태그 대신 CSS 파일을 import하세요. Next.js가 번들링과 최적화를 처리해요.
// 잘못된 예: 수동 link 태그
<link rel="stylesheet" href="/styles.css" />;
// 올바른 예: CSS import
import "./styles.css";
// 올바른 예: CSS Modules
import styles from "./Button.module.css";
폴리필
Next.js는 흔한 폴리필을 자동으로 포함해요. polyfill.io 같은 CDN에서 중복으로 로드하지 마세요.
이미 포함된 것: Array.from, Object.assign, Promise, fetch, Map, Set, Symbol, URLSearchParams 외 50개 이상.
// 잘못된 예: 중복 폴리필
<script src="https://polyfill.io/v3/polyfill.min.js?features=fetch,Promise,Array.from" />
// 올바른 예: Next.js가 자동으로 포함해요
ESM/CommonJS 문제
에러 신호:
SyntaxError: Cannot use import statement outside a module
Error: require() of ES Module
Module not found: ESM packages need to be imported
해결책: 패키지 트랜스파일
// next.config.js
module.exports = {
transpilePackages: ["some-esm-package", "another-package"],
};
흔한 문제 패키지
| 패키지 | 문제 | 해결책 |
|---|---|---|
sharp | 네이티브 바인딩 | serverExternalPackages: ['sharp'] |
bcrypt | 네이티브 바인딩 | serverExternalPackages: ['bcrypt'] 또는 bcryptjs 사용 |
canvas | 네이티브 바인딩 | serverExternalPackages: ['canvas'] |
recharts | window 사용 | dynamic(() => import('recharts'), { ssr: false }) |
react-quill | document 사용 | dynamic(() => import('react-quill'), { ssr: false }) |
mapbox-gl | window 사용 | dynamic(() => import('mapbox-gl'), { ssr: false }) |
monaco-editor | window 사용 | dynamic(() => import('@monaco-editor/react'), { ssr: false }) |
lottie-web | document 사용 | dynamic(() => import('lottie-react'), { ssr: false }) |
번들 분석
내장 분석기로 번들 크기를 분석해요 (Next.js 16.1+):
next experimental-analyze
다음이 가능한 인터랙티브 UI가 열려요:
- 라우트, 환경(클라이언트/서버), 타입별 필터
- 모듈 크기와 import 체인 검사
- 트리맵 시각화 보기
비교를 위해 출력 저장:
next experimental-analyze --output
# 출력이 .next/diagnostics/analyze에 저장됨
참고: https://nextjs.org/docs/app/guides/package-bundling
Webpack에서 Turbopack으로 마이그레이션
Turbopack이 Next.js 15+의 기본 번들러예요. 커스텀 webpack 설정이 있다면 Turbopack 호환 대안으로 마이그레이션하세요:
// next.config.js
module.exports = {
// 올바른 예: Turbopack에서 동작
serverExternalPackages: ["package"],
transpilePackages: ["package"],
// 잘못된 예: Webpack 전용 - 여기서 벗어나세요
webpack: (config) => {
// 커스텀 webpack 설정
},
};
참고: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack
14. 스크립트 (Scripts)
Next.js에서 서드파티 스크립트 로딩하기.
next/script 사용
더 나은 성능을 위해 네이티브 <script> 태그 대신 항상 next/script를 사용하세요.
// 잘못된 예: 네이티브 script 태그
<script src="https://example.com/script.js"></script>;
// 올바른 예: Next.js Script 컴포넌트
import Script from "next/script";
<Script src="https://example.com/script.js" />;
인라인 스크립트에는 ID 필요
Next.js가 추적할 수 있도록 인라인 스크립트에는 id 속성이 필요해요.
// 잘못된 예: id 누락
<Script dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
// 올바른 예: id 있음
<Script id="my-script" dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
// 올바른 예: id가 있는 인라인
<Script id="show-banner">
{`document.getElementById('banner').classList.remove('hidden')`}
</Script>
Script를 Head 안에 넣지 마세요
next/script는 next/head 안에 배치하면 안 돼요. 배치는 스스로 처리해요.
// 잘못된 예: Head 안의 Script
import Head from 'next/head'
import Script from 'next/script'
<Head>
<Script src="/analytics.js" />
</Head>
// 올바른 예: Head 밖의 Script
<Head>
<title>Page</title>
</Head>
<Script src="/analytics.js" />
로딩 전략
// afterInteractive (기본값) - 페이지가 인터랙티브해진 후 로드
<Script src="/analytics.js" strategy="afterInteractive" />
// lazyOnload - 유휴 시간에 로드
<Script src="/widget.js" strategy="lazyOnload" />
// beforeInteractive - 페이지가 인터랙티브해지기 전 로드 (아껴서 사용)
// app/layout.tsx 또는 pages/_document.js에서만 동작
<Script src="/critical.js" strategy="beforeInteractive" />
// worker - 웹 워커에서 로드 (실험적)
<Script src="/heavy.js" strategy="worker" />
Google Analytics
인라인 GA 스크립트 대신 @next/third-parties를 사용하세요.
// 잘못된 예: 인라인 GA 스크립트
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
<Script id="ga-init">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXX');`}
</Script>
// 올바른 예: Next.js 컴포넌트
import { GoogleAnalytics } from '@next/third-parties/google'
export default function Layout({ children }) {
return (
<html>
<body>{children}</body>
<GoogleAnalytics gaId="G-XXXXX" />
</html>
)
}
Google Tag Manager
import { GoogleTagManager } from "@next/third-parties/google";
export default function Layout({ children }) {
return (
<html>
<GoogleTagManager gtmId="GTM-XXXXX" />
<body>{children}</body>
</html>
);
}
기타 서드파티 스크립트
// YouTube 임베드
import { YouTubeEmbed } from "@next/third-parties/google";
<YouTubeEmbed videoid="dQw4w9WgXcQ" />;
// Google Maps
import { GoogleMapsEmbed } from "@next/third-parties/google";
<GoogleMapsEmbed
apiKey="YOUR_API_KEY"
mode="place"
q="Brooklyn+Bridge,New+York,NY"
/>;
빠른 참조
| 패턴 | 문제 | 해결책 |
|---|---|---|
<script src="..."> | 최적화 없음 | next/script 사용 |
id 없는 <Script> | 인라인 스크립트 추적 불가 | id 속성 추가 |
<Head> 안의 <Script> | 잘못된 배치 | Head 밖으로 이동 |
| 인라인 GA/GTM 스크립트 | 최적화 없음 | @next/third-parties 사용 |
레이아웃 밖의 strategy="beforeInteractive" | 동작 안 함 | 루트 레이아웃에서만 사용 |
15. Hydration 에러 (Hydration Errors)
React hydration 불일치 에러를 진단하고 수정해요.
에러 신호
- "Hydration failed because the initial UI does not match"
- "Text content does not match server-rendered HTML"
디버깅
개발 모드에서 hydration 에러를 클릭하면 서버/클라이언트 diff를 볼 수 있어요.
흔한 원인과 해결책
브라우저 전용 API
// 잘못된 예: 불일치 발생 - 서버에는 window가 없음
<div>{window.innerWidth}</div>;
// 올바른 예: mounted 체크가 있는 클라이언트 컴포넌트 사용
("use client");
import { useState, useEffect } from "react";
export function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? children : null;
}
날짜/시간 렌더링
서버와 클라이언트의 타임존이 다를 수 있어요:
// 잘못된 예: 불일치 발생
<span>{new Date().toLocaleString()}</span>;
// 올바른 예: 클라이언트에서만 렌더링
("use client");
const [time, setTime] = useState<string>();
useEffect(() => setTime(new Date().toLocaleString()), []);
랜덤 값이나 ID
// 잘못된 예: 랜덤 값은 서버와 클라이언트에서 다름
<div id={Math.random().toString()}>
// 올바른 예: useId 훅 사용
import { useId } from 'react'
function Input() {
const id = useId()
return <input id={id} />
}
유효하지 않은 HTML 중첩
// 잘못된 예: 유효하지 않음 - p 안의 div
<p><div>Content</div></p>
// 잘못된 예: 유효하지 않음 - p 안의 p
<p><p>Nested</p></p>
// 올바른 예: 유효한 중첩
<div><p>Content</p></div>
서드파티 스크립트
hydration 중에 DOM을 수정하는 스크립트가 원인이에요.
// 올바른 예: next/script를 afterInteractive로 사용
import Script from "next/script";
export default function Page() {
return (
<Script src="https://example.com/script.js" strategy="afterInteractive" />
);
}
16. Suspense 경계 (Suspense Boundaries)
Suspense 경계 없이 사용하면 CSR bailout을 일으키는 클라이언트 훅들이에요.
useSearchParams
정적 라우트에서 항상 Suspense 경계가 필요해요. 없으면 페이지 전체가 클라이언트 사이드 렌더링이 돼요.
// 잘못된 예: 페이지 전체가 CSR이 됨
"use client";
import { useSearchParams } from "next/navigation";
export default function SearchBar() {
const searchParams = useSearchParams();
return <div>Query: {searchParams.get("q")}</div>;
}
// 올바른 예: Suspense로 감싸기
import { Suspense } from "react";
import SearchBar from "./search-bar";
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SearchBar />
</Suspense>
);
}
usePathname
라우트에 동적 파라미터가 있을 때 Suspense 경계가 필요해요.
// 동적 라우트 [slug]에서
// 잘못된 예: Suspense 없음
"use client";
import { usePathname } from "next/navigation";
export function Breadcrumb() {
const pathname = usePathname();
return <nav>{pathname}</nav>;
}
// 올바른 예: Suspense로 감싸기
<Suspense fallback={<BreadcrumbSkeleton />}>
<Breadcrumb />
</Suspense>
generateStaticParams를 사용한다면 Suspense는 선택이에요.
빠른 참조
| 훅 | Suspense 필요 |
|---|---|
useSearchParams() | 예 |
usePathname() | 예 (동적 라우트) |
useParams() | 아니오 |
useRouter() | 아니오 |
17. 병렬 & 인터셉팅 라우트 (Parallel & Intercepting Routes)
병렬 라우트는 같은 레이아웃 안에서 여러 페이지를 렌더링해요. 인터셉팅 라우트는 앱 내부에서 이동할 때와 직접 URL로 접근할 때 다른 UI를 보여줘요. 둘을 조합하면 모달 패턴을 만들 수 있어요.
파일 구조
app/
├── @modal/ # 병렬 라우트 슬롯
│ ├── default.tsx # 필수! null 반환
│ ├── (.)photos/ # /photos/* 인터셉트
│ │ └── [id]/
│ │ └── page.tsx # 모달 콘텐츠
│ └── [...]catchall/ # 선택: 매칭 안 된 것 잡기
│ └── page.tsx
├── photos/
│ └── [id]/
│ └── page.tsx # 전체 페이지 (직접 접근)
├── layout.tsx # children과 @modal 모두 렌더링
└── page.tsx
1단계: 슬롯이 있는 루트 레이아웃
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
2단계: Default 파일 (핵심!)
모든 병렬 라우트 슬롯에는 반드시 default.tsx가 있어야 하드 내비게이션에서 404를 방지해요.
// app/@modal/default.tsx
export default function Default() {
return null;
}
이 파일이 없으면 어느 페이지든 새로고침 시 404가 나요 — Next.js가 @modal 슬롯에 무엇을 렌더링할지 결정할 수 없기 때문이에요.
3단계: 인터셉팅 라우트 (모달)
(.) 접두사는 같은 레벨의 라우트를 인터셉트해요.
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from "@/components/modal";
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
4단계: 전체 페이지 (직접 접근)
// app/photos/[id]/page.tsx
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="full-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</div>
);
}
5단계: 올바르게 닫는 모달 컴포넌트
핵심: 모달을 닫을 때는 router.push()나 <Link>가 아니라 router.back()을 사용하세요.
// components/modal.tsx
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
// ESC 키로 닫기
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
router.back(); // 올바름
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [router]);
// 오버레이 클릭으로 닫기
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
router.back(); // 올바름
}
},
[router],
);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<button
onClick={() => router.back()} // 올바름!
className="absolute top-4 right-4"
>
Close
</button>
{children}
</div>
</div>
);
}
왜 router.push('/')나 <Link href="/">는 안 되나요?
push나 Link로 모달을 "닫으면":
- 새 히스토리 항목이 추가돼요 (뒤로 가기 버튼이 모달을 다시 보여줌)
- 인터셉트된 라우트가 제대로 정리되지 않아요
- 모달이 깜빡이거나 예상치 않게 남을 수 있어요
router.back()은 올바르게:
- 인터셉트된 라우트를 히스토리에서 제거해요
- 이전 페이지로 돌아가요
- 모달을 올바르게 언마운트해요
라우트 매처 레퍼런스
매처는 파일시스템 경로가 아니라 라우트 세그먼트를 매칭해요:
| 매처 | 매칭 대상 | 예시 |
|---|---|---|
(.) | 같은 레벨 | @modal/(.)photos가 /photos를 인터셉트 |
(..) | 한 레벨 위 | /dashboard/@modal의 @modal/(..)settings가 /settings를 인터셉트 |
(..)(..) | 두 레벨 위 | 거의 안 쓰임 |
(...) | 루트부터 | @modal/(...)photos가 어디서든 /photos를 인터셉트 |
흔한 실수: (..)를 "부모 폴더"로 생각하는 것 — "부모 라우트 세그먼트"를 의미해요.
하드 내비게이션 처리
사용자가 /photos/123을 직접 방문하면 (북마크, 새로고침, 공유 링크):
- 인터셉팅 라우트는 우회돼요
- 전체
photos/[id]/page.tsx가 렌더링돼요 - 모달은 나타나지 않아요 (의도된 동작)
직접 접근에서도 모달이 뜨게 하려면 추가 로직이 필요해요:
// app/photos/[id]/page.tsx
import { Modal } from "@/components/modal";
export default async function PhotoPage({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
// 옵션: 직접 접근 시에도 모달로 렌더링
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
흔한 함정
1. default.tsx 누락 → 새로고침 시 404
모든 @slot 폴더에는 null(또는 적절한 콘텐츠)을 반환하는 default.tsx가 필요해요.
2. 내비게이션 후 모달이 남아 있음
router.back() 대신 router.push()를 쓰고 있는 거예요.
3. 중첩된 병렬 라우트에도 default 필요
라우트 그룹 안에 @modal이 있다면 각 레벨마다 자체 default.tsx가 필요해요:
app/
├── (marketing)/
│ ├── @modal/
│ │ └── default.tsx # 필요!
│ └── layout.tsx
└── layout.tsx
4. 인터셉트된 라우트가 잘못된 콘텐츠 표시
매처를 확인하세요:
(.)photos는 같은 라우트 레벨에서/photos를 인터셉트해요@modal이app/dashboard/@modal에 있으면(.)photos는/photos가 아니라/dashboard/photos를 인터셉트해요
5. params 타입스크립트 에러
Next.js 15+에서 params는 Promise예요:
// 올바름
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
}
완성 예제: 포토 갤러리 모달
app/
├── @modal/
│ ├── default.tsx
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx
├── photos/
│ ├── page.tsx # 갤러리 그리드
│ └── [id]/
│ └── page.tsx # 전체 사진 페이지
├── layout.tsx
└── page.tsx
갤러리의 링크:
// app/photos/page.tsx
import Link from "next/link";
export default async function Gallery() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-3 gap-4">
{photos.map((photo) => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.thumbnail} alt={photo.title} />
</Link>
))}
</div>
);
}
- 사진 클릭 → 모달 열림 (인터셉트됨)
- 직접 URL → 전체 페이지 렌더링
- 모달이 열린 상태로 새로고침 → 전체 페이지 렌더링
18. 셀프 호스팅 (Self-Hosting Next.js)
Vercel 밖에서 Next.js를 자신 있게 배포하세요.
빠른 시작: Standalone 출력
Docker나 컨테이너화된 배포에는 standalone 출력을 사용해요:
// next.config.js
module.exports = {
output: "standalone",
};
프로덕션 의존성만 담긴 최소한의 standalone 폴더가 생성돼요:
.next/
├── standalone/
│ ├── server.js # 진입점
│ ├── node_modules/ # 프로덕션 의존성만
│ └── .next/ # 빌드 출력
└── static/ # 별도로 복사해야 함
Docker 배포
Dockerfile
FROM node:20-alpine AS base
# 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# 빌드
FROM base AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 프로덕션
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# 비루트 사용자 생성
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# standalone 출력 복사
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Docker Compose
version: "3.8"
services:
web:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test:
["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
PM2 배포
전통적인 서버 배포에는:
// ecosystem.config.js
module.exports = {
apps: [
{
name: "nextjs",
script: ".next/standalone/server.js",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
},
},
],
};
npm run build
pm2 start ecosystem.config.js
ISR과 캐시 핸들러
문제
ISR(Incremental Static Regeneration)은 기본으로 파일시스템 캐싱을 사용해요. 이건 다중 인스턴스에서 깨져요:
- 인스턴스 A가 페이지를 재생성 → 자기 로컬 디스크에 저장
- 인스턴스 B는 낡은 페이지를 서빙 → A의 캐시를 보지 못함
- 로드 밸런서가 사용자를 무작위 인스턴스로 보냄 → 콘텐츠 불일치
해결책: 커스텀 캐시 핸들러
Next.js 14+는 공유 스토리지를 위한 커스텀 캐시 핸들러를 지원해요:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // 인메모리 캐시 비활성화
};
Redis 캐시 핸들러 예시
// cache-handler.js
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
const CACHE_PREFIX = "nextjs:";
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
const data = await redis.get(CACHE_PREFIX + key);
if (!data) return null;
const parsed = JSON.parse(data);
return {
value: parsed.value,
lastModified: parsed.lastModified,
};
}
async set(key, data, ctx) {
const cacheData = {
value: data,
lastModified: Date.now(),
};
// revalidate 옵션 기반 TTL 설정
if (ctx?.revalidate) {
await redis.setex(
CACHE_PREFIX + key,
ctx.revalidate,
JSON.stringify(cacheData),
);
} else {
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
}
}
async revalidateTag(tags) {
// 태그 기반 무효화 구현
// 어떤 키가 어떤 태그를 갖는지 추적이 필요해요
}
};
S3 캐시 핸들러 예시
// cache-handler.js
const {
S3Client,
GetObjectCommand,
PutObjectCommand,
} = require("@aws-sdk/client-s3");
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.CACHE_BUCKET;
module.exports = class CacheHandler {
async get(key) {
try {
const response = await s3.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
}),
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (err) {
if (err.name === "NoSuchKey") return null;
throw err;
}
}
async set(key, data, ctx) {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
Body: JSON.stringify({
value: data,
lastModified: Date.now(),
}),
ContentType: "application/json",
}),
);
}
};
그냥 되는 것 vs 추가 설정이 필요한 것
| 기능 | 단일 인스턴스 | 다중 인스턴스 | 비고 |
|---|---|---|---|
| SSR | 예 | 예 | 특별한 설정 불필요 |
| SSG | 예 | 예 | 배포 시 빌드됨 |
| ISR | 예 | 캐시 핸들러 필요 | 파일시스템 캐시가 깨짐 |
| 이미지 최적화 | 예 | 예 | CPU 집약적, CDN 고려 |
| Middleware | 예 | 예 | Node.js에서 실행 |
| Edge Runtime | 제한적 | 제한적 | 일부 기능은 Node 전용 |
revalidatePath/Tag | 예 | 캐시 핸들러 필요 | 캐시를 공유해야 함 |
next/font | 예 | 예 | 빌드 시 폰트 번들 |
| Draft Mode | 예 | 예 | 쿠키 기반 |
이미지 최적화
Next.js 이미지 최적화는 그냥 동작하지만 CPU 집약적이에요.
옵션 1: 내장 (간단)
자동으로 동작하지만 다음을 고려하세요:
- config에
deviceSizes와imageSizes를 설정해 변형 개수 제한 minimumCacheTTL로 재생성 감소
// next.config.js
module.exports = {
images: {
minimumCacheTTL: 60 * 60 * 24, // 24시간
deviceSizes: [640, 750, 1080, 1920], // 크기 제한
},
};
옵션 2: 외부 로더 (스케일에 권장)
Cloudinary, Imgix 등으로 오프로드:
// next.config.js
module.exports = {
images: {
loader: "custom",
loaderFile: "./lib/image-loader.js",
},
};
// lib/image-loader.js
export default function cloudinaryLoader({ src, width, quality }) {
const params = ["f_auto", "c_limit", `w_${width}`, `q_${quality || "auto"}`];
return `https://res.cloudinary.com/demo/image/upload/${params.join(",")}${src}`;
}
환경 변수
빌드 타임 vs 런타임
// 빌드 타임에만 사용 가능 (번들에 구워짐)
NEXT_PUBLIC_API_URL=https://api.example.com
// 런타임에 사용 가능 (서버 사이드 전용)
DATABASE_URL=postgresql://...
API_SECRET=...
런타임 설정
진짜 동적인 설정에는 NEXT_PUBLIC_*을 쓰지 마세요. 대신:
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL,
features: process.env.FEATURES?.split(","),
});
}
OpenNext: Vercel 없이 서버리스
OpenNext는 Next.js를 AWS Lambda, Cloudflare Workers 등에 맞게 변환해요.
npx create-sst@latest
# 또는
npx @opennextjs/aws build
지원 대상:
- AWS Lambda + CloudFront
- Cloudflare Workers
- Netlify Functions
- Deno Deploy
역주: 이 블로그도 OpenNext의 Cloudflare 어댑터(
@opennextjs/cloudflare)로 Cloudflare Workers에 배포되고 있어요.
헬스 체크 엔드포인트
로드 밸런서를 위해 항상 헬스 체크를 포함하세요:
// app/api/health/route.ts
export async function GET() {
try {
// 선택: 데이터베이스 연결 확인
// await db.$queryRaw`SELECT 1`;
return Response.json({ status: "healthy" }, { status: 200 });
} catch (error) {
return Response.json({ status: "unhealthy" }, { status: 503 });
}
}
배포 전 체크리스트
- 로컬에서 먼저 빌드:
npm run build— 배포 전에 에러를 잡으세요 - standalone 출력 테스트:
node .next/standalone/server.js - Docker에는
**output: 'standalone'설정** - 다중 인스턴스 ISR에는 캐시 핸들러 설정
- 컨테이너에는
**HOSTNAME="0.0.0.0"설정** **public/과.next/static/복사** — standalone에 포함되지 않아요- 헬스 체크 엔드포인트 추가
- 배포 후 ISR 재검증 테스트
- 메모리 사용량 모니터링 — Node.js 기본값은 튜닝이 필요할 수 있어요
캐시 핸들러 테스트
핵심: Next.js를 업그레이드할 때마다 캐시 핸들러를 테스트하세요:
# 여러 인스턴스 시작
PORT=3001 node .next/standalone/server.js &
PORT=3002 node .next/standalone/server.js &
# ISR 재검증 트리거
curl http://localhost:3001/api/revalidate?path=/posts
# 두 인스턴스 모두 업데이트를 보는지 확인
curl http://localhost:3001/posts
curl http://localhost:3002/posts
# 동일한 콘텐츠를 반환해야 함
19. 디버깅 트릭 (Debug Tricks)
Next.js 애플리케이션 디버깅을 빠르게 만드는 트릭들이에요.
MCP 엔드포인트 (개발 서버)
Next.js는 개발 모드에서 MCP(Model Context Protocol)를 통한 AI 기반 디버깅용 /_next/mcp 엔드포인트를 노출해요.
- Next.js 16+: 기본 활성화,
next-devtools-mcp사용 - Next.js < 16: next.config.js에
experimental.mcpServer: true필요
참고: https://nextjs.org/docs/app/guides/mcp
중요: 실행 중인 Next.js 개발 서버의 실제 포트를 찾으세요 (터미널 출력이나 package.json 스크립트 확인). 포트 3000이라고 가정하지 마세요.
요청 형식
엔드포인트는 HTTP POST로 JSON-RPC 2.0을 사용해요:
curl -X POST http://localhost:<port>/_next/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "<tool-name>",
"arguments": {}
}
}'
사용 가능한 도구
get_errors — 개발 서버의 현재 에러 조회 (빌드 에러, 소스맵이 적용된 스택의 런타임 에러):
{ "name": "get_errors", "arguments": {} }
get_routes — 파일시스템 스캔으로 모든 라우트 발견:
{ "name": "get_routes", "arguments": {} }
선택적으로: { "name": "get_routes", "arguments": { "routerType": "app" } }
반환: { "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }
get_project_metadata — 프로젝트 경로와 개발 서버 URL 조회:
{ "name": "get_project_metadata", "arguments": {} }
반환: { "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }
get_page_metadata — 현재 페이지 렌더의 런타임 메타데이터 조회 (활성 브라우저 세션 필요):
{ "name": "get_page_metadata", "arguments": {} }
레이아웃, 경계, 페이지 컴포넌트를 보여주는 세그먼트 트라이 데이터를 반환해요.
get_logs — Next.js 개발 로그 파일 경로 조회:
{ "name": "get_logs", "arguments": {} }
<distDir>/logs/next-development.log 경로를 반환해요.
get_server_action_by_id — ID로 Server Action 위치 찾기:
{
"name": "get_server_action_by_id",
"arguments": { "actionId": "<action-id>" }
}
예시: 에러 조회
curl -X POST http://localhost:<port>/_next/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"get_errors","arguments":{}}}'
특정 라우트만 재빌드 (Next.js 16+)
전체 앱 대신 특정 라우트만 다시 빌드하려면 --debug-build-paths를 사용해요:
# 특정 라우트 재빌드
next build --debug-build-paths "/dashboard"
# glob으로 매칭되는 라우트 재빌드
next build --debug-build-paths "/api/*"
# 동적 라우트
next build --debug-build-paths "/blog/[slug]"
다음에 사용하세요:
- 전체 재빌드 없이 빌드 수정을 빠르게 검증
- 특정 페이지의 정적 생성 문제 디버깅
- 빌드 에러를 더 빠르게 반복 수정