なぜサーバで描くのか
一言でいうと
SSRは最初のレスポンスのバイトの中に内容が入っているようにすることです。その代償として文字列を組み立てるコードが生まれ、そこからXSSとハイドレーションの不一致が出てきます。
何が違うのか
クライアントレンダリング(CSR)の最初のレスポンスは次のような形です。
<body><div id="root"></div><script src="/app.js"></script></body>
内容がありません。ブラウザーがJSを受け取って実行し、APIを呼び出して、ようやく文字が表示されます。その間、ユーザーは空の画面を見ています。そして、そのHTMLを読むのは人間だけではありません。
- 検索エンジンとリンクプレビュー: JavaScriptを実行しないクローラーが多くあります。OGタグと本文が最初のHTMLになければ、ないのと同じです
- 低速な端末・ネットワーク: JSバンドルが届いて実行されるまでが、すべて空の画面です
- JSが失敗すると: 何も表示されません
SSRは最初のレスポンスに完成したHTMLを入れます。そのあとでJavaScriptが届き、すでにあるDOMにイベントだけを取り付けます。これがハイドレーションです。
描き直すのではなく、すでに描かれたものに取っ手を付けることです。
生じる問題1: XSS
サーバーレンダリングは結局、文字列の連結です。
`<h1>${user.name}</h1>`
user.nameが<img src=x onerror=alert(1)>ならそのまま実行されます。フロントエンドのフレームワークが代わりに防いでくれていたことを、自分で行わなければなりません。
HTML本文に入れるときに、必ず置き換えなければならない5文字です。
& → & (먼저 바꿔야 한다)
< → <
> → >
" → "
' → '
&を先に置き換えないと、すでに作った<が&lt;に再び壊れてしまいます。順序が規則の一部です。
そして場所ごとに規則が違います。HTML本文、属性値、URL、JavaScript文字列、CSSがすべて異なるエスケープを要求します。1つですべてまかなえる関数はありません。
問題2: 初期状態を埋め込むとき
サーバーがすでにデータを取得したので、クライアントが再度呼び出す必要はありません。そこでHTMLに埋め込みます。
<script>window.__STATE__ = {"name":"...</script><script>alert(1)</script>"}</script>
データの中に</script>が入っていると、そこでスクリプトタグが終わります。その後ろは新しいスクリプトです。JSONだから安全そうに見えますが、まったく安全ではありません。
防ぐ方法です。
JSON.stringify(state).replace(/</g, '\u003C')
<をUnicodeエスケープに置き換えると、JSONの値はそのままで、タグが途切れません。<!--と<scriptも一緒に防げます。
より安全な方法は、実行されないタグに入れることです。
<script type="application/json" id="state">{...}</script>
typeがapplication/jsonなら、ブラウザーは実行しません。クライアントではJSON.parse(el.textContent)で読みます。それでも</script>のエスケープは依然として必要です。
問題3: ハイドレーションの不一致
最も見つけにくい種類です。
サーバーが作ったHTMLとクライアントが最初に作ったHTMLが、違っていなければならない理由はありません。違えばフレームワークは警告を出し、最悪の場合はその部分をまるごと描き直します。
原因はほとんどの場合、次の4つのどれかです。
- 時刻:
new Date().toLocaleString()。サーバーで描いた時刻とクライアントが描いた時刻が違います - 乱数:
Math.random()で作ったid - ロケール・タイムゾーン: サーバーはUTC、ブラウザーはKSTです
- ブラウザー専用の値:
window.innerWidth、localStorage
レンダー関数は純粋でなければなりません。同じ入力なら同じ出力です。
時刻や乱数が必要なら、サーバーで作って状態に入れて送ります。レンダー関数の中では呼び出しません。このラボのステップ5で、採点ツールがDate.nowとMath.randomを差し替えてから2回レンダリングして比較します。レンダリングの中でそれらを呼び出していると、すぐに発覚します。
ストリーミングSSR
ページ全体を作り終えてから送ると、最も遅いデータ1つが全体を足止めします。
[셸: <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
2つの韓国語コメントは、順に、匿名で同一内容のページと、ログインユーザーのページを述べています。
デフォルトをprivate, no-storeにして、公開してよいものだけを選んで開く方が安全です。逆にすると、いつか1つが漏れ出します。
SSRにしないのはいつか
すべてをSSRにする必要はありません。
- 静的生成(SSG): 内容があまり変わらないなら、ビルド時に作っておきます。最も安くて速い方法です
- CSR: ログイン後のダッシュボードのように、SEOが不要で操作が多い画面に向いています
- SSR: 内容がリクエストごとに変わり、かつ最初のHTMLに入っている必要がある画面に向いています
SSRはサーバーコストを使います。リクエストのたびにレンダリングが走ります。キャッシュがなければ、トラフィックがそのままCPUになります。選ぶ基準は「速いか」ではなく、最初のHTMLに内容が必要かどうかです。
現場で: SSRを有効にする前に決める3つのこと
導入する日に一緒に決めておかないと、あとで必ずツケが回ってくるものです。
- キャッシュキー: レスポンスがユーザーごとに違うか。違うなら
private, no-store、全員同じならs-maxageにします。迷ったら閉じておいて始めます - サーバーにないAPI:
window・localStorage・documentを参照するコードがサーバーバンドルに混ざると、ページが丸ごと落ちます。エントリーポイントで一度ふるいにかけておき、CIで確認します - データが遅いとき: ページ全体を足止めするのか、その部分だけ空けておいてクライアントで埋めるのかを決めます
3つともコードではなく決定です。あらかじめ決めておかないと、障害が起きた日にその場で決めることになります。