카테고리 없음

[Next.js] React Server Components 파헤치기

Withlaw 2024. 3. 8. 21:44

1. React Server Components (RSC)

- 리액트 서버 컴포넌트는 기존의 리액트 컴포넌트가 서버에서 실행되는 방식을 정의한 개념이다.

- 빌드 중에 실행되어 직접 파일 시스템을 통해 컨텐츠를 읽어오거나, API 없이 직접 데이터 계층에 접근할 수 있다.

- 이렇게 가져온 데이터를 클라이언트 컴포넌트에 props로 전달할 수 있다.

- JS 번들에 포함되지 않는다.

(출처: https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components)

 

 

2. Server component vs. Client component

- "서버 컴포넌트는 서버에서만 실행되고, 클라이언트 컴포넌트는 클라이언트에서만 실행된다."

- 단, 리액트 컴포넌트는 런타임 환경에 관계 없이 서버나 클라이언트 모두에서 렌더링 될 수 있다는 점에 주의하자.

- 브라우저와 같은 클라이언트 사이드나 node.js와 같은 서버 사이드 모두에서 리액트는 jsx를 렌더링하여 html을 반환한다.

 

- html 요소와 상호작용하는 코드는 js 번들에 포함된다.

- 이때 js 번들에 포함되는 것은 클라이언트 컴포넌트 로직 뿐이다.

(RSC를 사용하는 경우 js 번들에 포함시킬 요소를 구분하기 위해 'use client' 지시문을 모듈 파일에 명시한다.)

 

- 리액트 서버 컴포넌트(RSC)에서 또 중요한 특징은 '미리', 클라이언트 컴포넌트보다 '먼저' 실행된다는 것이다.

- 리믹스의 loader와 같이 서버 자원을 사용하여 html을 렌더링하고, 다른 컴포넌트에 props로 자원을 전달할 수 있다.

 

- 다시 한 번 강조하자면, 리액트 컴포넌트를 서버 컴포넌트와 클라이언트 컴포넌트로 나누는 기준은 html을 렌더링하는 물리적 위치에 의한 것이 아니다! (SSR과 RSC는 서로 다른 개념이다!)

- 서버 자원을 이용하고, 오직 서버에서만 실행되는 컴포넌트를 리액트 서버 컴포넌트(RSC)라 한다.

 

- 리액트 코드가 렌더링되는 시점에 따라 분류한 렌더링 방식은 다음과 같다.

    1. SSG (Static Site Generation): 리액트 코드가 애플리케이션이 빌드될 때 실행되며 생성된 결과물은 정적이다.

    2. SSR (Server-Side Rendering): 리액트 코드가 요청될 때 실행되며 나중을 위해 캐시될 수 있다.

    3. CSR (Client-Side Rendering): 리액트 코드가 브라우저로 전달된 후 실행되며 DOM에 삽입될 컨텐츠를 생성한다.

*Next.js에선 SSG가 기본 방식이고, 여기에서 SSR(dynamic)이나 CSR로 설정 할 수 있다.

 

 

3. RSC payload (of Next.js App router)

- 서버에서 생성된(ssr) html은 그냥 document 파일로 클라이언트에 전송된다. 

- 서버에서 fetch한 데이터는 html에 임베드 되거나, '직렬화(serialization)'되어 클라이언트 컴포넌트에 props로 전달된다.

- 사이트의 상호작용을 담당하는 부분은 주로 함수로 구현되고, 함수는 unserializable하기 때문에 서버 컴포넌트에서 처리될 수 없다.

    - 그래서 함수는 클라이언트 컴포넌트에 prop으로 전달할 수 없다. ***서버 액션은 예외다.

- 클라이언트 컴포넌트는 주로 event나 state 처리와 관련되므로, JS 번들에 포함되어 클라이언트에 전송된다.

- RSC payload에는 ssr 결과물인 html과 virtual DOM, 클라이언트 컴포넌트 참조 등이 포함된다.

 

- 아래는 Next.js App router로 빌드한 조그마한 웹 페이지의 소스인데, 이게 바로 RSC payload이다.

- 이 페이지 소스의 하단을 보면 여러 script 태그들이 포함되는 것을 확인할 수 있다.

 

- 스크립트를 열어보면 아래와 같이 페이지의 컨텐츠가 인코딩된 형식이 포함되어 있다.

- 이 형식은 리액트의 새로운 'line-based internal data streaming format'이라 부른다. (line-based: \n로 구분되는)

 

- 페이지의 컨텐츠는 여러 chunk로 분할되고, 각 스크립트 태그 내 배열로 푸쉬된다.

