웹 렌더링 방식 (1): CSR, SSR, SSG, ISR 개념과 동작

Frontend

웹 애플리케이션을 개발할 때 가장 중요한 결정 중 하나는 "어떤 방식으로 페이지를 렌더링할 것인가?"입니다. 렌더링 방식에 따라 초기 로딩 속도, SEO, 서버 부하, 사용자 경험이 크게 달라집니다.

이 글은 렌더링 방식 시리즈 1편입니다. 먼저 CSR, SSR, SSG, ISR이 각각 어떻게 동작하는지와, Next.js에서는 이를 어떤 식으로 구현하는지 정리합니다.

렌더링 패턴 개요

각 렌더링 방식을 이해하기 전에, "렌더링"이 정확히 무엇을 의미하는지 짚고 넘어가겠습니다.

렌더링이란 React 컴포넌트 코드를 실제 사용자가 볼 수 있는 HTML로 변환하는 과정입니다. 이 작업이 어디서(클라이언트/서버), 언제(빌드타임/요청시) 일어나는지에 따라 렌더링 방식이 구분됩니다.

방식 렌더링 위치 렌더링 시점 주요 특징
CSR 클라이언트 런타임 높은 인터랙션, 초기 로딩 느림
SSR 서버 요청 시마다 SEO 좋음, 서버 부하 있음
SSG 서버 빌드 타임 매우 빠름, 정적 콘텐츠에 적합
ISR 서버 빌드 + 백그라운드 SSG + 자동 업데이트

렌더링 방식 결정 플로우

graph TD
    A[프로젝트 시작] --> B{SEO가 중요한가?}
    B -->|No| C[CSR 고려]
    B -->|Yes| D{콘텐츠가 자주 변경되나?}
    D -->|매우 자주| E{실시간 데이터 필수?}
    D -->|가끔| F[ISR 추천]
    D -->|거의 없음| G[SSG 추천]
    E -->|Yes| H[SSR 추천]
    E -->|No| F
    C --> I{사용자별 개인화?}
    I -->|Yes| J[CSR 확정]
    I -->|No| K{초기 로딩 중요?}
    K -->|Yes| G
    K -->|No| J
 
    style F fill:#3b82f6
    style G fill:#60a5fa
    style H fill:#2563eb
    style J fill:#93c5fd

렌더링 방식을 이해할 때 먼저 봐야 할 것

렌더링 방식은 단순히 "어느 기술이 더 빠른가"로 고르는 문제가 아닙니다. 실무에서는 보통 아래 질문들이 먼저 따라옵니다.

  • 이 페이지는 검색 유입의 시작점인가?
  • 첫 화면에서 바로 보여줘야 하는 정보가 중요한가?
  • 데이터 최신성이 몇 초 단위로 중요한가?
  • 페이지를 캐시했을 때 잘못된 정보가 남아도 괜찮은가?
  • 서버 비용과 운영 복잡도를 어디까지 감당할 수 있는가?

즉, 렌더링 방식은 UI 구현 방식의 차이라기보다 사용자 경험, 캐싱 전략, 서버 비용, 운영 복잡도 사이에서 어디에 비용을 둘지 정하는 문제에 더 가깝습니다.

이 기준을 머리에 두고 아래 네 가지 방식을 보면, 단순 장단점 비교보다 훨씬 입체적으로 읽힙니다.


1. CSR (Client-Side Rendering)

클라이언트(브라우저)에서 JavaScript로 페이지를 렌더링하는 방식

개념과 역사

CSR은 전통적인 SPA(Single Page Application) 아키텍처의 핵심입니다. 2010년대 중반 React, Vue, Angular가 등장하면서 주류가 되었습니다. 서버는 단순히 정적 파일만 제공하고, 모든 렌더링 로직은 브라우저에서 JavaScript가 담당합니다.

전통적인 MPA(Multi-Page Application)에서는 페이지 이동 시마다 서버에서 새 HTML을 받아 전체 화면을 새로고침했지만, CSR은 한 번만 HTML을 로드하고 이후에는 JavaScript로 화면을 동적으로 교체합니다. 이를 통해 네이티브 앱과 유사한 부드러운 사용자 경험을 제공할 수 있습니다.

동작 원리

CSR의 생명주기를 단계별로 살펴보겠습니다:

1. 사용자가 페이지 접속
   └─ 브라우저가 서버에 HTML 요청
 
2. 서버에서 빈 HTML + JS 번들 전송
   └─ HTML은 거의 비어있음 (<div id="root"></div>)
   └─ CSS 파일과 JavaScript 번들 링크만 포함
 
