Next.js 렌더링의 진화 톺아보기

kimgho
Next.js
Next.js 렌더링의 진화 톺아보기

웹 개발을 하다 보면 "렌더링"이라는 단어를 자주 마주칩니다. SSR, SSG, CSR... 다양한 렌더링 방식이 있지만, 결국 이 모든 것의 본질은 하나입니다.

"무엇을, 어디서, 언제 그리는가?"

웹의 발전사는 결국 TTV(Time To View)TTI(Time To Interactive) 사이의 균형을 맞추기 위한 여정이었습니다. 이 글에서는 Next.js가 어떻게 이 균형점을 찾아왔는지, 그리고 미래에는 어떤 방향으로 나아가려 하는지 살펴보겠습니다.


1세대: 페이지 단위 렌더링 전략 (Classic Patterns)

Pages Router 시절의 렌더링 방식들입니다. 이 방식들의 핵심적인 한계는 페이지 전체를 단위로 동작한다는 것입니다.

CSR (Client Side Rendering)

핵심: 빈 HTML + 거대한 JS 번들

클라이언트에서 JavaScript를 사용하여 페이지를 렌더링하는 방식입니다. 서버는 최소한의 HTML 뼈대와 JS 번들만 보내고, 브라우저가 JS를 실행하여 데이터를 가져오고 동적으로 DOM을 구성합니다.

<!-- 서버가 보내는 HTML -->
<!DOCTYPE html>
<html>
  <body>
    <div id="root"></div>
    <!-- 텅 빈 컨테이너 -->
    <script src="/bundle.js"></script>
    <!-- 거대한 JS 번들 -->
  </body>
</html>

Waterfall 현상

CSR의 가장 큰 문제는 Waterfall(폭포수) 현상입니다.

HTML 다운로드 ->   JS 다운로드 ->  JS 파싱/실행 ->  API 호출  ->   렌더링
     |              |              |             |           |
     v              v              v             v           v
   [0ms]         [200ms]        [500ms]      [800ms]     [1200ms]

모든 단계가 순차적으로 진행되어야 하므로, 사용자는 의미 있는 콘텐츠를 보기까지 오랜 시간을 기다려야 합니다.

성능 지표 관점:

  • TTFB (Time To First Byte): 빠름 (빈 HTML이니까)
  • FCP (First Contentful Paint): 느림 (JS 실행 후에야 콘텐츠 표시)
  • TTI (Time To Interactive): 느림 (모든 JS가 로드되어야 상호작용 가능)

장점

  • 페이지 전환이 빠름 (SPA 특성)
  • 서버 부하가 적음
  • 사용자 인터랙션이 풍부함

단점

  • SEO가 어려움 (초기 HTML에 콘텐츠가 없음)
  • 초기 로딩이 느림
  • CLS(Cumulative Layout Shift) 발생 가능

적합한 사용 사례

  • 로그인 후 관리자 대시보드
  • 웹 메일, 소셜 미디어 피드
  • SEO가 필요 없는 내부 도구

SSG (Static Site Generation)

핵심: 빌드 타임 생성 + CDN 캐싱

빌드 시점에 미리 HTML 페이지를 생성하는 방식입니다. 사용자가 페이지를 요청하면 이미 생성되어 CDN에 캐싱된 정적 HTML 파일을 즉시 제공합니다.

// pages/posts/[slug].tsx (Pages Router)
export async function getStaticProps({ params }) {
  const post = await getPostBySlug(params.slug);
  return {
    props: { post },
  };
}
 
export async function getStaticPaths() {
  const posts = await getAllPosts();
  return {
    paths: posts.map((post) => ({ params: { slug: post.slug } })),
    fallback: false,
  };
}

CDN의 마법

SSG의 진정한 힘은 CDN 캐싱에서 나옵니다.

사용자 (서울) -> CDN 엣지 서버 (서울) -> 원본 서버 (미국)
                      |                 |
                  캐시 HIT           캐시 미스
                      |                 |
             즉시 HTML 반환 (~10ms)     HTML 생성 (~100ms)

성능 지표 관점:

  • TTFB: 매우 빠름 (CDN 엣지에서 즉시 응답)
  • FCP: 매우 빠름 (완성된 HTML 즉시 표시)
  • TTI: HTML 표시 후 JS 하이드레이션 필요

한계: 빌드 시점과 요청 시점간 미스매치

SSG의 치명적인 한계는 데이터 변경 시점사용자 요청 시점입니다.

