본문으로 건너뛰기
Advertisement

Next.js 소개 — Pages Router vs App Router, 파일 기반 라우팅, SSR/SSG/ISR

Next.js란 무엇인가?

Next.js는 React 기반의 풀스택 웹 프레임워크입니다. Vercel이 개발·유지보수하며, 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG), 파일 기반 라우팅 등 웹 개발에서 반복되는 복잡한 설정을 내장 기능으로 제공합니다.

React 자체는 UI 라이브러리이기 때문에 라우팅, 데이터 페칭, 빌드 최적화 등을 직접 구성해야 합니다. Next.js는 이런 공통 문제를 해결한 오피니언이 있는(opinionated) 프레임워크입니다.

Next.js가 해결하는 문제들

문제React 단독Next.js
라우팅react-router 별도 설치파일 시스템 기반 내장 라우팅
SEOCSR로 인한 크롤러 미지원SSR/SSG로 완전한 HTML 제공
이미지 최적화직접 구현<Image> 컴포넌트 내장
API 서버별도 Node 서버 필요Route Handlers 내장
코드 스플리팅수동 설정자동 코드 스플리팅
폰트 최적화직접 구현next/font 내장

Pages Router vs App Router

Next.js에는 두 가지 라우팅 시스템이 공존합니다.

Pages Router (구형, Next.js 12 이하 주력)

pages/ 디렉터리 기반의 전통적 라우팅입니다.

pages/
index.js → /
about.js → /about
blog/
[slug].js → /blog/:slug
api/
users.js → /api/users (API Route)

Pages Router의 데이터 페칭 방식:

// pages/posts/[id].js — Pages Router 방식
export async function getServerSideProps({ params }) {
// 매 요청마다 서버에서 실행 (SSR)
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();

return { props: { post } };
}

export async function getStaticProps({ params }) {
// 빌드 타임에 실행 (SSG)
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();

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

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

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

export default function PostPage({ post }) {
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}

App Router (Next.js 13+, 현재 표준)

app/ 디렉터리 기반의 새로운 라우팅입니다. React Server Components(RSC)를 핵심으로 사용하며 Next.js 15에서 안정화되었습니다.

app/
layout.js → 루트 레이아웃 (모든 페이지 공유)
page.js → / 경로
about/
page.js → /about
blog/
layout.js → /blog 하위 공통 레이아웃
page.js → /blog
[slug]/
page.js → /blog/:slug
api/
users/
route.js → /api/users (Route Handler)

Pages Router vs App Router 핵심 차이

항목Pages RouterApp Router
기본 컴포넌트Client ComponentServer Component
데이터 페칭getServerSideProps, getStaticPropsasync/await 컴포넌트 직접
레이아웃_app.js, _document.jslayout.js (중첩 가능)
로딩 UI직접 구현loading.js 자동 연동
에러 UI_error.jserror.js 자동 연동
스트리밍미지원<Suspense> 기반 지원
캐싱단순세밀한 요청 수준 캐싱

Next.js 15부터는 App Router가 기본 표준입니다. 신규 프로젝트는 App Router를 사용하세요.


파일 기반 라우팅 (App Router)

App Router에서 라우팅은 파일 시스템 구조로 결정됩니다.

특수 파일 규칙

파일명역할
page.js해당 경로의 UI (라우트 접근 가능하게 만듦)
layout.js공유 레이아웃 (리렌더 없이 유지)
loading.js로딩 중 UI (자동 Suspense 래핑)
error.js에러 UI (자동 Error Boundary)
not-found.js404 UI
route.jsAPI Route Handler
template.js탐색 시마다 새 인스턴스 생성 레이아웃
default.jsParallel Routes 폴백

동적 라우트

app/
blog/
[slug]/ → /blog/:slug (단일 동적 세그먼트)
page.js
shop/
[...categories]/ → /shop/a/b/c (catch-all)
page.js
docs/
[[...slug]]/ → /docs 또는 /docs/a/b (optional catch-all)
page.js

동적 파라미터 접근:

// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function BlogPost({ params, searchParams }: Props) {
const { slug } = await params;
const { page = '1' } = await searchParams;

return (
<article>
<h1>포스트: {slug}</h1>
<p>페이지: {page}</p>
</article>
);
}

Next.js 15 변경사항: paramssearchParams가 Promise로 변경되었습니다. await로 언래핑해야 합니다.

Route Groups

URL 경로에 영향 없이 파일을 논리적으로 그룹화할 수 있습니다.

