컴퓨터공학 300 주제 시리즈의 215번째 글이다. 전체 지도는 여기.

한 줄 요약

렌더링 방식의 차이는 “화면에 보일 HTML 을 누가, 언제 만드는가”다. CSR 은 브라우저가 실행 시점에, SSR 은 서버가 요청 시점에, SSG 는 빌드 도구가 배포 전에 만들고, 서버에서 만든 HTML 을 브라우저에서 다시 상호작용 가능하게 만드는 과정이 하이드레이션이다.

왜 필요한가

같은 React·Vue 코드로도 HTML 을 만드는 시점은 여러 가지로 고를 수 있다. 이 선택이 다음을 좌우한다.

  • 첫 화면이 얼마나 빨리 뜨는가 (LCP)
  • 화면이 떴는데 눌리지 않는 구간이 있는가
  • 검색 엔진·링크 미리보기 봇이 내용을 읽을 수 있는가
  • 서버 비용과 캐시 전략, 장애 시 영향 범위

“요즘은 SSR 이 대세” 같은 말로 고르면 안 된다. 블로그, 관리 화면, 쇼핑몰 상품 페이지는 요구가 다르다. 각 방식이 무엇을 얻고 무엇을 내주는지 알아야 페이지별로 고를 수 있다.

핵심 개념

세 방식의 흐름

CSR (Client-Side Rendering)
 브라우저 ─▶ 빈 HTML + JS 번들 ─▶ JS 실행 ─▶ API 호출 ─▶ 화면 그림
             (내용 없음)                                  ↑ 여기서야 보임

SSR (Server-Side Rendering)
 브라우저 ─▶ 서버가 데이터 조회 + HTML 생성 ─▶ 완성된 HTML 표시 ─▶ JS 로 하이드레이션
                                              ↑ 보임            ↑ 여기서 상호작용 가능

SSG (Static Site Generation)
 빌드 시 ─▶ 모든 페이지 HTML 생성 ─▶ CDN 배포
 브라우저 ─▶ CDN 의 정적 HTML 표시 ─▶ (필요하면) 하이드레이션

비교

항목 CSR SSR SSG
HTML 생성 시점 브라우저 실행 시 요청마다 서버에서 빌드할 때 한 번
첫 내용 표시 느림 (JS 다운로드·실행·API 후) 빠름 가장 빠름 (CDN)
서버 부하 정적 파일만 요청마다 렌더링 정적 파일만
데이터 최신성 실시간 실시간 빌드 시점에 고정
개인화 쉬움 가능 (캐시 어려워짐) 어려움 (클라이언트에서 보충)
JS 없는 봇 내용을 못 봄 읽음 읽음
맞는 곳 로그인 뒤 대시보드, 관리 도구 개인화·실시간 페이지 블로그, 문서, 마케팅 페이지

이 블로그처럼 Jekyll 로 만든 사이트는 SSG 의 전형이다. 글을 push 하면 빌드가 HTML 파일을 만들고, 독자는 그 파일을 받는다.

하이드레이션

SSR·SSG 로 받은 HTML 은 보이기는 하지만 그 자체로는 정적이다. 버튼에 이벤트 리스너가 없다. 브라우저가 같은 컴포넌트 코드를 내려받아 실행하면서, 이미 있는 DOM 을 새로 만들지 않고 “이 DOM 은 이 컴포넌트의 결과”라고 연결하고 리스너를 붙이는 과정이 하이드레이션이다. React 에서는 hydrateRoot 가 이 일을 한다.

여기서 두 가지 문제가 생긴다.

  1. 보이지만 눌리지 않는 구간: HTML 은 떴는데 JS 가 아직 실행되지 않아 클릭이 반응하지 않는다. 번들이 클수록 길어진다.
  2. 불일치(mismatch): 서버에서 만든 HTML 과 클라이언트의 첫 렌더 결과가 다르면 오류나 경고가 나고, 해당 부분을 다시 그린다. 현재 시각, 난수, window 크기, 로캘에 따라 다른 출력을 렌더 중에 만들면 생긴다. React 문서도 서버와 클라이언트의 첫 렌더 결과가 같아야 한다고 설명한다.