3. 브라우저가 JS 다운로드 & 파싱
   └─ 네트워크 속도에 따라 수 초 소요 가능
   └─ 이 시간 동안 사용자는 빈 화면 또는 로딩 스피너만 봄
 
4. React가 실행되면서 Virtual DOM 생성
   └─ 컴포넌트 트리 구성
   └─ 초기 상태(state) 설정
 
5. API 호출하여 데이터 가져오기
   └─ useEffect에서 fetch/axios 호출
   └─ 서버로부터 JSON 데이터 수신
 
6. 화면에 콘텐츠 표시
   └─ setState로 리렌더링 트리거
   └─ Virtual DOM → Real DOM 변환
   └─ 사용자가 비로소 콘텐츠를 볼 수 있음

핵심 특징: 초기 HTML은 거의 비어있고, JavaScript가 화면을 처음부터 렌더링합니다. 즉, 서버가 미리 만들어둔 HTML에 hydration하는 방식이 아니라 클라이언트에서 렌더링이 시작됩니다.

브라우저에서 실제로 일어나는 일

<!-- 서버에서 전송되는 초기 HTML -->
<!DOCTYPE html>
<html>
  <head>
    <title>My App</title>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <div id="root"></div>
    <!-- 완전히 빈 div! -->
    <script src="/bundle.js"></script>
    <!-- 300KB~2MB의 JS 번들 -->
  </body>
</html>

이 HTML을 받은 브라우저는:

  1. HTML 파싱: 빈 <div id="root">만 렌더링 → 빈 화면
  2. CSS 다운로드 & 적용: 기본 스타일만 적용됨
  3. JS 번들 다운로드: 네트워크 시간 소요 (3G: 10초, 4G: 3초)
  4. JS 파싱 & 실행: CPU 집약적 작업 (저사양 기기: 2-5초)
  5. React 렌더링: Virtual DOM 생성 후 Real DOM에 반영
  6. API 데이터 fetch: 추가 네트워크 요청 (1-2초)
  7. 최종 렌더링: 사용자가 콘텐츠를 볼 수 있음

총 소요 시간: 3G 환경에서 15초 이상 걸릴 수 있습니다!

CSR 렌더링 타임라인

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#3b82f6','primaryTextColor':'#fff','primaryBorderColor':'#2563eb','lineColor':'#60a5fa','secondaryColor':'#93c5fd','tertiaryColor':'#dbeafe'}}}%%
gantt
    title CSR 페이지 로딩 타임라인 (3G 환경)
    dateFormat X
    axisFormat %Ss
 
    section 네트워크
    HTML 다운로드        :done, html, 0, 500
    JS 번들 다운로드     :active, js, 500, 10500
    API 데이터 요청      :api, 14000, 16000
 
    section 처리
    JS 파싱/실행         :parse, 10500, 14000
    React 렌더링         :render, 14000, 14500
    최종 콘텐츠 표시     :crit, content, 16000, 16500
 
    section 사용자 경험
    빈 화면             :blank, 0, 500
    로딩 스피너         :spinner, 500, 16000
    콘텐츠 표시         :done, show, 16000, 16500

Next.js에서 CSR 구현

'use client';
 
import { useState, useEffect } from 'react';
 
interface Post {
  id: number;
  title: string;
  content: string;
}
 