app/
(marketing)/ → URL에 포함 안 됨
page.js → /
about/page.js → /about
(shop)/ → URL에 포함 안 됨
products/page.js → /products
cart/page.js → /cart
(auth)/
layout.js → 인증 전용 레이아웃
login/page.js → /login
register/page.js → /register

Parallel Routes & Intercepting Routes

app/
@modal/ → 병렬 라우트 (슬롯)
(.)photo/[id]/ → 인터셉팅: 현재 레이아웃에서 모달로 열기
page.js
photo/
[id]/
page.js → 직접 접근 시 전체 페이지
layout.js → { children, modal } props 받음

SSR / SSG / ISR 렌더링 전략

1. SSR — 서버 사이드 렌더링

매 요청마다 서버에서 HTML을 생성합니다.

// app/dashboard/page.tsx
// 기본적으로 캐시하지 않으면 SSR
export default async function Dashboard() {
// 매 요청마다 실행
const data = await fetch('https://api.example.com/stats', {
cache: 'no-store', // SSR: 캐시 비활성화
}).then((r) => r.json());

return (
<main>
<h1>대시보드</h1>
<p>활성 사용자: {data.activeUsers}</p>
</main>
);
}

사용 시점: 실시간 데이터, 사용자별 개인화 페이지, 인증이 필요한 페이지

2. SSG — 정적 사이트 생성

빌드 타임에 HTML을 미리 생성합니다.

// app/blog/[slug]/page.tsx
// generateStaticParams로 빌드 타임에 경로 목록 생성
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((r) =>
r.json()
);

return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}

export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;

// fetch는 기본적으로 캐시됨 (SSG)
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.json()
);

return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}

사용 시점: 블로그, 문서, 마케팅 페이지 등 정적 콘텐츠

3. ISR — 점진적 정적 재생성

SSG의 장점(빠른 응답)과 SSR의 장점(최신 데이터)을 결합합니다.

// app/products/page.tsx
export const revalidate = 3600; // 1시간마다 재검증

export default async function Products() {
const products = await fetch('https://api.example.com/products').then((r) =>
r.json()
);

return (
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id}>
{p.name} — ₩{p.price.toLocaleString()}
</li>
))}
</ul>
);
}

On-Demand ISR: 특정 이벤트 발생 시 즉시 재검증

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');

if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}

const tag = searchParams.get('tag');
if (tag) {
revalidateTag(tag); // 특정 태그의 캐시 무효화
}

const path = searchParams.get('path');
if (path) {
revalidatePath(path); // 특정 경로 캐시 무효화
}

return NextResponse.json({ revalidated: true, now: Date.now() });
}

렌더링 전략 선택 가이드

데이터가 자주 바뀌나요?
├── 예 → 사용자마다 다른가요?
│ ├── 예 → SSR (cache: 'no-store')
│ └── 아니오 → ISR (revalidate 설정)
└── 아니오 → SSG (generateStaticParams)

실전 예제: 블로그 사이트 구조

my-blog/
app/
layout.tsx # 루트 레이아웃
page.tsx # 홈 (SSG)
blog/
page.tsx # 블로그 목록 (ISR, 1시간)
[slug]/
page.tsx # 블로그 상세 (SSG + generateStaticParams)
about/
page.tsx # 소개 (SSG)
api/
revalidate/
route.ts # ISR 웹훅 엔드포인트
components/
Header.tsx
Footer.tsx
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Header from '@/components/Header';
import Footer from '@/components/Footer';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
title: {
template: '%s | 내 블로그',
default: '내 블로그',
},
description: 'Next.js App Router로 만든 블로그',
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
// app/blog/page.tsx — ISR
export const revalidate = 3600;

interface Post {
slug: string;
title: string;
date: string;
excerpt: string;
}

export default async function BlogList() {
const posts: Post[] = await fetch('https://api.example.com/posts').then(
(r) => r.json()
);

return (
<section>
<h1>블로그</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<time>{post.date}</time>
<p>{post.excerpt}</p>
</a>
</li>
))}
</ul>
</section>
);
}
// app/blog/[slug]/page.tsx — SSG
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Post {
slug: string;
title: string;
date: string;
content: string;
}

export async function generateStaticParams() {
const posts: Post[] = await fetch('https://api.example.com/posts').then(
(r) => r.json()
);
return posts.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post: Post = await fetch(
`https://api.example.com/posts/${slug}`
).then((r) => r.json());

return {
title: post.title,
description: post.content.slice(0, 160),
};
}

