React로 서비스 만들다 보면 결국 부딪히는 벽이 있다. SEO, 초기 로딩 속도, 라우팅 설정... 이런 것들을 프레임워크 레벨에서 해결해주는 게 Next.js다. 자주 헷갈리는 주제라 핵심만 정리해봤다.

React만으로는 왜 부족한가

React는 클라이언트 사이드 렌더링(CSR) 기반이에요. 브라우저가 빈 HTML을 받고, JavaScript 번들을 다운로드한 뒤에야 화면이 그려집니다.

이게 왜 문제냐면요:

  • SEO: 검색 엔진 크롤러가 빈 HTML만 보고 돌아갑니다. Googlebot이 JavaScript를 실행할 수 있긴 한데, 완벽하지 않고 크롤링 예산도 낭비돼요
  • 초기 로딩: 번들 크기가 커질수록 첫 화면이 뜨기까지 시간이 오래 걸려요. FCP(First Contentful Paint)가 늦어지면 사용자 이탈률이 올라갑니다
  • 라우팅: React 자체에는 라우팅이 없어서 react-router 같은 라이브러리를 따로 붙여야 해요. 코드 스플리팅도 직접 설정해야 하고요
  • 메타 태그 관리: 페이지별로 다른 OG 태그를 넣으려면 꽤 번거롭습니다

Next.js는 이 문제들을 프레임워크 차원에서 풀어줍니다. 서버 사이드 렌더링, 정적 생성, 파일 기반 라우팅, 이미지 최적화까지 한 번에요.

Pages Router vs App Router

Next.js의 라우팅 시스템은 두 세대를 거쳤습니다.

Pages Router (v1 ~ v12)

pages/ 디렉토리 안에 파일을 만들면 그게 곧 라우트가 돼요.

PLAINTEXT
pages/
├── index.tsx          → /
├── about.tsx          → /about
├── blog/
│   ├── index.tsx      → /blog
│   └── [slug].tsx     → /blog/:slug
└── _app.tsx           → 전체 레이아웃

데이터 페칭은 페이지 레벨의 함수로 처리했어요:

  • getServerSideProps → 매 요청마다 서버에서 데이터 가져오기
  • getStaticProps → 빌드 타임에 데이터 가져오기
  • getStaticPaths → 동적 경로의 정적 생성

단순하고 직관적이었는데, 레이아웃 공유가 어렵고 컴포넌트 단위 데이터 페칭이 안 된다는 한계가 있었습니다.

App Router (v13.4~, 현재 권장)

app/ 디렉토리 기반으로 완전히 새로운 패러다임이에요. React Server Components를 기본으로 채택했고, 레이아웃 시스템이 근본적으로 달라졌습니다.

현재 Next.js 공식 문서에서도 App Router를 기본으로 안내하고 있어요. 신규 프로젝트라면 App Router를 쓰는 게 맞고, Pages Router는 레거시 프로젝트 유지보수용이라고 보면 됩니다.

렌더링 전략 — SSR, SSG, ISR

Next.js가 강력한 이유 중 하나는 페이지마다 렌더링 전략을 다르게 가져갈 수 있다는 점이에요.

SSR (Server-Side Rendering)

매 요청마다 서버에서 HTML을 생성해서 내려줍니다.

TSX
// Pages Router
export async function getServerSideProps(context) {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();

  return { props: { data } };
}

항상 최신 데이터가 필요한 페이지에 적합해요. 대시보드, 사용자별 맞춤 페이지 같은 곳이죠. 다만 매번 서버가 일해야 하니까 TTFB(Time to First Byte)가 느려질 수 있습니다.

SSG (Static Site Generation)

빌드할 때 HTML을 미리 만들어 둡니다. CDN에 올려두면 끝이에요.

TSX
// Pages Router
export async function getStaticProps() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return { props: { posts } };
}

export async function getStaticPaths() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return {
    paths: posts.map((post) => ({ params: { id: post.id } })),
    fallback: 'blocking',
  };
}

블로그, 마케팅 페이지, 문서 사이트처럼 내용이 자주 안 바뀌는 곳에 최적입니다. 응답 속도가 압도적으로 빨라요.

ISR (Incremental Static Regeneration)

