renderToReadableStream
renderToReadableStream
๋ Readable Web Stream.๋ฅผ ์ด์ฉํด React tree๋ฅผ ๊ทธ๋ฆฝ๋๋ค.
const stream = await renderToReadableStream(reactNode, options?)
- Reference
- ์ฌ์ฉ ์์
- Readable Web Stream์ ์ด์ฉํด React tree๋ฅผ HTML์ฒ๋ผ ๋ ๋๋งํ๊ธฐ
- ๋ ๋ง์ ์ปจํ ์ธ ๋ฅผ ์คํธ๋ฆฌ๋ฐํ๋ฉด์ ๋ก๋ํ๊ธฐ
- Specifying what goes into the shell
- ์๋ฒ์ ์ถฉ๋์ ๋ก๊น ํ๊ธฐ
- shell ๋ด๋ถ์ ์๋ฌ๋ก๋ถํฐ ํ๋ณตํ๊ธฐ
- shell ์ธ๋ถ์ ์๋ฌ๋ก๋ถํฐ ํ๋ณตํ๊ธฐ
- ์ํ ์ฝ๋ ์ค์ ํ๊ธฐ
- ๊ฐ๊ธฐ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ๋ค๋ฅธ ์ข ๋ฅ์ ์๋ฌ๋ฅผ ์ฒ๋ฆฌํ๊ธฐ
- ์ ์ ์์ฑ๊ณผ ํฌ๋กค๋ฌ๋ฅผ ์ํด ๋ชจ๋ ์ปจํ ์ธ ๊ฐ ๋ก๋ฉ๋๋ ๊ฒ์ ๊ธฐ๋ค๋ฆฌ๊ธฐ
- ์๋ฒ ๋ ๋๋ง ๋ฉ์ถ๊ธฐ
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์ ์ํธ์์ฉ ๊ฐ๋ฅํ๋๋ก ๋ง๋ญ๋๋ค.
์๋์์ ๋ ๋ง์ ์์๋ฅผ ์ฐธ๊ณ ํ์ธ์.
Parameter
-
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)๋ฅผ ์ค์ ํฉ๋๋ค.
- optional
๋ฐํ๊ฐ
renderToReadableStream
๋ Promise๋ฅผ ๋ฐํํฉ๋๋ค.
- shell๋ ๋๋ง์ ์ฑ๊ณตํ๋ค๋ฉด, ๋ฐํ๋ Promise๋ Readable Web Stream์ผ๋ก ํด๊ฒฐ๋ฉ๋๋ค.
- shell๋ ๋๋ง์ ์คํจํ๋ฉด, ๋ฐํ๋ Promise๋ ์ทจ์๋ฉ๋๋ค. shell ๋ ๋๋ง์ ์คํจ์, ์ด๊ฒ์ ์ด์ฉํด ์คํจ ๊ฒฐ๊ณผ๋ฅผ ์ถ๋ ฅํ์ธ์.
๋ฐํ๋ ์คํธ๋ฆผ์ ๋ค์๊ณผ ๊ฐ์ ์ถ๊ฐ์ ์ธ ํ๋กํผํฐ๋ฅผ ๊ฐ์ง๊ณ ์์ต๋๋ค.
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
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์ด ์ด๋ป๊ฒ ๋์ํ๋์ง ๋ ์ฝ์ด๋ณด๊ธฐ.
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๋ ์๋ฌ๋ก ๋ถํฐ ํ๋ณตํ๋ ค๊ณ ํ ๊ฒ์
๋๋ค:
- ๊ฐ์ฅ ๊ฐ๊น์ด
<Suspense>
๊ฒฝ๊ณ์ ๋ก๋ฉ ๋์ฒด์ธ (PostsGlimmer
)๋ฅผ HTML๋ก ๋ณด๋ ๋๋ค. - ์๋ฒ์์ ๋์ด์์
Posts
์ ๊ทธ ๋ด๋ถ๋ฅผ ๋ ๋๋งํ๋ ๊ฒ์ โํฌ๊ธฐโํฉ๋๋ค. - ํด๋ผ์ด์ธํธ์์ ์๋ฐ์คํฌ๋ฆฝํธ ์ฝ๋๊ฐ ๋ก๋ฉ๋์์ ๋, 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๋ก ๋ด๋ณด๋ผ ๊ฒ์ด๊ณ , ํด๋ผ์ด์ธํธ์์ ๊ทธ ๋๋จธ์ง ๋ ๋๋ง์ ๊ณ์ํ ๊ฒ์ ๋๋ค.