renderToPipeableStream์€ React ํŠธ๋ฆฌ๋ฅผ ํŒŒ์ดํ”„ ๊ฐ€๋Šฅํ•œ Node.js ์ŠคํŠธ๋ฆผ์œผ๋กœ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

const { pipe, abort } = renderToPipeableStream(reactNode, options?)

์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค!

์ด API๋Š” Node.js ์ „์šฉ์ž…๋‹ˆ๋‹ค. Deno ๋ฐ ์ตœ์‹  ์—ฃ์ง€ ๋Ÿฐํƒ€์ž„๊ณผ ๊ฐ™์€ Web ์ŠคํŠธ๋ฆผ์ด ์žˆ๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ๋Œ€์‹  renderToReadableStream์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.


๋ ˆํผ๋Ÿฐ์Šค

renderToPipeableStream(reactNode, options?)

renderToPipeableStream์„ ํ˜ธ์ถœํ•˜์—ฌ React ํŠธ๋ฆฌ๋ฅผ HTML๋กœ Node.js ์ŠคํŠธ๋ฆผ์— ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

import { renderToPipeableStream } from 'react-dom/server';

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});

ํด๋ผ์ด์–ธํŠธ์—์„œ hydrateRoot๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์„œ๋ฒ„์—์„œ ์ƒ์„ฑ๋œ HTML์„ ์ƒํ˜ธ์ž‘์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ญ๋‹ˆ๋‹ค.

์•„๋ž˜์—์„œ ๋” ๋งŽ์€ ์˜ˆ์‹œ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.

๋งค๊ฐœ๋ณ€์ˆ˜

  • reactNode: HTML๋กœ ๋ Œ๋”๋งํ•˜๋ ค๋Š” React ๋…ธ๋“œ. ์˜ˆ๋ฅผ ๋“ค์–ด, <App />๊ณผ ๊ฐ™์€ JSX ์—˜๋ฆฌ๋จผํŠธ์ž…๋‹ˆ๋‹ค. ์ „์ฒด ๋ฌธ์„œ๋ฅผ ๋‚˜ํƒ€๋‚ผ ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋˜๋ฏ€๋กœ App ์ปดํฌ๋„ŒํŠธ๋Š” <html> ํƒœ๊ทธ๋ฅผ ๋ Œ๋”๋งํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • ์„ ํƒ ์‚ฌํ•ญ options: ์ŠคํŠธ๋ฆฌ๋ฐ ์˜ต์…˜์ด ์žˆ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค.

    • ์„ ํƒ ์‚ฌํ•ญ bootstrapScriptContent: ์ง€์ •ํ•˜๋ฉด ์ด ๋ฌธ์ž์—ด์ด ์ธ๋ผ์ธ <script> ํƒœ๊ทธ์— ๋ฐฐ์น˜๋ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ bootstrapScripts: ํŽ˜์ด์ง€์— ํ‘œ์‹œํ•  <script> ํƒœ๊ทธ์— ๋Œ€ํ•œ ๋ฌธ์ž์—ด URL ๋ฐฐ์—ด์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ hydrateRoot๋ฅผ ํ˜ธ์ถœํ•˜๋Š” <script>๋ฅผ ํฌํ•จํ•˜์„ธ์š”. ํด๋ผ์ด์–ธํŠธ์—์„œ React๋ฅผ ์ „ํ˜€ ์‹คํ–‰ํ•˜์ง€ ์•Š์œผ๋ ค๋ฉด ์ƒ๋žตํ•˜์„ธ์š”.
    • ์„ ํƒ ์‚ฌํ•ญ bootstrapModules: bootstrapScripts์™€ ๊ฐ™์ง€๋งŒ ๋Œ€์‹  <script type="module">๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ identifierPrefix: React๊ฐ€ useId์— ์˜ํ•ด ์ƒ์„ฑ๋œ ID์— ์‚ฌ์šฉํ•˜๋Š” ๋ฌธ์ž์—ด ์ ‘๋‘์‚ฌ์ž…๋‹ˆ๋‹ค. ๊ฐ™์€ ํŽ˜์ด์ง€์—์„œ ์—ฌ๋Ÿฌ ๋ฃจํŠธ๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ์ถฉ๋Œ์„ ํ”ผํ•˜๋Š” ๋ฐ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค. hydrateRoot์— ์ „๋‹ฌ๋œ ๊ฒƒ๊ณผ ๋™์ผํ•œ ์ ‘๋‘์‚ฌ์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ namespaceURI: ์ŠคํŠธ๋ฆผ์˜ ๋ฃจํŠธ ๋„ค์ž„์ŠคํŽ˜์ด์Šค URI๊ฐ€ ํฌํ•จ๋œ ๋ฌธ์ž์—ด์ž…๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ ์ผ๋ฐ˜ HTML์ž…๋‹ˆ๋‹ค. SVG์˜ ๊ฒฝ์šฐ 'http://www.w3.org/2000/svg'๋ฅผ, MathML์˜ ๊ฒฝ์šฐ 'http://www.w3.org/1998/Math/MathML'๋ฅผ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ nonce: script-src Content-Security-Policy์— ๋Œ€ํ•œ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ํ—ˆ์šฉํ•˜๋Š” nonce ๋ฌธ์ž์—ด์ž…๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ onAllReady: ์…ธ๊ณผ ๋ชจ๋“  ์ถ”๊ฐ€ ์ฝ˜ํ…์ธ ๋ฅผ ํฌํ•จํ•˜์—ฌ ๋ชจ๋“  ๋ Œ๋”๋ง์ด ์™„๋ฃŒ๋˜๋ฉด ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ์ž…๋‹ˆ๋‹ค. ํฌ๋กค๋Ÿฌ ๋ฐ ์ •์  ์ƒ์„ฑ์— onShellReady ๋Œ€์‹  ์ด ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•˜๋ฉด ํ”„๋กœ๊ทธ๋ ˆ์‹œ๋ธŒ ๋กœ๋”ฉ์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ŠคํŠธ๋ฆผ์—๋Š” ์ตœ์ข… HTML์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ onError: ๋ณต๊ตฌ ๊ฐ€๋Šฅ ๋˜๋Š” ๋ถˆ๊ฐ€๋Šฅ์— ๊ด€๊ณ„์—†์ด ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ๋•Œ๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ์ž…๋‹ˆ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ console.error๋งŒ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. ์ด ํ•จ์ˆ˜๋ฅผ ์žฌ์ •์˜ํ•˜์—ฌ ํฌ๋ž˜์‹œ ๋ฆฌํฌํŠธ๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฒฝ์šฐ console.error๋ฅผ ๊ณ„์† ํ˜ธ์ถœํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์…ธ์ด ์ถœ๋ ฅ๋˜๊ธฐ ์ „์— ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์กฐ์ •ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ onShellReady: ์ดˆ๊ธฐ ์…ธ์ด ๋ Œ๋”๋ง๋œ ์งํ›„์— ์‹คํ–‰๋˜๋Š” ์ฝœ๋ฐฑ์ž…๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•˜๊ณ  pipe๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. React๋Š” HTML ๋กœ๋”ฉ ํด๋ฐฑ์„ ์ฝ˜ํ…์ธ ๋กœ ๋Œ€์ฒดํ•˜๋Š” ์ธ๋ผ์ธ <script> ํƒœ๊ทธ์™€ ํ•จ๊ป˜ ์…ธ ๋’ค์— ์ถ”๊ฐ€ ์ฝ˜ํ…์ธ ๋ฅผ ์ŠคํŠธ๋ฆฌ๋ฐํ•ฉ๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ onShellError: ์ดˆ๊ธฐ ์…ธ์„ ๋ Œ๋”๋งํ•˜๋Š” ๋ฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ์ž…๋‹ˆ๋‹ค. ์˜ค๋ฅ˜๋ฅผ ์ธ์ž๋กœ ๋ฐ›์Šต๋‹ˆ๋‹ค. ์ŠคํŠธ๋ฆผ์—์„œ ์•„์ง ๋ฐ”์ดํŠธ๊ฐ€ ์ „์†ก๋˜์ง€ ์•Š์•˜๊ณ , onShellReady๋‚˜ onAllReady๋„ ํ˜ธ์ถœ๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ ํด๋ฐฑ HTML ์…ธ์„ ์ถœ๋ ฅ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
    • ์„ ํƒ ์‚ฌํ•ญ progressiveChunkSize: ์ฒญํฌ์˜ ๋ฐ”์ดํŠธ ์ˆ˜์ž…๋‹ˆ๋‹ค. ๊ธฐ๋ณธ ํœด๋ฆฌ์Šคํ‹ฑ์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ์•„๋ณด์„ธ์š”.

