지난 세션에서 서버 컴포넌트가 데이터를 어디서 가져오는지 배웠습니다. 외부 API, 데이터베이스, 파일 시스템 - 어디든 서버에서 직접 접근할 수 있었죠. 이번 세션의 질문은 다릅니다: "그 데이터로 화면을 언제 만들까?"
생각해 보면 두 가지 시점이 가능합니다:
Next.js는 이 두 가지를 정적 렌더링(Static Rendering)과 동적 렌더링(Dynamic Rendering)이라 부릅니다.
정적 렌더링은 Next.js의 기본값입니다. 컴포넌트 안에서 요청 시점 정보(cookies(), headers(), searchParams)를 사용하지 않으면, Next.js는 빌드 시점에 HTML을 미리 생성합니다.
// app/about/page.tsx
export default function About() {
return (
<div>
<h1>블로그 소개</h1>
<p>이 블로그는 웹 개발 경험을 공유하는 공간입니다.</p>
<p>React와 Next.js를 중심으로 다양한 주제를 다룹니다.</p>
</div>
);
}
이 페이지는 요청 시점 정보를 전혀 사용하지 않습니다. npm run build를 실행하면 HTML 파일이 미리 만들어지고, 빌드 로그에서 ○ 표시로 확인할 수 있습니다:
Route (app)
┌ ○ /about
└ ○ /blog
○ (Static) prerendered as static content
미리 만들어진 HTML은 CDN에 캐싱되어 즉시 응답할 수 있습니다. 서버가 매번 렌더링할 필요가 없으므로 속도와 비용 모두 유리합니다.
next dev)에서는 코드 변경을 즉시 반영하기 위해 모든 페이지가 요청마다 렌더링됩니다. 정적/동적 구분은 npm run build로 프로덕션 빌드를 할 때 적용되는 최적화입니다.
검색 결과 페이지를 생각해 보세요. 사용자가 어떤 검색어를 입력할지는 빌드 시점에 알 수 없습니다. 검색어는 URL의 쿼리 스트링(?q=react)으로 전달되고, 이 값은 요청이 들어와야 알 수 있기 때문에 HTML을 미리 만들어 둘 수 없습니다.
Next.js는 다음 함수나 값을 사용하면 자동으로 동적 렌더링으로 전환합니다:
cookies() - 쿠키 읽기headers() - 요청 헤더 읽기searchParams - URL 쿼리 스트링
// app/blog/search/page.tsx
import { SearchablePostList } from '@/features/library/components/SearchablePostList';
export default async function BlogSearch(
props: { searchParams: Promise<{ q?: string }> }
) {
const { q } = await props.searchParams;
const res = await fetch(
'http://localhost:4000/posts?search=' + (q || '')
);
const posts = await res.json();
return (
<div>
<h1>검색 결과: {q}</h1>
<SearchablePostList posts={posts} />
</div>
);
}
searchParams를 사용했으므로 이 페이지는 자동으로 동적 렌더링됩니다. 빌드 로그에서 ƒ 표시로 확인할 수 있습니다:
Route (app)
┌ ○ /about
├ ○ /blog
└ ƒ /blog/search
ƒ (Dynamic) server-rendered on demand
searchParams와 params는 모두 Promise 타입이므로 반드시 await해야 합니다. 동기적으로 접근하면 에러가 발생합니다.
핵심 규칙은 간단합니다: 기본값은 정적, 요청 시점 함수를 사용하면 자동으로 동적 전환. 개발자가 직접 "이 페이지는 정적이야" 또는 "이 페이지는 동적이야"라고 선언할 필요가 없습니다.
| 정적 렌더링 | 동적 렌더링 | |
|---|---|---|
| 생성 시점 | 빌드 시 | 요청 시 |
| 적합한 상황 | 소개 페이지, 블로그 글, 문서 | 검색, 대시보드, 인증 후 페이지 |
| 응답 속도 | 매우 빠름 (CDN) | 서버 처리 시간만큼 소요 |
| 데이터 신선도 | 빌드 시점 데이터 | 항상 최신 |
| 빌드 로그 | ○ (Static) | ƒ (Dynamic) |
app/blog/[slug]/page.tsx는 동적 경로입니다. URL에 따라 다른 글을 보여주기 때문입니다. 하지만 slug 목록을 미리 알 수 있다면, 각 경로의 HTML을 빌드 시점에 미리 만들 수 있습니다.
generateStaticParams 함수를 내보내면 Next.js가 빌드 시 이 함수를 호출해서 생성할 경로 목록을 얻습니다:
// app/blog/[slug]/page.tsx
import { LikeButton } from '@/app/components/LikeButton';
export async function generateStaticParams() {
const res = await fetch('http://localhost:4000/posts');
const posts = await res.json();
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export default async function Post(
props: { params: Promise<{ slug: string }> }
) {
const { slug } = await props.params;
const res = await fetch(`http://localhost:4000/posts?slug=${slug}`);
const posts = await res.json();
const post = posts[0];
return (
<article>
<h1>{post.title}</h1>
<LikeButton />
</article>
);
}
빌드 로그에서 각 slug 경로가 미리 생성된 것을 확인할 수 있습니다:
Route (app)
└ ● /blog/[slug]
├ /blog/nextjs-routing
└ /blog/react-server-components
● (SSG) prerendered as static HTML (uses generateStaticParams)
| 개념 | 핵심 내용 |
|---|---|
| 정적 렌더링 | 빌드 시 HTML 생성, CDN에서 즉시 제공. Next.js 기본값 |
| 동적 렌더링 | 요청마다 서버에서 생성. cookies(), headers(), searchParams 사용 시 자동 전환 |
| 자동 판단 | 개발자가 선언하지 않아도 Next.js가 코드를 분석해 결정 |
generateStaticParams | 동적 경로의 페이지를 빌드 시 미리 생성 |
다음 세션에서는 서버 렌더링의 또 다른 문제를 다룹니다. 데이터를 가져오는 데 3초가 걸리면, 사용자는 3초 동안 빈 화면을 봐야 할까요? Streaming과 Suspense로 이 문제를 해결합니다.