(번역) Decoding React Server Component Payloads

기운찬곰·2025년 3월 20일
0

출처 (RSC 2편): https://edspencer.net/2024/7/1/decoding-react-server-component-payloads

React Server Components를 사용해 본 적이 있다면 웹 페이지 하단에 다음과 같은 항목이 있는 것을 본 적이 있을 것입니다.

<script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script>
<script>self.__next_f.push([1,"1:HL[\"/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n2:HL[\"/_next/static/css/app/layout.css?v=1719846361489\",\"style\"]\n0:D{\"name\":\"r0\",\"env\":\"Server\"}\n"])</script>
<script>self.__next_f.push([1,"3:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n5:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"ClientPageRoot\"]\n6:I[\"(app-pages-browser)/./app/flight/page.tsx\",[\"app/flight/page\",\"static/chunks/app/flight/page.js\"],\"default\"]\n7:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n8:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\nc:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n4:D{\"name\":\"\",\"env\":\"Server\"}\n9:D{\"name\":\"RootLayout\",\"env\":\"Server\"}\na:D{\"name\":\"NotFound\",\"env\":\"Server\"}\na:[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"childr"])</script>
<script>self.__next_f.push([1,"en\":\"This page could not be found.\"}]}]]}]}]]\n9:[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"className\":\"__className_aaf875\",\"children\":[\"$\",\"$L7\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L8\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$a\",\"notFoundStyles\":[],\"styles\":null}]}]}]\nb:D{\"name\":\"\",\"env\":\"Server\"}\nd:[]\n0:[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/app/layout.css?v=1719846361489\",\"precedence\":\"next_static/css/app/layout.css\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"$L3\",null,{\"buildId\":\"development\",\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/flight\",\"initialTree\":[\"\",{\"children\":[\"flight\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"flight\",{\"children\":[\"__PAGE__\",{},[[\"$L4\",[\"$\",\"$L5\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$6\"}]],null],null]},[\"$\",\"$L7\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"flight\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L8\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\",\"styles\":null}],null]},[\"$9\",null],null],\"couldBeIntercepted\":false,\"initialHead\":[false,\"$Lb\"],\"globalErrorComponent\":\"$c\",\"missingSlots\":\"$Wd\"}]]\n"])</script>
<script>self.__next_f.push([1,"b:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}],[\"$\",\"title\",\"2\",{\"children\":\"React Server Components Payloads\"}],[\"$\",\"meta\",\"3\",{\"name\":\"description\",\"content\":\"By Ed Spencer - edspencer.net\"}],[\"$\",\"link\",\"4\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"meta\",\"5\",{\"name\":\"next-size-adjust\"}]]\n4:null\n"])</script>

이 모든 것이 무슨 뜻인지 궁금할 겁니다. 잘 문서화되지 않았고, 모두 새롭습니다. 업무에서 걱정할 일은 아니지만, 저처럼 호기심이 많은 괴짜라면 계속 읽어보세요.

당신이 보고 있는 것은 페이지 끝에 자동으로 삽입된 script 태그의 무리입니다. 위의 내용은 우리가 상상할 수 있는 가장 기본적인 Next JS 애플리케이션에서 복사하여 붙여넣은 것입니다. 그것은 layout.tsx와 page.tsx의 두 가지 구성 요소로 구성되어 있습니다.

import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "React Server Components Payloads",
  description: "By Ed Spencer - edspencer.net",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
export default function Home() {
  return (
    <div>
      <h1>Server Component</h1>
    </div>
  );
}

이론상, 이 콘텐츠는 공백을 포함하여 236 바이트인 다음과 같은 HTML 문서로 렌더링될 수 있습니다.

<html lang="en">
  <head>
    <title>React Server Component Payloads</title>
    <meta name="description" content="By Ed Spencer - edspencer.net">
  </head>
  <body>
    <div>
      <h1>Server Component</h1>
    </div>
  </body>
</html>

대신 우리는 약 5kb의 데이터를 얻을 수 있으며, 주로 self._next_f.push([1, ...]) 호출의 형태로 이루어집니다. 이러한 호출은 Next.js가 리소스를 페치하고 페이지를 하이드레이션하는 데 사용하는 배열로 페이로드를 밀어 넣습니다. 페이로드는 Next.js가 서버와 클라이언트 간 통신에 사용하는 사용자 지정 형식인 RSC 페이로드입니다.

React Server Component Payloads

저는 몇 시간 동안 React와 Next.js 소스 코드를 파헤쳐 이 페이로드가 무엇이고 어떻게 작동하는지 알아냈습니다. 이 작업에서 유용한 소스 코드 파일은 다음과 같습니다.

next/src/client/app-index.tsx 에서 self.__next_f 배열이 생성되고 push 함수가 nextServerDataCallback을 호출할 때마다 __next_f.push(...)를 호출하는 것으로 재정의된 것을 볼 수 있습니다. 각 배열에 대한 푸시는 2-튜플 형태로 예상되며, 첫 번째 요소는 0에서 3 사이의 숫자이고 두 번째 요소는 일부 데이터 문자열(또는 첫 번째 요소가 0인 경우 정의되지 않음)입니다.

app-index.tsx 와 서버 측 use-flight-response.tsx 에서 첫 번째 요소의 숫자가 4가지 의미 중 하나를 의미하는 것을 볼 수 있습니다.

const INLINE_FLIGHT_PAYLOAD_BOOTSTRAP = 0
const INLINE_FLIGHT_PAYLOAD_DATA = 1
const INLINE_FLIGHT_PAYLOAD_FORM_STATE = 2
const INLINE_FLIGHT_PAYLOAD_BINARY = 3

대부분의 경우 대부분의 페이로드는 유형은 INLINE_FLIGHT_PAYLOAD_DATA(1)이며, 여기에는 다양한 유형의 콘텐츠를 포함할 수 있습니다.

Basic format

페이로드가 디코딩되는 방식은 배열의 첫 번째 요소 값에 따라 달라집니다. 가장 일반적이고 가장 흥미로운 INLINE_FLIGHT_PAYLOAD_DATA(1) 페이로드를 보내는 호출에 집중해 보겠습니다. 각 페이로드 항목에는 하나 이상의 rows, 각각에는 다음 부분이 포함됩니다.

  • ROW_ID: 페이로드의 고유 식별자
  • ROW_TAG: 페이로드 유형을 식별하는 문자열
  • ROW_DATA: 실제 페이로드 데이터
  • NEW_LINE: 줄바꿈 문자( \n)는 행의 끝을 나타냅니다.

가장 먼저 주입된 스크립트를 살펴보겠습니다.

self.__next_f.push([1,"1:HL[\"/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2\",\"font\"
,{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n2:HL[\"/_next/static/css/app/layout.css?v=1719846361489\"
,\"style\"]\n0:D{\"name\":\"r0\",\"env\":\"Server\"}\n"])

여기서 우리는 이것이 유형 1의 페이로드이고, 데이터는 3개의 행을 포함하는 문자열임을 알 수 있습니다. 이러한 각 행은 줄바꿈 문자로 구분됩니다. 첫 번째 행은 글꼴이고, 두 번째 행은 스타일이고, 세 번째 행은 데이터 행입니다. 데이터 행은 name와 env 속성을 가진 JSON 객체입니다.

조금 더 이해하기 쉽게 재구성해 보겠습니다.

{
  "rows": [
    {
      "ROW_ID": 1,
      "ROW_TAG": "HL",
      "ROW_DATA": ["_next/static/media/c9a5bc6a7c948fb0-s.p.woff2", "font", {"crossOrigin": "", "type": "font/woff2"}]
    },
    {
      "ROW_ID": 2,
      "ROW_TAG": "HL",
      "ROW_DATA": ["_next/static/css/app/layout.css?v=1719846361489", "style"]
    },
    {
      "ROW_ID": 0,
      "ROW_TAG": "D",
      "ROW_DATA": {"name": "r0", "env": "Server"}
    }
  ]
}

오케이, 좀 더 흥미롭네요. 우리는 실제로 첫 번째 script 태그에서 3개의 데이터 행을 얻었습니다. 그 중 두 개는 태그 유형으로 HL로, "hints"이며 궁극적으로 다양한 유형의 link 태그로 변환되어 CSS, 폰트, JS 및 기타 리소스를 로드하는 것과 유사합니다. 세 번째 줄은 데이터 행으로, 서버에서 클라이언트로 데이터를 전달하는 데 사용됩니다. 행 ID는 순서대로 되어 있지 않지만 그다지 중요하지 않은 것 같습니다.

2kb at a time

여기서의 문제점은 페이로드가 2kb 크기로 제한된다는 것입니다. 그보다 큰 페이로드를 푸시하려고 하면 Next.js가 여러 페이로드로 분할합니다. 이는 다음 __next_f.push 호출에서 확인할 수 있습니다.

self.__next_f.push([1,"3:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n5:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"ClientPageRoot\"]\n6:I[\"(app-pages-browser)/./app/flight/page.tsx\",[\"app/flight/page\",\"static/chunks/app/flight/page.js\"],\"default\"]\n7:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n8:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\nc:I[\"(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js\",[\"app-pages-internals\",\"static/chunks/app-pages-internals.js\"],\"\"]\n4:D{\"name\":\"\",\"env\":\"Server\"}\n9:D{\"name\":\"RootLayout\",\"env\":\"Server\"}\na:D{\"name\":\"NotFound\",\"env\":\"Server\"}\na:[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":\"404\"}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"childr"])

self.__next_f.push([1,"en\":\"This page could not be found.\"}]}]]}]}]]\n9:[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"className\":\"__className_aaf875\",\"children\":[\"$\",\"$L7\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L8\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$a\",\"notFoundStyles\":[],\"styles\":null}]}]}]\nb:D{\"name\":\"\",\"env\":\"Server\"}\nd:[]\n0:[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/app/layout.css?v=1719846361489\",\"precedence\":\"next_static/css/app/layout.css\",\"crossOrigin\":\"$undefined\"}]],[\"$\",\"$L3\",null,{\"buildId\":\"development\",\"assetPrefix\":\"\",\"initialCanonicalUrl\":\"/flight\",\"initialTree\":[\"\",{\"children\":[\"flight\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"flight\",{\"children\":[\"__PAGE__\",{},[[\"$L4\",[\"$\",\"$L5\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$6\"}]],null],null]},[\"$\",\"$L7\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\",\"flight\",\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L8\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"notFoundStyles\":\"$undefined\",\"styles\":null}],null]},[\"$9\",null],null],\"couldBeIntercepted\":false,\"initialHead\":[false,\"$Lb\"],\"globalErrorComponent\":\"$c\",\"missingSlots\":\"$Wd\"}]]\n"])

여기서 살펴보는 것은 14개 행의 데이터로, 단일 페이로드로 전송되지만 두 개의 청크로 분할됩니다. 첫 번째 예제에서 페이로드가 \n로 끝나는 반면, 여기서는 다릅니다. 이는 페이로드가 두 부분으로 분할되고 두 번째 부분이 별도의 페이로드로 전송되기 때문입니다.

스크롤링이 마음에 들지 않는다면 위의 첫 번째 줄의 끝은 다음과 같습니다.

\":0},\"childr"])

그리고 두 번째 줄의 시작은 다음과 같습니다.

self.__next_f.push([1,"en\":\"This page could not be found.\"}]}]]}]}]]\n

두 번째의 문자열 내용이 "en"으로 시작하고 첫 번재의 문자열 "childr"와 합쳐지는 것을 보세요. 이것을 보면 "children" 단어가 두 태그에 걸쳐 분할된다는 것이 매우 분명합니다. 브라우저 콘솔을 열고 첫 번째 청크(로 끝나는 청크)의 길이를 보면 2048자 길이라는 것을 알 수 있는데, 이는 꽤 주목할 만한 숫자입니다.

따라서 전체 페이로드를 JSON 형식으로 구문 분석하면 다음과 같은 결과가 나옵니다.

{
  "rows": [
    {
      "ROW_ID": 3,
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js", ["app-pages-internals", "static/chunks/app-pages-internals.js"], ""]
    },
    {
      "ROW_ID": 5,
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./node_modules/next/dist/client/components/client-page.js", ["app-pages-internals", "static/chunks/app-pages-internals.js"], "ClientPageRoot"]
    },
    {
      "ROW_ID": 6,
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./app/flight/page.tsx", ["app/flight/page", "static/chunks/app/flight/page.js"], "default"]
    },
    {
      "ROW_ID": 7,
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js", ["app-pages-internals", "static/chunks/app-pages-internals.js"], ""]
    },
    {
      "ROW_ID": 8,
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js", ["app-pages-internals", "static/chunks/app-pages-internals.js"], ""]
    },
    {
      "ROW_ID": "c",
      "ROW_TAG": "I",
      "ROW_DATA": ["(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js",["app-pages-internals", "static/chunks/app-pages-internals.js"],""]
    },
    {
      "ROW_ID": 12,
      "ROW_TAG": "D",
      "ROW_DATA": {"name": "", "env": "Server"}
    },
    {
      "ROW_ID": 4,
      "ROW_TAG": "D",
      "ROW_DATA": {"name": "RootLayout", "env": "Server"}
    },
    {
      "ROW_ID": "a",
      "ROW_TAG": "D",
      "ROW_DATA": {"name": "NotFound", "env": "Server"}
    },
    {
      "ROW_ID": "a",
      "ROW_TAG": "D",
      "ROW_DATA": [["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":[["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)"}}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]]
    },
    {
      "ROW_ID": 13,
      "ROW_TAG": "D",
      "ROW_DATA": [["$","html",null,{"lang":"en","children":[["$","body",null,{"className":"__className_aaf875","children":[["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$a","notFoundStyles":[],"styles":null}]}]}]]}]
    },
    {
      "ROW_ID": 14,
      "ROW_TAG": "D",
      "ROW_DATA": {"name": "", "env": "Server"}
    },
    {
      "ROW_ID": 15,
      "ROW_TAG": "D",
      "ROW_DATA": []
    },
    {
      "ROW_ID": 0,
      "ROW_TAG": "D",
      "ROW_DATA": [[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/app/layout.css?v=1719846361489","precedence":"next_static/css/app/layout.css","crossOrigin":"$undefined"}]],[["$","$L3",null,{"buildId":"development","assetPrefix":"","initialCanonicalUrl":"/flight","initialTree":["",{"children":["flight",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"initialSeedData":["",{"children":["flight",{"children":["__PAGE__",{},[["$L4",["$","$L5",null,{"props":{"params":{},"searchParams":{}},"Component":"$6"}]],null],null]},["$","$L7",null,{"parallelRouterKey":"children","segmentPath":["children","flight","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L8",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null],"$9",null],null],"couldBeIntercepted":false,"initialHead":[false,"$Lb"],"globalErrorComponent":"$c","missingSlots":"$Wd"}]]
    }
  ]
}

여기서는 몇 가지 흥미로운 일이 일어나고 있습니다. 대부분의 행은 "I(imports)" 인 유형입니다. 이는 페이지에 필요한 다양한 JavaScript 구성 요소를 로드하는 데 사용됩니다. 일부 행 ID는 중복됩니다 (ex. "a"가 두번 나타남). 행은 순서가 없지만 다시 말하지면 이는 문제가 되지 않는 듯합니다. 중복된 문자열이 꽤 있지만 gzip은 이러한 문자열을 잘 처리합니다.

여기 응답에 기본 404 React 페이지가 인라인으로 되어 있는 것 같은데, 흥미롭네요. 마지막으로, 모든 것이 페이지와 동일한 HTTP 응답으로 전송되므로 이 데이터를 가져오는 데 추가 HTTP 요청이 필요하지 않습니다 (물론 해당 블록에 정의된 "I" 리소스에 대한 몇 가지 요청은 여전히 요청할 것입니다)

불투명한가요? 네, 어느 정도는 그렇지만, 다양한 소스 파일을 살펴볼 시간이 있다면 간신히 알아볼 수 있을 정도입니다. 몇 가지 예를 더 살펴보겠습니다.

Suspense 사용

Suspense를 사용하는 page.tsx 파일로 바꿔봅시다. 이것은 async 함수라는 점에 유의하세요(나중에 자세히 설명하겠습니다):

import { Suspense } from "react";

async function getData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("resolved data");
    }, 1000);
  });
}

export default async function SuspensePage() {
  const data = await getData();
  return <Suspense fallback={<div>Loading...</div>}>{data}</Suspense>;
}

우리의 HTML 파일은 대체로 비슷해 보이지만 끝에 다음과 같은 추가 페이로드가 있습니다.

<script>self.__next_f.push([1,"d:\"$Sreact.suspense\"\n5:[\"$\",\"$d\",null,{\"fallback\":[\"$\",\"div\",null,{\"children\":\"Loading...\"}],\"children\":\"resolved data\"}]\n"])</script>

또는 이를 약간 재구성하면:

{
  "rows": [
    {
      "ROW_ID": "d",
      "ROW_TAG": undefined,
      "ROW_DATA": "$Sreact.suspense"
    },
    {
      "ROW_ID": 5,
      "ROW_TAG": undefined,
      "ROW_DATA": [["$","$d",null,{"fallback":["$","div",null,{"children":"Loading..."}],"children":"resolved data"}]]
    }
  ]
}

소스를 보면, 이 첫 번째 행이 Symbol.for로 구현되어 react.suspense 심볼을 조회할 것임을 알 수 있습니다. 더 이상 추적하지는 않았지만, 이는 Next.js에게 우리가 Suspense를 사용하고 있으니 적절한 리소스를 로드하라고 알리는 것 같습니다.

그런 다음 행 ID 5는 클라이언트에서 렌더링될 React 요소에 대한 사양을 정의합니다. 이는 "Loading..." 이라고 표시되는 div의 fallback과 "resolved data"라는 children 을 가진 Suspense 구성 요소입니다. 이는 SuspensePage 컴포넌트의 getData 함수에서 resolve 된 데이터입니다. 중요한 부분은 마지막 요소로, 렌더링될 때 createElement 호출을 통해 구성 요소에 전달될 props입니다.

$d는 Resact Suspense 구성 요소로 해결될 내부 매핑인 것 같습니다. 이 HTML 응답이 스트리밍되는 것을 보면, Suspense 구성 요소를 정의하는 마지막 태그를 제외하고는 거의 즉시 모든 script 태그가 렌더링되는 것을 볼 수 있습니다. 이는 setTimeout에 따라 1000ms 후에 제공되는 Suspense 구성 요소를 정의하는 마지막 태그를 제외하고 말입니다. 즉, Suspense 구성 요소는 데이터가 확보된 후에만 렌더링되기 때문에 브라우저에 "Loading..." 텍스트가 실제로 표시되지 않는다는 뜻입니다. 자세한 내용은 Async RSC와 Suspense에 대한 제 게시물을 확인해 보세요.

Using a Promise

우리가 할 수 있는 또 다른 일은 컴포넌트에서 Promise를 반환하는 것입니다. 이렇게 하면 로딩 UI를 볼 수 있습니다. page.tsx 파일을 다음과 같이 바꿔보죠.

import { Suspense } from "react";
import ClientPromise from "./component";

async function getData(): Promise<any> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("promise resolved data");
    }, 1000);
  });
}