๋ฐ˜ํ™˜๊ฐ’

renderToPipeableStream์€ ๋‘ ๊ฐœ์˜ ๋ฉ”์„œ๋“œ๊ฐ€ ์žˆ๋Š” ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

  • pipe๋Š” HTML์„ ์ œ๊ณต๋œ ์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•œ Node.js ์ŠคํŠธ๋ฆผ์œผ๋กœ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์ŠคํŠธ๋ฆฌ๋ฐ์„ ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด onShellReady์—์„œ, ํฌ๋กค๋Ÿฌ์™€ ์ •์  ์ƒ์„ฑ์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด onAllReady์—์„œ pipe๋ฅผ ํ˜ธ์ถœํ•˜์„ธ์š”.
  • abort๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์„œ๋ฒ„ ๋ Œ๋”๋ง์„ ์ค‘๋‹จํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์‚ฌ์šฉ๋ฒ•

React ํŠธ๋ฆฌ๋ฅผ HTML๋กœ Node.js ์ŠคํŠธ๋ฆผ์— ๋ Œ๋”๋งํ•˜๊ธฐ

renderToPipeableStream์„ ํ˜ธ์ถœํ•˜์—ฌ React ํŠธ๋ฆฌ๋ฅผ HTML๋กœ Node.js ์ŠคํŠธ๋ฆผ์— ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

import { renderToPipeableStream } from 'react-dom/server';

// ๊ฒฝ๋กœ ํ•ธ๋“ค๋Ÿฌ ๋ฌธ๋ฒ•์€ ๋ฐฑ์—”๋“œ ํ”„๋ ˆ์ž„์›Œํฌ์— ๋”ฐ๋ผ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.
app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});

๋ฃจํŠธ ์ปดํฌ๋„ŒํŠธ์™€ ํ•จ๊ป˜ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ <script> ๊ฒฝ๋กœ ๋ชฉ๋ก์„ ์ œ๊ณตํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฃจํŠธ ์ปดํฌ๋„ŒํŠธ๋Š” ๋ฃจํŠธ <html> ํƒœ๊ทธ๋ฅผ ํฌํ•จํ•œ ์ „์ฒด ๋ฌธ์„œ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ‘œ์‹œ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css"></link>
<title>My app</title>
</head>
<body>
<Router />
</body>
</html>
);
}