export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    fetch('/api/posts')
      .then((res) => res.json())
      .then((data) => {
        setPosts(data);
        setLoading(false);
      });
  }, []);
 
  if (loading) return <div>로딩 중...</div>;
 
  return (
    <div>
      <h1>블로그 포스트</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

장점

1. 풍부한 사용자 경험

  • 페이지 전환 시 전체 새로고침 없이 부드러운 화면 전환
  • SPA(Single Page Application) 구현에 적합
  • 인터랙티브한 UI 구현이 쉬움

2. 서버 부하 감소

  • 서버는 정적 파일만 제공하면 됨
  • CDN을 통한 효율적인 배포
  • 서버 비용 절감

3. 개발 경험이 좋음

  • 프론트엔드와 백엔드 완전 분리
  • API 기반 개발로 협업이 쉬움
  • Hot Module Replacement로 빠른 개발

단점

1. 초기 로딩이 느림

  • JS 번들을 모두 다운로드해야 화면 표시
  • 큰 애플리케이션은 번들 크기가 커짐
  • 저사양 기기나 느린 네트워크에서 불리

2. SEO 문제

  • 초기 HTML이 비어있어 검색엔진 크롤러가 내용을 읽지 못함
  • Open Graph 태그 동적 설정 불가
  • 소셜 미디어 미리보기가 작동하지 않음

3. 성능 지표 저하

  • FCP(First Contentful Paint)가 느림
  • LCP(Largest Contentful Paint)가 느림
  • Lighthouse 점수가 낮아짐

적합한 사용 사례

  • 대시보드, 관리자 페이지: SEO가 중요하지 않고 인증된 사용자만 접근
  • 실시간 데이터 표시: 주식 거래, 채팅 앱, 협업 도구
  • 개인화된 콘텐츠: 사용자마다 다른 내용을 보여줘야 하는 경우

실무에서 CSR을 고를 때 자주 보는 포인트

CSR은 흔히 "SEO가 필요 없으면 쓰면 되는 방식"으로 단순화되지만, 실제로는 개인화와 상호작용이 핵심인 화면에 비용을 집중하는 선택에 가깝습니다.

예를 들면:

  • 로그인 이후 화면은 검색 유입보다 상호작용 속도가 더 중요하고
  • 사용자별 상태가 많을수록 서버가 미리 HTML을 만들어주는 이점이 줄어들며
  • 빠른 화면 전환과 풍부한 인터랙션이 제품 경험의 중심이 되기 쉽습니다

반대로 CSR은 "서버 비용이 낮다"는 장점 뒤에, 번들 크기 관리와 로딩 전략, 저사양 기기 대응 같은 과제를 함께 가져옵니다. 즉, 서버 비용을 줄이는 대신 클라이언트 성능 최적화 비용을 더 많이 치르는 구조라고 볼 수 있습니다.


2. SSR (Server-Side Rendering)

요청마다 서버에서 HTML을 생성하여 전송하는 방식

개념과 배경

SSR은 사실 웹의 전통적인 방식입니다. PHP, Ruby on Rails, Django 등이 모두 SSR을 사용했죠. 하지만 React 시대의 SSR은 조금 다릅니다. 서버에서 React 컴포넌트를 실행하여 HTML을 생성한 후, 클라이언트에서 같은 React 코드를 다시 실행하여 "Hydration"하는 과정을 거칩니다.

CSR의 SEO 문제와 느린 초기 로딩을 해결하기 위해 등장했으며, Next.js, Remix, Nuxt.js 같은 프레임워크가 이를 쉽게 구현할 수 있게 해줍니다.

핵심 아이디어: 사용자는 완성된 HTML을 즉시 보지만, 실제로 클릭 가능한 인터랙티브 상태가 되려면 JavaScript가 로드되어야 합니다.

동작 원리

SSR의 생명주기는 서버와 클라이언트 양쪽에서 일어납니다:

[서버 사이드]
1. 사용자가 페이지 접속
   └─ 브라우저가 서버에 GET 요청
 
2. 서버가 React 컴포넌트를 실행
   └─ Node.js 환경에서 React 렌더링
   └─ ReactDOMServer.renderToString() 호출
 
3. 서버가 데이터 fetching
   └─ DB 쿼리, 외부 API 호출
   └─ 모든 데이터를 기다림 (Waterfall)
 
4. 완성된 HTML 문자열 생성
   └─ 데이터가 포함된 완전한 HTML
   └─ 메타 태그, Open Graph 태그 포함
 
5. HTML을 클라이언트에 전송
   └─ 네트워크를 통해 전달
 
[클라이언트 사이드]
6. 브라우저가 HTML을 즉시 표시 (First Paint)
   └─ 사용자가 콘텐츠를 볼 수 있음 (단, 클릭은 안 됨!)
   └─ 이 상태를 "인터랙티브하지 않은(non-interactive)" 상태라고 함
 
7. JS 번들 다운로드
   └─ CSR과 동일한 JS 파일 필요
   └─ 네트워크 시간 소요
 
8. Hydration 프로세스
   └─ React가 서버에서 생성한 HTML에 이벤트 리스너 부착
   └─ useState, useEffect 등 클라이언트 로직 활성화
   └─ 기존 DOM을 재사용하므로 깜빡임 없음
 
9. 완전한 인터랙티브 상태
   └─ 이제 버튼 클릭, 폼 입력 등이 가능
   └─ 이후부터는 CSR처럼 동작

중요한 개념: Hydration

Hydration은 "건조한 HTML에 물(JavaScript)을 주입한다"는 의미입니다. 서버에서 만든 정적 HTML을 동적이고 인터랙티브한 React 앱으로 "재수화"하는 과정입니다.

// 서버에서
const html = ReactDOMServer.renderToString(<App />);
// → "<div><button>Click</button></div>" (단순 문자열)
 
// 클라이언트에서
ReactDOM.hydrate(<App />, document.getElementById('root'));
// → 같은 HTML에 onClick 등의 이벤트 핸들러 부착

SSR의 함정: TTI와 TBT

사용자는 콘텐츠를 빨리 보지만, 실제로 사용 가능해지는 시간(TTI, Time To Interactive)은 여전히 오래 걸릴 수 있습니다. 이 시간 동안 버튼을 클릭해도 아무 반응이 없어 사용자가 혼란스러워할 수 있습니다.

FCP (First Contentful Paint): 0.8초 ✅
LCP (Largest Contentful Paint): 1.2초 ✅
TTI (Time To Interactive): 5.5초 ❌ ← Hydration 대기
TBT (Total Blocking Time): 3.2초 ❌ ← JS 파싱/실행

SSR 렌더링 타임라인

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#3b82f6','primaryTextColor':'#fff','primaryBorderColor':'#2563eb','lineColor':'#60a5fa','secondaryColor':'#93c5fd','tertiaryColor':'#dbeafe'}}}%%
gantt
    title SSR 페이지 로딩 타임라인
    dateFormat X
    axisFormat %Ss
 
    section 서버
    React 렌더링        :server, 0, 800
    API/DB 쿼리         :db, 200, 700
    HTML 생성           :html, 700, 900
 
    section 네트워크
    HTML 전송           :transfer, 900, 1200
    JS 번들 다운로드    :js, 1200, 3200
 
    section 클라이언트
    HTML 표시 (FCP)     :done, fcp, 1200, 1300
    JS 파싱/실행        :parse, 3200, 5200
    Hydration           :hydrate, 5200, 5500
    인터랙티브 (TTI)    :crit, tti, 5500, 5600
 
    section 사용자 경험
    빈 화면             :blank, 0, 1200
    콘텐츠 표시 (클릭 불가) :content, 1200, 5500
    완전 인터랙티브     :done, interactive, 5500, 5600

Next.js에서 SSR 구현

// app/posts/page.tsx (App Router)
interface Post {
  id: number;
  title: string;
  content: string;
  createdAt: string;
}
 
async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store', // 매 요청마다 새로 가져오기
  });
 
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}
 
