서버 사이드 렌더링 (SSR)
요청 시점의 데이터로 HTML을 만드는 SSR을 구현하고 최신성·개인화·서버 비용을 기준으로 SSG와 구분합니다.
이전 절에서는 SSG로 빌드 시점에 페이지를 미리 생성하는 방법을 배웠습니다.
SSG는 매우 빠르지만 모든 서비스에 항상 맞는 해법은 아닙니다.
실시간 데이터나 사용자별 맞춤 콘텐츠가 필요하면, 요청 시점에 데이터를 가져와 렌더링하는 방식이 필요합니다.
이때 사용하는 전략이 서버 사이드 렌더링(Server Side Rendering, SSR)입니다.
이 절에서는 Next.js App Router에서 SSR이 어떻게 작동하는지, 그리고 어떤 상황에서 SSR을 선택해야 하는지 자세히 알아보겠습니다.
SSR이란 무엇인가요?
서버 사이드 렌더링(SSR)은 사용자 요청 시점에 서버에서 화면을 렌더링하는 방식입니다. 렌더에 쓰는 데이터의 캐시·원본 조회 정책은 따로 정합니다.
서버는 준비된 HTML을 스트리밍할 수 있습니다. 브라우저는 이를 표시하고, JavaScript가 클라이언트 컴포넌트를 하이드레이션(Hydration)해 이벤트를 연결합니다.
SSR의 주요 특징 및 이점- 요청 시 데이터 조회: 페이지 요청 시점에 서버가 원본을 조회하도록 구성할 수 있습니다. 다만 외부 API·데이터베이스의 캐시와 일관성 수준까지 자동으로 최신을 보장하지는 않으므로, 재고나 결제처럼 민감한 값은 해당 원본의 일관성 계약도 확인합니다.
- 향상된 SEO: 미리 렌더링된 HTML이 검색 엔진 크롤러에게 전달되므로, SSG와 마찬가지로 SEO에 유리합니다.
- 빠른 초기 로딩: 클라이언트에서 JavaScript를 다운로드하고 실행하기 전에 사용자가 콘텐츠를 볼 수 있으므로, 초기 로딩 속도 및 사용자 경험(UX)이 향상됩니다.
- 동적인 콘텐츠 제공: 사용자 로그인 상태, 지역, A/B 테스트 그룹 등 요청에 따라 다르게 콘텐츠를 렌더링할 수 있습니다.
- 데이터의 신선도가 중요할 때: 주식 가격, 실시간 재고, 개인화된 피드 등 자주 변경되거나 실시간으로 업데이트되어야 하는 데이터.
- 사용자별 맞춤형 콘텐츠: 로그인한 사용자의 대시보드, 장바구니, 개인 설정 페이지 등 사용자 인증 또는 특정 조건에 따라 콘텐츠가 달라지는 경우.
- 큰 데이터 세트: 모든 가능한 경로를 미리 빌드할 수 없는 방대한 동적 데이터가 있을 때.
App Router에서 SSR 구현하기
이 장은 cacheComponents를 활성화하지 않은 Next.js 16을 기준으로 합니다. App Router의 페이지·레이아웃은 기본적으로 서버 컴포넌트지만, 서버 컴포넌트라는 실행 모델이 곧 요청 시점 SSR을 뜻하지는 않습니다.
요청 정보가 필요하지 않은 라우트는 서버 컴포넌트여도 빌드 시점에 정적으로 프리렌더될 수 있습니다.
요청마다 실행하려면 cookies, headers 같은 동적 API를 사용하거나 connection()으로 요청 시점 경계를 명시합니다.
- 요청 시점 경계: 요청마다 달라지는 계산만 있는 예제라면 페이지에서
await connection()을 호출합니다. - 동적 API: 쿠키나 헤더처럼 실제 요청 정보가 필요하면
cookies(),headers()를 사용합니다. - 데이터 캐시: 외부 API를 매 요청 새로 읽어야 한다면
fetch에cache: 'no-store'를 지정합니다.
connection() 뒤에서 지연·난수·시간을 계산하는 페이지입니다. 원문에는 실제 날씨 API나 fetch 호출이 없습니다.
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로 접속하여 페이지를 확인해 보세요.
처음 접속: 서버 콘솔의 로그와 모의 날씨 표시를 비교합니다. 개발 모드에서 로그 횟수를 정확히 한 번으로 가정하지 않습니다.
새로고침: 요청 경계 뒤의 계산이 다시 수행되는지 확인합니다. 난수나 초 단위 표시 시각은 같을 수도 있으며, 값이 같다는 사실만으로 캐시 재사용이라고 단정할 수 없습니다.
같은 상품의 가격이 바뀌면
날씨 예제의 난수나 시각은 요청 뒤 계산되는 값입니다. 이제 앞 절의 SSG 상품 페이지와 같은 products/1을 읽어, 원본 가격의 변경이 응답에 반영되는 시점을 비교해 보겠습니다. 아래 두 파일을 추가합니다.
SSR 파일은 connection()으로 요청을 기다린 뒤 no-store로 원본을 읽습니다. 요청 시 렌더링과 데이터 캐시 정책을 각각 코드에 드러낸 것입니다.
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도 먼저 확인합니다.
빌드와 원본 변경을 나누어 관찰하기
앞 절의 비교 전제를 확인하고, Mock 서버를 계속 실행한 상태에서 Next.js 프로젝트의 프로덕션 빌드와 서버를 사용합니다. 일반적인 프로젝트의 다음 스크립트는 각각 next build와 next start를 실행합니다.
npm run build
npm run starthttp://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의 보이는 값이 성공한 재생성 뒤 바뀝니다. |
원본 변경 뒤 응답이 다르게 보이면, 먼저 원본 URL의 가격과 Next.js 서버가 실제로 받은 새 요청을 확인합니다. <Link>의 프리페치, 뒤로 가기, 브라우저의 Router Cache는 이전 RSC 결과를 사용할 수 있습니다. 이 비교에서는 주소를 직접 열거나 전체 새로고침으로 새 서버 응답을 확인하고, 브라우저의 Network와 서버·Mock 로그를 함께 봅니다. 개발 모드의 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 (요청 시 생성)
-
데이터는 자주 변하지만, 사용자가 최신 데이터를 즉시 볼 필요는 없고, 빠른 로딩이 더 중요하다면? ISR (SSG +
revalidate옵션) -
사용자 상호작용이 많고, 초기 로딩 후 클라이언트에서 데이터가 자주 업데이트된다면? CSR (Client Side Rendering) - 6장 5절에서 다룰 내용