export default function SuspensePage() {
  return (
    <div>
      <h1>Server Component</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <ClientPromise dataPromise={getData()} />
      </Suspense>
    </div>
  );
}

SuspensePage 에서 async 를 제거하고 해결되지 않은 Promise를 자식 컴포넌트에 직접 전달한다는 점에 유의하세요. 이는 다음과 같습니다 (use client를 사용한 클라이언트 컴포넌트임에 유의하세요.)

"use client";
import { use } from "react";

export default function ClientPromise({ dataPromise }: { dataPromise: Promise<string> }) {
  console.log(dataPromise);
  const data = use(dataPromise);

  return <p>{data}</p>;
}

서버 구성 요소나 클라이언트 구성 요소에서 aysnc 기능은 어느 쪽에서도 정의되어 있지 않지만, 새로운 use 훅 마법 덕분에 여전히 비동기 동작이 수행 됩니다.

참고) 저는 Next.js가 해당 스크립트 태그의 내용을 읽은 후 파싱된 데이터를 쉽게 얻을 수 있는 방법을 보여주기 위해 일부러 console.log를 남겼습니다. 그 dataPromise 객체는 __next_f에 푸시된 모든 청크를 포함하는 _response 속성을 가진 Chunk 인스턴스입니다.

놀랍네요. 방금 서버에서 클라이언트로 완료되지 않은 Promise를 전송했는데... 작동했어요? 페이로드를 살펴보죠(명확성을 위해 잘라낸 부분):