React๋Š” doctype๊ณผ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ <script> ํƒœ๊ทธ๋ฅผ ๊ฒฐ๊ณผ HTML ์ŠคํŠธ๋ฆผ์— ์‚ฝ์ž…ํ•ฉ๋‹ˆ๋‹ค.

<!DOCTYPE html>
<html>
<!-- ... ์ปดํฌ๋„ŒํŠธ์˜ HTML ... -->
</html>
<script src="/main.js" async=""></script>

ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์Šคํฌ๋ฆฝํŠธ๋Š” hydrateRoot๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์ „์ฒด document๋ฅผ ํ•˜์ด๋“œ๋ ˆ์ดํŠธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(document, <App />);

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์„œ๋ฒ„์—์„œ ์ƒ์„ฑ๋œ HTML์— ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๊ฐ€ ์ฒจ๋ถ€๋˜์–ด ์ƒํ˜ธ์ž‘์šฉ์ด ๊ฐ€๋Šฅํ•ด์ง‘๋‹ˆ๋‹ค.

Deep Dive

๋นŒ๋“œ ์ถœ๋ ฅ์—์„œ CSS ๋ฐ JS ์—์…‹ ๊ฒฝ๋กœ ์ฝ๊ธฐ

์ตœ์ข… ์—์…‹ URL(์˜ˆ: ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ ๋ฐ CSS ํŒŒ์ผ)์€ ๋นŒ๋“œ ํ›„์— ํ•ด์‹œ ์ฒ˜๋ฆฌ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด styles.css ๋Œ€์‹  styles.123456.css๋กœ ๋๋‚  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ •์  ์—์…‹ ํŒŒ์ผ๋ช…์„ ํ•ด์‹œํ•˜๋ฉด ๋™์ผํ•œ ์—์…‹์˜ ๋ชจ๋“  ๋ณ„๊ฐœ์˜ ๋นŒ๋“œ์—์„œ ๋‹ค๋ฅธ ํŒŒ์ผ๋ช…์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์ •์  ์ž์‚ฐ์— ๋Œ€ํ•œ ์žฅ๊ธฐ ์บ์‹ฑ์„ ์•ˆ์ „ํ•˜๊ฒŒ ํ™œ์„ฑํ™”ํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค. ํŠน์ • ์ด๋ฆ„์„ ๊ฐ€์ง„ ํŒŒ์ผ์€ ์ฝ˜ํ…์ธ ๊ฐ€ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ ๋นŒ๋“œ๊ฐ€ ๋๋‚  ๋•Œ๊นŒ์ง€ ์—์…‹ URL์„ ๋ชจ๋ฅด๋Š” ๊ฒฝ์šฐ ์†Œ์Šค ์ฝ”๋“œ์— ๋„ฃ์„ ๋ฐฉ๋ฒ•์ด ์—†์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ์•ž์„œ์ฒ˜๋Ÿผ "/styles.css"๋ฅผ JSX์— ํ•˜๋“œ์ฝ”๋”ฉํ•˜๋ฉด ์ž‘๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์†Œ์Šค ์ฝ”๋“œ์— ํฌํ•จ๋˜์ง€ ์•Š๋„๋ก ํ•˜๋ ค๋ฉด ๋ฃจํŠธ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ํ”„๋กœํผํ‹ฐ๋กœ ์ „๋‹ฌ๋œ ๋งต์—์„œ ์‹ค์ œ ํŒŒ์ผ๋ช…์„ ์ฝ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

export default function App({ assetMap }) {
return (
<html>
<head>
...
<link rel="stylesheet" href={assetMap['styles.css']}></link>
...
</head>
...
</html>
);
}

์„œ๋ฒ„์—์„œ <App assetMap={assetMap} />๋ฅผ ๋ Œ๋”๋งํ•˜๊ณ  ์—์…‹ URL๊ณผ ํ•จ๊ป˜ assetMap์„ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

// ๋นŒ๋“œ ๋„๊ตฌ์—์„œ ์ด JSON์„ ๊ฐ€์ ธ์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค(์˜ˆ: ๋นŒ๋“œ ์ถœ๋ ฅ์—์„œ ์ฝ์–ด์˜ค๊ธฐ).
const assetMap = {
'styles.css': '/styles.123456.css',
'main.js': '/main.123456.js'
};

app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
bootstrapScripts: [assetMap['main.js']],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});

์ด์ œ ์„œ๋ฒ„์—์„œ <App assetMap={assetMap} />๋ฅผ ๋ Œ๋”๋งํ•˜๊ณ  ์žˆ์œผ๋ฏ€๋กœ ํด๋ผ์ด์–ธํŠธ์—์„œ๋„ assetMap์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ Œ๋”๋งํ•ด์•ผ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์˜ค๋ฅ˜๋ฅผ ๋ฐฉ์ง€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์ด assetMap์„ ์ง๋ ฌํ™”ํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ์— ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// ๋นŒ๋“œ ๋„๊ตฌ์—์„œ ์ด JSON์„ ๊ฐ€์ ธ์™€์•ผ ํ•ฉ๋‹ˆ๋‹ค.
const assetMap = {
'styles.css': '/styles.123456.css',
'main.js': '/main.123456.js'
};

