renderToReadableStream๋Š” Readable Web Stream.๋ฅผ ์ด์šฉํ•ด React tree๋ฅผ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

const stream = await renderToReadableStream(reactNode, options?)

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

์ด API๋Š” Web Streams.์— ์˜์กดํ•ฉ๋‹ˆ๋‹ค. Node.js์˜ ๊ฒฝ์šฐ, renderToPipeableStream๋ฅผ ๋Œ€์‹  ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.


Reference

renderToReadableStream(reactNode, options?)

renderToReadableStream๋ฅผ ํ˜ธ์ถœํ•ด Readable Web Stream์œผ๋กœ ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ React tree๋ฅผ HTML์ฒ˜๋Ÿผ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

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

async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

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

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

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

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

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

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

๋ฐ˜ํ™˜๊ฐ’

renderToReadableStream๋Š” Promise๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ˜ํ™˜๋œ ์ŠคํŠธ๋ฆผ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ถ”๊ฐ€์ ์ธ ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

  • allReady: ๋ชจ๋“  ์ถ”๊ฐ€ ์ปจํ…์ธ ์™€ shell์˜ ๋ Œ๋”๋ง์„ ํฌํ•จํ•œ ๋ชจ๋“  ๋ Œ๋”๋ง์ด ์™„๋ฃŒ๋œ Promise์˜ ์ถ”๊ฐ€ ํ”„๋กœํผํ‹ฐ์ž…๋‹ˆ๋‹ค. ํฌ๋กค๋Ÿฌ์™€ ์ •์  ์ƒ์„ฑ์„ ์œ„ํ•ด await stream.allReady๋ฅผ ์‘๋‹ต ๋ฐ˜ํ™˜ ์ „์— ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • ์‹œ์—”, ๋กœ๋”ฉ ์ง„ํ–‰ ์ƒํƒœ๋ฅผ ๋ฐ›์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ŠคํŠธ๋ฆผ์€ ์ตœ์ข… HTML์„ ํฌํ•จํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.

์‚ฌ์šฉ ์˜ˆ์‹œ

Readable Web Stream์„ ์ด์šฉํ•ด React tree๋ฅผ HTML์ฒ˜๋Ÿผ ๋ Œ๋”๋งํ•˜๊ธฐ

renderToReadableStream๋ฅผ ํ˜ธ์ถœํ•ด Readable Web Stream์„ ํ†ตํ•ด React tree๋ฅผ HTML์ฒ˜๋Ÿผ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค.

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

async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

root ์ปดํฌ๋„ŒํŠธ์™€ ํ•จ๊ป˜, bootstrap <script> ๊ฒฝ๋กœ ๋ฆฌ์ŠคํŠธ๋ฅผ ์ œ๊ณตํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค. ์ œ๊ณต๋œ root ์ปดํฌ๋„ŒํŠธ๋Š” ์ตœ์ƒ์œ„ <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์™€ bootstrap <script> ํƒœ๊ทธ๋“ค์„ ๊ฒฐ๊ณผ HTML ์ŠคํŠธ๋ฆผ์— ์ฃผ์ž…ํ•ฉ๋‹ˆ๋‹ค:

<!DOCTYPE html>
<html>
<!-- ... ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ์ž‘์„ฑํ•œ ์ปดํฌ๋„ŒํŠธ์˜ HTML ... -->
</html>
<script src="/main.js" async=""></script>

ํด๋ผ์ด์–ธํŠธ์—์„ , ์ถ”๊ฐ€๋œ bootstrap ์Šคํฌ๋ฆฝํŠธ๋Š” hydrateRoot๋ฅผ ํ˜ธ์ถœํ•ด document ์ „์ฒด๋ฅผ hydrate ํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค:

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

hydrateRoot(document, <App />);

์ด ๊ณผ์ •์€ ์„œ๋ฒ„์—์„œ ๋ Œ๋”๋ง๋œ HTML์— ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๋“ค์„ ๋ถ™์ด๊ณ , HTML์„ ์ƒํ˜ธ์ž‘์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

Deep Dive

๋นŒ๋“œ ๊ฒฐ๊ณผ๋ฌผ์—์„œ CSS์™€ JS์˜ ๊ฒฝ๋กœ ์ฝ์–ด์˜ค๊ธฐ

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

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