SSG의 장점은 살리면서 "특정 시간마다 다시 빌드"할 수 있는 전략이에요.

TSX
export async function getStaticProps() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return {
    props: { products },
    revalidate: 60, // 60초마다 재생성
  };
}

revalidate: 60이면 마지막 생성 이후 60초가 지난 시점에 요청이 들어오면, 일단 캐시된 페이지를 보여주고 백그라운드에서 새 페이지를 만들어요. 다음 요청부터 새 페이지가 나갑니다. 이걸 stale-while-revalidate 패턴이라고 불러요.

쇼핑몰 상품 목록이나 뉴스 같은 데 쓰면 딱이에요.

App Router 파일 컨벤션

App Router에서는 특별한 파일 이름이 각각의 역할을 맡습니다.

PLAINTEXT
app/
├── layout.tsx         → 공통 레이아웃 (중첩 가능)
├── page.tsx           → 해당 경로의 페이지 컴포넌트
├── loading.tsx        → 로딩 UI (React Suspense 경계)
├── error.tsx          → 에러 UI (Error Boundary)
├── not-found.tsx      → 404 페이지
├── template.tsx       → 레이아웃과 비슷하지만 매번 새로 마운트
├── route.ts           → API 라우트 (Route Handler)
└── dashboard/
    ├── layout.tsx     → 대시보드 전용 레이아웃
    └── page.tsx       → /dashboard 페이지

layout.tsx

레이아웃은 중첩 됩니다. 루트 layout.tsx가 전체 앱을 감싸고, 하위 폴더의 layout.tsx는 해당 세그먼트만 감싸요. 네비게이션 시에도 레이아웃은 리렌더링되지 않고 상태가 유지됩니다.

TSX
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <header>공통 헤더</header>
        {children}
      </body>
    </html>
  );
}

loading.tsx

이 파일을 넣어두면 해당 세그먼트가 로딩 중일 때 자동으로 Suspense 경계가 생겨요. 스켈레톤 UI 같은 걸 여기에 넣으면 됩니다.

error.tsx

런타임 에러가 발생하면 가장 가까운 error.tsx가 잡아줍니다. React Error Boundary를 자동으로 감싸주는 셈이에요. 반드시 "use client"로 선언해야 합니다.

TSX
'use client';

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>문제가 발생했습니다</h2>
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  );
}

Server Components vs Client Components

App Router에서 가장 큰 변화가 바로 이거예요. 기본이 Server Component 라는 점입니다.

Server Component (기본값)

  • 서버에서만 실행돼요. 클라이언트로 JavaScript가 전송되지 않습니다
  • async 컴포넌트가 가능해서 직접 DB를 조회하거나 파일 시스템을 읽을 수 있어요
  • useState, useEffect 같은 훅 사용 불가
  • 브라우저 API (window, document) 접근 불가
TSX
// 이게 서버 컴포넌트 — 별도 선언 없이 그냥 쓰면 된다
async function ProductList() {
  const products = await db.product.findMany();

  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Client Component

파일 맨 위에 "use client" 디렉티브를 붙이면 클라이언트 컴포넌트가 됩니다.

TSX
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

인터랙티브한 UI, 브라우저 API가 필요한 곳에서만 쓰면 돼요.

핵심 원칙

"use client"는 서버-클라이언트의 경계선 이에요. 이 디렉티브가 붙은 파일에서 import하는 모든 모듈은 클라이언트 번들에 포함됩니다. 그래서 가능한 한 컴포넌트 트리의 말단(leaf) 에만 "use client"를 붙이는 게 좋아요.

서버 컴포넌트 안에서 클라이언트 컴포넌트를 자식으로 쓸 수 있지만, 반대로 클라이언트 컴포넌트 안에서 서버 컴포넌트를 직접 import할 수는 없습니다. children prop으로 넘기는 건 가능해요.

데이터 페칭

App Router에서는 데이터 페칭 방식이 완전히 달라졌어요.

fetch() 확장

Next.js는 Web의 fetch() API를 확장해서 캐싱과 재검증 옵션을 추가했습니다.

TSX
// 기본 — 요청 결과를 캐시 (SSG와 유사)
const res = await fetch('https://api.example.com/data');

// 캐시 안 함 — 매 요청마다 새로 (SSR과 유사)
const res = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// 시간 기반 재검증 (ISR과 유사)
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 },
});