app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
// ์กฐ์‹ฌํ•˜์„ธ์š”: ์ด ๋ฐ์ดํ„ฐ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑํ•œ ๊ฒƒ์ด ์•„๋‹ˆ๋ฏ€๋กœ stringify()ํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
bootstrapScripts: [assetMap['main.js']],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});

์œ„ ์˜ˆ์‹œ์—์„œ bootstrapScriptContent ์˜ต์…˜์€ ํด๋ผ์ด์–ธํŠธ์—์„œ ์ „์—ญ window.assetMap ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜๋Š” ์ถ”๊ฐ€ ์ธ๋ผ์ธ <script> ํƒœ๊ทธ๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๊ฐ€ ๋™์ผํ•œ assetMap์„ ์ฝ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(document, <App assetMap={window.assetMap} />);

ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„ ๋ชจ๋‘ ๋™์ผํ•œ assetMap ํ”„๋กœํผํ‹ฐ๋กœ App์„ ๋ Œ๋”๋งํ•˜๋ฏ€๋กœ ํ•˜์ด๋“œ๋ ˆ์ด์…˜ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.


์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋˜๋Š” ๋™์•ˆ ๋” ๋งŽ์€ ์ฝ˜ํ…์ธ  ์ŠคํŠธ๋ฆฌ๋ฐํ•˜๊ธฐ

์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‚ฌ์šฉํ•˜๋ฉด ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์„œ๋ฒ„์— ๋กœ๋“œ๋˜๊ธฐ ์ „์—๋„ ์‚ฌ์šฉ์ž๊ฐ€ ์ฝ˜ํ…์ธ ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ํ‘œ์ง€์™€ ์นœ๊ตฌ ๋ฐ ์‚ฌ์ง„์ด ์žˆ๋Š” ์‚ฌ์ด๋“œ๋ฐ”, ๊ธ€ ๋ชฉ๋ก์ด ํ‘œ์‹œ๋˜๋Š” ํ”„๋กœํ•„ ํŽ˜์ด์ง€๋ฅผ ์ƒ๊ฐํ•ด ๋ณด์„ธ์š”.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Posts />
</ProfileLayout>
);
}

<Posts />์— ๋Œ€ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๋Š” ๋ฐ ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฐ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ์ด์ƒ์ ์œผ๋กœ๋Š” ๊ฒŒ์‹œ๋ฌผ์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  ๋‚˜๋จธ์ง€ ํ”„๋กœํ•„ ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ ๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ‘œ์‹œํ•˜๊ณ  ์‹ถ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ ค๋ฉด, <Posts>๋ฅผ <Suspense> ๊ฒฝ๊ณ„๋กœ ๊ฐ์‹ธ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}

์ด๊ฒƒ์€ Posts๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๊ธฐ ์ „์— React๊ฐ€ HTML ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•˜๋„๋ก ์ง€์‹œํ•ฉ๋‹ˆ๋‹ค. React๋Š” ๋กœ๋”ฉ ํด๋ฐฑ(PostsGlimmer)์„ ์œ„ํ•œ HTML์„ ๋จผ์ € ์ „์†กํ•œ ๋‹ค์Œ, Posts๊ฐ€ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ์„ ์™„๋ฃŒํ•˜๋ฉด ๋‚˜๋จธ์ง€ HTML์„ ์ธ๋ผ์ธ <script> ํƒœ๊ทธ์™€ ํ•จ๊ป˜ ์ „์†กํ•˜์—ฌ ๋กœ๋”ฉ ํด๋ฐฑ์„ ํ•ด๋‹น HTML๋กœ ๋Œ€์ฒดํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ์ž…์žฅ์—์„œ๋Š” ํŽ˜์ด์ง€๊ฐ€ ๋จผ์ € PostsGlimmer๋กœ ํ‘œ์‹œ๋˜๊ณ  ๋‚˜์ค‘์— Posts๋กœ ๋Œ€์ฒด๋ฉ๋‹ˆ๋‹ค.

<Suspense> ๊ฒฝ๊ณ„๋ฅผ ๋” ์ค‘์ฒฉํ•˜์—ฌ ๋ณด๋‹ค ์„ธ๋ถ„ํ™”๋œ ๋กœ๋”ฉ ์‹œํ€€์Šค๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}

์ด ์˜ˆ์ œ์—์„œ React๋Š” ํŽ˜์ด์ง€ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ๋” ์ผ์ฐ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ProfileLayout๊ณผ ProfileCover๋งŒ <Suspense> ๊ฒฝ๊ณ„๋กœ ๋‘˜๋Ÿฌ์‹ธ์—ฌ ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ๋จผ์ € ๋ Œ๋”๋ง์„ ์™„๋ฃŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ Sidebar, Friends, Photos์ด ์ผ๋ถ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•ด์•ผ ํ•˜๋Š” ๊ฒฝ์šฐ React๋Š” ๋Œ€์‹  BigSpinner ํด๋ฐฑ์„ ์œ„ํ•œ HTML์„ ์ „์†กํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜๋ฉด ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ํ‘œ์‹œ๋  ๋•Œ๊นŒ์ง€ ๋” ๋งŽ์€ ์ฝ˜ํ…์ธ ๊ฐ€ ๊ณ„์† ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