[빌드 시점]             [데이터 변경]          [사용자 요청]
    |                     |                    |
    v                     v                    v
 가격: 10,000원      가격: 8,000원        여전히 10,000원 표시

또한 페이지 수가 많아질수록 빌드 시간이 기하급수적으로 증가합니다.

장점

  • 가장 빠른 TTFB
  • 서버 부하 없음
  • 보안성이 높음 (공격 표면이 적음)
  • SEO 친화적

단점

  • 빌드 후 데이터 업데이트 불가
  • 빌드 시간이 길어질 수 있음
  • 사용자별 맞춤 콘텐츠 제공 어려움

적합한 사용 사례

  • 블로그, 문서 사이트
  • 랜딩 페이지, 포트폴리오
  • 콘텐츠 변경이 적은 정적 페이지

SSR (Server Side Rendering) - Traditional

핵심: 요청 타임 생성 + 동적 데이터

요청 시점마다 서버에서 HTML 페이지를 생성하는 방식입니다. 사용자가 페이지를 요청하면 서버는 필요한 데이터를 가져와 동적으로 HTML을 렌더링합니다.

// pages/dashboard.tsx (Pages Router)
export async function getServerSideProps({ req }) {
  const user = await getUser(req.cookies.token);
  const data = await getDashboardData(user.id);
 
  return {
    props: { user, data },
  };
}

All or Nothing 문제

전통적인 SSR의 가장 큰 문제는 전부 아니면 전무(All or Nothing) 입니다.

export async function getServerSideProps() {
  // 이 모든 데이터가 준비될 때까지 사용자는 하얀 화면만 봅니다
  const user = await getUser(); // 100ms
  const posts = await getPosts(); // 500ms
  const recommendations = await getRecommendations(); // 2000ms(병목지점)
 
  // 총 2.6초 후에야 HTML 전송 시작
  return { props: { user, posts, recommendations } };
}
[요청]                                    [첫 응답]
   |──────────── 2.6초 대기 ──────────────→|
   |           (하얀 화면)                  | HTML 전송

하이드레이션(Hydration) 비용

HTML은 빨리 보이지만, JS가 다 로드되기 전까지 버튼이 동작하지 않습니다.

[HTML 표시]              [JS 로드 완료]             [상호작용 가능]
     |                        |                        |
     v                        v                        v
  화면은 보임            클릭해도 반응 없음                  동작
 
|-- FCP --|----------- "유령 UI" 구간 ------------|---- TTI ----|

장점

  • 항상 최신 데이터 표시
  • SEO 친화적
  • 사용자별 맞춤 콘텐츠 가능

단점

  • 느린 데이터가 전체 응답을 지연시킴
  • 서버 부하 증가
  • 서버 장애 시 페이지 제공 불가

적합한 사용 사례

  • 사용자별 대시보드, 마이페이지
  • 실시간 재고/가격이 중요한 이커머스
  • 인증 상태에 따른 동적 페이지

ISR (Incremental Static Regeneration)

핵심: SSG의 속도 + SSR의 신선함

SSG의 성능과 SSR의 최신성을 절충한 방식입니다. 빌드 시점에 페이지를 생성하지만, 설정된 주기가 지나면 백그라운드에서 페이지를 다시 생성합니다.

// pages/products/[id].tsx
export async function getStaticProps({ params }) {
  const product = await getProduct(params.id);
 
  return {
    props: { product },
    revalidate: 60, // 60초마다 백그라운드에서 재생성
  };
}

Stale-While-Revalidate 패턴

ISR은 SWR(Stale-While-Revalidate) 캐싱 전략을 사용합니다.

[첫 번째 요청]        [60초 후 요청]         [백그라운드 재생성]          [다음 요청]
      |                  |                     |                    |
      v                  v                     v                    v
   캐시 생성            캐시 반환              새 HTML 생성            새 캐시 반환
                   (stale 데이터)           (백그라운드)

On-Demand ISR

API 요청을 통해 특정 페이지만 수동으로 재생성할 수도 있습니다.

// pages/api/revalidate.ts
export default async function handler(req, res) {
  await res.revalidate("/products/123");
  return res.json({ revalidated: true });
}

장점

  • SSG처럼 빠른 첫 응답
  • 전체 빌드 없이 페이지 업데이트 가능
  • 서버 부하 적음

단점

  • revalidate 주기 동안 stale 데이터 가능
  • 여전히 페이지 단위 갱신
  • 실시간성 완전 보장 불가

