페이지 렌더링 방식: 무엇을 측정해야 하는가?
CSR, SSR, SSG, ISR의 동작 방식과 트레이드오프를 비교하고, 측정 지표를 기반으로 렌더링 전략을 선택하는 방법을 정리합니다.

초기의 웹 사이트
초기의 웹 사이트는 유저 인터랙션이 적고 컴퓨터의 성능도 그다지 좋지 않았기 때문에, 단순히 HTML과 CSS만으로 페이지를 보여주는 정적인 애플리케이션이 대부분이었습니다.
그래서 페이지마다 HTML 파일을 미리 만들어두고, 클라이언트가 요청했을 때 그에 맞는 페이지를 서버에서 전송해주는 MPA 형식의 웹 사이트가 많았습니다.
MPA (Multi Page Application)
MPA는 이름 그대로 여러 개의 페이지로 구성된 웹 애플리케이션입니다.
서버에 미리 만들어져 있는 정적인 HTML 파일을 클라이언트가 받아 화면에 표시하고, 페이지를 이동할 때마다 HTML 파일을 새로 다운로드해 화면을 업데이트하는 방식을 사용했습니다.

- 클라이언트가 서버에 리소스를 요청하면, 서버는 해당 요청에 맞는 페이지의 HTML 파일을 응답합니다.
- 이후 애플리케이션에서 링크를 이동하거나 서버에 변경 사항을 요청하면, 서버는 새로운 HTML 파일을 다시 응답합니다.
이렇게 MPA 방식으로 동작할 때, 사용자는 같은 웹 애플리케이션 안에서 움직이고 있지만 페이지가 깜박거리며 새로운 HTML 파일을 불러오기 때문에 지연 시간이 생기고 불편함을 느끼게 됩니다. 또한 작은 변화라도 매번 페이지 전체를 새로 불러와야 해서 서버 비용이 증가한다는 단점도 있습니다.
이러한 불편함, 그리고 웹 내 유저 인터랙션이 이전보다 많아진 점, 컴퓨터 성능이 향상된 점, 모바일 사용자가 늘어나면서 페이지 간 연속적인 사용자 경험이 중요해진 점이 맞물리면서 SPA 웹 애플리케이션이 등장했고, 이후 널리 사용되게 되었습니다.
SPA (Single Page Application)
SPA는 이름 그대로 하나의 페이지로 구성된 웹 애플리케이션입니다.
서버에서 HTML 파일 하나만 받아온 뒤, 화면은 동적으로 바꿔가는 방식입니다.
브라우저는 빈 HTML 파일을 받아 자바스크립트로 화면을 그리고, 페이지가 이동할 때도 자바스크립트를 이용해 동적으로 화면을 업데이트합니다.

- 초기에 서버에 리소스를 요청하면, 서버는 해당 요청에 맞는 페이지의 HTML 파일을 응답합니다.
- 이후 애플리케이션 내에서 링크를 이동하거나 서버에 변경 사항이 생겨도, AJAX를 통해 필요한 부분만 요청하고 서버도 변경된 데이터만 응답해줌으로써 불필요한 서버 요청을 줄였습니다.
AJAX (Asynchronous JavaScript And XML)
JavaScript와 XML(현재는 대부분 JSON)을 이용해 서버와 필요한 부분만 요청하고 응답받는 기법입니다.
AJAX를 통해 변경이 필요한 부분만 서버에 요청하고 응답받으면서, MPA의 단점이었던 페이지 깜박거림과 지연 시간을 줄여 서버 부하 감소와 사용자 경험 향상을 동시에 달성할 수 있었습니다.
웹의 렌더링 방식
MPA와 SSR은 같은 개념이 아니고, SPA와 CSR도 같은 개념이 아닙니다.
다만 대부분의 MPA는 SSR 방식으로 화면을 렌더링하고, 대부분의 SPA는 CSR 방식으로 화면을 렌더링한다는 점에서 서로 연관되어 있습니다.
CSR (Client Side Rendering)
CSR은 클라이언트 사이드 렌더링으로, 클라이언트가 하나의 HTML 파일만 받고 나머지 화면은 JS 파일을 이용해 그려나가는 방식을 말합니다.

