ywc.life

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.tsx404 UI
route.tsAPI 엔드포인트
template.tsxlayout과 비슷하지만 내비게이션마다 재렌더링
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-15middleware.tsmiddleware()config
v16+proxy.tsproxy()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'를 추가하기 전에** 확인하세요:

  1. 프로젝트가 이미 Edge 런타임을 사용하는가?
  2. 특별한 지연 시간 요구사항이 있는가?
  3. 모든 의존성이 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.tscacheComponents: 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
useSearchParamsURL 검색 파라미터 읽기Docs
useParams동적 라우트 파라미터 접근Docs
useSelectedLayoutSegment활성 자식 세그먼트 (한 레벨)Docs
useSelectedLayoutSegments레이아웃 아래 모든 활성 세그먼트Docs
useLinkStatus링크 프리페치 상태 확인Docs
useReportWebVitalsCore Web Vitals 지표 리포트Docs

서버 함수

함수용도레퍼런스
cookies쿠키 읽기/쓰기Docs
headers요청 헤더 읽기Docs
draftMode미발행 CMS 콘텐츠 미리보기 활성화Docs
after응답 스트리밍 완료 후 코드 실행Docs
connection동적 렌더링 전 연결 대기Docs
userAgentUser-Agent 헤더 파싱Docs

Generate 함수

함수용도레퍼런스
generateStaticParams빌드 타임에 동적 라우트 사전 렌더링Docs
generateMetadata동적 메타데이터Docs
generateViewport동적 viewport 설정Docs
generateSitemaps대형 사이트용 다중 사이트맵Docs
generateImageMetadata라우트당 다중 OG 이미지Docs

Request/Response

함수용도레퍼런스
NextRequest헬퍼가 추가된 확장 RequestDocs
NextResponse헬퍼가 추가된 확장 ResponseDocs
ImageResponseOG 이미지 생성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 found
  • forbidden() - 403 forbidden
  • unauthorized() - 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.tspage.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 HandlersServer 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'가 있다면:

  1. 가능하면 'use client'를 제거하고 클라이언트 로직을 자식 컴포넌트로 이동
  2. 또는 메타데이터를 부모 Server Component 레이아웃으로 추출
  3. 또는 파일 분리: 메타데이터를 가진 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.pngApple 앱 아이콘
opengraph-image.pngOG 이미지
twitter-image.png트위터 카드 이미지
sitemap.ts / sitemap.xml사이트맵 (여러 개는 generateSitemaps 사용)
robots.ts / robots.txtRobots 지시문
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 이미지를 생성해요.

중요 규칙:

  1. **next/og를 사용하세요** — @vercel/og가 아니에요 (Next.js에 내장돼 있어요)
  2. searchParams 사용 불가 — OG 이미지는 검색 파라미터에 접근할 수 없어요. 라우트 파라미터를 사용하세요
  3. 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']
rechartswindow 사용dynamic(() => import('recharts'), { ssr: false })
react-quilldocument 사용dynamic(() => import('react-quill'), { ssr: false })
mapbox-glwindow 사용dynamic(() => import('mapbox-gl'), { ssr: false })
monaco-editorwindow 사용dynamic(() => import('@monaco-editor/react'), { ssr: false })
lottie-webdocument 사용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/scriptnext/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로 모달을 "닫으면":

  1. 새 히스토리 항목이 추가돼요 (뒤로 가기 버튼이 모달을 다시 보여줌)
  2. 인터셉트된 라우트가 제대로 정리되지 않아요
  3. 모달이 깜빡이거나 예상치 않게 남을 수 있어요

router.back()은 올바르게:

  1. 인터셉트된 라우트를 히스토리에서 제거해요
  2. 이전 페이지로 돌아가요
  3. 모달을 올바르게 언마운트해요

라우트 매처 레퍼런스

매처는 파일시스템 경로가 아니라 라우트 세그먼트를 매칭해요:

매처매칭 대상예시
(.)같은 레벨@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를 인터셉트해요
  • @modalapp/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 --from=deps /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 --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /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 고려
MiddlewareNode.js에서 실행
Edge Runtime제한적제한적일부 기능은 Node 전용
revalidatePath/Tag캐시 핸들러 필요캐시를 공유해야 함
next/font빌드 시 폰트 번들
Draft Mode쿠키 기반

이미지 최적화

Next.js 이미지 최적화는 그냥 동작하지만 CPU 집약적이에요.

옵션 1: 내장 (간단)

자동으로 동작하지만 다음을 고려하세요:

  • config에 deviceSizesimageSizes를 설정해 변형 개수 제한
  • 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 });
  }
}

배포 전 체크리스트

  1. 로컬에서 먼저 빌드: npm run build — 배포 전에 에러를 잡으세요
  2. standalone 출력 테스트: node .next/standalone/server.js
  3. Docker에는 **output: 'standalone' 설정**
  4. 다중 인스턴스 ISR에는 캐시 핸들러 설정
  5. 컨테이너에는 **HOSTNAME="0.0.0.0" 설정**
  6. **public/.next/static/ 복사** — standalone에 포함되지 않아요
  7. 헬스 체크 엔드포인트 추가
  8. 배포 후 ISR 재검증 테스트
  9. 메모리 사용량 모니터링 — 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]"

다음에 사용하세요:

  • 전체 재빌드 없이 빌드 수정을 빠르게 검증
  • 특정 페이지의 정적 생성 문제 디버깅
  • 빌드 에러를 더 빠르게 반복 수정

참고 자료

  1. next-best-practices 스킬 원문 (vercel-labs/openreview)
  2. Next.js 공식 문서
  3. React Server Components 지시어 (react.dev)
  4. OpenNext
  5. React Best Practices 번역 글

댓글