[CS300 #215] SSR·CSR·SSG — HTML 을 언제, 어디서 만들 것인가
컴퓨터공학 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 가 이 일을 한다.
여기서 두 가지 문제가 생긴다.
- 보이지만 눌리지 않는 구간: HTML 은 떴는데 JS 가 아직 실행되지 않아 클릭이 반응하지 않는다. 번들이 클수록 길어진다.
- 불일치(mismatch): 서버에서 만든 HTML 과 클라이언트의 첫 렌더 결과가 다르면 오류나 경고가 나고, 해당 부분을 다시 그린다. 현재 시각, 난수,
window크기, 로캘에 따라 다른 출력을 렌더 중에 만들면 생긴다. React 문서도 서버와 클라이언트의 첫 렌더 결과가 같아야 한다고 설명한다.
중간 지대
순수한 세 방식 사이에 여러 변형이 있다.
- 증분 정적 재생성(ISR): 정적 페이지를 일정 시간이 지나거나 요청이 있을 때 백그라운드에서 다시 생성한다. SSG 의 속도와 어느 정도의 최신성을 함께 얻는다.
- 스트리밍 SSR: HTML 을 다 만들 때까지 기다리지 않고, 준비된 부분부터 흘려보낸다. 느린 데이터 영역은 나중에 채운다.
- 부분 하이드레이션·아일랜드: 상호작용이 필요한 조각만 하이드레이션하고 나머지는 정적 HTML 로 둔다. 내려보내는 JS 가 줄어든다.
- 서버 컴포넌트: 일부 컴포넌트를 서버에서만 실행하고 그 결과만 보내, 해당 컴포넌트 코드를 클라이언트 번들에서 뺀다. Next.js 의 App Router 가 이 모델을 쓴다.
web.dev 의 Rendering on the Web 은 이 선택지를 TTFB, FCP, 상호작용 가능 시점 같은 지표와 함께 비교한다.
고르는 질문
- 이 페이지는 로그인 뒤에만 보이는가? → 검색 노출이 필요 없으니 CSR 도 충분하다.
- 내용이 모든 사용자에게 같은가? 얼마나 자주 바뀌는가? → 같고 드물게 바뀌면 SSG(필요하면 ISR).
- 사용자마다 다르거나 실시간이어야 하는가? → SSR, 또는 정적 골격 + 클라이언트에서 개인화 부분만 채우기.
- 상호작용이 적은 콘텐츠 페이지인가? → 정적 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 로, 개인화 부분은 클라이언트에서 따로 불러오는 식으로 나눈다.
확인 문제
- CSR, SSR, SSG 각각에서 HTML 이 만들어지는 시점은?
- 하이드레이션이란 무엇이며, 왜 필요한가.
- 하이드레이션 불일치를 일으키는 코드의 예 두 가지를 들라.
- 로그인 뒤에만 쓰는 사내 관리 도구에 CSR 이 무난한 이유는?
- 상품 가격이 하루 몇 번 바뀌는 쇼핑몰 상품 상세 페이지에 어울리는 방식과 그 이유는?
풀이
- CSR 은 브라우저에서 JS 가 실행될 때, SSR 은 요청을 받을 때마다 서버에서, SSG 는 배포 전 빌드할 때.
- 서버에서 만든 정적 HTML 에 클라이언트 JS 가 컴포넌트를 연결하고 이벤트 리스너를 붙여 상호작용 가능하게 만드는 과정이다. HTML 만으로는 동작이 없기 때문에 필요하다.
- 렌더 중에 현재 시각이나 난수를 출력하는 코드,
window·localStorage값에 따라 다른 결과를 렌더링하는 코드. 서버와 브라우저의 로캘·시간대 차이도 원인이다. - 검색 노출이 필요 없고, 사용자가 한 번 받은 앱을 오래 쓰며, 내용이 모두 개인화·실시간이라 서버 렌더링의 이점이 작기 때문이다.
- SSG 에 주기적·요청 기반 재생성(ISR)을 더하거나, 정적 골격에 가격·재고만 클라이언트에서 불러오는 방식. 대부분의 내용은 모두에게 같아 캐시 이득이 크고, 바뀌는 부분만 최신으로 유지할 수 있다.
더 읽을거리 (References)
- web.dev, Rendering on the Web: https://web.dev/articles/rendering-on-the-web
- React, hydrateRoot: https://react.dev/reference/react-dom/client/hydrateRoot
- Next.js, Server and Client Components: https://nextjs.org/docs/app/getting-started/server-and-client-components