서버 사이드 렌더링 (SSR)
요청 시점의 데이터로 HTML을 만드는 SSR을 구현하고 최신성·개인화·서버 비용을 기준으로 SSG와 구분합니다.
앞 글에서는 SSG로 빌드할 때 페이지를 미리 만드는 방법을 배웠습니다. SSG는 빠르지만 모든 서비스에 언제나 맞는 해법은 아닙니다. 실시간 데이터나 사용자마다 다른 내용이 필요하다면 요청할 때 데이터를 가져와 렌더링하는 방식이 필요하며, 이때 쓰는 전략이 서버 사이드 렌더링(Server Side Rendering, SSR)입니다. 이 글에서는 Next.js App Router에서 SSR이 어떻게 동작하는지, 어떤 상황에서 SSR을 골라야 하는지 자세히 알아봅니다.
SSR이란 무엇인가요?
서버 사이드 렌더링(SSR)은 사용자가 요청한 시점에 서버에서 화면을 렌더링하는 방식이며, 렌더링에 쓰는 데이터의 캐시와 원본 조회 정책은 따로 정합니다. 서버는 준비된 HTML을 스트리밍할 수 있고, 브라우저는 그것을 보여 준 뒤 JavaScript가 클라이언트 컴포넌트를 하이드레이션(Hydration)해 이벤트를 연결합니다.
SSR에서는 페이지를 요청한 시점에 서버가 원본을 조회하도록 구성할 수 있습니다. 다만 외부 API나 데이터베이스의 캐시와 일관성 수준까지 저절로 최신을 보장하지는 않으므로, 재고나 결제처럼 민감한 값은 그 원본이 어느 정도의 일관성을 약속하는지도 확인합니다. 미리 렌더링한 HTML이 검색 엔진 크롤러에게 전달되므로 SSG처럼 SEO에 유리하고, 클라이언트에서 JavaScript를 내려받아 실행하기 전에 사용자가 내용을 볼 수 있으므로 처음 로딩과 사용자 경험(UX)이 좋아지며, 로그인 상태나 지역, A/B 테스트 그룹처럼 요청에 따라 다른 내용을 렌더링할 수 있습니다.
그래서 SSR은 주식 가격이나 실시간 재고, 개인화된 피드처럼 데이터의 신선도가 중요할 때, 로그인한 사용자의 대시보드나 장바구니, 개인 설정 페이지처럼 사용자나 특정 조건에 따라 내용이 달라질 때, 가능한 모든 경로를 미리 빌드할 수 없을 만큼 동적인 데이터가 많을 때 씁니다.
App Router에서 SSR 구현하기
이 장은 cacheComponents를 켜지 않은 Next.js 16을 기준으로 합니다. App Router의 페이지와 레이아웃은 기본으로 서버 컴포넌트이지만, 서버 컴포넌트라는 실행 모델이 곧 요청 시점의 SSR을 뜻하지는 않습니다. 요청 정보가 필요 없는 라우트는 서버 컴포넌트여도 빌드할 때 정적으로 사전 렌더링될 수 있습니다.
요청마다 실행하게 하려면 cookies, headers 같은 요청 시점 API를 쓰거나 connection()으로 요청 시점에 렌더링한다고 밝힙니다. 요청마다 달라지는 계산만 있는 예제라면 페이지에서 await connection()을 부르고, 쿠키나 헤더처럼 실제 요청 정보가 필요하면 cookies()나 headers()를 쓰며, 외부 API를 요청할 때마다 새로 읽어야 한다면 fetch에 cache: 'no-store'를 지정합니다(Next.js 문서의 connection).
요청할 때 계산하는 모의 날씨 페이지
다음은 connection() 뒤에서 지연과 난수, 시간을 계산하는 페이지이며, 실제 날씨 API나 fetch 호출은 없습니다. 볼 곳은 페이지 컴포넌트의 첫 줄인 await connection()입니다. 이 호출은 실제 요청이 들어올 때까지 그 아래 코드를 실행하지 않게 하므로, 난수와 현재 시각을 쓰는 getWeatherData가 빌드할 때 한 번 계산되어 굳지 않고 요청마다 다시 실행됩니다. 그래서 새로고침할 때마다 약 1초를 기다린 뒤 도시, 온도, 시각이 새로 정해진 화면이 나옵니다.
import { connection } from 'next/server';
interface WeatherData {
city: string;
temperature: number;
condition: string;
timestamp: string; // 서버가 모의 데이터를 만든 시각
}
// 이 함수는 요청 시점에 서버에서 실행됩니다.
async function getWeatherData(): Promise<WeatherData> {
console.log('SSR 🚀: Fetching weather data on demand...'); // 요청 시에만 이 로그가 보입니다.
// 실제 날씨 API 대신 더미 데이터와 지연 시간을 시뮬레이션합니다.
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 지연
const cities = ['Seoul', 'Busan', 'Jeju'];
const conditions = ['맑음', '흐림', '비', '눈'];
const randomCity = cities[Math.floor(Math.random() * cities.length)];
const randomCondition = conditions[Math.floor(Math.random() * conditions.length)];
return {
city: randomCity,
temperature: Math.floor(Math.random() * 15) + 10, // 10~24도
condition: randomCondition,
timestamp: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
};
}
// 페이지 컴포넌트를 async 함수로 정의합니다.
// 이 컴포넌트는 요청 시점에 서버에서 렌더링됩니다.
export default async function WeatherPage() {
// 이 아래 코드는 실제 요청이 들어온 뒤 실행됩니다.
await connection();
const weather = await getWeatherData(); // 요청 시마다 데이터 페칭
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
<h1 style={{ color: '#007bff', textAlign: 'center', marginBottom: '20px' }}>현재 날씨 정보 (SSR)</h1>
<div style={{ fontSize: '1.2em', lineHeight: '1.8' }}>
<p><strong>도시:</strong> {weather.city}</p>
<p><strong>온도:</strong> {weather.temperature}°C</p>
<p><strong>상태:</strong> {weather.condition}</p>
<p style={{ fontSize: '0.9em', color: '#666' }}>
모의 데이터 생성 시간: <strong>{weather.timestamp}</strong>
</p>
</div>
<p style={{ marginTop: '25px', textAlign: 'center', color: '#888' }}>
이 페이지는 요청 시점에 서버에서 렌더링되므로, 새로고침할 때마다 새로운 데이터를 가져옵니다.
</p>
</div>
);
}src/app/weather 폴더를 만들고 그 안에 위 내용으로 page.tsx를 만든 뒤, 개발 서버(npm run dev)가 실행 중이라면 http://localhost:3000/weather에 들어가 보세요. 처음 들어오면 서버 콘솔의 로그와 모의 날씨를 비교하되, 개발 모드에서 로그가 정확히 한 번 찍힌다고 가정하지 않습니다. 새로고침하면 connection() 뒤의 계산이 다시 이루어지는지 확인하며, 난수나 초 단위의 시각은 우연히 같을 수도 있으므로 값이 같다는 것만으로 캐시를 재사용했다고 단정할 수 없습니다.
더 깊이: connection()이 필요한 경우와 빌드 출력으로 확인하기
Next.js 문서의 connection은 이 함수가 필요한 경우를 좁게 정합니다. cookies()나 headers() 같은 요청 시점 API(Request-time API)를 이미 쓰고 있다면 그것만으로 요청 시점 렌더링이 되므로 따로 부를 필요가 없습니다. 이런 API를 쓰지 않는데도 Math.random()이나 new Date()처럼 요청마다 다른 결과를 내야 할 때 connection()을 씁니다. 같은 문서는 better-sqlite3 같은 동기식 데이터베이스 드라이버의 조회가 사전 렌더링 중에 끝나 버린다는 점도 짚으며, 조회하기 전에 connection()을 부르면 그 조회를 사전 렌더링에서 뺄 수 있습니다. 이 함수는 Next.js 15에서 안정화되었고, 이전의 unstable_noStore를 대신합니다.
어떤 라우트가 실제로 빌드할 때 만들어졌는지는 코드만 보고 짐작하기보다 next build의 출력으로 확인하는 편이 정확합니다. 빌드 출력의 라우트 표에서 ○ (Static)은 정적 콘텐츠로 사전 렌더링된 라우트이고, ƒ (Dynamic)은 요청이 올 때 서버에서 렌더링하는 라우트입니다. 요청마다 계산하려던 페이지가 ○로 표시되었다면 요청 시점을 알리는 호출이 빠졌다는 뜻이므로, 서버를 띄워 화면을 비교하기 전에 이 표부터 봅니다.
같은 상품의 가격이 바뀌면
날씨 예제의 난수와 시각은 요청한 뒤에 계산되는 값입니다. 이제 앞 글 정적 데이터 생성 (SSG)의 상품 페이지와 같은 products/1을 읽어, 원본 가격이 바뀐 것이 응답에 반영되는 시점을 비교해 봅니다. 아래 두 파일을 더합니다. SSR 파일은 connection()으로 요청을 기다린 뒤 no-store로 원본을 읽어, 요청 시점의 렌더링과 Data Cache 정책을 각각 코드에 드러냅니다.
import { connection } from 'next/server';
interface Product {
id: number;
name: string;
price: number;
}
async function getProduct(): Promise<Product> {
const res = await fetch('http://localhost:4000/products/1', {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`상품 조회 실패: ${res.status}`);
}
return res.json();
}
export default async function SsrProductPage() {
await connection();
const product = await getProduct();
return (
<main>
<h1>SSR 상품 가격</h1>
<p>{product.name} (상품 {product.id})</p>
<p>가격: {product.price.toLocaleString('ko-KR')}원</p>
</main>
);
}ISR 파일은 요청 시점 API를 넣지 않고 next.revalidate를 10초로 지정합니다. 빌드에서 준비한 정적 응답을 쓰다가, 재검증 시간이 지난 뒤에 들어온 요청을 계기로 백그라운드에서 갱신하며, 10초는 차이를 확인하려는 짧은 예시 값입니다.
interface Product {
id: number;
name: string;
price: number;
}
async function getProduct(): Promise<Product> {
const res = await fetch('http://localhost:4000/products/1', {
next: { revalidate: 10 },
});
if (!res.ok) {
throw new Error(`상품 조회 실패: ${res.status}`);
}
return res.json();
}
export default async function IsrProductPage() {
const product = await getProduct();
return (
<main>
<h1>ISR 상품 가격</h1>
<p>{product.name} (상품 {product.id})</p>
<p>가격: {product.price.toLocaleString('ko-KR')}원</p>
</main>
);
}세 파일의 Product 필드와 원본 URL은 같습니다. 모두 HTTP 실패에서는 오류를 던지고 연결 실패나 JSON 파싱 오류도 호출한 쪽에 전달하며, 타입 선언만으로 응답을 검증하지 않으므로 원본의 id, name, price도 먼저 확인합니다.
빌드와 원본 변경을 나누어 확인하기
6장 1절 서버 컴포넌트에서 데이터 페칭의 비교 전제를 확인하고, 목 API를 계속 실행한 상태에서 Next.js 프로젝트의 프로덕션 빌드와 서버를 씁니다. 일반적인 프로젝트에서 다음 스크립트는 각각 next build와 next start를 실행합니다.
npm run build
npm run start먼저 http://localhost:4000/products/1의 가격이 299000인지 확인하고 빌드한 뒤, 성공한 빌드의 서버에서 /render-compare/ssg, /render-compare/ssr, /render-compare/isr을 각각 열어 시작 가격을 기록합니다. 그다음 db.json에서 이 상품의 price만 349000으로 바꾸고, id와 name은 그대로 둔 채 원본 URL이 새 가격을 돌려주는지 먼저 확인하며, Next.js의 코드 수정이나 재빌드는 하지 않습니다.
세 페이지에 새로 전체 요청을 보내 가격을 비교하면, SSG는 이전 빌드의 “299,000원”을, SSR은 정상 원본의 “349,000원”을 보여 줍니다. ISR은 아직 유효한 캐시를 재사용하거나, 만료된 뒤의 첫 요청에 오래된 “299,000원”을 보내면서 백그라운드 갱신을 시작합니다. ISR의 갱신이 성공한 뒤 새로 요청하면 “349,000원”을 받으며, 10초가 지났다는 것만으로 갱신이 끝났다고 판단하지 말고 만료 뒤의 요청과 그다음의 응답을 나누어 기록합니다.
| 단계 | SSG | SSR | ISR |
|---|---|---|---|
원본 299000, 성공한 빌드의 시작 응답 | 299,000원 | 299,000원 | 299,000원 |
원본을 349000으로 바꾼 뒤, 갱신 전 요청 | 299,000원 | 349,000원 | 299,000원(캐시 유효, 또는 만료 뒤 첫 요청) |
| ISR 백그라운드 갱신 성공 뒤 새 요청 | 299,000원 | 349,000원 | 349,000원 |
이 표는 원본이 정상으로 응답하고 시작 가격의 캐시가 준비된 경우입니다. ISR의 시간은 브라우저를 처음 연 순간부터 새로 세는 타이머가 아니며, 빌드한 때부터 시간이 흘렀으므로 첫 방문 전에 이미 만료되었을 수도 있습니다. 빌드와 원본 수정, 갱신을 일으킨 요청의 순서를 기록해 두면 결과를 해석하기 쉽습니다.
같은 상품의 SSG·SSR·ISR 응답 순서
원본 가격이 바뀐 뒤 SSG·SSR·ISR의 관측값이 다르게 바뀌는 시간 사례입니다. 소스 변경·만료 요청·백그라운드 갱신 완료의 동일 시점에서 세 방식의 보이는 값을 함께 추적할 수 있습니다.
NEXT · CH6-3
원본 가격은 같아도 반영 시점은 다릅니다
Next.js 16 · Cache Components 비활성화. 상품 1의 가격을 299000에서 349000으로 바꾸되, Next.js는 재빌드하지 않습니다. 시작 캐시와 원본 조회가 성공한 경우의 요청 순서입니다.
시작 결과 · 원본 299000
SSG·ISR의 빌드와 첫 SSR 조회가 성공한 상태입니다. 세 페이지 모두 299,000원을 표시합니다.
가격 변경 뒤 · ISR 만료 후 첫 요청
원본은 349000입니다. SSG는 빌드 가격 299,000원, SSR은 원본 가격 349,000원을 보냅니다. ISR은 이전 299,000원을 보내고 백그라운드 갱신을 시작합니다.
ISR 갱신 성공 뒤 새 요청
ISR도 새 캐시의 349,000원을 보냅니다. 같은 빌드의 SSG는 299,000원이며, SSR은 계속 원본을 조회합니다. 단계 간 간격은 실제 시간 길이를 뜻하지 않습니다.
- ISR의 이전 응답과 갱신 성공 뒤 응답
10초 경과만으로 자동 갱신하지 않습니다. 만료 뒤 요청이 필요하며, 빌드부터 이미 시간이 흘렀을 수 있습니다. 재검증 오류가 던져지면 이전 성공 결과를 유지하고 다음 요청에서 재시도합니다. 첫 생성부터 실패하면 유지할 결과가 없습니다.
세 고정 라우트는 /render-compare/ssg · /render-compare/ssr · /render-compare/isr입니다. 프리페치·Router Cache·개발 HMR의 화면 재사용과 새 서버 응답을 구분하고, 표시 시각 하나로 캐시 적중을 판정하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 가격 변경 뒤에도 SSG는 빌드 가격을 유지하고 SSR은 새 원본을 읽으며 ISR은 만료 뒤 요청에 이전 가격을 보내고 백그라운드 갱신 성공 후 새 요청에 새 가격을 보냅니다.
- 위에서 아래로 세 요청 단계가 이어집니다. 세로 선은 요청 순서이며 시간 길이를 뜻하지 않습니다. 각 단계의 SSG, SSR, ISR 가격을 같은 원본 변경 조건에서 비교합니다. 만료 뒤 첫 ISR은 이전 응답이며 갱신 성공 뒤 새 요청은 새 응답입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 빌드·캐시의 초기 가격 원본 299000으로 성공한 빌드·시작 응답을 고정합니다. | SSG: 299000 · SSR: 299000 · ISR: 299000 | 같은 상품의 출발 가격을 맞춥니다. |
| 원본 가격 변경 원본만 349000으로 바꾸고 재빌드 없이 새 전체 요청을 보냅니다. | SSG: 299000 · SSR: 349000 · ISR: 299000 | 원본 변경과 표시 변경이 서로 달라집니다. |
| ISR 만료 요청 10초 재검증 간격 만료 뒤 요청은 이전 가격을 보내면서 백그라운드 갱신을 시작합니다. | ISR 응답: 299000 · 재생성: 백그라운드 · SSG·SSR: 299000·349000 | 만료 요청이 갱신을 촉발하지만 즉시 새 값을 보내지는 않습니다. |
| 성공 뒤 새 요청 갱신 성공 뒤 다음 ISR 요청이 349000을 봅니다. | ISR: 349000 · SSR: 349000 · SSG: 299000 | ISR의 보이는 값이 성공한 재생성 뒤 바뀝니다. |
같은 상품의 SSG·SSR·ISR 응답 순서
원본 가격이 바뀐 뒤 SSG·SSR·ISR의 관측값이 다르게 바뀌는 시간 사례입니다. 소스 변경·만료 요청·백그라운드 갱신 완료의 동일 시점에서 세 방식의 보이는 값을 함께 추적할 수 있습니다.
NEXT · CH6-3
원본 가격은 같아도 반영 시점은 다릅니다
Next.js 16 · Cache Components 비활성화. 상품 1의 가격을 299000에서 349000으로 바꾸되, Next.js는 재빌드하지 않습니다. 시작 캐시와 원본 조회가 성공한 경우의 요청 순서입니다.
시작 결과 · 원본 299000
SSG·ISR의 빌드와 첫 SSR 조회가 성공한 상태입니다. 세 페이지 모두 299,000원을 표시합니다.
가격 변경 뒤 · ISR 만료 후 첫 요청
원본은 349000입니다. SSG는 빌드 가격 299,000원, SSR은 원본 가격 349,000원을 보냅니다. ISR은 이전 299,000원을 보내고 백그라운드 갱신을 시작합니다.
ISR 갱신 성공 뒤 새 요청
ISR도 새 캐시의 349,000원을 보냅니다. 같은 빌드의 SSG는 299,000원이며, SSR은 계속 원본을 조회합니다. 단계 간 간격은 실제 시간 길이를 뜻하지 않습니다.
- ISR의 이전 응답과 갱신 성공 뒤 응답
10초 경과만으로 자동 갱신하지 않습니다. 만료 뒤 요청이 필요하며, 빌드부터 이미 시간이 흘렀을 수 있습니다. 재검증 오류가 던져지면 이전 성공 결과를 유지하고 다음 요청에서 재시도합니다. 첫 생성부터 실패하면 유지할 결과가 없습니다.
세 고정 라우트는 /render-compare/ssg · /render-compare/ssr · /render-compare/isr입니다. 프리페치·Router Cache·개발 HMR의 화면 재사용과 새 서버 응답을 구분하고, 표시 시각 하나로 캐시 적중을 판정하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 가격 변경 뒤에도 SSG는 빌드 가격을 유지하고 SSR은 새 원본을 읽으며 ISR은 만료 뒤 요청에 이전 가격을 보내고 백그라운드 갱신 성공 후 새 요청에 새 가격을 보냅니다.
- 위에서 아래로 세 요청 단계가 이어집니다. 세로 선은 요청 순서이며 시간 길이를 뜻하지 않습니다. 각 단계의 SSG, SSR, ISR 가격을 같은 원본 변경 조건에서 비교합니다. 만료 뒤 첫 ISR은 이전 응답이며 갱신 성공 뒤 새 요청은 새 응답입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 빌드·캐시의 초기 가격 원본 299000으로 성공한 빌드·시작 응답을 고정합니다. | SSG: 299000 · SSR: 299000 · ISR: 299000 | 같은 상품의 출발 가격을 맞춥니다. |
| 원본 가격 변경 원본만 349000으로 바꾸고 재빌드 없이 새 전체 요청을 보냅니다. | SSG: 299000 · SSR: 349000 · ISR: 299000 | 원본 변경과 표시 변경이 서로 달라집니다. |
| ISR 만료 요청 10초 재검증 간격 만료 뒤 요청은 이전 가격을 보내면서 백그라운드 갱신을 시작합니다. | ISR 응답: 299000 · 재생성: 백그라운드 · SSG·SSR: 299000·349000 | 만료 요청이 갱신을 촉발하지만 즉시 새 값을 보내지는 않습니다. |
| 성공 뒤 새 요청 갱신 성공 뒤 다음 ISR 요청이 349000을 봅니다. | ISR: 349000 · SSR: 349000 · SSG: 299000 | ISR의 보이는 값이 성공한 재생성 뒤 바뀝니다. |
위 그림은 같은 상품에 대한 SSG, SSR, ISR의 응답을 요청의 순서대로 보여 줍니다. 그림의 요약처럼 원본 가격은 같아도 반영되는 시점이 다르며, Next.js 16에서 Cache Components를 끄고 상품 1의 가격을 299000에서 349000으로 바꾸되 재빌드하지 않은, 시작 캐시와 원본 조회가 성공한 경우입니다. 첫 단계에서는 세 페이지 모두 299,000원을 보여 주고, 가격을 바꾼 뒤의 요청에서는 SSG가 같은 빌드의 299,000원을, SSR이 connection과 no-store로 새로 조회한 349,000원을 보내며, ISR은 만료 뒤의 첫 요청에 이전 응답인 299,000원을 보내면서 갱신을 시작합니다. 갱신이 성공한 뒤의 요청에서는 ISR도 새 캐시의 349,000원을 보냅니다. 그림의 캡션이 밝히듯 단계 사이의 간격은 실제 시간의 길이에 비례하지 않습니다.
그림 아래의 설명은 ISR의 조건을 덧붙입니다. 10초가 지났다고 저절로 갱신되지는 않으며 만료 뒤의 요청이 필요하고, 빌드한 때부터 이미 시간이 흘렀을 수 있습니다. 재검증 중에 오류가 던져지면 이전의 성공 결과를 유지하고 다음 요청에서 다시 시도하며, 첫 생성부터 실패하면 유지할 결과가 없습니다. 그림의 마지막 설명처럼 세 고정 라우트를 비교할 때는 프리페치와 Router Cache, 개발 HMR이 화면을 재사용하는 것과 새 서버 응답을 구분하고, 화면에 보이는 시각 하나로 캐시 적중을 판정하지 않습니다.
원본을 바꾼 뒤 응답이 예상과 다르게 보이면, 먼저 원본 URL의 가격과 Next.js 서버가 실제로 받은 새 요청을 확인합니다. <Link>의 프리페치와 뒤로 가기, 브라우저의 Router Cache는 이전의 RSC 결과를 쓸 수 있으므로, 이 비교에서는 주소를 직접 열거나 전체 새로고침으로 새 서버 응답을 확인하고, 브라우저의 Network 탭과 서버·목 API의 로그를 함께 봅니다. 개발 모드의 HMR도 no-store 요청을 재사용할 수 있으므로 npm run dev의 화면만으로 프로덕션의 캐시 수명을 판단하지 않습니다. new Date()로 붙인 시각이나 난수가 달라졌다는 것은 원본을 조회했다거나 캐시에 적중했다는 뜻이 아니며, 로그도 정확히 한 번 찍힌다고 가정하지 않고, 바꾼 원본 가격과 응답 가격, 요청의 순서를 함께 비교합니다.
원본 조회가 실패했을 때
원본 서버를 끄거나 HTTP 오류가 나면 SSR의 새 조회는 오류를 던집니다. 앞에서 만든 SSG의 결과는 원본을 다시 읽지 않으므로 같은 빌드에서는 이전 가격을 계속 보여 줄 수 있고, ISR은 재검증 중에 오류가 던져지면 마지막으로 성공한 결과를 유지하고 다음 요청에서 다시 시도합니다. ISR도 이전의 성공 결과가 없는 첫 생성에 실패하면 보여 줄 이전 가격이 없습니다. 실패를 [] 같은 정상 반환값으로 바꾸는 6장 1절 서버 컴포넌트에서 데이터 페칭의 목록 예제와 지금의 오류 처리는 다르며, 재검증의 자세한 흐름은 증분 정적 재생성 (ISR)에서 이어 갑니다.
SSR과 SSG를 고르는 기준
SSR과 SSG는 장단점이 분명하므로 애플리케이션의 요구에 따라 알맞은 렌더링 전략을 골라야 합니다.
| 방식 | 비용과 데이터 조건 |
|---|---|
| SSR | 요청할 때 서버 계산이 필요하며, 실제 신선도는 원본 조회와 캐시, 일관성 정책을 따릅니다. |
| SSG | 처음의 계산을 빌드로 옮기고 요청할 때 결과를 재사용하지만, 빌드 시간과 갱신 전략을 고려합니다. |
| 공통 | 처음 HTML을 크롤러와 브라우저에 줄 수 있지만, 검색 순위나 체감 속도가 이 방식만으로 보장되지는 않습니다. |
데이터가 거의 바뀌지 않고 모든 사용자에게 똑같이 보여야 한다면 빌드할 때 만드는 SSG를, 데이터가 자주 바뀌고 요청할 때 원본을 조회해야 한다면 요청할 때 만드는 SSR을 고릅니다. 데이터는 자주 바뀌지만 사용자가 최신 데이터를 곧바로 볼 필요는 없고 빠른 로딩이 더 중요하다면 SSG에 revalidate를 더한 ISR을, 사용자 상호작용이 많고 처음 로딩 뒤 클라이언트에서 데이터가 자주 바뀐다면 6장 5절에서 다룰 CSR(Client Side Rendering)을 검토합니다.
정리
- SSR은 요청한 시점에 서버에서 화면을 렌더링하며, 신선도가 중요하거나 사용자마다 다른 내용에 알맞습니다.
cacheComponents를 켜지 않은 Next.js 16에서 서버 컴포넌트라도 요청 정보가 필요 없으면 정적으로 사전 렌더링되므로, 요청마다 렌더링하려면connection()이나cookies(),headers()를 씁니다.- 외부 데이터를 요청마다 새로 읽으려면
fetch에cache: 'no-store'를 지정합니다. - 원본을 바꾸면 SSG는 같은 빌드의 값을, SSR은 새 값을, ISR은 만료 뒤의 요청과 백그라운드 갱신을 거쳐 새 값을 보여 줍니다.
- 결과를 비교할 때는 프리페치나 Router Cache, 개발 HMR의 재사용과 새 서버 응답을 구분합니다.
연습 문제
1. 서버 컴포넌트인데 바뀌지 않는 페이지
connection()을 지운 weather/page.tsx를 next build 뒤 next start로 실행했더니, 새로고침해도 같은 도시와 온도가 계속 보입니다. 왜 그럴까요?
해설 보기
connection()이 없으면 이 페이지에는 요청 정보를 읽는 API도 no-store 요청도 없으므로, Next.js가 빌드할 때 정적으로 사전 렌더링할 수 있습니다. 그러면 빌드할 때 한 번 계산한 난수와 시각이 결과에 고정되어 새로고침해도 같은 값이 보입니다. 요청마다 계산하게 하려면 await connection()을 다시 넣거나, 실제로 요청 정보가 필요한 cookies()나 headers()를 씁니다.
2. 원본이 꺼졌을 때의 세 페이지
세 비교 페이지가 모두 299,000원으로 정상 응답한 뒤 목 API를 끄고 세 페이지를 새로고침했습니다. ISR은 이미 만료된 상태라고 가정합니다. 각 페이지에서 무슨 일이 일어날까요?
해설 보기
SSR은 요청마다 원본을 조회하므로 연결이 실패해 오류가 던져지고 오류 화면이 보입니다. SSG는 원본을 다시 읽지 않으므로 같은 빌드의 299,000원을 계속 보여 줍니다. ISR은 만료 뒤의 요청에 이전 응답인 299,000원을 보내면서 백그라운드 재검증을 시도하고, 재검증이 실패하면 마지막 성공 결과를 유지한 채 다음 요청에서 다시 시도하므로 299,000원이 계속 보입니다.
3. 렌더링 방식 고르기
다음 세 페이지에 알맞은 렌더링 방식을 골라 보세요. 첫째는 한 달에 한 번 바뀌는 회사 소개 페이지, 둘째는 로그인한 사용자의 주문 내역 페이지, 셋째는 한 시간에 몇 번 바뀌지만 몇 분 늦게 보여도 되는 인기 상품 목록입니다.
해설 보기
회사 소개 페이지는 거의 바뀌지 않고 모든 사용자에게 같으므로 SSG가 알맞습니다. 주문 내역은 사용자마다 다르고 최신이어야 하므로 요청 정보(쿠키 등)를 읽는 SSR이 알맞습니다. 인기 상품 목록은 모든 사용자에게 같지만 주기적으로 바뀌고 약간의 지연이 허용되므로, 적절한 revalidate를 둔 ISR이 알맞습니다.
다음 글 증분 정적 재생성 (ISR)에서는 정적 페이지를 일정 시간마다 또는 필요할 때 다시 만드는 ISR을 자세히 알아봅니다.