export default async function PostsPage() {
  const posts = await getPosts();
 
  return (
    <div>
      <h1>블로그 포스트</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <time>{new Date(post.createdAt).toLocaleDateString()}</time>
        </article>
      ))}
    </div>
  );
}
// Pages Router 방식
import { GetServerSideProps } from 'next';
 
export const getServerSideProps: GetServerSideProps = async (context) => {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();
 
  return {
    props: {
      posts,
    },
  };
};
 
export default function PostsPage({ posts }: { posts: Post[] }) {
  return (
    <div>
      <h1>블로그 포스트</h1>
      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}
    </div>
  );
}

장점

1. 빠른 초기 렌더링

  • 서버에서 완성된 HTML을 받아 즉시 표시
  • FCP, LCP 성능 지표 개선
  • 사용자가 빈 화면을 보는 시간 최소화

2. 완벽한 SEO

  • 검색엔진 크롤러가 완성된 HTML을 읽을 수 있음
  • 메타 태그, Open Graph 태그 동적 생성 가능
  • 소셜 미디어 공유 시 미리보기 정상 작동

3. 최신 데이터 보장

  • 요청마다 서버에서 데이터를 가져옴
  • 항상 최신 정보 표시
  • 캐싱 전략으로 성능 조절 가능

단점

1. 서버 부하 증가

  • 모든 요청마다 서버 리소스 사용
  • 트래픽이 많으면 서버 비용 증가
  • 스케일링 필요

2. 응답 시간이 느릴 수 있음

  • API 호출, DB 쿼리 대기 시간
  • 서버 처리 시간 + 네트워크 시간
  • 사용자가 빈 화면을 보는 시간 발생

3. 복잡한 캐싱 전략 필요

  • CDN 캐싱 설정
  • Stale-While-Revalidate 패턴
  • 캐시 무효화 관리

적합한 사용 사례

  • 뉴스 사이트: 최신 기사를 항상 표시해야 함
  • 전자상거래 상품 페이지: 재고, 가격 정보가 실시간으로 변경
  • 개인화된 대시보드: 로그인한 사용자별로 다른 콘텐츠
  • 동적 콘텐츠가 많은 페이지: URL 파라미터에 따라 다른 내용

