LabHub

SSR — 서버가 먼저 그린다 · 문자열과 그 함정 · 이론

왜 서버에서 그리나

LabHub 에서 이어서 보기

한 줄 요약

SSR 은 첫 응답 바이트 안에 내용이 들어 있게 하는 일이다. 그 대가로 문자열을 만드는 코드가 생기고, 거기서 XSS 와 하이드레이션 불일치가 나온다.

무엇이 다른가

클라이언트 렌더링(CSR)의 첫 응답은 이렇게 생겼다.

<body><div id="root"></div><script src="/app.js"></script></body>

내용이 없다. 브라우저가 JS 를 받고, 실행하고, API 를 부르고, 그제야 글자가 나온다. 그동안 사용자는 빈 화면을 본다. 그리고 그 HTML 을 읽는 것은 사람만이 아니다.

SSR 은 첫 응답에 완성된 HTML 을 담는다. 그다음 자바스크립트가 도착해 이미 있는 DOM 에 이벤트만 붙인다 — 이게 하이드레이션이다.

> 다시 그리는 것이 아니라, 이미 그려진 것에 손잡이를 다는 것이다.

그래서 생기는 문제 1 — XSS

서버 렌더링은 결국 문자열 이어 붙이기다.

`<h1>${user.name}</h1>`

user.name<img src=x onerror=alert(1)> 이면 그대로 실행된다. 프런트엔드 프레임워크가 대신 막아 주던 것을 직접 해야 한다.

HTML 본문에 넣을 때 반드시 바꿔야 하는 다섯 글자.

&  →  &amp;      (먼저 바꿔야 한다)<  →  &lt;>  →  &gt;"  →  &quot;'  →  &#39;

& 를 먼저 바꾸지 않으면 이미 만든 &lt;&amp;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>

typeapplication/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.nowMath.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 할 필요는 없다.

SSR 은 서버 비용을 쓴다. 요청마다 렌더가 돈다. 캐시가 없으면 트래픽이 그대로 CPU 다. 고르는 기준은 "빠른가" 가 아니라 "첫 HTML 에 내용이 있어야 하는가" 다.

현장에서 — SSR 을 켜기 전에 정할 것 셋

붙이는 날 함께 정해 두지 않으면 나중에 반드시 물리는 것들이다.

셋 다 코드가 아니라 결정이다. 미리 정하지 않으면 장애가 난 날 즉석에서 정하게 된다.