์ŠคํŠธ๋ฆฌ๋ฐ์€ ๋ธŒ๋ผ์šฐ์ €์—์„œ React ์ž์ฒด๊ฐ€ ๋กœ๋“œ๋˜๊ฑฐ๋‚˜ ์•ฑ์ด ์ƒํ˜ธ์ž‘์šฉ ๊ฐ€๋Šฅํ•ด์งˆ ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆด ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„์˜ HTML ์ฝ˜ํ…์ธ ๋Š” <script> ํƒœ๊ทธ๊ฐ€ ๋กœ๋“œ๋˜๊ธฐ ์ „์— ์ ์ง„์ ์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

์ŠคํŠธ๋ฆฌ๋ฐ HTML์˜ ์ž‘๋™ ๋ฐฉ์‹์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ์•„๋ณด์„ธ์š”.

์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค!

Suspense ๊ฐ€ ํ™œ์„ฑํ™”๋œ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋งŒ Suspense ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์—๋Š” ๋‹ค์Œ์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.

  • Relay์™€ Next.js ๊ฐ™์ด Suspense๊ฐ€ ๊ฐ€๋Šฅํ•œ ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
  • lazy๋ฅผ ํ™œ์šฉํ•œ ์ง€์—ฐ ๋กœ๋”ฉ ์ปดํฌ๋„ŒํŠธ
  • use๋ฅผ ์‚ฌ์šฉํ•ด์„œ Promise ๊ฐ’ ์ฝ๊ธฐ

Suspense ๋Š” ์ดํŽ™ํŠธ ๋˜๋Š” ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ๋•Œ๋ฅผ ๊ฐ์ง€ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์œ„์˜ Posts ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๋Š” ์ •ํ™•ํ•œ ๋ฐฉ๋ฒ•์€ ํ”„๋ ˆ์ž„์›Œํฌ์— ๋”ฐ๋ผ ๋‹ค๋ฆ…๋‹ˆ๋‹ค. Suspense ์ง€์› ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ํ•ด๋‹น ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ ๋ฌธ์„œ์—์„œ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋…์ž์ ์ธ ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” Suspense ๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ๋Š” ์•„์ง ์ง€์›๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. Suspense ๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•œ ์š”๊ตฌ ์‚ฌํ•ญ์€ ๋ถˆ์•ˆ์ •ํ•˜๊ณ  ๋ฌธ์„œํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ Suspense ์™€ ํ†ตํ•ฉํ•˜๊ธฐ ์œ„ํ•œ ๊ณต์‹ API๋Š” ํ–ฅํ›„ React ๋ฒ„์ „์—์„œ ์ถœ์‹œ๋  ์˜ˆ์ •์ž…๋‹ˆ๋‹ค.


์…ธ์— ๋“ค์–ด๊ฐˆ ๋‚ด์šฉ ์ง€์ •ํ•˜๊ธฐ

์•ฑ์˜ <Suspense> ๊ฒฝ๊ณ„ ๋ฐ–์— ์žˆ๋Š” ๋ถ€๋ถ„์„ ์…ธ์ด๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}

์‚ฌ์šฉ์ž๊ฐ€ ๋ณผ ์ˆ˜ ์žˆ๋Š” ๊ฐ€์žฅ ๋น ๋ฅธ ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.

<ProfileLayout>
<ProfileCover />
<BigSpinner />
</ProfileLayout>

์ „์ฒด ์•ฑ์„ ๋ฃจํŠธ์˜ <Suspense> ๊ฒฝ๊ณ„๋กœ ๊ฐ์‹ธ๋ฉด ์…ธ์—๋Š” ํ•ด๋‹น ์Šคํ”ผ๋„ˆ๋งŒ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ํ™”๋ฉด์— ํฐ ์Šคํ”ผ๋„ˆ๊ฐ€ ํ‘œ์‹œ๋˜๋ฉด ์กฐ๊ธˆ ๋” ๊ธฐ๋‹ค๋ ธ๋‹ค๊ฐ€ ์‹ค์ œ ๋ ˆ์ด์•„์›ƒ์„ ๋ณด๋Š” ๊ฒƒ๋ณด๋‹ค ๋Š๋ฆฌ๊ณ  ์„ฑ๊ฐ€์‹œ๊ฒŒ ๋Š๊ปด์งˆ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์ด ์ข‹์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๊ทธ๋ ‡๊ธฐ ๋•Œ๋ฌธ์— ์ผ๋ฐ˜์ ์œผ๋กœ ์…ธ์ด ์ „์ฒด ํŽ˜์ด์ง€ ๋ ˆ์ด์•„์›ƒ์˜ ์Šค์ผˆ๋ ˆํ†ค์ฒ˜๋Ÿผ ์ตœ์†Œํ•œ์˜ ์™„์ „ํ•จ์„ ๋Š๋‚„ ์ˆ˜ ์žˆ๋„๋ก <Suspense> ๊ฒฝ๊ณ„๋ฅผ ๋ฐฐ์น˜ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

์ „์ฒด ์…ธ์ด ๋ Œ๋”๋ง๋˜๋ฉด onShellReady ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ๋ณดํ†ต ์ด๋•Œ ์ŠคํŠธ๋ฆฌ๋ฐ์ด ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});

onShellReady๊ฐ€ ์‹คํ–‰๋  ๋•Œ ์ค‘์ฒฉ๋œ <Suspense> ๊ฒฝ๊ณ„์— ์žˆ๋Š” ์ปดํฌ๋„ŒํŠธ๋Š” ์—ฌ์ „ํžˆ ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๊ณ  ์žˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