실무에서 SSR이 비싸지는 순간

SSR은 "SEO와 최신성을 같이 잡는 방식"처럼 보이지만, 운영 단계에서는 꽤 빠르게 비용이 드러납니다.

  • 요청마다 데이터를 다시 가져오게 되면 백엔드 의존도가 높아지고
  • 트래픽이 커질수록 서버 연산 비용이 함께 커지며
  • 캐싱을 잘못 잡으면 SSR의 장점도 놓치고 비용만 늘어날 수 있습니다

특히 상품 상세, 뉴스, 검색 결과처럼 요청 수가 많은 페이지는 SSR이 자연스러워 보이지만, 실제 운영에서는 "정말 모든 요청을 동적으로 처리해야 하는가?"를 계속 다시 보게 됩니다. 이 지점에서 ISR이나 캐싱 전략이 같이 논의되는 경우가 많습니다.


3. SSG (Static Site Generation)

빌드 타임에 미리 HTML을 생성하는 방식

개념과 철학

SSG는 **"왜 매번 같은 HTML을 생성하는가?"**라는 질문에서 시작합니다. 블로그 글, 문서, 마케팅 페이지처럼 자주 변하지 않는 콘텐츠를 매 요청마다 서버에서 렌더링하는 것은 낭비입니다.

대신 빌드 타임(배포 시점)에 모든 페이지를 HTML로 미리 만들어두고, CDN에 배포하여 전 세계 어디서든 빠르게 제공하는 것이 SSG의 핵심입니다. 이는 Jamstack(JavaScript, APIs, Markup) 아키텍처의 기반이 됩니다.

철학: "서버는 정적 파일만 제공하면 되고, 동적인 부분은 클라이언트와 API가 처리한다."

동작 원리

SSG는 배포 시점런타임으로 나뉩니다:

[빌드 타임 - 개발자의 로컬 or CI/CD 환경]
1. next build 명령 실행
   └─ Next.js가 모든 페이지를 순회
 
2. getStaticPaths로 생성할 경로 결정
   └─ 예: /posts/post-1, /posts/post-2, ...
   └─ 동적 라우트([slug])의 모든 가능한 조합 생성
 
3. 각 경로마다 getStaticProps 실행
   └─ API, DB, 파일 시스템에서 데이터 fetch
   └─ 외부 API 호출도 가능 (빌드 타임에만)
 
4. React 컴포넌트를 HTML로 렌더링
   └─ 서버 사이드 렌더링과 동일한 과정
   └─ 하지만 사용자 요청이 아닌 빌드 시점에 실행
 
5. HTML 파일을 디스크에 저장
   └─ out/ 또는 .next/ 디렉토리에 저장
   └─ posts/post-1.html, posts/post-2.html, ...
 
6. CDN에 배포
   └─ Vercel, Netlify, Cloudflare Pages 등
   └─ 전 세계 엣지 서버에 복제됨
 
[런타임 - 사용자 요청 시]
7. 사용자가 /posts/post-1 접속
   └─ 가장 가까운 CDN 엣지 서버에서 응답
 
8. 미리 생성된 HTML 즉시 전송
   └─ 서버 연산 0초
   └─ TTFB(Time To First Byte) < 100ms
 
9. 브라우저가 HTML 렌더링
   └─ 즉시 콘텐츠 표시
 
10. Hydration (선택적)
    └─ 인터랙티브 기능 활성화
    └─ 정적 콘텐츠만 있다면 생략 가능

빌드 프로세스 자세히 보기

1000개의 블로그 포스트가 있다면:

$ next build
 
# Next.js의 출력
Creating an optimized production build...
Generating static pages (0/1000)   [==>                     ]  10%
Generating static pages (500/1000) [============>           ]  50%
Generating static pages (1000/1000)[========================] 100%
 
Route (pages)                              Size     First Load JS
 /posts/[slug]                          2.4 kB         85.1 kB
 /posts/nextjs-introduction          (1000 more)
 /posts/typescript-basics
 /                                      3.1 kB         86.8 kB
 
 (Static)  automatically rendered as static HTML
 (SSG)     automatically generated as static HTML + JSON
 
# 결과물
.next/
  server/
    pages/
      posts/
        nextjs-introduction.html 완성된 HTML
        typescript-basics.html
        ... (998 more)

빌드 시간 예측:

  • 100개 페이지: ~30초
  • 1,000개 페이지: ~5분
  • 10,000개 페이지: ~30분-1시간

이것이 SSG의 주요 한계입니다. 페이지 수가 많아질수록 빌드 시간이 기하급수적으로 증가하여 배포 속도가 느려집니다.