- 클라이언트가 서버에 요청을 보내면, 서버는 내용이 거의 없는 빈 HTML 파일을 응답합니다.
- HTML 파일에 내용이 없으므로 브라우저는 즉시 JS 파일을 다운로드합니다.
- JS 파일 다운로드가 끝나면 자바스크립트를 통해 동적으로 화면을 렌더링합니다. 화면이 브라우저에 보이는 순간부터 사용자는 상호작용이 가능합니다.
- 이후 페이지를 이동할 때는 AJAX를 이용해 서버에 필요한 부분만 요청하고 화면을 렌더링합니다.
CSR 방식은 아래처럼(CRA로 프로젝트를 생성했을 때 만들어지는 구조와 유사하게) HTML 마크업이 아닌 자바스크립트 파일을 이용해 동적으로 화면을 렌더링합니다. 그래서 화면을 보기까지의 시간은 느리지만, 화면이 한 번 렌더링된 이후에는 사용자가 인터랙션할 수 있게 되기까지의 시간이 빠른 편입니다.
"use client";
import { useEffect, useState } from "react";
async function fetchPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const data = await res.json();
return data;
}
export default function CSRPage() {
const [post, setPost] = useState([]);
useEffect(() => {
fetchPosts().then((data) => {
setPost(data);
console.log("csr - render");
});
}, []);
return (
<main>
<h1>CSR PAGE</h1>
{JSON.stringify(post)}
</main>
);
}
CSR은 언제 선택하면 좋을까요?
- SEO가 중요하지 않을 때: 로그인 이후에만 접근 가능한 화면처럼, 검색엔진 크롤러가 인덱싱할 필요가 없는 경우에 적합합니다.
- 인터랙션이 많고 앱처럼 동작해야 할 때: 대시보드, 어드민 도구, 실시간 협업 에디터처럼 페이지 이동보다 화면 안에서의 상호작용이 중심인 서비스에 적합합니다.
- 서버 자원보다 클라이언트 자원을 활용하고 싶을 때: 렌더링 부담을 브라우저 쪽으로 넘기기 때문에 서버 비용을 아낄 수 있습니다. 다만 사용자의 기기 성능이나 네트워크 환경이 좋지 않으면 체감 속도가 떨어질 수 있다는 점은 감안해야 합니다.
SSR (Server Side Rendering)
SSR은 서버 사이드 렌더링으로, 클라이언트가 요청할 때마다 서버에서 HTML 파일을 컴파일해 브라우저에 전달하고 브라우저는 이를 즉시 렌더링하는 방식을 말합니다.

- 클라이언트가 서버에 요청을 보내면, 서버는 요청에 맞는 HTML 파일을 컴파일한 뒤 브라우저에 응답합니다.
- 브라우저는 전달받은 HTML 파일을 화면에 렌더링합니다. 이때부터 JS 파일을 다운로드하기 시작합니다.
- JS 파일 다운로드와 하이드레이션이 모두 끝나야 사용자와의 상호작용이 가능해집니다.
- 이후 페이지를 이동할 때도 서버에서 미리 렌더링한 HTML 파일을 응답받는 과정이 반복됩니다.
SSR 방식은 서버에서 미리 화면을 렌더링해 브라우저에 전달하기 때문에 사용자가 화면을 보기까지의 시간은 빠르지만, JS 파일 다운로드와 하이드레이션이 끝나야 인터랙션이 가능해서 인터랙션까지 걸리는 시간은 상대적으로 느립니다.
async function fetchPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
cache: "no-store",
});
const data = await res.json();
return data;
}
export default async function SSRPage() {
const posts = await fetchPosts();
console.log("ssr - render");
return (
<main>
<h1>SSR PAGE</h1>
{JSON.stringify(posts)};
</main>
);
}
SSR은 언제 선택하면 좋을까요?
- SEO가 중요한 콘텐츠 중심 페이지일 때: 블로그 글, 상품 상세 페이지, 랜딩 페이지처럼 검색엔진에 잘 노출되어야 하는 페이지에 적합합니다.
- 매 요청마다 최신 데이터를 반영해야 할 때: 로그인한 사용자의 대시보드나 실시간성이 필요한 개인화 콘텐츠처럼, 캐싱된 정적 페이지로는 대응하기 어려운 경우입니다.
- 초기 로딩 체감 속도가 중요할 때: 첫 화면을 최대한 빨리 보여줘야 하는 마케팅 페이지 등에 유리합니다.
- 다만 요청마다 서버에서 렌더링을 수행하므로 서버 자원 소모가 크고, TTFB가 CSR보다 길어질 수 있다는 점은 감안해야 합니다.
SSG
SSG(Static Site Generation)는 빌드 타임에 미리 페이지를 렌더링해 정적인 HTML로 만들어두는 방식입니다.
이름 그대로 사전에 정적 페이지를 만들어놓기 때문에, 클라이언트가 페이지를 요청하면 서버는 이미 만들어져 있는 HTML을 그대로 응답하고 브라우저는 이를 즉시 화면에 띄우기만 하면 됩니다. 요청 시점에 별도의 렌더링 과정을 거치지 않기 때문에 TTFB가 매우 짧고, CDN에 캐싱해두기도 좋습니다.
다만 빌드 이후에는 콘텐츠가 바뀌어도 페이지가 갱신되지 않기 때문에, 자주 바뀌지 않는 콘텐츠(블로그 글, 문서, 약관 페이지 등)에 적합합니다.

