SSR — 서버가 먼저 그린다 · 문자열과 그 함정 · 이론
왜 서버에서 그리나
한 줄 요약
SSR 은 첫 응답 바이트 안에 내용이 들어 있게 하는 일이다. 그 대가로 문자열을 만드는 코드가 생기고, 거기서 XSS 와 하이드레이션 불일치가 나온다.
무엇이 다른가
클라이언트 렌더링(CSR)의 첫 응답은 이렇게 생겼다.
<body><div id="root"></div><script src="/app.js"></script></body>내용이 없다. 브라우저가 JS 를 받고, 실행하고, API 를 부르고, 그제야 글자가 나온다. 그동안 사용자는 빈 화면을 본다. 그리고 그 HTML 을 읽는 것은 사람만이 아니다.
- 검색 엔진과 링크 미리보기 — 자바스크립트를 실행하지 않는 크롤러가 많다. OG 태그와 본문이 첫 HTML 에 없으면 없는 것이다
- 느린 기기·네트워크 — JS 번들이 도착해 실행될 때까지가 전부 빈 화면이다
- JS 가 실패하면 — 아무것도 안 나온다
SSR 은 첫 응답에 완성된 HTML 을 담는다. 그다음 자바스크립트가 도착해 이미 있는 DOM 에 이벤트만 붙인다 — 이게 하이드레이션이다.
> 다시 그리는 것이 아니라, 이미 그려진 것에 손잡이를 다는 것이다.
그래서 생기는 문제 1 — XSS
서버 렌더링은 결국 문자열 이어 붙이기다.
`<h1>${user.name}</h1>`user.name 이 <img src=x onerror=alert(1)> 이면 그대로 실행된다. 프런트엔드 프레임워크가 대신 막아 주던 것을 직접 해야 한다.
HTML 본문에 넣을 때 반드시 바꿔야 하는 다섯 글자.
& → & (먼저 바꿔야 한다)< → <> → >" → "' → '& 를 먼저 바꾸지 않으면 이미 만든 < 가 &lt; 로 다시 망가진다. 순서가 규칙의 일부다.
그리고 자리마다 규칙이 다르다. HTML 본문, 속성값, URL, 자바스크립트 문자열, CSS 가 전부 다른 이스케이프를 요구한다. 하나로 다 되는 함수는 없다.
문제 2 — 초기 상태를 심을 때
서버가 이미 데이터를 가져왔으니 클라이언트가 다시 부를 필요가 없다. 그래서 HTML 에 심는다.
<script>window.__STATE__ = {"name":"...</script><script>alert(1)</script>"}</script>데이터 안에 </script> 가 들어 있으면 거기서 스크립트 태그가 끝난다. 그 뒤는 새로운 스크립트다. JSON 이라 안전할 것 같지만 전혀 아니다.
막는 방법.
JSON.stringify(state).replace(/</g, '\u003C')< 를 유니코드 이스케이프로 바꾸면 JSON 값은 그대로이면서 태그가 끊기지 않는다. <!-- 와 <script 도 함께 막힌다.
더 안전한 방법은 실행되지 않는 태그에 담는 것이다.
<script type="application/json" id="state">{...}</script>type 이 application/json 이면 브라우저가 실행하지 않는다. 클라이언트에서 JSON.parse(el.textContent) 로 읽는다. 그래도 </script> 이스케이프는 여전히 필요하다.
문제 3 — 하이드레이션 불일치
가장 찾기 어려운 종류다.
서버가 만든 HTML 과 클라이언트가 처음 만든 HTML 이 달라야 할 이유가 없다. 다르면 프레임워크는 경고를 내고, 최악의 경우 그 부분을 통째로 다시 그린다.
원인은 거의 항상 넷 중 하나다.
1. 시간 — new Date().toLocaleString(). 서버에서 그린 시각과 클라이언트가 그린 시각이 다르다
2. 난수 — Math.random() 으로 만든 id
3. 로캘·시간대 — 서버는 UTC, 브라우저는 KST
4. 브라우저 전용 값 — window.innerWidth, localStorage
> 렌더 함수는 순수해야 한다. 같은 입력이면 같은 출력.
시간이나 난수가 필요하면 서버에서 만들어 상태에 담아 내려보낸다. 렌더 함수 안에서 부르지 않는다. 이 실습의 5단계에서 채점기가 Date.now 와 Math.random 을 바꿔치기한 뒤 두 번 렌더해 비교한다 — 렌더 안에서 그것들을 부르면 즉시 드러난다.
스트리밍 SSR
전체 페이지를 다 만들고 보내면, 가장 느린 데이터 하나가 전부를 붙잡는다.
[셸: <head>, 스타일, 상단바] ← 즉시 보낼 수 있다[본문: DB 조회 300ms] ← 이것 때문에 셸까지 늦어진다스트리밍은 셸을 먼저 흘려보내고, 준비된 조각을 이어 보낸다. 브라우저는 받는 대로 파싱하므로 CSS 와 폰트를 훨씬 일찍 받기 시작한다.
이게 React 의 renderToPipeableStream 과 <Suspense> 가 하는 일이다. 앞의 SSE 실습에서 본 것과 같은 원리이고, 막는 것도 같다 — 프록시 버퍼링과 gzip.
캐시 — SSR 에서 제일 위험한 곳
SSR 페이지에는 개인화된 내용이 섞이기 쉽다. 로그인 이름, 장바구니 개수, 권한에 따라 다른 메뉴.
그 응답에 Cache-Control: public 이 붙으면 CDN 이 A 의 페이지를 B 에게 준다. 실제로 일어나는 사고이고, 발견될 때는 이미 늦다.
익명·동일한 페이지 Cache-Control: public, s-maxage=60, stale-while-revalidate=300로그인 사용자 페이지 Cache-Control: private, no-store기본값을 private, no-store 로 두고, 공개해도 되는 것만 골라서 여는 편이 안전하다. 반대로 하면 언젠가 하나가 새어 나간다.
언제 SSR 이 아닌가
전부 SSR 할 필요는 없다.
- 정적 생성(SSG) — 내용이 자주 안 바뀌면 빌드 때 만들어 둔다. 가장 싸고 빠르다
- CSR — 로그인 후 대시보드처럼 SEO 가 필요 없고 상호작용이 많은 화면
- SSR — 내용이 매 요청 달라지면서 첫 HTML 에 들어 있어야 하는 화면
SSR 은 서버 비용을 쓴다. 요청마다 렌더가 돈다. 캐시가 없으면 트래픽이 그대로 CPU 다. 고르는 기준은 "빠른가" 가 아니라 "첫 HTML 에 내용이 있어야 하는가" 다.
현장에서 — SSR 을 켜기 전에 정할 것 셋
붙이는 날 함께 정해 두지 않으면 나중에 반드시 물리는 것들이다.
- 캐시 키 — 응답이 사용자마다 다른가. 다르면
private, no-store, 모두에게 같으면s-maxage. 애매하면 닫아 두고 시작한다 - 서버에 없는 API —
window·localStorage·document를 참조하는 코드가 서버 번들에 섞이면 페이지가 통째로 죽는다. 진입점에서 한 번 걸러 두고 CI 에서 확인한다 - 데이터가 늦을 때 — 페이지 전체를 붙잡을 것인가, 그 부분만 비우고 클라이언트에서 채울 것인가
셋 다 코드가 아니라 결정이다. 미리 정하지 않으면 장애가 난 날 즉석에서 정하게 된다.