// 태그 기반 재검증
const res = await fetch('https://api.example.com/data', {
  next: { tags: ['products'] },
});

태그 기반 재검증은 revalidateTag('products')를 호출하면 해당 태그가 붙은 모든 캐시를 무효화해요. 시간 기반보다 정밀한 제어가 가능해서 실제로 많이 씁니다.

서버 컴포넌트에서의 데이터 로딩

서버 컴포넌트는 async 함수니까, 컴포넌트 안에서 직접 데이터를 가져올 수 있어요. 더 이상 getServerSideProps 같은 별도 함수가 필요 없습니다.

TSX
async function UserProfile({ userId }: { userId: string }) {
  const user = await prisma.user.findUnique({ where: { id: userId } });

  if (!user) notFound();

  return <div>{user.name}</div>;
}

요청 중복도 자동으로 처리돼요. 같은 URL에 대한 fetch()는 React가 자동으로 중복 제거(deduplicate)해줍니다.

Server Actions

Server Actions는 서버에서 실행되는 함수를 클라이언트에서 직접 호출할 수 있게 해주는 기능이에요. API 라우트를 따로 만들 필요 없이 폼 처리나 데이터 변경(mutation)을 할 수 있습니다.

TSX
// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({ data: { title, content } });

  revalidatePath('/posts');
}
TSX
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">작성</button>
    </form>
  );
}

"use server" 디렉티브를 붙이면 해당 함수는 서버에서만 실행돼요. 클라이언트에서는 자동으로 POST 요청으로 변환되어 호출됩니다. JavaScript가 비활성화된 환경에서도 폼이 동작한다는 게 큰 장점이에요.

클라이언트 컴포넌트에서도 쓸 수 있어요:

TSX
'use client';

import { createPost } from '@/app/actions';
import { useTransition } from 'react';

export default function CreateButton() {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      await createPost(new FormData());
    });
  };

  return <button onClick={handleClick} disabled={isPending}>생성</button>;
}

미들웨어

middleware.ts 파일을 프로젝트 루트에 두면, 모든 요청이 이 미들웨어를 거쳐요. Edge Runtime에서 실행되기 때문에 응답이 빠릅니다.

TSX
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token');

  // 인증 안 된 사용자 리다이렉트
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 헤더 추가
  const response = NextResponse.next();
  response.headers.set('x-custom-header', 'hello');
  return response;
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

주로 쓰는 곳은요:

  • 인증 체크: 로그인 여부 확인 후 리다이렉트
  • A/B 테스팅: 쿠키 기반으로 다른 페이지 서빙
  • 국제화(i18n): Accept-Language 헤더 보고 로케일 분기
  • Bot 감지: User-Agent 기반 차단

주의할 점은 미들웨어에서 Node.js API를 전부 쓸 수 있는 건 아니라는 거예요. Edge Runtime은 fs 같은 모듈을 지원하지 않습니다. 가볍고 빠른 처리만 하는 데 적합해요.

Image/Font 최적화

next/image

<img> 태그 대신 next/image를 쓰면 자동으로 여러 최적화가 들어갑니다.

TSX
import Image from 'next/image';

export default function Avatar() {
  return (
    <Image
      src="/profile.jpg"
      alt="프로필"
      width={200}
      height={200}
      placeholder="blur"
      blurDataURL="data:image/..."
    />
  );
}
  • WebP/AVIF 포맷 자동 변환
  • 뷰포트 크기에 맞는 이미지 제공 (srcset 자동 생성)
  • Lazy loading 기본 적용
  • CLS(Cumulative Layout Shift) 방지를 위한 자동 사이즈 예약

next/font

폰트 최적화도 프레임워크가 알아서 해줘요. Google Fonts를 쓸 때 외부 네트워크 요청 없이 빌드 타임에 폰트를 다운로드해서 셀프 호스팅합니다.

TSX
import { Noto_Sans_KR } from 'next/font/google';

const notoSansKr = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={notoSansKr.className}>
      <body>{children}</body>
    </html>
  );
}

font-display: swap 설정으로 폰트 로딩 중에도 텍스트가 보이게 하고, size-adjust로 레이아웃 시프트도 최소화해요.