async function fetchPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
cache: "force-cache",
});
const data = await res.json();
return data;
}
export default async function SSGPage() {
const posts = await fetchPosts();
console.log("ssg - render");
return (
<main>
<h1>SSG PAGE</h1>
{JSON.stringify(posts)};
</main>
);
}
ISR (Incremental Static Regeneration)
ISR은 SSG처럼 서버에서 미리 렌더링한 페이지를 클라이언트에게 HTML로 전달하는 방식입니다.
차이점은 설정한 주기(revalidate)마다 페이지를 백그라운드에서 재생성해 최신 콘텐츠로 갱신한다는 점입니다.
즉, SSG는 빌드 타임에 한 번 생성된 이후 더 이상 수정되지 않지만, ISR은 지정한 주기마다 페이지를 다시 생성해서 정적 페이지의 빠른 응답 속도와 콘텐츠 최신성을 함께 가져갈 수 있습니다.

async function fetchPosts() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
next: { revalidate: 100 },
});
const data = await res.json();
return data;
}
export default async function ISRPage() {
const posts = await fetchPosts();
console.log("isr - render");
return (
<main>
<h1>ISR PAGE</h1>
{JSON.stringify(posts)};
</main>
);
}
ISR 캐시는 어디에 저장될까요?
Next.js를 셀프 호스팅하는 경우 기본적으로 ISR 캐시는 로컬 파일 시스템(메모리 + 디스크)에 저장됩니다. 문제는 캐시가 인스턴스 단위로 분리되어 있다는 점인데, 로드밸런서 뒤에 여러 인스턴스를 두고 운영하면 인스턴스마다 캐시가 따로 생성되어서 한 인스턴스에서 revalidate가 일어나도 다른 인스턴스에는 반영되지 않는 문제가 생길 수 있습니다. 이런 환경에서는 cacheHandler를 커스터마이징해 Redis 같은 공유 캐시 저장소를 붙이고, 모든 인스턴스가 같은 캐시를 읽고 쓰도록 구성하는 방법을 사용합니다.
반면 Vercel에 배포하는 경우에는 별도 설정 없이도 ISR 캐시가 배포된 Function 리전에 durable storage 형태로 저장되고, CDN 뒤에서 서빙됩니다. 이 캐시는 revalidate가 일어나거나 최대 31일이 지날 때까지 유지되며, 배포(deployment) 단위로 구분됩니다.
실험하기
아래와 같은 지표들을 측정해보려고 합니다.
TTFB (Time To First Byte)
- 정의: 사용자가 페이지를 요청한 순간부터, 브라우저가 서버로부터 첫 번째 바이트를 응답받기까지 걸린 시간입니다.
- 의미: 네트워크와 서버 성능을 모두 반영하는 지표입니다. → 서버 렌더링(SSR)에서는 특히 중요하고, CDN 캐싱을 활용하는 SSG/ISR에서는 크게 단축됩니다.
for r in csr ssr ssg isr; do
awk -F',' -v r="$r" '$2~"route="r && $3~"mode=warm"{
for(i=1;i<=NF;i++) if($i~/^ttfb=/){split($i,a,"="); print a[2]}
}' perf.csv | sort -n | awk -v r="$r" '{
a[NR]=$1
} END {
if(NR){ m = (NR%2)?a[(NR+1)/2]:(a[NR/2]+a[NR/2+1])/2; printf "%s TTFB_p50=%fs\n", r, m }
}'
done
결과