- 스크립트에 있는 raw React streaming data format들은 아래와 같이 줄바꿈(\n)으로 구분되는 문자열이다. ("ID:TYPE?JSON")

1:HL["/_next/static/css/77f8f57adc6c0157.css",{"as":"style"}]
0:"$L2"
3:HL["/_next/static/css/b206048fcfbdc57f.css",{"as":"style"}]
4:I{"id":2353,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
6:I{"id":7330,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"","async":false}
7:I{"id":7676,"chunks":["7095:static/chunks/7095-0fd44f107e319f96.js","3185:static/chunks/app/layout-cba4a74ece8734c4.js"],"name":"","async":false}
8:I{"id":7095,"chunks":["7095:static/chunks/7095-0fd44f107e319f96.js","3607:static/chunks/app/app-router/nested/routing/deeper/page-0fd36f02661d6d5b.js"],"name":"","async":false}
a:I{"id":9180,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
b:I{"id":2306,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
c:I{"id":1905,"chunks":["5334:static/chunks/app/static-content/2/page-8d785d74cd2a9d84.js"],"name":"","async":false}
d:I{"id":1905,"chunks":["5334:static/chunks/app/static-content/2/page-8d785d74cd2a9d84.js"],"name":"filterRawEvents","async":false}
e:I{"id":1905,"chunks":["5334:static/chunks/app/static-content/2/page-8d785d74cd2a9d84.js"],"name":"filterVirtualDom","async":false}
2:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/77f8f57adc6c0157.css","precedence":"next"}]],["$","$L4",null,{"buildId":"q_Y0XmaURTuu7dMa1dYrf","assetPrefix":"","initialCanonicalUrl":"/static-content/2/","initialTree":["",{"children":["static-content",{"children":["2",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"initialHead":["$L5",null],"globalErrorComponent":"$6","notFound":["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135567," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$L9",[],"404"]]}]]}],"asNotFound":false,"children":[["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135567," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"404","notFoundStyles":[],"childProp":{"current":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children","static-content","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children","static-content","children","2","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":[[["$","h2",null,{"children":"Virtual DOM"}],["$","p",null,{"children":["If you ",["$","span",null,{"dangerouslySetInnerHTML":{"__html":"<a href=\"#\" onClick=\"this.href='/view-source'+location.pathname+''\" class=\"view-source\" target=\"_blank\">View Source</a>"}}]," of this page, you'll see the javascript at the bottom contains script tags that contain an encoded form of this content:"]}],["$","$Lc",null,{"inline":true,"filter":"$d"}],["$","p",null,{"children":"This is React's new line-based internal data streaming format."}],["$","p",null,{"children":"The content is pushed into an array to be processed and split by newlines, which results in the content above, which is the raw React streaming data format."}],["$","h3",null,{"children":"What Is It?"}],["$","p",null,{"children":"It's a compact string representation of the virtual DOM, with abbreviations, internal references, and characters with encoded special meanings."}],["$","h3",null,{"children":"Why is it needed?"}],["$","ul",null,{"children":[["$","li",null,{"children":["It contains a Virtual DOM representation of the static page that was generated. ",["$","i",null,{"children":"(See the box below for an expanded view of the Virtual DOM of the generated page)"}]]}],["$","li",null,{"children":"If there are client (browser) React components, it needs to know where to insert them at run-time"}],["$","li",null,{"children":"When routing happens, only the modified parts of the page will be updated (we'll see this later), so it needs to have a virtual representation of the page to dynamically update the DOM"}]]}],["$","h3",null,{"children":"Current Mental Model"}],["$","p",null,{"children":["$","img",null,{"src":"/mental-model-2.png"}]}],["$","h3",null,{"children":"A few notes about this format"}],["$","ul",null,{"children":[["$","li",null,{"children":"Lines are separated with a \\n, so this is a line-based format, not JSON, for example"}],["$","li",null,{"children":"The content is actually split into chunks in the source and pushed into an array inside script tags. The above format is only visible after combining the pieces and splitting on \\n"}],["$","li",null,{"children":"Each line is in the format \"ID:TYPE?JSON\""}],["$","li",null,{"children":"This data format is not documented anywhere!"}],["$","li",null,{"children":"The exact format has been seen to change between React canary releases, so it's a moving target."}],["$","li",null,{"children":"Lines can contain references to other lines by using $ID or $LID (ex: $2 or $L2) in their content"}],["$","li",null,{"children":"This format is considered \"internal\" to React and is not meant for human consumption. You do not need to know how it works to make apps with RSC. But it's useful to see what is happening behind the scenes."}],["$","li",null,{"children":["The ",["$","a",null,{"href":"https://rsc-parser.vercel.app/","target":"_blank","children":"RSC Parser"}]," is a tool built by Alvar Lagerlöf that explores this format."]}]]}],["$","h3",null,{"children":"Virtual DOM Detailed View"}],["$","p",null,{"children":"This is React's internal representation of the current page/DOM structure. If it needs to update the page, it can compare what it wants with what it knows is there, and perform an efficient update to the DOM."}],["$","p",null,{"children":"You can see all the visible content on this page within this Virtual DOM."}],["$","$Lc",null,{"inline":true,"filter":"$e"}],["$","h3",null,{"children":"Virtual DOM Reconciliation"}],["$","p",null,{"children":"When the page loads, React does a reconciliation between the Virtual DOM that it thinks represents the page, and the actual static DOM that the server returned. If any differences are found, it throws a console error. This is because it won't be able to accurately update the DOM if it doesn't have the correct structure representation. We will explore why this could happen in just a bit."}],["$","p",null,{"children":["$","img",null,{"src":"/mental-model-3.png"}]}],["$","p",null,{"children":["$","a",null,{"className":"button","href":"/client-components/","children":"Integrating Client Components"}]}]],null],"segment":"__PAGE__"},"styles":[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/b206048fcfbdc57f.css","precedence":"next"}]]}],"segment":"2"},"styles":[]}],"segment":"static-content"},"styles":[]}]]}]]}],null]}]]
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
5:[["$","meta","0",{"charSet":"utf-8"}],["$","title","1",{"children":"Demystifying React Server Components with NextJS 13 App Router"}],["$","meta","2",{"name":"description","content":"Understand RSC by digging into the details of how it really works"}],["$","meta","3",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","4",{"property":"og:image:type","content":"image/png"}],["$","meta","5",{"property":"og:image:width","content":"1200"}],["$","meta","6",{"property":"og:image:height","content":"630"}],["$","meta","7",{"property":"og:image","content":"https://demystifying-gl2gttyup-matt-kruse.vercel.app/opengraph-image.png?6e09e2f872185621"}],["$","meta","8",{"name":"twitter:card","content":"summary"}],["$","meta","9",{"name":"twitter:image:type","content":"image/png"}],["$","meta","10",{"name":"twitter:image:width","content":"1200"}],["$","meta","11",{"name":"twitter:image:height","content":"630"}],["$","meta","12",{"name":"twitter:image","content":"https://demystifying-gl2gttyup-matt-kruse.vercel.app/opengraph-image.png?6e09e2f872185621"}],["$","link","13",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]]

(*React canary releases에 소개된 내용이며 언제든 바뀔 수 있다.)

 

- 이 문자열에는 서버에서 생성된 정적 virtual DOM과 여러 약어, 내부 참조, 특별한 의미 있는 인코딩된 문자 등이 압축되어 있다.

- 정적 virtual DOM은 페이지를 탐색할 때 수정된 부분만 동적으로 업데이트 하기 위해 사용된다.

- $ID 혹은 $LID (ex: $2 or $L2)와 같은 문자는 다른 줄을 참조할 때 사용된다.

- 클라이언트 컴포넌트가 존재할 경우 런타임 때 이들을 삽입할 위치와 참조할 js에 관한 정보를 담고 있다.

 

- 페이지가 로드되면 리액트는 서버에서 받은 virtual DOM(RSC에 포함된)과 브라우저에서 생성한 DOM을 reconciliate한다.

- 만약 두 virtual DOM 간에 차이점이 발견되면 리액트는 오류를 내보낸다.

 

- 여러 상호작용이나 useState 같은 훅은 전형적인 리액트 컴포넌트로, 서버가 아닌 브라우저에서 실행된다.

- RSC는 서버 컴포넌트를 디폴트로 간주하고, 브라우저에서 실행되어야 하는 것은 클라이언트 컴포넌트로 처리한다.

- Next.js의 Pages Router와 App Router의 근본적인 차이점은 이 부분이다. 

- Pages Router에서는 클라이언트 컴포넌트를 디폴트로 하고, SSR을 제어하기 위해 getServerProps() 같은 메소드로 서버 사이드 동작을 처리한다.

 

- 아래 ClientComponent는 js 번들에 포함되어 브라우저에 전달된 후, 브라우저에서 html을 렌더링하고 DOM에 삽입된다.

'use client'
export const ClientComponent = () => {
  console.log('Running client component');
  return <div className="client-component">Client Component HTML</div>
}

- 하지만 페이지의 소스를 확인해보면 div.client-component 요소가 static html에 포함되어 있는 것을 확인할 수 있다.

- 기본적으로 클라이언트 컴포넌트도 서버에서 pre-rendering되어 static html에 포함되는데, 이는 한 동안 SSR라고 불리며 사용되어 왔다.

- RSC는 이와 다르다. 브라우저 콘솔을 확인해보면 'Running client component' 로그 문을 확인할 수 있다. (서버 로그에서도 확인 가능)

 

- 위에서 살펴 본 RSC payload에서 virtual DOM과 관련 있는 부분은 다음과 같다.

c:I{"id":6135,"chunks":["6207:static/chunks/app/client-components/page-96de3ae5bce9c2a7.js"],"name":"ClientComponent","async":false}
2:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/77f8f57adc6c0157.css","precedence":"next"}]],["$","$L4",null,{"buildId":"q_Y0XmaURTuu7dMa1dYrf","assetPrefix":"","initialCanonicalUrl":"/client-components/","initialTree":["",{"children":["client-components",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],"initialHead":["$L5",null],"globalErrorComponent":"$6","notFound":["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135567," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$L9",[],"404"]]}]]}],"asNotFound":false,"children":[["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135567," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"404","notFoundStyles":[],"childProp":{"current":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children","client-components","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":[[["$","h2",null,{"children":"Client Components"}],["$","p",null,{"children":"If components require interactivity or hooks like useState, or are basically any \"typical\" React component, then they expect to run in the browser, not on the server."}],["$","p",null,{"children":"The default with RSC is to treat components as server components, and you must opt into treating them as client components that run in the browser."}],["$","p",null,{"children":"This differs with the previous /pages directory in NextJS, for example, where everything is a client component and you opted into server-side behavior by defining a getServerProps() method etc to control SSR."}],["$","h3",null,{"children":"Example Client Component Output"}],["$","$Lc",null,{}],["$","h3",null,{"children":"Client Component Source"}],["",["$","pre",null,{"className":"code","children":"'use client'\nexport const ClientComponent = ()=>{\n  console.log('Running client component');\n  return <div className={\"client-component\"}>Client Component HTML</div>\n}\n"}]],["$","p",null,{"children":"This component was delivered as javascript to the browser where it ran, generated html, and then that html was inserted into the DOM."}],["$","p",null,{"children":["But if you ",["$","span",null,{"dangerouslySetInnerHTML":{"__html":"<a href=\"#\" onClick=\"this.href='/view-source'+location.pathname+'?highlight=Client Component HTML'\" class=\"view-source\" target=\"_blank\">View Source</a>"}}]," you will see that static html was returned from this client component. Why would that happen if it's a client component?"]}],["$","p",null,{"children":"Because by default, client components are pre-rendered on the server, and their html is included in the static output. This is called SSR and has been around for a while. It's different than RSC."}],["$","p",null,{"children":"If you look in your browser console, you will see a log statement showing that the client component DID run."}],["$","code",null,{"children":"\"Running client component\""}],["$","p",null,{"children":"So let's dissect what just happened:"}],["$","h3",null,{"children":"Virtual DOM"}],["$","p",null,{"children":"The relevant pieces of the React Virtual DOM for the client component are below."}],["$","p",null,{"children":"The 1st line is a reference to the JS file that contains the component."}],["$","p",null,{"children":"The 2nd line is the entire Virtual DOM."}],["$","$Ld",null,{"inline":true,"filter":"$e"}],["$","p",null,{"children":"Below we have chopped down the long Virtual DOM line to show the important part:"}],["$","$Ld",null,{"inline":true,"filter":"$f","escapeHtml":false}],["$","p",null,{"children":"The highlighted part of the second row is a reference of the form $L[id] where the Client Component should be in the DOM (right after the header, see above in the page). This tells React to resolve the [id] line and if/when it exists, execute the component and insert its output in this position in the DOM."}],["$","h3",null,{"children":"'use client'"}],["$","p",null,{"children":"The reason that this component was split into a separate js file and inserted as a reference in the Virtual DOM is because this is a Client Component."}],["$","p",null,{"children":["The ",["$","code",null,{"style":{"display":"inline"},"children":"'use client'"}]," directive at the top of the Component's source file is what told the bundler and RSC to treat it that way."]}],["$","p",null,{"children":"More on that later."}],["$","h3",null,{"children":"Hydration"}],["$","p",null,{"children":"As React reconciles the DOM created from the static html with its Virtual DOM, it recognizes that part of the static DOM was SSR output from a Client Component. It knows this because the $L[id] reference in the Virtual DOM is in place of the html in the static DOM."}],["$","p",null,{"children":"At this point, React renders the Client Component. The resulting Client Component html is inserted into the DOM where the static html is currently."}],["$","p",null,{"children":"This is called Hydration - turning static SSR html into dynamic client-side (CSR) html."}],["$","h3",null,{"children":"Current Mental Model"}],["$","p",null,{"children":"At this point, React also compares the SSR html to the CSR html, and expects them to be identical. This is because it assumes the initial state of the component as rendered on the server should match the initial state of the component as rendered in the browser."}],["$","p",null,{"children":["$","img",null,{"src":"/mental-model-4.png"}]}],["$","p",null,{"children":["But they aren't ",["$","b",null,{"children":"always"}]," the same..."]}],["$","a",null,{"className":"button","href":"/client-components/hydration-failed/","children":"Hydration Failure"}]],null],"segment":"__PAGE__"},"styles":[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/b206048fcfbdc57f.css","precedence":"next"}]]}],"segment":"client-components"},"styles":[]}]]}]]}],null]}]]

- 첫 번째 줄은 클라이언트 컴포넌트를 포함하고 있는 JS 파일에 관한 참조이고, 두 번째 줄은 virtual DOM이다.

 

- 아래 virtual DOM에서 빨간색으로 표시한 $L[id] 부분이 바로 그 위치에 삽입될 클라이언트 컴포넌트에 관한 참조 형식이다.

- 리액트는 [id]에 해당하는 'c' 줄을 읽고, 해당 컴포넌트를 렌더링하여 DOM의 그 위치에 삽입한다.

c:I{"id":6135,"chunks":["6207:static/chunks/app/client-components/page-96de3ae5bce9c2a7.js"],"name":"C...
2: ... ["$","h3",null,{"children":"Example Client Component Output"}],["$","$Lc",null,{}] ...

 

- 클라이언트 컴포넌트는 이렇게 실제 코드는 JS 파일에 포함되고, virtual DOM을 통해 참조될 수 있다.

- 이것이 바로 'use client' 지시문의 역할이다.

- 번들러는 이 지시문을 읽으면 해당 모듈을 자체 url을 갖는 JS 파일로 분할하고, 필요할 때 로드 되도록(lazy-loading) 설정한다.

- RSC의 static virtual DOM에는 이 컴포넌트에 대한 '참조 표시자'를 포함한다.

- 컴파일러는 RSC를 통해 언제 이 컴포넌트가 필요한 지, 언제 이 컴포넌트의 JS 파일을 로드해야 하는 지 알 수 있다.

 

 

4. Hydration

- 리액트는 브라우저에서 static html의 DOM과 virtual DOM을 조화(reconcile)시키면서 DOM의 일부가 SSR된 클라이언트 컴포넌트라는 것을 확인한다.

- virtual DOM의 $L[id] 참조에 대한 부분(클라이언트 컴포넌트)이 static DOM의 동일한 위치에 있기 때문에 이를 알 수 있다.

- 리액트는 해당 참조를 읽어 클라이언트 컴포넌트를 렌더링하고 DOM에 삽입한다.

- 이 과정을 Hydration이라 한다. static SSR HTML을 dynamic CSR HTML로 바꾸는 것이다.

- 이때 서버에서 렌더링된 컴포넌트의 초기 상태와 클라이언트에서 렌더링된 컴포넌트의 초기 상태가 일치해야 한다.

 

 

- Hydration: 서버에서 렌더링 된 클라이언트 컴포넌트를 브라우저에서 다시 렌더링하여 업데이트 하는 과정.

- 페이지가 로드되면 서버에서 미리 렌더링된 static html의 DOM이 생성되고, 리액트는 이것과 RSC의 virtual DOM을 비교한다.

- 만약 서버에서 렌더링된 부분과 클라이언트에서 렌더링된 부분이 서로 다르면 mismatch 에러를 내보낸다. 

- 이 에러는 hydration 과정 중에 일어나므로 리액트 Supense boundary 외부에서 발생하게 된다.

- 리액트는 차이가 난 부분을 조정하기 위해 루트 전체를 클라이언트 렌더링으로 전환한다.

- RSC 내 virtual DOM에는 클라이언트 컴포넌트 뿐만 아니라 모든 static contents가 포함되어 있으므로 리액트는 이를 통해 문서를 완전히 다시 렌더링 할 수 있다.

 

- 아래 컴포넌트는 hydration 에러를 발생시키지 않는다.

'use client'
import {useEffect, useState} from "react";

export default ()=>{
  const [isServer,setServer] = useState(true);
  useEffect(setServer,[]);

  return (
      <p class={"client-component"}>
        This content is generated client-side. Datestamp: {isServer ? '' : Date.now()}
      </p>
  );
}

- 이 컴포넌트가 렌더링되는 과정을 살펴보자.

- 먼저 로컬 상태 변수를 사용하여 현재 서버에서 실행 중인지 여부를 추적한다.

- 기본 값이 'true'이기 때문에 서버에서 실행될 때 'isServer === true' 조건으로 렌더링된다.

- 'useEffect' 훅은 서버 사이드에서 실행되지 않으므로 SSR 중에는 'setServer()'가 호출되지 않아 'isServer' 값은 'true'로 유지된다.

- 'useEffect' 훅은 브라우저에서 컴포넌트가 처음 렌더링된 이후에 실행되기 때문에 isServer는 hydration 과정에서도 true 값을 갖는다.

- 따라서 서버 출력('')과 클라이언트 출력('')은 서로 일치하므로 hydration 에러는 발생하지 않는다.

 

 

5. SSR을 비활성화 하는 방법 (by next/dynamic)

import dynamic from 'next/dynamic'
const ClientComponent = dynamic(() => import('./ClientComponent'), {
  ssr: false
})
export const ClientComponentNoSSR = ()=><ClientComponent/>

- next/dynamic은 next.js에서 React.lazy()와 <Suspense>를 추상화 한 것이다.

- dynamic(()=>{...}, { ssr:false })에서 'ssr' 속성은 이 컴포넌트가 서버에서 렌더링(ssr)될 지 여부를 설정하는 옵션이다.

 

- no-ssr 컴포넌트는 static html에 다음과 같이 임베드된다.

<!--$!--><template data-dgst="DYNAMIC_SERVER_USAGE" data-msg="DYNAMIC_SERVER_USAGE" 
data-stck=" at ServerComponentWrapper (C:\workspace\demystifying-rsc
\node_modules\next\dist\server\app-render\create-server-components-renderer.js:78:31) 
at InsertedHTML (C:\workspace\demystifying-rsc\node_modules\next\dist\server\app-render
\app-render.js:835:33)"></template><!--/$-->

- 클라이언트 사이드에서 reconciliation이 일어날 때 이 부분은 단순히 클라이언트 컴포넌트가 렌더링 될 '자리'를 표시하는 역할이기 때문에 hydration error를 일으키지 않는다.

- 클라이언트 컴포넌트가 렌더링되면 위 부분을 대체하게 된다.

 

 

6. Suspense

- RSC의 static virtual DOM에서 '$Sreact.suspense'는 <suspense> 태그와 관련된 코드를 살펴보자.

1:HL["/_next/static/css/77f8f57adc6c0157.css",{"as":"style"}]
0:"$L2"
3:HL["/_next/static/css/b206048fcfbdc57f.css",{"as":"style"}]
4:I{"id":2353,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
6:I{"id":7330,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"","async":false}
7:I{"id":7676,"chunks":["7095:static/chunks/7095-0fd44f107e319f96.js","3185:static/chunks/app/layout-cba4a74ece8734c4.js"],"name":"","async":false}
8:I{"id":7095,"chunks":["7095:static/chunks/7095-0fd44f107e319f96.js","3607:static/chunks/app/app-router/nested/routing/deeper/page-0fd36f02661d6d5b.js"],"name":"","async":false}
a:I{"id":9180,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
b:I{"id":2306,"chunks":["2272:static/chunks/webpack-b3d267849f2da05c.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
c:"$Sreact.suspense"
d:I{"id":761,"chunks":["1039:static/chunks/app/client-components/next-dynamic/page-53c45634ecd3ca4f.js"],"name":"NoSSR","async":false}
f:I{"id":1905,"chunks":["5334:static/chunks/app/static-content/2/page-8d785d74cd2a9d84.js"],"name":"","async":false}
10:I{"id":1905,"chunks":["5334:static/chunks/app/static-content/2/page-8d785d74cd2a9d84.js"],"name":"filterRawEvents","async":false}
2:[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/77f8f57adc6c0157.css","precedence":"next"}]],["$","$L4",null,{"buildId":"q_Y0XmaURTuu7dMa1dYrf","assetPrefix":"","initialCanonicalUrl":"/client-components/next-dynamic/","initialTree":["",{"children":["client-components",{"children":["next-dynamic",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"initialHead":["$L5",null],"globalErrorComponent":"$6","notFound":["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135484," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$L9",[],"404"]]}]]}],"asNotFound":false,"children":[["$","html",null,{"lang":"en","children":[["$","head",null,{"children":["$","$L7",null,{"id":"watcher","src":"/RSCObserver.js","strategy":"beforeInteractive"}]}],["$","body",null,{"children":[["$","div",null,{"className":"page-info","children":["/layout.js"," ",null," @ ",1688734135484," ",["$","$L8",null,{"href":"/","children":"[Home]","prefetch":false}]]}],["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"404","notFoundStyles":[],"childProp":{"current":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children","client-components","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":["$","$La",null,{"parallelRouterKey":"children","segmentPath":["children","client-components","children","next-dynamic","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$Lb",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","childProp":{"current":[[["$","h2",null,{"children":"Disabling Component SSR Server-Side"}],["$","$c",null,{"fallback":null,"children":["$","$Ld",null,{"children":"$Le"}]}],["$","p",null,{"children":["The above component is a client component that does not trigger the hydration errors - ",["$","b",null,{"children":"because it never ran on the server!"}]," Did you notice how it \"popped in\"? Reload to see it again - remember, the red outline highlights elements that are dynamically inserted into the DOM. That's because there was no initial html rendered and delivered to the browser. After the page loaded, the Client Component loaded and rendered itself."]}],["$","p",null,{"children":"The source doesn't use the state tricks in the previous example to ensure that the SSR output matches the CSR output:"}],[["$","span",null,{"className":"file-source-title","children":"ClientComponent.js"}],["$","pre",null,{"className":"code","children":"'use client'\nexport default () => <p className={\"client-component\"}>\n  This content is generated client-side. Datestamp: {Date.now()}\n</p>\n"}]],["$","h3",null,{"children":"import with next/dynamic"}],["$","p",null,{"children":"The difference here is that we are importing the component in a different way in our RSC code. We've created a wrapper component around the Client Component that imports it using next/dynamic:"}],[["$","span",null,{"className":"file-source-title","children":"ClientComponentSSRWrapper.js"}],["$","pre",null,{"className":"code","children":"import dynamic from 'next/dynamic'\nconst ClientComponent = dynamic(() => import('./ClientComponent'), {\n  ssr: false\n})\nexport const ClientComponentNoSSR = ()=><ClientComponent/>\n"}]],["$","h3",null,{"children":"Key Points"}],["$","ol",null,{"children":[["$","li",null,{"children":"next/dynamic is just a composite of React.lazy() and <Suspense>"}],["$","li",null,{"children":"The second options argument to dynamic() set ssr:false to tell the server to skip this component entirely when rendering html."}]]}],["$","h3",null,{"children":"Generated HTML"}],["$","p",null,{"children":"So if the component doesn't SSR, what is put in its place in the generated static html?"}],["$","p",null,{"children":["If you ",["$","span",null,{"dangerouslySetInnerHTML":{"__html":"<a href=\"#\" onClick=\"this.href='/view-source'+location.pathname+'?highlight=/<!--\\\\$!-->[\\\\s\\\\S]*?<!--\\\\/\\\\$-->/'\" class=\"view-source\" target=\"_blank\">View Source</a>"}}]," of this page, you'll see the static html that gets generated in place of where the Client Component will evenually be rendered:"]}],["$","pre",null,{"className":"code wrap","children":"<!--$!--><template data-dgst=\"DYNAMIC_SERVER_USAGE\" data-msg=\"DYNAMIC_SERVER_USAGE\" data-stck=\" at ServerComponentWrapper (C:\\workspace\\demystifying-rsc\\node_modules\\next\\dist\\server\\app-render\\create-server-components-renderer.js:78:31) at InsertedHTML (C:\\workspace\\demystifying-rsc\\node_modules\\next\\dist\\server\\app-render\\app-render.js:835:33)\"></template><!--/$-->"}],["$","p",null,{"children":"When React reconciles the static html above with the Virtual DOM - which contains a reference to the Client Component - it doesn't generate a hydration error because the above content is a valid placeholder and it knows that a Client Component will go there."}],["$","p",null,{"children":"When the Client Component generates its html, that html entirely replaces the content above in the document."}],["$","h3",null,{"children":"The Virtual DOM for Client-Only Components"}],["$","p",null,{"children":"The Virtual DOM representation of this page has more content and a few new things, like $Sreact.suspense:"}],["$","$Lf",null,{"filter":"$10","inline":true}],["$","p",null,{"children":"What's going on here?"}],["$","p",null,{"children":"Let's dig in a little more..."}],["$","p",null,{"children":["$","a",null,{"className":"button","href":"/client-components/details/","children":"Client Component Details"}]}]],null],"segment":"__PAGE__"},"styles":[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/b206048fcfbdc57f.css","precedence":"next"}]]}],"segment":"next-dynamic"},"styles":[]}],"segment":"client-components"},"styles":[]}]]}]]}],null]}]]
11:I{"id":7735,"chunks":["1039:static/chunks/app/client-components/next-dynamic/page-53c45634ecd3ca4f.js"],"name":"","async":false}
e:["$","$L11",null,{}]
9:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
5:[["$","meta","0",{"charSet":"utf-8"}],["$","title","1",{"children":"Demystifying React Server Components with NextJS 13 App Router"}],["$","meta","2",{"name":"description","content":"Understand RSC by digging into the details of how it really works"}],["$","meta","3",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","4",{"property":"og:image:type","content":"image/png"}],["$","meta","5",{"property":"og:image:width","content":"1200"}],["$","meta","6",{"property":"og:image:height","content":"630"}],["$","meta","7",{"property":"og:image","content":"https://demystifying-gl2gttyup-matt-kruse.vercel.app/opengraph-image.png?6e09e2f872185621"}],["$","meta","8",{"name":"twitter:card","content":"summary"}],["$","meta","9",{"name":"twitter:image:type","content":"image/png"}],["$","meta","10",{"name":"twitter:image:width","content":"1200"}],["$","meta","11",{"name":"twitter:image:height","content":"630"}],["$","meta","12",{"name":"twitter:image","content":"https://demystifying-gl2gttyup-matt-kruse.vercel.app/opengraph-image.png?6e09e2f872185621"}],["$","link","13",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]]

 

- 간략히 나타내면 다음과 같다.

c:"$Sreact.suspense"
d:I{"id":761,"chunks":["1039:static/chunks/app/client-components/next-dynamic/page-53c45634ecd3ca4f.js"],"name":"NoSSR","async":false}
2: ... ["$","$c",null,{"fallback":["$","div",null,{"className":"client-component","children":"Loading Slow Client Component..."}],"children":["$","$Ld",null,{"children":"$Le"}]}] ...
10:I{"id":9143,"chunks":["2567:static/chunks/app/client-components/details/page-89104db9084bb225.js"],"name":"ClientComponent","async":false}

- '2:'로 시작하는 세 번째 줄은 virtual DOM에 해당하는 부분으로, '$c' 코드가 포함되어 있다. 

- 이는 'c' 줄을 참조하는 코드인데, html을 렌더링할 때 '$c'이 있던 자리에 <suspense>를 삽입한다.

- 그 다음의 'fallback' 속성은 페이지가 로드될 때 해당 자리에서 처음 화면에 나타나는 컴포넌트이다.

- 그 다음의 'children' 속성에는( "children":["$","$Ld",null,{"children":"$Le"}]}  부분) SSR을 하지 않고서 임포트된 클라이언트 컴포넌트에 대해 Next.js가 자동으로 삽입한 래퍼에 대한 참조가 온다.

  - 이 래퍼에는 내부적으로 해당 부분이 서버에서 html이 생성되지 않도록 하는(NoSSR) 코드가 존재한다.

  - 그리고 클라이언트 환경에서(typeof window !== 'undefined') 실행되면 이 속성의 'children'을 반환한다.

- NoSSR 컴포넌트는 내부적으로 Ract.lazy()을 사용하여 클라이언트 컴포넌트를 동적으로 로드한다.

- 이 NoSSR 래퍼의 children 속성은("children":"$Le") 리졸브 되면 화면에 나타나는 클라이언트 컴포넌트에 대한 참조이다.

 

- 클라이언트 컴포넌트의 소스 코드는 다음과 같다.

// ClientComponent.js
'use client'
function pause(t) {
  return new Promise(r=>setTimeout(r,t))
}
export const ClientComponent = async() => {
  await pause(3000);
  return <p className={"client-component"}>
    This content is generated client-side. Datestamp: {Date.now()}
  </p>
}

- 이 클라이언트 컴포넌트는 비동기로 정의되며, 컨텐츠 반환을 지연시키고 있다.

- *비동기 클라이언트 컴포넌트는 React 18의 canary 릴리스에서만 사용할 수 있다.

 

- 이 클라이언트 컴포넌트를 사용하기 위해 다음과 같이 래퍼 서버 컴포넌트로 래핑하여 서버에서 실행되는 것을 방지한다.

import dynamic from 'next/dynamic'
const ClientComponent = dynamic(() => import('./ClientComponent.js').then(m=>({"default":m.ClientComponent})), {
  ssr: false,
  loading: () => <div className={"client-component"}>Loading Slow Client Component...</div>
})
export const ClientComponentNoSSR = ()=><ClientComponent/>

- *then()의 'default' 구문은 next/dynamic에서 React.lazy()를 사용하기 위해 필요한 'hack'이다. (named export)

- next/dynamic은 번들러에게 해당 클라이언트 컴포넌트 소스를 별도로 번들링하여 조건부로 브라우저에 보내도록 한다.

 

 

 

 

참고: https://demystifying-rsc.vercel.app/