중간 지대

순수한 세 방식 사이에 여러 변형이 있다.

  • 증분 정적 재생성(ISR): 정적 페이지를 일정 시간이 지나거나 요청이 있을 때 백그라운드에서 다시 생성한다. SSG 의 속도와 어느 정도의 최신성을 함께 얻는다.
  • 스트리밍 SSR: HTML 을 다 만들 때까지 기다리지 않고, 준비된 부분부터 흘려보낸다. 느린 데이터 영역은 나중에 채운다.
  • 부분 하이드레이션·아일랜드: 상호작용이 필요한 조각만 하이드레이션하고 나머지는 정적 HTML 로 둔다. 내려보내는 JS 가 줄어든다.
  • 서버 컴포넌트: 일부 컴포넌트를 서버에서만 실행하고 그 결과만 보내, 해당 컴포넌트 코드를 클라이언트 번들에서 뺀다. Next.js 의 App Router 가 이 모델을 쓴다.

web.dev 의 Rendering on the Web 은 이 선택지를 TTFB, FCP, 상호작용 가능 시점 같은 지표와 함께 비교한다.

고르는 질문

  1. 이 페이지는 로그인 뒤에만 보이는가? → 검색 노출이 필요 없으니 CSR 도 충분하다.
  2. 내용이 모든 사용자에게 같은가? 얼마나 자주 바뀌는가? → 같고 드물게 바뀌면 SSG(필요하면 ISR).
  3. 사용자마다 다르거나 실시간이어야 하는가? → SSR, 또는 정적 골격 + 클라이언트에서 개인화 부분만 채우기.
  4. 상호작용이 적은 콘텐츠 페이지인가? → 정적 HTML 에 JS 를 최소로.

한 사이트 안에서도 페이지마다 다르게 고르는 것이 보통이다.

직접 해 보기

세 방식으로 같은 상품 목록 페이지를 만들고, 응답 생성 시간과 “자바스크립트를 실행하지 않는 봇이 볼 수 있는 내용”을 비교한다.

import html, os, re, tempfile, time

PRODUCTS = [{"id": i, "name": f"상품 {i}", "price": i * 1000} for i in range(1, 4)]

def page(body, script=""):
    return f"<!doctype html><html><body>{body}{script}</body></html>"

def render_list(items):
    rows = "".join(f"<li>{html.escape(p['name'])} - {p['price']}원</li>" for p in items)
    return f"<ul id='list'>{rows}</ul>"

# CSR: 서버는 빈 껍데기와 스크립트만 준다. 목록은 브라우저가 API 를 불러 그린다.
def csr():
    return page("<div id='app'></div>", "<script src='/app.js'></script>")

# SSR: 요청마다 서버가 데이터를 조회해 HTML 을 완성한다.
def ssr():
    time.sleep(0.05)                     # DB 조회 등 요청당 비용이라고 가정
    return page(render_list(PRODUCTS), "<script src='/hydrate.js'></script>")

# SSG: 빌드할 때 한 번 만들어 파일로 저장하고, 요청엔 파일만 준다.
build_dir = tempfile.mkdtemp()
with open(os.path.join(build_dir, "index.html"), "w") as f:
    f.write(page(render_list(PRODUCTS), "<script src='/hydrate.js'></script>"))
def ssg():
    with open(os.path.join(build_dir, "index.html")) as f:
        return f.read()

for name, fn in [("CSR", csr), ("SSR", ssr), ("SSG", ssg)]:
    t = time.perf_counter()
    for _ in range(10):
        body = fn()
    ms = (time.perf_counter() - t) * 100        # 10회 평균(ms)
    visible = re.findall(r"<li>(.*?)</li>", body)
    print(f"{name}: 응답 생성 {ms:5.1f}ms/회, JS 없이 보이는 상품 {len(visible)}개 {visible[:1]}")

실행 결과(시간은 기기마다 조금 다르다):