Next.js에서 SSG 구현

// app/posts/[slug]/page.tsx (App Router)
interface Post {
  slug: string;
  title: string;
  content: string;
  publishedAt: string;
}
 
// 빌드 타임에 생성할 경로 목록
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  return posts.map((post: Post) => ({
    slug: post.slug,
  }));
}
 
// 각 경로의 데이터 가져오기
async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    cache: 'force-cache', // 빌드 타임에 캐싱
  });
 
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}
 
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
 
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
// Pages Router 방식
import { GetStaticPaths, GetStaticProps } from 'next';
 
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  const paths = posts.map((post: Post) => ({
    params: { slug: post.slug },
  }));
 
  return {
    paths,
    fallback: false, // 없는 경로는 404
  };
};
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetch(`https://api.example.com/posts/${params?.slug}`).then((res) =>
    res.json()
  );
 
  return {
    props: {
      post,
    },
  };
};
 
export default function PostPage({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

장점

1. 최고의 성능

  • CDN에서 정적 파일 제공으로 초고속 로딩
  • 서버 연산 없음
  • TTFB(Time To First Byte)가 매우 빠름
  • 완벽한 Lighthouse 점수

2. 서버 비용 거의 없음

  • 정적 파일 호스팅만 필요
  • Vercel, Netlify 등에서 무료 제공
  • 트래픽이 증가해도 비용 변화 없음

3. 뛰어난 안정성

  • 서버 다운 걱정 없음
  • DB 장애와 무관하게 서비스 제공
  • 높은 트래픽에도 안정적

4. 완벽한 SEO

  • 정적 HTML이므로 검색엔진 최적화
  • 모든 메타 태그가 HTML에 포함
  • 크롤링 속도가 빠름

단점

1. 콘텐츠 업데이트가 어려움

  • 내용 변경 시 전체 빌드 필요
  • 빌드 시간이 오래 걸릴 수 있음
  • 실시간 데이터 표시 불가

2. 빌드 시간 증가

  • 페이지가 많으면 빌드가 매우 느림
  • 1,000개 페이지 = 수십 분 소요
  • CI/CD 파이프라인 지연

3. 동적 콘텐츠 처리 불가

  • 사용자별 개인화 어려움
  • URL 파라미터에 따른 다양한 조합 처리 힘듦
  • A/B 테스팅 불가

적합한 사용 사례

  • 블로그, 문서 사이트: 콘텐츠가 자주 바뀌지 않음
  • 마케팅 랜딩 페이지: 정적 콘텐츠, 빠른 로딩 중요
  • 포트폴리오 사이트: 개인 프로젝트 소개
  • 회사 소개 페이지: About, 채용 공고 등

실무에서 SSG가 가장 빛나는 조건

SSG는 보통 "정적 콘텐츠에 좋다"로 끝나지만, 실제로는 운영 복잡도를 크게 줄여주는 방식이라는 점이 더 중요합니다.

  • 서버 렌더링 비용이 거의 없고
  • 배포 결과물이 단순하며
  • 장애 포인트가 적고
  • 전 세계 어디서든 CDN으로 빠르게 제공할 수 있습니다

그래서 블로그, 문서, 소개 페이지처럼 콘텐츠 성격이 분명한 곳에서는 성능뿐 아니라 운영 안정성까지 함께 얻는 경우가 많습니다.

반대로 페이지 수가 많아지거나 콘텐츠 변경 주기가 짧아지면 빌드 시간이 곧 운영 비용이 됩니다. 즉, SSG의 한계는 기술 자체보다 빌드와 배포 파이프라인이 감당할 수 있는 범위에서 드러나는 경우가 많습니다.


4. ISR (Incremental Static Regeneration)

SSG + 자동 업데이트를 결합한 방식

개념과 혁신

ISR은 Next.js 9.5(2020년)에 도입된 기능으로, SSG의 대표적인 한계였던 "빌드 시간"과 "콘텐츠 업데이트" 문제를 보완하면서도 SSG의 성능 이점은 유지합니다.

핵심 아이디어 3가지:

  1. 부분 빌드: 모든 페이지를 빌드 타임에 생성하지 않아도 됨
  2. 자동 재생성: 오래된 페이지를 백그라운드에서 자동으로 새로 고침
  3. On-Demand 재생성: 필요할 때 즉시 특정 페이지만 재생성

비유: SSG가 "책을 출판하는 것"이라면, ISR은 "전자책을 출판하고 주기적으로 업데이트하는 것"입니다.

동작 원리

ISR은 Stale-While-Revalidate 캐싱 전략을 사용합니다:

[빌드 타임]
1. next build 실행
   └─ 인기 있는 페이지 10개만 미리 생성
   └─ 나머지 990개는 생성하지 않음 (빌드 시간 90% 단축!)
 
[첫 요청 - 미리 생성하지 않은 페이지]
2. 사용자가 /posts/post-500 접속
   └─ 해당 HTML이 없음 (fallback)
 
3. 서버가 즉시 생성 (On-Demand Generation)
   └─ SSR처럼 동작 (첫 방문자는 약간 느림)
   └─ 생성된 HTML을 캐시에 저장
 
4. 이후 요청은 캐시된 HTML 제공
   └─ SSG처럼 빠름!
 
[시간이 지난 후 - revalidate 시간 초과]
5. revalidate: 60 (60초 설정)
   └─ 60초가 지났는지 타이머 체크
 
6. 61초 후 첫 번째 사용자 요청
   └─ Stale(오래된) HTML을 즉시 제공 ✅
   └─ 사용자는 빠른 응답 경험
   └─ 동시에 백그라운드 재생성 시작 🔄
 
7. 백그라운드에서 페이지 재생성
   └─ API에서 최신 데이터 fetch
   └─ 새 HTML 생성
   └─ 캐시 업데이트
 
8. 다음 사용자부터는 새 HTML 제공
   └─ 모든 사용자가 빠른 경험 + 최신 데이터

Stale-While-Revalidate 전략 상세

이 전략은 "오래된 콘텐츠를 보여주면서 동시에 새로 고친다"는 의미입니다:

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#3b82f6','primaryTextColor':'#fff','primaryBorderColor':'#2563eb','lineColor':'#60a5fa','secondaryColor':'#93c5fd','tertiaryColor':'#dbeafe'}}}%%
gantt
    title ISR Stale-While-Revalidate 동작 (revalidate: 60초)
    dateFormat X
    axisFormat %Ss
 
    section 페이지 버전
    v1 (빌드 시)        :done, v1, 0, 61000
    v2 (재생성)         :active, v2, 61000, 63000
    v2 (캐시됨)         :done, v2cache, 63000, 70000
 
    section 사용자 요청
    A 방문 (v1 제공)    :milestone, a, 10000, 10000
    B 방문 (v1 제공)    :crit, b, 61000, 61000
    재생성 시작         :active, regen, 61000, 63000
    C 방문 (v2 제공)    :milestone, c, 63000, 63000
 
    section 응답 속도
    A - 즉시 (<100ms)   :done, 10000, 10100
    B - 즉시 (<100ms)   :done, 61000, 61100
    C - 즉시 (<100ms)   :done, 63000, 63100

핵심 포인트:

  • B 사용자: 오래된 v1을 보지만 빠르게 응답받음 (< 100ms)
  • 백그라운드에서 v2 재생성 (사용자는 기다리지 않음)
  • C 사용자부터: 새로운 v2를 빠르게 받음

장점:

  • 사용자는 항상 빠른 응답 (캐시 사용)
  • 콘텐츠는 점진적으로 최신 상태 유지
  • 서버 부하 최소화 (백그라운드 재생성)

단점:

  • 완벽한 실시간성은 없음 (최대 revalidate 시간만큼 지연)
  • 첫 방문자만 오래된 콘텐츠를 볼 수 있음

Fallback 전략

generateStaticParams에 없는 경로 요청 시 어떻게 처리할지 결정:

// fallback: false
// → 404 페이지 (빌드 타임에 생성 안 한 경로는 접근 불가)
 
// fallback: true
// → 즉시 fallback UI 표시 후 생성 완료 시 교체
// → 사용자는 로딩 스피너를 봄
 
// fallback: 'blocking'
// → 서버에서 생성 완료까지 대기 (SSR처럼 동작)
// → 사용자는 대기하지만 완성된 페이지를 봄

실무 권장: fallback: 'blocking' - SEO에 유리하고 UX가 일관적

Next.js에서 ISR 구현

// app/posts/[slug]/page.tsx (App Router)
interface Post {
  slug: string;
  title: string;
  content: string;
  views: number;
  updatedAt: string;
}
 
export const revalidate = 60; // 60초마다 재검증
 
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  // 인기 있는 포스트 10개만 빌드 타임에 생성
  return posts.slice(0, 10).map((post: Post) => ({
    slug: post.slug,
  }));
}
 
async function getPost(slug: string): Promise<Post> {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 }, // 60초마다 재검증
  });
 
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}
 
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div className="flex gap-4 text-sm text-muted-foreground">
        <span>조회수: {post.views}</span>
        <time>업데이트: {new Date(post.updatedAt).toLocaleDateString()}</time>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