์„œ๋ฒ„์—์„œ ํฌ๋ž˜์‹œ ๋กœ๊น…ํ•˜๊ธฐ

๊ธฐ๋ณธ์ ์œผ๋กœ ์„œ๋ฒ„์˜ ๋ชจ๋“  ์˜ค๋ฅ˜๋Š” ์ฝ˜์†”์— ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค. ์ด ๋™์ž‘์„ ์žฌ์ •์˜ํ•˜์—ฌ ํฌ๋ž˜์‹œ ๋ณด๊ณ ์„œ๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
},
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});

์‚ฌ์šฉ์ž ์ •์˜ onError ๊ตฌํ˜„์„ ์ œ๊ณตํ•˜๋Š” ๊ฒฝ์šฐ ์œ„์™€ ๊ฐ™์ด ์ฝ˜์†”์— ์˜ค๋ฅ˜๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฒƒ๋„ ์žŠ์ง€ ๋งˆ์„ธ์š”.


์…ธ ๋‚ด๋ถ€์˜ ์˜ค๋ฅ˜๋กœ๋ถ€ํ„ฐ ๋ณต๊ตฌํ•˜๊ธฐ

์ด ์˜ˆ์ œ์—์„œ๋Š” ์…ธ์— ProfileLayout, ProfileCover, PostsGlimmer๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}

์ด๋Ÿฌํ•œ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ Œ๋”๋งํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด React๋Š” ํด๋ผ์ด์–ธํŠธ์— ๋ณด๋‚ผ ์˜๋ฏธ ์žˆ๋Š” HTML์„ ๊ฐ–์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์ง€๋ง‰ ์ˆ˜๋‹จ์œผ๋กœ ์„œ๋ฒ„ ๋ Œ๋”๋ง์— ์˜์กดํ•˜์ง€ ์•Š๋Š” ํด๋ฐฑ HTML์„ ๋ณด๋‚ด๋ ค๋ฉด onShellError๋ฅผ ์žฌ์ •์˜ํ•˜์„ธ์š”.

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
},
onShellError(error) {
response.statusCode = 500;
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});

์…ธ์„ ์ƒ์„ฑํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด onError์™€ onShellError๊ฐ€ ๋ชจ๋‘ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ์˜ค๋ฅ˜ ๋ณด๊ณ ์—๋Š” onError๋ฅผ ์‚ฌ์šฉํ•˜๊ณ , ๋Œ€์ฒด HTML ๋ฌธ์„œ๋ฅผ ๋ณด๋‚ด๋ ค๋ฉด onShellError๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ํด๋ฐฑ HTML์ด ์˜ค๋ฅ˜ ํŽ˜์ด์ง€์ผ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค. ๋Œ€์‹  ํด๋ผ์ด์–ธํŠธ์—์„œ๋งŒ ์•ฑ์„ ๋ Œ๋”๋งํ•˜๋Š” ๋Œ€์ฒด ์…ธ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


์…ธ ์™ธ๋ถ€์˜ ์˜ค๋ฅ˜๋กœ๋ถ€ํ„ฐ ๋ณต๊ตฌํ•˜๊ธฐ

์ด ์˜ˆ์ œ์—์„œ๋Š” <Posts /> ์ปดํฌ๋„ŒํŠธ๊ฐ€ <Suspense>๋กœ ๋ž˜ํ•‘๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ์…ธ์˜ ์ผ๋ถ€๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.

function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}

Posts ์ปดํฌ๋„ŒํŠธ ๋˜๋Š” ๊ทธ ๋‚ด๋ถ€ ์–ด๋”˜๊ฐ€์—์„œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด React๋Š” ์ด๋ฅผ ๋ณต๊ตฌํ•˜๋ ค๊ณ  ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.

  1. ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด <Suspense> ๊ฒฝ๊ณ„(PostsGlimmer)์— ๋Œ€ํ•œ ๋กœ๋”ฉ ํด๋ฐฑ์„ HTML๋กœ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค.
  2. ๋” ์ด์ƒ ์„œ๋ฒ„์—์„œ Posts ์ฝ˜ํ…์ธ ๋ฅผ ๋ Œ๋”๋งํ•˜๋Š” ๊ฒƒ์„ โ€œํฌ๊ธฐโ€ํ•ฉ๋‹ˆ๋‹ค.
  3. ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ ์ฝ”๋“œ๊ฐ€ ํด๋ผ์ด์–ธํŠธ์—์„œ ๋กœ๋“œ๋˜๋ฉด React๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ Posts ๋ Œ๋”๋ง์„ ์žฌ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.

ํด๋ผ์ด์–ธํŠธ์—์„œ Posts ๋ Œ๋”๋ง์„ ๋‹ค์‹œ ์‹œ๋„ํ•ด๋„ ์‹คํŒจํ•˜๋ฉด React๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ์—๋Ÿฌ๋ฅผ ๋˜์ง‘๋‹ˆ๋‹ค. ๋ Œ๋”๋ง ์ค‘์— ๋ฐœ์ƒํ•˜๋Š” ๋ชจ๋“  ์—๋Ÿฌ์™€ ๋งˆ์ฐฌ๊ฐ€์ง€๋กœ, ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๋ถ€๋ชจ ์—๋Ÿฌ ๊ฒฝ๊ณ„์— ๋”ฐ๋ผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์—๋Ÿฌ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ๊ฒฐ์ •๋ฉ๋‹ˆ๋‹ค. ์‹ค์ œ๋กœ๋Š” ์˜ค๋ฅ˜๋ฅผ ๋ณต๊ตฌํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๊ฒƒ์ด ํ™•์‹คํ•ด์งˆ ๋•Œ๊นŒ์ง€ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋กœ๋”ฉ ํ‘œ์‹œ๊ธฐ๊ฐ€ ํ‘œ์‹œ๋œ๋‹ค๋Š” ์˜๋ฏธ์ž…๋‹ˆ๋‹ค.