export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`);

if (!res.ok) notFound();

const post: Post = await res.json();

return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// app/blog/[slug]/loading.tsx — 자동 Suspense
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-8" />
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
// app/blog/[slug]/error.tsx — 자동 Error Boundary
'use client'; // Error 컴포넌트는 반드시 Client Component

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}

Server Components vs Client Components

App Router의 핵심 개념입니다.

Server Component (기본값)

// 'use client' 없으면 Server Component
// app/components/ProductList.tsx
async function ProductList() {
// 서버에서만 실행 — API 키, DB 접근 가능
const products = await fetch('https://api.example.com/products', {
headers: { Authorization: `Bearer ${process.env.API_SECRET_KEY}` },
}).then((r) => r.json());

return (
<ul>
{products.map((p: { id: number; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
  • 번들 크기 0 (클라이언트로 JS 미전송)
  • useState, useEffect 사용 불가
  • 이벤트 핸들러 사용 불가
  • 브라우저 API 사용 불가

Client Component

'use client'; // 반드시 파일 최상단에 선언

import { useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</div>
);
}

조합 패턴: Server → Client 전달

// app/page.tsx (Server Component)
import SearchBar from '@/components/SearchBar'; // Client Component
import ProductList from '@/components/ProductList'; // Server Component

export default async function Home() {
const featured = await fetch('https://api.example.com/featured').then((r) =>
r.json()
);

return (
<div>
{/* Client Component에 서버 데이터 props로 전달 */}
<SearchBar />
<ProductList products={featured} />
</div>
);
}

중요: Client Component 안에서 Server Component를 직접 import하면 안 됩니다. children prop을 통해 합성해야 합니다.


고수 팁

1. 렌더링 전략 세분화

같은 페이지라도 컴포넌트마다 다른 전략을 적용할 수 있습니다.

// app/dashboard/page.tsx
import { Suspense } from 'react';

// 정적 헤더 (SSG)
function StaticHeader() {
return <h1>대시보드</h1>;
}

// 동적 통계 (SSR)
async function LiveStats() {
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
}).then((r) => r.json());
return <div>실시간: {stats.value}</div>;
}

// ISR 차트 (1분 캐시)
async function CachedChart() {
const data = await fetch('https://api.example.com/chart-data', {
next: { revalidate: 60 },
}).then((r) => r.json());
return <div>차트 데이터: {data.length}</div>;
}

export default function Dashboard() {
return (
<div>
<StaticHeader />
<Suspense fallback={<div>통계 로딩중...</div>}>
<LiveStats />
</Suspense>
<Suspense fallback={<div>차트 로딩중...</div>}>
<CachedChart />
</Suspense>
</div>
);
}

2. fetch 요청 중복 제거 (Request Memoization)

같은 URL의 fetch는 렌더링 중 자동으로 중복 제거됩니다.

// 두 컴포넌트가 같은 URL을 fetch해도 실제 네트워크 요청은 1번만 발생
async function UserName() {
const user = await fetch('/api/user').then((r) => r.json()); // 1번째
return <span>{user.name}</span>;
}

async function UserAvatar() {
const user = await fetch('/api/user').then((r) => r.json()); // 중복 제거됨
return <img src={user.avatar} alt="avatar" />;
}

3. 메타데이터 자동 생성

// generateMetadata로 동적 OG 태그 생성
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`/api/posts/${slug}`).then((r) => r.json());

return {
title: post.title,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
},
};
}

4. Middleware로 인증 처리

// middleware.ts (루트 위치)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}

return NextResponse.next();
}

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

5. Partial Prerendering (PPR) — 실험적 기능

Next.js 15에서 실험적으로 제공하는 하이브리드 렌더링입니다. 정적 셸을 즉시 제공하고, 동적 부분은 스트리밍으로 채웁니다.

// next.config.ts
const nextConfig = {
experimental: {
ppr: 'incremental', // 또는 true (전체 앱 적용)
},
};

// app/product/[id]/page.tsx
export const experimental_ppr = true;

import { Suspense } from 'react';

export default function ProductPage() {
return (
<div>
{/* 정적 부분: 즉시 HTML 제공 */}
<StaticProductInfo />
{/* 동적 부분: 스트리밍으로 나중에 채워짐 */}
<Suspense fallback={<div>재고 확인중...</div>}>
<DynamicInventory />
</Suspense>
</div>
);
}
Advertisement