렌더링과 Hydration
렌더링 개념과 Hydration, 그리고 CSR·SSR·SSG·ISR 네 방식의 동작 순서를 다룹니다. 코드는 Next.js 16 App Router 기준입니다.
렌더링이란 무엇인가
렌더링은 웹 애플리케이션에서 컴포넌트와 데이터를 브라우저가 화면에 보여줄 수 있는 HTML로 변환하는 과정입니다.
컴포넌트 하나를 예로 봅니다.
export default function Page() {
return <h1>Hello World</h1>;
}
이 컴포넌트를 렌더링한 결과는 HTML입니다.
<h1>Hello World</h1>
여기까지는 어떤 방식이든 같습니다. 갈라지는 지점은 이 변환을 누가 언제 하느냐입니다.
| 방식 | 언제 HTML을 만드는가 |
|---|---|
| CSR | 브라우저에서 JavaScript가 돌아간 뒤 |
| SSR | 요청이 들어올 때마다 서버에서 |
| SSG | 빌드할 때 한 번 |
| ISR | 빌드할 때 만들고 주기가 지나면 다시 |
시간 축에 올려 보면 이렇게 배치됩니다.
왼쪽으로 갈수록 미리 만들어 두는 쪽이라 응답이 빠릅니다. 오른쪽으로 갈수록 요청 시점에 가까워져 데이터가 최신입니다.
Hydration이란 무엇인가
서버가 이런 HTML을 만들어 보냈다고 하겠습니다.
<button>장바구니에 담기</button>
장바구니 버튼은 화면에 그려집니다. 눌러도 아무 일이 없습니다. HTML에는 모양만 있고 onClick이나 useState 같은 동작이 붙어 있지 않기 때문입니다.
브라우저가 JavaScript를 내려받아 React를 실행하면 React가 이미 있는 HTML을 찾아 자기 컴포넌트와 짝을 맞춥니다. 이 과정이 Hydration입니다.
연결되는 컴포넌트는 이런 모습입니다.
'use client';
import { useState } from 'react';
export default function AddToCartButton() {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(!added)}>
{added ? '장바구니에 담김' : '장바구니에 담기'}
</button>
);
}
React는 DOM을 새로 만들지 않습니다. 서버가 보낸 <button>을 그대로 쓰면서 이벤트 핸들러와 상태만 연결합니다. 그래서 화면이 깜빡이지 않습니다.
Hydration은 CSR이 아닙니다
빈 <div id="root">로 시작하는 앱에는 Hydration이 없습니다. Vite로 만든 React 앱이 여기 해당합니다. React가 처음부터 DOM을 만드니 이어받을 HTML이 아예 없습니다.
오른쪽 경로에는 화면이 먼저 보이고 나서 눌리기 시작하는 구간이 있습니다. 왼쪽 경로에는 그 구간이 없는 대신 화면 자체가 늦게 나옵니다.
다만 Next.js App Router에서는 이 구분이 그대로 적용되지 않습니다. 'use client'를 붙인 컴포넌트도 첫 마크업은 서버에서 나오기 때문입니다. 브라우저가 채우는 것은 그 안에 들어갈 데이터뿐이라서 Hydration은 그대로 일어납니다. Hydration이 아예 없는 경우는 서버가 빈 껍데기만 보내는 순수 CSR 앱입니다.
CSR (Client-Side Rendering)
브라우저에서 JavaScript가 실행된 다음 데이터를 받아 화면을 채우는 방식입니다.
'use client';
import { useEffect, useState } from 'react';
export default function CartPage() {
const [items, setItems] = useState<CartItem[]>([]);
// 브라우저에서 실행되므로 첫 HTML에는 장바구니가 비어 있다
useEffect(() => {
fetch('/api/cart')
.then((res) => res.json())
.then(setItems);
}, []);
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
- 첫 HTML에는 데이터가 비어 있습니다
- 데이터를 브라우저가 직접 가져옵니다
- 조작이 많은 화면에 잘 맞습니다
- JS를 내려받은 뒤에야 데이터 요청이 시작되니 초기 로딩이 가장 느립니다
SSR (Server-Side Rendering)
요청이 들어올 때마다 서버가 데이터를 가져와 HTML을 완성해 보내는 방식입니다.
export default async function OrderListPage() {
// no-store는 요청마다 새로 가져온다. 반대로 force-cache를 주면 캐시된 응답을 계속 쓴다
const response = await fetch('https://example.com/api/orders', {
cache: 'no-store',
});
const orders: Order[] = await response.json();
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.title}</li>
))}
</ul>
);
}
- 요청 시점의 최신 데이터가 HTML에 들어갑니다
- 사용자마다 다른 내용을 보여줄 수 있습니다
- JS 없이도 내용이 보이니 검색 노출에 유리합니다
- 요청마다 서버가 일하니 첫 응답이 SSG보다 늦습니다
SSG (Static Site Generation)
빌드할 때 HTML을 완성해 두고 모든 요청에 같은 파일을 돌려주는 방식입니다.
export default async function EventListPage() {
// force-cache는 빌드 때 가져온 응답을 계속 쓴다. 반대로 no-store를 주면 요청마다 새로 가져온다
const response = await fetch('https://example.com/api/events', {
cache: 'force-cache',
});
const events: Event[] = await response.json();
return (
<ul>
{events.map((event) => (
<li key={event.id}>{event.title}</li>
))}
</ul>
);
}
cache: 'force-cache'를 굳이 적는 이유는 버전 때문입니다. Next 13부터 14까지는 fetch 응답을 기본으로 캐시했는데 15에서 그 기본값이 뒤집혔습니다. 16도 캐시를 직접 켜야 하는 쪽이라 적지 않으면 응답이 캐시에 남지 않습니다.
- 빌드할 때 HTML이 확정됩니다
- 요청마다 서버가 렌더링하지 않습니다
- CDN에 얹기 좋아 응답이 가장 빠릅니다
- 내용을 바꾸려면 다시 빌드해야 하고 개인화는 못 합니다
ISR (Incremental Static Regeneration)
SSG로 만든 정적 페이지를 쓰면서 정한 시간이 지나면 다시 만들어 교체하는 방식입니다.
revalidate: 60은 60초마다 알아서 다시 만든다는 뜻이 아닙니다. 60초가 지난 뒤 요청이 들어왔을 때 낡은 HTML을 먼저 주고 서버가 뒤에서 새로 만듭니다. 새 HTML은 그다음 요청부터 나갑니다.
아무도 들어오지 않으면 재생성도 일어나지 않습니다.
export default async function ProductListPage() {
// 60초가 지난 뒤 첫 요청이 재생성을 유발한다. false면 무기한 캐시, 0이면 캐시하지 않는다
const response = await fetch('https://example.com/api/products', {
next: { revalidate: 60 },
});
const products: Product[] = await response.json();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
- 응답 속도는 SSG와 같습니다
- 전체를 다시 빌드하지 않고 페이지 단위로 갱신됩니다
- 갱신 주기만큼 낡은 데이터가 보일 수 있습니다
- 실시간까지는 필요 없고 주기적으로만 최신이면 되는 화면에 맞습니다
fetch 없이 라우트 설정으로 정하기
앞의 네 예시는 fetch 옵션으로 방식을 갈랐습니다. DB를 직접 읽는 경우처럼 fetch가 없을 때는 파일 맨 위에 라우트 설정을 내보내 정합니다.
| 방식 | 라우트 설정 |
|---|---|
| SSR | export const dynamic = 'force-dynamic' |
| SSG | 적지 않아도 됩니다. 기본이 정적입니다 |
| ISR | export const revalidate = 60 |
SSR은 이렇게 씁니다.
// 요청마다 서버에서 다시 렌더링한다
export const dynamic = 'force-dynamic';
export default async function OrderListPage() {
const orders = await db.orders.getAll();
return (
<ul>
{orders.map((order) => (
<li key={order.id}>{order.title}</li>
))}
</ul>
);
}
ISR은 숫자만 내보내면 됩니다.
// 60초가 지난 뒤 첫 요청에 재생성한다
export const revalidate = 60;
export default async function ProductListPage() {
const products = await db.products.getAll();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
SSG는 적을 것이 없습니다. 설정을 비워 두면 빌드할 때 한 번 실행되고 그대로 정적으로 남습니다. 실수로 동적이 되는 것을 막고 싶으면 export const dynamic = 'error'를 걸어 정적으로 만들 수 없을 때 빌드가 실패하게 할 수 있습니다.
revalidate 값은 빌드할 때 읽을 수 있는 숫자여야 합니다. revalidate = 600은 되지만 revalidate = 60 * 10은 동작하지 않습니다.
dynamic과 revalidate는 아래 Cache Components를 켜면 없어집니다. 그때는 cacheLife로 대신합니다.
Next.js 16의 Cache Components
앞의 두 방법은 fetch 옵션과 라우트 설정으로 방식을 정했습니다. 16에는 세 번째 방법이 있습니다. cacheComponents를 켜면 됩니다.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
캐시할 함수나 컴포넌트 안에 'use cache'를 적고 cacheLife로 수명을 정합니다.
import { cacheLife } from 'next/cache';
export default async function ProductListPage() {
'use cache';
cacheLife('hours'); // 이 결과를 몇 시간 단위로 재사용한다
const products = await getProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
fetch 옵션이나 export const revalidate 대신 캐시할 대상 자체에 표시를 붙이는 셈입니다.
왜 이렇게 바뀌었나
이전 모델의 판정 단위는 라우트였습니다. 페이지 어딘가에서 cookies()를 한 번 읽으면 그 페이지 전체가 요청 시점 렌더링으로 넘어갑니다. 상품 목록은 캐시해도 되는데 추천 영역 하나 때문에 전부 다시 그리게 되는 식입니다.
Cache Components는 그 단위를 함수와 컴포넌트로 내렸습니다.
Data fetching is dynamic by default, and you choose what to cache at the page, component, or function level.
그래서 한 페이지 안에 정적인 껍데기와 캐시된 영역과 요청마다 그리는 영역을 함께 둘 수 있습니다. 요청마다 달라지는 부분만 <Suspense>로 감싸면 나머지는 미리 만들어 둔 껍데기로 먼저 나갑니다. 이것이 Partial Prerendering이고 cacheComponents를 켜면 기본 동작이 됩니다.
캐시를 켜는 방식도 뒤집혔습니다. 이전에는 프레임워크가 fetch 옵션과 요청 타임 API 사용 여부를 보고 알아서 판정했습니다. 왜 내 페이지가 동적이 됐는지 되짚기 어려웠던 이유입니다. 지금은 기본이 동적이고 캐시할 것에 직접 표시합니다.
붙는 위치도 넓어졌습니다. 이전 캐시 옵션은 fetch에 달려 있어서 DB를 직접 읽는 코드는 unstable_cache라는 별도 API가 필요했습니다. 'use cache'는 async 함수면 어디든 붙습니다.
두 모델은 함께 쓸 수 없습니다. cacheComponents를 켜면 dynamic, dynamicParams, revalidate, fetchCache가 제거되어 남겨 두면 에러가 납니다.
16의 기본값은 꺼져 있습니다. 앞의 두 방법을 먼저 이해하고 넘어가도 됩니다.
네 방식 한눈에 보기
| 방식 | HTML 생성 시점 | HTML 생성 위치 | 데이터 조회 | Hydration |
|---|---|---|---|---|
| CSR | 브라우저 실행 후 | 브라우저 | 브라우저 | 없음 (순수 CSR 기준) |
| SSR | 요청 시 | 서버 | 요청 시 | 있음 |
| SSG | 빌드 시 | 빌드 서버 | 빌드 시 | 있음 |
| ISR | 빌드 + 재생성 시 | 서버 | 재생성 시 | 있음 |
Next.js App Router에서 Client Component를 쓰는 경우는 첫 마크업이 서버에서 나오므로 Hydration이 일어납니다.
한 서비스 안에서 네 방식을 어떻게 나눠 쓰는지는 이렇게 그려집니다.
브랜드 소개는 SSG, 상품 목록은 ISR, 개인화된 추천은 SSR, 필터와 장바구니 버튼은 CSR로 두는 조합입니다.