적합한 사용 사례

  • 주기적으로 업데이트되는 블로그/뉴스
  • 실시간이 아니어도 되는 이커머스 상품 목록
  • 댓글이 추가되는 게시물

2세대: 컴포넌트 단위와 스트리밍 (The Paradigm Shift)

여기서부터 Next.js 13+ (App Router)의 핵심입니다. 페이지 단위에서 컴포넌트 단위로의 패러다임 전환이 일어납니다.

RSC (React Server Components)

개념: 서버 컴포넌트와 클라이언트 컴포넌트의 분리

App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다.

// app/products/[id]/page.tsx - 서버 컴포넌트
import { db } from "@/lib/db";
 
export default async function ProductPage({ params }) {
  // 컴포넌트 내부에서 직접 DB 쿼리
  const product = await db.product.findUnique({
    where: { id: params.id },
  });
 
  return (
    <div>
      <h1>{product.name}</h1>
      <AddToCartButton product={product} /> {/* 클라이언트 컴포넌트 */}
    </div>
  );
}
// components/AddToCartButton.tsx - 클라이언트 컴포넌트
"use client";
 
import { useState } from "react";
 
export function AddToCartButton({ product }) {
  const [isAdding, setIsAdding] = useState(false);
  // 상태, 이벤트 핸들러 사용 가능
}

Zero Bundle Size

서버 컴포넌트의 가장 큰 혁신은 Zero Bundle Size입니다.

// 서버 컴포넌트
// 30KB
import { format } from "date-fns";
import { marked } from "marked";
// 20KB
import { highlight } from "prismjs";
 
// 50KB
 
export default async function BlogPost({ content }) {
  // 이 모든 라이브러리는 서버에서만 실행됨
  // 클라이언트 번들에는 포함되지 않음 = 0KB
  const html = marked(content);
  return <article dangerouslySetInnerHTML={{ __html: html }} />;
}

클라이언트 번들 크기 비교:

  • CSR: marked + date-fns + prismjs = ~100KB
  • RSC: 0KB (서버에서 이미 처리됨)

백엔드 직접 접근 + Data Cache

API 레이어를 거치지 않아 Latency가 감소합니다.

[기존 CSR]
클라이언트 -> API Route -> 데이터베이스
    |           |            |
 100ms       100ms        50ms  = 총 250ms
 
[RSC]
서버 컴포넌트 -> 데이터베이스
      |            |
   (동일 서버)     50ms  = 총 50ms

게다가 서버 컴포넌트에서 fetch를 사용할 경우, 결과를 Data Cache에 저장할 수 있습니다. 동일한 요청이 들어오면 DB나 외부 API를 다시 호출하지 않고 캐시된 데이터를 즉시 반환합니다.

// Next.js 15+에서는 캐싱 X
// DB 직접 쿼리 시에는 React의 cache 래퍼 사용 (버전 무관)
import { cache } from "react";
 
// Next.js 15 이상에서는 캐싱을 원하면 명시적으로 옵션 지정 필요
const cachedData = await fetch(url, { cache: "force-cache" }); // 캐싱 O
const freshData = await fetch(url);
 
export const getPost = cache(async (slug) => {
  return await db.query.posts.findFirst({ where: eq(posts.slug, slug) });
});

Streaming SSR (스트리밍 SSR)

"HTML은 하나의 거대한 덩어리가 아니라, 흐르는 물줄기다."

기존 SSR의 문제 해결

Streaming SSR은 데이터가 준비되는 순서대로 HTML 조각(Chunk)을 보냅니다.

// app/dashboard/page.tsx
import { Suspense } from "react";
 
import Header from "./Header";
import Recommendations from "./Recommendations";
import UserInfo from "./UserInfo";
 
export default function Dashboard() {
  return (
    <div>
      <Header /> {/* 즉시 렌더링 */}
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo /> {/* 100ms 후 스트리밍 */}
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations /> {/* 2초 후 스트리밍 */}
      </Suspense>
    </div>
  );
}

동작 방식 시각화

시간 →    0ms     100ms    500ms    1000ms   2000ms
          |        |        |        |        |
          v        v        v        v        v
[서버]   ┌────────┬────────┬────────┬────────┬────────┐
        │ Header │UserInfo│        │        │ Recom  │
        │ Shell  │ Chunk  │        │        │ Chunk  │
        └────────┴────────┴────────┴────────┴────────┘
              |       |                         |
[브라우저] ┌─────────────────────────────────────────┐
         │ Header │ UserInfo │  Loading... │→│ 추천 │
         └─────────────────────────────────────────┘