// Pages Router 방식
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await fetch(`https://api.example.com/posts/${params?.slug}`).then((res) =>
    res.json()
  );
 
  return {
    props: {
      post,
    },
    revalidate: 60, // 60초마다 재생성
  };
};
 
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  const paths = posts.slice(0, 10).map((post: Post) => ({
    params: { slug: post.slug },
  }));
 
  return {
    paths,
    fallback: 'blocking', // 없는 경로는 온디맨드로 생성
  };
};

On-Demand Revalidation

특정 이벤트(예: CMS에서 글 수정) 발생 시 즉시 재생성할 수도 있습니다.

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
 
export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');
 
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
  }
 
  const path = request.nextUrl.searchParams.get('path');
 
  if (path) {
    revalidatePath(path);
    return NextResponse.json({ revalidated: true, path });
  }
 
  return NextResponse.json({ message: 'Missing path' }, { status: 400 });
}
# CMS 웹훅에서 호출
curl -X POST "https://yoursite.com/api/revalidate?secret=YOUR_SECRET&path=/posts/my-post"

장점

1. SSG의 속도 + SSR의 유연성

  • 초기 로딩은 SSG처럼 빠름
  • 콘텐츠 업데이트는 자동으로 반영
  • 최상의 사용자 경험

2. 빌드 시간 단축

  • 인기 페이지만 미리 생성
  • 나머지는 첫 요청 시 생성 (fallback)
  • 수천 개 페이지도 빠른 빌드