ํด๋ผ์ด์–ธํŠธ์—์„œ Posts ๋ Œ๋”๋ง์„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์—ฌ ์„ฑ๊ณตํ•˜๋ฉด ์„œ๋ฒ„์˜ ๋กœ๋”ฉ ํด๋ฐฑ์ด ํด๋ผ์ด์–ธํŠธ ๋ Œ๋”๋ง ์ถœ๋ ฅ์œผ๋กœ ๋Œ€์ฒด๋ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค๋Š” ์‚ฌ์‹ค์„ ์•Œ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์„œ๋ฒ„ onError ์ฝœ๋ฐฑ ๋ฐ ํด๋ผ์ด์–ธํŠธ onRecoverableError ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋˜์–ด ์˜ค๋ฅ˜์— ๋Œ€ํ•œ ์•Œ๋ฆผ์„ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


์ƒํƒœ ์ฝ”๋“œ ์„ค์ •ํ•˜๊ธฐ

์ŠคํŠธ๋ฆฌ๋ฐ์—๋Š” ์žฅ๋‹จ์ ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์ฝ˜ํ…์ธ ๋ฅผ ๋” ๋นจ๋ฆฌ ๋ณผ ์ˆ˜ ์žˆ๋„๋ก ๊ฐ€๋Šฅํ•œ ํ•œ ๋นจ๋ฆฌ ํŽ˜์ด์ง€ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•˜๊ณ  ์‹ถ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•˜๋ฉด ๋” ์ด์ƒ ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

์•ฑ์„ ์…ธ(ํŠนํžˆ <Suspense> ๊ฒฝ๊ณ„ ๋ฐ”๊นฅ)๊ณผ ๋‚˜๋จธ์ง€ ์ฝ˜ํ…์ธ ๋กœ ๋‚˜๋ˆ„๋ฉด ์ด ๋ฌธ์ œ์˜ ์ผ๋ถ€๋ฅผ ์ด๋ฏธ ํ•ด๊ฒฐํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์…ธ์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์˜ค๋ฅ˜ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋Š” onShellError ์ฝœ๋ฐฑ์„ ๋ฐ›๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ์•ฑ์ด ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ณต๊ตฌ๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ โ€œOKโ€๋ฅผ ๋ณด๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.statusCode = 200;
response.setHeader('content-type', 'text/html');
pipe(response);
},
onShellError(error) {
response.statusCode = 500;
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});

์…ธ ์™ธ๋ถ€(์ฆ‰, <Suspense> ๊ฒฝ๊ณ„ ์•ˆ์ชฝ)์— ์žˆ๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์—๋Ÿฌ๋ฅผ ๋˜์ ธ๋„ React๋Š” ๋ Œ๋”๋ง์„ ๋ฉˆ์ถ”์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ฆ‰, onError ์ฝœ๋ฐฑ์ด ์‹คํ–‰๋˜์ง€๋งŒ onShellError ๋Œ€์‹  onShellReady๊ฐ€ ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค. ์ด๋Š” ์œ„์—์„œ ์„ค๋ช…ํ•œ ๊ฒƒ์ฒ˜๋Ÿผ React๊ฐ€ ํด๋ผ์ด์–ธํŠธ์—์„œ ํ•ด๋‹น ์˜ค๋ฅ˜๋ฅผ ๋ณต๊ตฌํ•˜๋ ค๊ณ  ์‹œ๋„ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋‚˜ ์›ํ•˜๋Š” ๊ฒฝ์šฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค๋Š” ์‚ฌ์‹ค์„ ์‚ฌ์šฉํ•˜์—ฌ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

let didError = false;

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.statusCode = didError ? 500 : 200;
response.setHeader('content-type', 'text/html');
pipe(response);
},
onShellError(error) {
response.statusCode = 500;
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});

์ด๋Š” ์ดˆ๊ธฐ ์…ธ ์ฝ˜ํ…์ธ ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋™์•ˆ ๋ฐœ์ƒํ•œ ์…ธ ์™ธ๋ถ€์˜ ์˜ค๋ฅ˜๋งŒ ํฌ์ฐฉํ•˜๋ฏ€๋กœ ์™„์ „ํ•œ ๊ฒƒ์€ ์•„๋‹™๋‹ˆ๋‹ค. ์ผ๋ถ€ ์ฝ˜ํ…์ธ ์—์„œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋Š”์ง€ ์—ฌ๋ถ€๋ฅผ ํŒŒ์•…ํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•œ ๊ฒฝ์šฐ ํ•ด๋‹น ์ฝ˜ํ…์ธ ๋ฅผ ์…ธ๋กœ ์ด๋™ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.


๋‹ค์–‘ํ•œ ์˜ค๋ฅ˜๋ฅผ ์„œ๋กœ ๋‹ค๋ฅธ ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ธฐ