csr TTFB_p50=0.058s ssr TTFB_p50=0.533s ssg TTFB_p50=0.068s isr TTFB_p50=0.058s
LCP (Largest Contentful Paint)
- 뷰포트 내에서 가장 큰 콘텐츠(보통 이미지나 큰 텍스트 블록)가 화면에 표시되는 시점을 의미합니다. → 사용자가 체감하는 로딩 속도를 보여주는 지표입니다.
INP (Interaction to Next Paint)
- 사용자가 클릭, 탭, 타이핑 등으로 입력했을 때 화면이 반응해 다시 페인트되기까지 걸린 시간입니다. → 인터랙션에 대한 체감 반응 속도를 보여주는 지표입니다.
FID (First Input Delay)
- 사용자가 페이지에서 처음으로 입력(클릭, 탭, 키 입력 등)했을 때, 브라우저가 그 입력을 실제로 처리하기 시작하기까지 걸린 시간입니다.
- “페이지가 처음으로 반응할 수 있는 시점”을 보여주는 지표입니다.
- 참고로 FID는 2024년 3월부로 Core Web Vitals에서 공식적으로 제외되고 INP로 대체되었습니다. 첫 입력에 대한 반응 속도만 측정하던 FID와 달리, INP는 방문 전체 구간의 상호작용 반응성을 측정하기 때문입니다. 다만 이 글에서는 렌더링 방식별 초기 반응성을 비교하는 지표 중 하나로 함께 살펴봤습니다.
정리
- LCP → “언제 보여?” (화면 보이는 속도)
- FID → “첫 입력에 바로 반응해?” (첫 클릭/탭 반응 속도)
- INP → “계속 반응 잘해?” (전체 상호작용 반응성)
- TTFB → “서버에서 얼마나 빨리 응답 시작해?” (네트워크+백엔드 성능)
지표가 커질 때 의미하는 것
LCP (Largest Contentful Paint)
- 값이 크다면:
- 화면에 표시되는 주요 이미지나 텍스트의 용량이 크거나 (예: 큰 hero 이미지)
- 네트워크 대역폭이 느리거나
- JS/CSS 파싱과 렌더링이 오래 걸려서 콘텐츠가 늦게 그려지는 경우입니다.
- 즉, 리소스 크기 + 네트워크 속도 + 렌더링 성능의 영향을 받는다고 볼 수 있습니다.
INP (Interaction to Next Paint)
- 값이 크다면:
- 메인 스레드가 무거운 JS 실행에 막혀 있는 경우입니다 (예: 무거운 연산, 최적화되지 않은 이벤트 핸들러).
- React 같은 프레임워크가 리렌더링을 너무 많이 일으켜서 반응이 지연되는 경우입니다.
- 디바이스 성능이 낮은 경우에도 INP가 커질 수 있습니다.
- 즉, 코드 최적화 부족 + CPU 부하 + JS 블로킹 문제로 볼 수 있습니다.
FID (First Input Delay)
- 값이 크다면:
- 초기 로딩 시 JS 번들이 너무 커서, 브라우저 메인 스레드가 실행을 끝낼 때까지 입력을 처리하지 못하는 경우입니다.
- SPA에서 hydration이 길어질 때 흔히 발생합니다.
- 즉, 초기 JS 번들 크기 + hydration 지연 문제로 볼 수 있습니다.
TTFB (Time To First Byte)
- 값이 크다면:
- 서버 응답이 느린 경우입니다 (DB 쿼리 지연, SSR에서의 API 호출 지연 등).
- 네트워크 왕복(RTT)이 먼 경우입니다 (예: 한국에서 미국 서버로 요청하는 경우).
- CDN 캐시 미스(cache miss)로 원 서버까지 요청이 다녀오는 경우입니다.
- 즉, 네트워크 지연 + 서버 처리 속도 + 캐싱 전략 문제로 볼 수 있습니다.