export default function App({ assetMap }) {
return (
<html>
<head>
<title>My app</title>
<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'
};

async function handler(request) {
const stream = await renderToReadableStream(<App assetMap={assetMap} />, {
bootstrapScripts: [assetMap['/main.js']]
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

์„œ๋ฒ„๊ฐ€ <App assetMap={assetMap} />๋ฅผ ๋ Œ๋”๋งํ•œ ์ดํ›„์—”, ํด๋ผ์ด์–ธํŠธ์—์„œ๋„ hydration ์—๋Ÿฌ๋ฅผ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด assetMap๊ณผ ํ•จ๊ป˜ ๋ Œ๋”๋งํ•ด์•ผํ•ฉ๋‹ˆ๋‹ค. assetMap์„ ์ง๋ ฌํ™”ํ•˜๊ณ  ํด๋ผ์ด์–ธํŠธ์— ์ „๋‹ฌํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

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

async function handler(request) {
const stream = await renderToReadableStream(<App assetMap={assetMap} />, {
// ์ฃผ์˜: ์ด ๋ฐ์ดํ„ฐ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— stringify()๋ฅผ ์‚ฌ์šฉํ•ด๋„ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
bootstrapScripts: [assetMap['/main.js']],
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

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

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

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

ํด๋ผ์ด์–ธํŠธ์™€ ์„œ๋ฒ„๋Š” ๋ชจ๋‘ ๊ฐ™์€ assetMap prop์„ ์ด์šฉํ•ด App์„ ๋ Œ๋”๋งํ•˜๋ฏ€๋กœ, hydration ์—๋Ÿฌ๊ฐ€ ์ผ์–ด๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.


๋” ๋งŽ์€ ์ปจํ…์ธ ๋ฅผ ์ŠคํŠธ๋ฆฌ๋ฐํ•˜๋ฉด์„œ ๋กœ๋“œํ•˜๊ธฐ

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

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

<Posts />์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์•ฝ๊ฐ„์˜ ์‹œ๊ฐ„์ด ํ•„์š”ํ•˜๋‹ค๊ณ  ๊ฐ€์ •ํ•ด๋ด…์‹œ๋‹ค. ์ด ๊ฒฝ์šฐ, ์‚ฌ์šฉ์ž๊ฐ€ ํฌ์ŠคํŠธ ๋ชฉ๋ก์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ ๋„ ํ”„๋กœํ•„ ํŽ˜์ด์ง€์˜ ๋‚˜๋จธ์ง€ ์ปจํ…์ธ ๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๋„๋ก ํ•˜๊ณ  ์‹ถ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ ์œ„ํ•ด, <Suspense>๋ฅผ ์‚ฌ์šฉํ•ด Posts๋ฅผ ๊ฐ์‹ธ์ฃผ์„ธ์š”:

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

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด React๋Š” Posts๊ฐ€ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์ „๊นŒ์ง€, HTML ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค. React๋Š” ๋จผ์ € ๋กœ๋”ฉ ๋Œ€์ฒด ์ปจํ…์ธ ์ธ <PostsGlimmer />๋ฅผ HTML๋กœ ๋ณด๋‚ด๊ณ , Posts์˜ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ์ด ์™„๋ฃŒ๋˜๋ฉด, <PostsGlimmer />๋ฅผ <Posts />๋กœ ๊ต์ฒดํ•  HTML๊ณผ ์ธ๋ผ์ธ <script> ํƒœ๊ทธ๋ฅผ ํ•จ๊ป˜ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž ์ž…์žฅ์—์„ , ๋จผ์ € <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> ๊ฒฝ๊ณ„์—๋„ ๊ฐ์‹ธ์ ธ์žˆ์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, React๋Š” ๋จผ์ € ์ด ๋‘ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ Œ๋”๋งํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, Sidebar๋‚˜ Friends ํ˜น์€ Photos๊ฐ€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ํ•„์š”๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ์—”, BigSpinner๋ฅผ ๋Œ€์ฒด HTML๋กœ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๊ทธ ํ›„, ๋ฐ์ดํ„ฐ๊ฐ€ ๋” ๋ถˆ๋Ÿฌ์™€์ง€๋ฉด, ๋” ๋งŽ์€ ์ปจํ…์ธ ๊ฐ€ ๋ณด์—ฌ์ง€๊ฒŒ ๋˜๊ณ  ์ด ๊ณผ์ •์€ ๋ชจ๋“  ์ปจํ…์ธ ๊ฐ€ ๋ณด์—ฌ์งˆ ๋•Œ๊นŒ์ง€ ๋ฐ˜๋ณต๋ฉ๋‹ˆ๋‹ค.

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

์ŠคํŠธ๋ฆฌ๋ฐ HTML์ด ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€ ๋” ์ฝ์–ด๋ณด๊ธฐ.

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

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

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

Suspense๋Š” Effect๋‚˜ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ๋‚ด๋ถ€์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ๊ฒฝ์šฐ, ์ด๋ฅผ, ๊ฐ์ง€ํ•˜์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค.

Posts ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ •ํ™•ํ•œ ๋ฐฉ๋ฒ•์€, ์œ„์— ์„ค๋ช…๋œ ํ”„๋ ˆ์ž„์›Œํฌ์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค. Suspense-๊ฐ€๋Šฅํ•œ ํ”„๋ ˆ์ž„์›Œํฌ๋ฅผ ์ด์šฉํ•˜๋Š” ๊ฒฝ์šฐ, ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐฉ๋ฒ•๊ณผ ๊ทธ ์ž์„ธํ•œ ์‚ฌํ•ญ์€ ํ•ด๋‹น ํ”„๋ ˆ์ž„์›Œํฌ ๋ฌธ์„œ์—์„œ ์ฐพ์œผ์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ฃผ๊ด€์ ์œผ๋กœ ์ œ์‹œ๋œ ํ”„๋ ˆ์ž„์›Œํฌ ์—†์ด Suspense-๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋ฐฉ๋ฒ•์€ ์•„์ง ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ”„๋ ˆ์ž„์›Œํฌ ์—†์ด Suspense-๊ฐ€๋Šฅํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋งŒ๋“ค๊ณ  ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐฉ๋ฒ•์€ ์•„์ง ๋ถˆ์•ˆ์ •ํ•˜๊ณ  ๋ฌธ์„œํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ Suspense์™€ ํ•จ๊ป˜ ์œตํ•ฉํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ๊ณต์‹์ ์ธ API๋Š” React์˜ ํ–ฅํ›„ ๋ฒ„์ „์—์„œ ์ง€์›๋  ์˜ˆ์ •์ž…๋‹ˆ๋‹ค.


Specifying what goes into the shell

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

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

์ด๋Š” ์œ ์ €๊ฐ€ ๋ณด๋Š” ์ตœ์ดˆ์˜ ๋กœ๋”ฉ ์ƒํƒœ๋ฅผ ์ •ํ•ด์ค๋‹ˆ๋‹ค:

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

๋งŒ์•ฝ, <Suspense> ๊ฒฝ๊ณ„๋ฅผ root์— ๊ฑธ์–ด ์•ฑ ์ „์ฒด๋ฅผ ๊ฐ์ŒŒ๋‹ค๋ฉด, shell์€ spinner๋งŒ์„ ๋ณด์—ฌ์ค„ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์ด๋Š” ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์žˆ์–ด์„œ ์ข‹์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํฐ spinner๋ฅผ ๋ณด๋Š” ๊ฒƒ์€ ๋น„๋ก ๋” ๊ธฐ๋‹ค๋ฆฌ๊ฒŒ ๋  ์ง€ ์–ธ์ •, ์‹ค์ œ ๋ ˆ์ด์•„์›ƒ์ด ๋‚˜ํƒ€๋‚˜๋Š” ๊ฒƒ๋ณด๋‹ค ๋” ๋Š๋ฆฌ๊ณ  ๋” ์งœ์ฆ๋‚˜๋Š” ๊ฒฝํ—˜์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฐ ์ด์œ ๋กœ ๊ฐœ๋ฐœ์ž๋“ค์€ <Suspense> ๊ฒฝ๊ณ„๋ฅผ ํ†ตํ•ด shell์„ ์ „์ฒด ํŽ˜์ด์ง€ ๋ ˆ์ด์•„์›ƒ์˜ ๋ผˆ๋Œ€์ฒ˜๋Ÿผ ์ตœ์†Œํ•œ์œผ๋กœ ์™„์„ฑ๋œ ์ƒํƒœ์ด๋‹ค๋ผ๋Š” ๋Š๋‚Œ์„ ์ค„ ์ˆ˜ ์žˆ๋„๋ก ํ•˜๊ณ  ์‹ถ์„ ๊ฒƒ์ž…๋‹ˆ๋‹ค.

renderToReadableStream๋ฅผ ๋น„๋™๊ธฐ ํ˜ธ์ถœํ•˜์—ฌ ๋ชจ๋“  shell์ด ๋ Œ๋”๋ง๋  ๋•Œ๊นŒ์ง€ stream์œผ๋กœ ์œ„ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค. ๋ณดํ†ต, stream์„ ๊ฐ€์ง„ ์‘๋‹ต์„ ์ƒ์„ฑํ•˜๊ณ  ๋ฐ˜ํ™˜ํ•จ์œผ๋กœ์„œ ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.

async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

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


์„œ๋ฒ„์˜ ์ถฉ๋Œ์„ ๋กœ๊น…ํ•˜๊ธฐ

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

async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}

๋งŒ์•ฝ onError๋ฅผ ์ง์ ‘ ์ œ๊ณตํ–ˆ๋‹ค๋ฉด, ์œ„์™€ ๊ฐ™์ด ์ฝ˜์†”์— ์˜ค๋ฅ˜๋ฅผ ๋กœ๊น…ํ•˜๋Š” ๊ฒƒ๋„ ์žŠ์ง€ ๋งˆ์„ธ์š”.


shell ๋‚ด๋ถ€์˜ ์—๋Ÿฌ๋กœ๋ถ€ํ„ฐ ํšŒ๋ณตํ•˜๊ธฐ

์ด๋ฒˆ ์˜ˆ์‹œ์—์„œ, shell์€ ProfileLayout, ProfileCover ๊ทธ๋ฆฌ๊ณ  PostsGlimmer๋ฅผ ํฌํ•จํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

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

๋งŒ์•ฝ, ์œ„์˜ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ๋ Œ๋”๋งํ•˜๋‹ค๊ฐ€ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค๋ฉด, React๋Š” ํด๋ผ์ด์–ธํŠธ๋กœ ๋ณด๋‚ผ ์˜๋ฏธ์žˆ๋Š” HTML์„ ๊ฐ€์ง€๊ณ  ์žˆ์ง€ ์•Š์„ ๊ฒƒ ์ž…๋‹ˆ๋‹ค. ์ด๋Ÿฐ ๋•Œ๋ฅผ ๋Œ€๋น„ํ•ด renderToReadableStream์„ try...catch๋กœ ๊ฐ์‹ธ ์„œ๋ฒ„ ๋ Œ๋”๋ง์— ์˜์กดํ•˜์ง€ ์•Š๋Š” ๋Œ€์ฒด HTML์„ ๋ณด๋‚ผ ์ˆ˜ ์žˆ๋„๋ก ํ•˜์„ธ์š”.

async function handler(request) {
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Something went wrong</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}

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


shell ์™ธ๋ถ€์˜ ์—๋Ÿฌ๋กœ๋ถ€ํ„ฐ ํšŒ๋ณตํ•˜๊ธฐ

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

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 ๋ Œ๋”๋ง ์žฌ์‹œ๋„๊ฐ€ ์„ฑ๊ณตํ•˜๋ฉด, ์„œ๋ฒ„์—์„œ ์˜จ ๋กœ๋”ฉ ๋Œ€์ฒด HTML์ด ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ Œ๋”๋ง๋œ ๊ฒฐ๊ณผ๋กœ ๊ต์ฒด๋ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ์„œ๋ฒ„์—์„œ ์—๋Ÿฌ๊ฐ€ ์žˆ์—ˆ๋Š”์ง€ ๋ชจ๋ฅผ ๊ฒƒ์ž…๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ, ์„œ๋ฒ„์˜ onError ์ฝœ๋ฐฑ๊ณผ ํด๋ผ์ด์–ธํŠธ์˜ onRecoverableError ์ฝœ๋ฐฑ์€ ๊ทธ๋Œ€๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด ์—๋Ÿฌ ๋‚ด์šฉ์„ ๋ฐ›์•„์„œ ๋กœ๊น…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


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

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

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

async function handler(request) {
try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Something went wrong</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}

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

ํ•˜์ง€๋งŒ, ๊ทธ๋ž˜๋„ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด, ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค๋Š” ์‚ฌ์‹ค์„ ์ด์šฉํ•˜์—ฌ ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

async function handler(request) {
try {
let didError = false;
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: didError ? 500 : 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Something went wrong</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}

์ด๋Š”, ์ดˆ๊ธฐ shell ์ฝ˜ํ…์ธ ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋™์•ˆ ๋ฐœ์ƒํ•œ shell ์™ธ๋ถ€์—์„œ ์ผ์–ด๋‚œ ์—๋Ÿฌ๋งŒ ์žก์„ ๊ฒƒ์ด๋ฏ€๋กœ, ์™„์ „ํ•œ ๋ฐฉ๋ฒ•์€ ์•„๋‹™๋‹ˆ๋‹ค. ๋งŒ์•ฝ, ์–ด๋–ค ์ปจํ…์ธ ๊ฐ€ ์ •๋ง ์ค‘์š”ํ•ด์„œ ํ•ด๋‹น ์ปจํ…์ธ ์— ๋ฐœ์ƒํ•œ ์—๋Ÿฌ๋ฅผ ์•Œ๊ณ  ์‹ถ๋‹ค๋ฉด, ๊ทธ๊ฒƒ์„ shell ์•ˆ์œผ๋กœ ์˜ฎ๊ฒจ ์—๋Ÿฌ๋ฅผ ์•Œ์•„๋‚ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


๊ฐ๊ธฐ ๋‹ค๋ฅธ ๋ฐฉ์‹์œผ๋กœ ๋‹ค๋ฅธ ์ข…๋ฅ˜์˜ ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ

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

async function handler(request) {
let didError = false;
let caughtError = null;

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

try {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
caughtError = error;
console.error(error);
logServerCrashReport(error);
}
});
return new Response(stream, {
status: getStatusCode(),
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Something went wrong</h1>', {
status: getStatusCode(),
headers: { 'content-type': 'text/html' },
});
}
}

๋ช…์‹ฌํ•ด์•ผ ํ•  ๊ฒƒ์€, shell์„ ์ „์†กํ•˜๊ณ  ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‹œ์ž‘ํ•œ ํ›„์—” ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.


์ •์  ์ƒ์„ฑ๊ณผ ํฌ๋กค๋Ÿฌ๋ฅผ ์œ„ํ•ด ๋ชจ๋“  ์ปจํ…์ธ ๊ฐ€ ๋กœ๋”ฉ๋˜๋Š” ๊ฒƒ์„ ๊ธฐ๋‹ค๋ฆฌ๊ธฐ

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

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

stream.allReady Promise๋ฅผ ๊ธฐ๋‹ค๋ฆผ์œผ๋กœ์จ ๋ชจ๋“  ์ปจํ…์ธ ๊ฐ€ ๋กœ๋“œ๋  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

async function handler(request) {
try {
let didError = false;
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
let isCrawler = // ... depends on your bot detection strategy ...
if (isCrawler) {
await stream.allReady;
}
return new Response(stream, {
status: didError ? 500 : 200,
headers: { 'content-type': 'text/html' },
});
} catch (error) {
return new Response('<h1>Something went wrong</h1>', {
status: 500,
headers: { 'content-type': 'text/html' },
});
}
}

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


์„œ๋ฒ„ ๋ Œ๋”๋ง ๋ฉˆ์ถ”๊ธฐ

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

async function handler(request) {
try {
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 10000);

const stream = await renderToReadableStream(<App />, {
signal: controller.signal,
bootstrapScripts: ['/main.js'],
onError(error) {
didError = true;
console.error(error);
logServerCrashReport(error);
}
});
// ...
}
}

React๋Š” ๋‚˜๋จธ์ง€ ๋กœ๋”ฉ ๋Œ€์ฒด ๋‚ด์šฉ์„ HTML๋กœ ๋‚ด๋ณด๋‚ผ ๊ฒƒ์ด๊ณ , ํด๋ผ์ด์–ธํŠธ์—์„œ ๊ทธ ๋‚˜๋จธ์ง€ ๋ Œ๋”๋ง์„ ๊ณ„์†ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.