<script>self.__next_f.push([1,"... snip {\"dataPromise\":\"$@8\"}]}]]}]\n snip ...])</script>

이 작은 스니펫에서 우리는 "$@8" 값을 가진 dataPromise (prop의 이름)라는 키를 볼 수 있습니다. 조금 후에 우리는 이것을 얻게 됩니다:

<script>self.__next_f.push([1,"8:\"promise resolved data\"\n"])</script>

숫자 8이 다시 등장합니다. 응답이 스트리밍되는 것을 보면 이 마지막 script 태그가 다른 모든 태그보다 1초 늦게 도착했고, Promise에서 해결된 데이터가 포함되어 있습니다. Suspense는 이 작은 스니펫을 통해 페이지를 업데이트하는 데 사용됩니다.

<div hidden id="S:0">
  <p>promise resolved data</p>
</div>
<script>$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};$RC("B:0","S:0")</script>

참고) 해당 $RC 함수는 completeBoundary의 축소된 버전일 뿐이며, 원래 형태로 읽는 것이 더 쉽습니다.

이전 응답에서 Suspense는 다음과 같은 fallack HTML을 반환했습니다( B:0 ID 참고):

<!--$?--><template id="B:0"></template><div>Loading...</div><!--/$-->

이전 블록의 끝에는 페이지의 B:0 요소를 S:0 요소의 내용으로 기본적으로 대체하는 $RC라는 JavaScript 함수가 있었습니다. 이렇게 하면 Promise에서 해결된 데이터가 페이지에 주입됩니다. 전혀 마법이 아니라 낮은 수준의 DOM 조작일 뿐입니다.