์ž์‹ ๋งŒ์˜ Error ์„œ๋ธŒ ํด๋ž˜์Šค๋ฅผ ์ƒ์„ฑํ•˜๊ณ  instanceof ์—ฐ์‚ฐ์ž๋ฅผ ์‚ฌ์šฉํ•ด ์–ด๋–ค ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์‚ฌ์šฉ์ž ์ •์˜ NotFoundError๋ฅผ ์ •์˜ํ•˜๊ณ  ์ปดํฌ๋„ŒํŠธ์—์„œ ์ด๋ฅผ ๋˜์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์˜ค๋ฅ˜ ์œ ํ˜•์— ๋”ฐ๋ผ onError, onShellReady, onShellError ์ฝœ๋ฐฑ์ด ๋‹ค๋ฅธ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

let didError = false;
let caughtError = null;

function getStatusCode() {
if (didError) {
if (caughtError instanceof NotFoundError) {
return 404;
} else {
return 500;
}
} else {
return 200;
}
}

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.statusCode = getStatusCode();
response.setHeader('content-type', 'text/html');
pipe(response);
},
onShellError(error) {
response.statusCode = getStatusCode();
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onError(error) {
didError = true;
caughtError = error;
console.error(error);
logServerCrashReport(error);
}
});

์…ธ์„ ๋‚ด๋ณด๋‚ด๊ณ  ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•˜๋ฉด ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋‹ค๋Š” ์ ์— ์œ ์˜ํ•˜์„ธ์š”.


ํฌ๋กค๋Ÿฌ ๋ฐ ์ •์  ์ƒ์„ฑ์„ ์œ„ํ•ด ๋ชจ๋“  ์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฌ๊ธฐ

์ŠคํŠธ๋ฆฌ๋ฐ์€ ์ฝ˜ํ…์ธ ๊ฐ€ ์ œ๊ณต๋  ๋•Œ ๋ฐ”๋กœ ๋ณผ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๋” ๋‚˜์€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๊ทธ๋Ÿฌ๋‚˜ ํฌ๋กค๋Ÿฌ๊ฐ€ ํŽ˜์ด์ง€๋ฅผ ๋ฐฉ๋ฌธํ•˜๊ฑฐ๋‚˜ ๋นŒ๋“œ ์‹œ์ ์— ํŽ˜์ด์ง€๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๊ฒฝ์šฐ ๋ชจ๋“  ์ฝ˜ํ…์ธ ๋ฅผ ์ ์ง„์ ์œผ๋กœ ํ‘œ์‹œํ•˜๋Š” ๋Œ€์‹  ๋ชจ๋“  ์ฝ˜ํ…์ธ ๋ฅผ ๋จผ์ € ๋กœ๋“œํ•œ ๋‹ค์Œ ์ตœ์ข… HTML ์ถœ๋ ฅ์„ ์ƒ์„ฑํ•˜๋Š” ๊ฒƒ์ด ์ข‹์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

onAllReady ์ฝœ๋ฐฑ์„ ์‚ฌ์šฉํ•˜์—ฌ ๋ชจ๋“  ์ฝ˜ํ…์ธ ๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

let didError = false;
let isCrawler = // ... ๋ด‡ ํƒ์ง€ ์ „๋žต์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค ...

const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
if (!isCrawler) {
response.statusCode = didError ? 500 : 200;
response.setHeader('content-type', 'text/html');
pipe(response);
}
},
onShellError(error) {
response.statusCode = 500;
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onAllReady() {
if (isCrawler) {
response.statusCode = didError ? 500 : 200;
response.setHeader('content-type', 'text/html');
pipe(response);
}
},
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});

์ผ๋ฐ˜ ๋ฐฉ๋ฌธ์ž๋Š” ์ ์ง„์ ์œผ๋กœ ๋กœ๋“œ๋˜๋Š” ์ฝ˜ํ…์ธ  ์ŠคํŠธ๋ฆผ์„ ๋ฐ›๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ํฌ๋กค๋Ÿฌ๋Š” ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ๋กœ๋“œ๋œ ํ›„ ์ตœ์ข… HTML ์ถœ๋ ฅ์„ ๋ฐ›๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ด๋Š” ํฌ๋กค๋Ÿฌ๊ฐ€ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋‹ค๋ ค์•ผ ํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•˜๋ฉฐ, ๊ทธ์ค‘ ์ผ๋ถ€๋Š” ๋กœ๋“œ ์†๋„๊ฐ€ ๋Š๋ฆฌ๊ฑฐ๋‚˜ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•ฑ์— ๋”ฐ๋ผ ํฌ๋กค๋Ÿฌ์—๋„ ์…ธ์„ ๋ณด๋‚ด๋„๋ก ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


์„œ๋ฒ„ ๋ Œ๋”๋ง ์ค‘๋‹จํ•˜๊ธฐ

์‹œ๊ฐ„ ์ดˆ๊ณผ ํ›„ ์„œ๋ฒ„ ๋ Œ๋”๋ง์„ ๊ฐ•์ œ๋กœ โ€˜ํฌ๊ธฐโ€™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

const { pipe, abort } = renderToPipeableStream(<App />, {
// ...
});

setTimeout(() => {
abort();
}, 10000);

React๋Š” ๋‚˜๋จธ์ง€ ๋กœ๋”ฉ ํด๋ฐฑ์„ HTML๋กœ ํ”Œ๋Ÿฌ์‹œํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ Œ๋”๋ง์„ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.