배포

Vercel

Next.js 만든 회사의 플랫폼이니까 당연히 궁합이 가장 좋아요. Git push만 하면 자동 배포, 미리보기 배포, Edge Function 지원까지. 개인 프로젝트나 소규모 팀이면 무료 플랜으로도 충분합니다.

셀프 호스팅 (standalone)

next.config.jsoutput: 'standalone'을 설정하면 필요한 파일만 모아서 경량 빌드를 만들어줍니다.

JS
// next.config.js
module.exports = {
  output: 'standalone',
};

빌드 후 .next/standalone 폴더에 server.js가 생겨요. node server.js로 바로 실행 가능합니다.

Docker

standalone 빌드를 Docker로 감싸는 게 일반적이에요.

DOCKERFILE
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["node", "server.js"]

멀티 스테이지 빌드로 이미지 크기를 줄이는 게 핵심이에요. standalone 모드면 node_modules를 통째로 복사할 필요 없이 필요한 의존성만 포함됩니다.

주의할 점

Hydration이란?

서버에서 렌더링한 HTML이 브라우저에 도착하면, React가 그 HTML에 이벤트 핸들러를 붙이고 인터랙티브하게 만드는 과정을 Hydration이라고 해요. 서버 HTML과 클라이언트 렌더링 결과가 달라지면 hydration mismatch 에러가 발생합니다. typeof window !== 'undefined' 같은 조건부 렌더링이 이 문제를 일으키는 대표적인 케이스예요.

RSC Payload란?

React Server Components가 클라이언트로 전달되는 형태예요. 일반 HTML이 아니라 React 트리의 직렬화된 표현인데, 클라이언트 컴포넌트 참조와 서버 컴포넌트의 렌더링 결과를 함께 담고 있습니다. JSON보다 스트리밍에 최적화된 포맷이고, 이걸 통해 부분적으로 UI를 업데이트할 수 있어요.

Streaming SSR이란?

전통적인 SSR은 전체 페이지가 다 렌더링될 때까지 기다렸다가 한 번에 보내는 방식이에요. Streaming SSR은 준비된 부분부터 차례차례 보내줍니다. React 18의 renderToPipeableStream과 Suspense를 조합해서 구현해요.

App Router에서 loading.tsx를 쓰면 자동으로 Streaming이 적용되는 건 이런 원리 때문이에요. 느린 데이터 소스가 있어도 나머지 UI는 먼저 보여줄 수 있어서 체감 성능이 크게 올라갑니다.

Next.js vs Remix vs Nuxt

Next.jsRemixNuxt
기반ReactReactVue
렌더링SSR/SSG/ISR/RSCSSR 중심SSR/SSG
데이터 로딩fetch 확장, Server Componentsloader/action (웹 표준 중심)useFetch, useAsyncData
폼 처리Server ActionsForm + action (네이티브 폼 활용)자체 유틸리티
번들러Turbopack (기본), webpackesbuild, ViteVite
배포Vercel 최적화플랫폼 독립적Nitro 엔진

Remix는 웹 표준에 충실한 접근을 강조해요. Request/Response API를 직접 쓰고, 중첩 라우트에서의 데이터 로딩이 깔끔합니다. Next.js가 Server Actions를 도입하면서 둘의 차이가 좀 줄어든 감이 있긴 한데, Remix는 프로그레시브 인핸스먼트 철학이 더 강해요.

Nuxt는 Vue 생태계의 Next.js라고 보면 되는데, 구조나 개념이 상당히 비슷합니다. Vue 프로젝트라면 Nuxt, React 프로젝트라면 Next.js가 자연스러운 선택이에요.

파생 개념

  • React — Next.js의 기반이에요. Virtual DOM, Reconciliation, Hooks 등 React 핵심 개념을 알아야 Next.js도 제대로 이해할 수 있습니다
  • 웹 성능 최적화 — CRP, Core Web Vitals, 코드 스플리팅 등. Next.js가 최적화해주는 영역들의 원리를 이해하면 깊이 있는 이해가 가능해요
  • TypeScript — Next.js는 TypeScript를 first-class로 지원합니다. next.config.ts, 타입 안전한 라우트 등 TS 없이 쓰면 놓치는 게 많아요
댓글 로딩 중...