요약하자면, React는 fallback을 렌더링하고 ID B:0을 부여했으며, ID=8인 Promise가 어느 시점에 해결될 것이라고 스스로에게 알리기 위해 기록을 했습니다. 그런 다음, 그 Promise가 해결되면 ID S:0인 숨겨진 div와 Promise ID에 키가 있는 script 태그로 변환하여 하이드레이션이 가능하도록 했습니다. 그런 다음 B:0을 S:0으로 대체하기 위해 인라인 JavaScript를 실행했습니다.

결론 및 추가 자료

여기서 톱니바퀴가 어떻게 돌아가는지 깊이 이해할 필요는 전혀 없지만, 흥미롭습니다. RSC 페이로드는 약간 신비롭고 이해하기 어렵지만, 궁극적으로 마법은 아닙니다. 저는 이것이 얼마나 복잡하고 독점적인지 좋아하지 않지만, 그것이 제가 그것을 사용하는 것을 막지는 못할 것입니다. 성숙해짐에 따라 그것이 어떻게 그리고 왜 작동하는지 설명하는 더 많은 기사와 문서가 있을 것이라고 확신하지만, 지금은 이와 같은 블로그 게시물 외에는 자료가 많지 않습니다.

이 내용을 이해하는 데 정말 유용하다고 생각되는 게시물과 자료는 다음과 같습니다.

실제로 RSC 페이로드에 대해 제가 여기서 다룬 것보다 훨씬 더 많은 내용이 있지만, 이 글은 이미 충분히 길었습니다. 곧 Promises와 RSC 페이로드를 통해 이 방식으로 보낼 수 있는 것과 보낼 수 없는 것에 대한 제약에 대해 더 짧은 글을 쓸 것입니다.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글