기존 SSR과의 비교:

  • 기존 SSR: 2초 후에야 첫 HTML 응답
  • Streaming SSR: 즉시 Shell 전송, 이후 점진적 업데이트

성능 지표 관점:

  • TTFB: 개선 (Shell 의존)
  • FCP: 빠름 (즉시 의미 있는 콘텐츠 표시)
  • LCP: 개선 (메인 콘텐츠가 빨리 표시됨)

Selective Hydration (선택적 하이드레이션)

개념: 필요한 부분만 먼저 깨우기

전통적인 하이드레이션은 전체 페이지의 JS를 한 번에 로드하고 실행했습니다. Selective Hydration은 필요한 부분만 우선적으로 하이드레이션합니다.

[전통적 하이드레이션]
전체 JS 로드 ->   전체 하이드레이션 ->    전체 상호작용 가능
     |                |                    |
   2000ms          500ms               2500ms (TTI)
 
[선택적 하이드레이션]
Header JS -> UserInfo JS -> 사용자 클릭 -> 우선 하이드레이션
   |            |              |              |
 200ms       400ms          500ms          600ms (해당 부분 TTI)

사용자 중심 우선순위

페이지 전체가 로드되지 않았어도, 사용자가 상호작용하려는 부분을 React가 감지하여 해당 부분의 하이드레이션을 최우선으로 처리합니다.

<Suspense fallback={<CommentsSkeleton />}>
  <Comments /> {/* 사용자가 여기를 클릭하면 */}
</Suspense>   {/* React가 이 부분을 먼저 하이드레이션 */}

3세대: 정적과 동적의 완벽한 결합 (The Future)

Partial Prerendering (PPR)

"SSG의 속도와 SSR의 유연성을 하나의 HTTP 요청에 담다."

개념: Shell & Holes 아키텍처

웹사이트의 90%는 정적이고, 10%만 동적입니다. PPR은 이 사실을 활용합니다.

// app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id); // 빌드 타임에 생성 (정적)
 
  return (
    <div>
      {/* Static Shell - 빌드 타임에 생성 */}
      <Header />
      <ProductInfo product={product} />
      <Footer />
 
      {/* Dynamic Holes - 요청 타임에 스트리밍 */}
      <Suspense fallback={<CartSkeleton />}>
        <CartWidget /> {/* 사용자별 장바구니 */}
      </Suspense>
 
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} /> {/* 실시간 리뷰 */}
      </Suspense>
    </div>
  );
}

동작 방식

[빌드 타임]
┌──────────────────────────────────────┐
│  Header  │  ProductInfo  │  Footer   │  ← 정적 Shell 생성
├──────────┼───────────────┼───────────┤
│  [HOLE]  │               │  [HOLE]   │  ← 동적 영역 마킹
└──────────────────────────────────────┘
 
[요청 타임]
1. 즉시 정적 Shell 전송 (CDN 속도)
2. 동적 Hole들을 서버에서 스트리밍
3. 클라이언트에서 Hole에 콘텐츠 삽입

기존 방식과의 차별점

기존 SSG + CSR:

[정적 페이지 로드] -> [클라이언트 JS 실행]   ->   [API 호출] -> [동적 데이터 표시]
      |                   |                   |              |
    50ms                200ms               300ms          500ms
                          |
                    Waterfall 발생

PPR:

[정적 Shell 즉시 전송]  ->   [동적 Hole 스트리밍]
         |                       |
       50ms                 100ms (병렬 처리)
         |                       |
      CDN 속도             서버 -> 브라우저 직통

Waterfall이 완전히 제거됩니다.


렌더링 전략 비교 요약

렌더링 별 요약표

결론: 어떤 전략을 선택해야 할까?

렌더링 전략 선택의 핵심은 트레이드오프를 이해하는 것입니다.

의사결정 플로우

데이터가 자주 변하나요?
├── 아니오 -> SSG (블로그, 문서)
└── 예 -> 실시간이어야 하나요?
         ├── 아니오 -> ISR (뉴스 목록, 상품 카탈로그)
         └── 예 -> SEO가 중요한가요?
                  ├── 아니오 -> CSR (대시보드, 관리자 페이지)
                  └── 예 -> 일부만 동적인가요?
                           ├── 예 -> PPR (이커머스, 포털)
                           └── 아니오 -> Streaming SSR (소셜 피드)

반드시 이 플로우가 정답은 아닙니다. 상황,리소스 등 고려사항이 많기 때문에 가장 최선의 선택을 찾아가는 것이 중요합니다.


참고 자료