3. 비용 효율적

  • 대부분 정적 파일 제공
  • 재생성은 백그라운드에서 비동기로
  • SSR보다 훨씬 저렴

4. 실시간성과 성능의 균형

  • 조회수, 댓글 수 등 업데이트 가능
  • 캐시 덕분에 여전히 빠름
  • revalidate 시간으로 균형 조절

단점

1. 완벽한 실시간성은 없음

  • revalidate 시간만큼 지연
  • 급하게 수정해도 즉시 반영 안 될 수 있음
  • On-Demand Revalidation으로 해결 가능

2. 복잡한 캐싱 로직

  • stale 상태 이해 필요
  • 디버깅이 어려울 수 있음
  • 캐시 동작 예측이 까다로움

3. 서버 필요

  • 정적 호스팅만으로는 불가능
  • Vercel, Netlify 등 서버리스 환경 필요
  • SSG보다는 비용 발생

적합한 사용 사례

  • 블로그 (조회수, 댓글): 콘텐츠는 정적이지만 메타 정보는 변경
  • 전자상거래 상품 목록: 가격, 재고는 주기적 업데이트
  • 뉴스 사이트: 기사는 업데이트되지만 빠른 로딩 필요
  • 대규모 콘텐츠 사이트: 수천 개 페이지, 일부만 자주 접근

실무에서 ISR이 매력적인 이유와 어려운 이유

ISR은 많은 팀이 "가장 현실적인 균형점"으로 보는 방식입니다. 성능, 비용, 최신성 사이에서 아주 극단적인 선택을 하지 않아도 되기 때문입니다.

하지만 운영 관점에서는 이해해야 할 것이 더 많아집니다.

  • 사용자가 보는 시점과 백그라운드 재생성 시점이 다를 수 있고
  • stale 상태를 팀이 정확히 이해해야 하며
  • 긴급 수정이 필요할 때 On-Demand Revalidation 같은 운영 수단을 같이 준비해야 합니다

즉, ISR은 단순히 SSG에 revalidate만 붙인 기능이 아니라, 캐시 일관성과 콘텐츠 신선도를 팀이 함께 관리하는 방식에 가깝습니다.

그래서 실무에서는 ISR을 도입할 때 보통 이런 질문이 따라옵니다.

  • 몇 초 또는 몇 분까지 늦게 보여줘도 괜찮은가?
  • 잘못된 정보가 잠깐 노출돼도 되는가?
  • CMS나 어드민 시스템에서 재검증을 트리거할 수 있는가?

다음 글에서 이어서 보기

여기까지는 CSR, SSR, SSG, ISR이 각각 어떻게 동작하는지와 장단점을 정리했습니다. 다음 글에서는 네 가지 방식을 한눈에 비교하고, 하이브리드 렌더링 전략과 Next.js 실무 선택 기준을 정리합니다.