CSR: 응답 생성   0.0ms/회, JS 없이 보이는 상품 0개 []
SSR: 응답 생성  50.4ms/회, JS 없이 보이는 상품 3개 ['상품 1 - 1000원']
SSG: 응답 생성   0.1ms/회, JS 없이 보이는 상품 3개 ['상품 1 - 1000원']

CSR 은 서버가 할 일이 거의 없지만 HTML 에 내용이 없다. 사용자는 JS 와 API 응답을 기다려야 하고, JS 를 실행하지 않는 봇은 빈 페이지를 본다. SSR 은 내용이 들어 있지만 요청마다 데이터 조회 비용(여기서는 가정한 50ms)을 치른다. SSG 는 미리 만든 파일을 읽기만 하므로 빠르고 내용도 있지만, 가격이 바뀌면 다시 빌드해야 한다. render_list 를 서버(SSR·SSG)와 브라우저(하이드레이션) 양쪽에서 같은 결과로 실행해야 한다는 점이 하이드레이션 불일치 문제의 핵심이다.

현업에서는

  • “화면은 떴는데 버튼이 안 눌려요”: SSR 페이지에서 큰 번들의 하이드레이션이 끝나지 않은 구간이다. 번들을 나누고, 상호작용이 없는 부분은 하이드레이션하지 않도록 구조를 바꾼다.
  • 하이드레이션 불일치 경고: 날짜를 new Date().toLocaleString() 으로 렌더링하면 서버와 브라우저의 시간대가 달라 불일치가 난다. 서버 시간대(대개 UTC)와 사용자 시간대 차이를 의식하고, 시간 표시는 클라이언트에서만 하거나 같은 시간대로 고정한다.
  • SSR 서버 운영: SSR 은 결국 “요청마다 계산하는 서버”다. 홈랩 k3s 에 SSR 앱을 올리면 정적 파일 서버와 달리 CPU·메모리 리소스와 오토스케일링을 따로 고민해야 하고, 서버가 죽으면 페이지가 아예 안 뜬다. 반면 SSG 결과물은 nginx 같은 정적 서버나 CDN 에 올리면 끝이다.
  • 캐시와 개인화의 충돌: SSR 페이지에 사용자 이름을 넣는 순간 CDN 캐시를 공유할 수 없다. 공통 부분은 캐시 가능한 HTML 로, 개인화 부분은 클라이언트에서 따로 불러오는 식으로 나눈다.

확인 문제

  1. CSR, SSR, SSG 각각에서 HTML 이 만들어지는 시점은?
  2. 하이드레이션이란 무엇이며, 왜 필요한가.
  3. 하이드레이션 불일치를 일으키는 코드의 예 두 가지를 들라.
  4. 로그인 뒤에만 쓰는 사내 관리 도구에 CSR 이 무난한 이유는?
  5. 상품 가격이 하루 몇 번 바뀌는 쇼핑몰 상품 상세 페이지에 어울리는 방식과 그 이유는?

풀이

  1. CSR 은 브라우저에서 JS 가 실행될 때, SSR 은 요청을 받을 때마다 서버에서, SSG 는 배포 전 빌드할 때.
  2. 서버에서 만든 정적 HTML 에 클라이언트 JS 가 컴포넌트를 연결하고 이벤트 리스너를 붙여 상호작용 가능하게 만드는 과정이다. HTML 만으로는 동작이 없기 때문에 필요하다.
  3. 렌더 중에 현재 시각이나 난수를 출력하는 코드, window·localStorage 값에 따라 다른 결과를 렌더링하는 코드. 서버와 브라우저의 로캘·시간대 차이도 원인이다.
  4. 검색 노출이 필요 없고, 사용자가 한 번 받은 앱을 오래 쓰며, 내용이 모두 개인화·실시간이라 서버 렌더링의 이점이 작기 때문이다.
  5. SSG 에 주기적·요청 기반 재생성(ISR)을 더하거나, 정적 골격에 가격·재고만 클라이언트에서 불러오는 방식. 대부분의 내용은 모두에게 같아 캐시 이득이 크고, 바뀌는 부분만 최신으로 유지할 수 있다.

더 읽을거리 (References)