본문 바로가기

FrontEnd

리액트 서버 컴포넌트와 리믹스[React Server Components and Remix]

반응형

리믹스와 리액트 서버 컴포넌트의 관계를 알아봅니다.

원문 링크입니다 : https://remix.run/blog/react-server-components

 

React Server Components and Remix

The current state of React Server Components their future in Remix.

remix.run

Remix v1 릴리스 후 한 질문이 계속 반복되었습니다.
리액트 서버 컴포넌트는요?

 

좋은 질문입니다!
많은 분들과 마찬가지로 우리는 2018년에 처음 발표된 이후로 React Suspense를 계속 실험해 왔습니다.
사실, 초기 버전의 Remix에서 해당 API를 사용했습니다만, 리믹스의 출시 준비가 되었을때 해당 기능이 공개되지 않을 것임을 알고나서,
Remix의 비동기 파트를 직접 구축했으며 결과에 매우 만족했습니다.

 

React Server Components(RSC)가 요새 더 많은 관심을 받고 있는 것 같아서,
Remix에서 이를 활용하는 가장 좋은 방법을 찾기 위해 다시 살펴보고 몇 가지 테스트를 수행했습니다.
 
 
우리는 RSC가 아직 실험적임을 잘 알고 있기 때문에,
이 연구가 RSC와 Remix에 관계에 대해 마지막으로 언급하는 것은 아닐 것입니다.
우리는 현재 Remix(및 React Router)를 사용하고 있으며, RSC 사용에 대해 궁금해하는 모든 사람들을 위해
우리의 관점을 공유하는 것이 유용할 것이라고 생각했습니다.
 
그러나 먼저 약간의 배경 지식이 필요합니다. 리액트 서버 컴포넌트란 뭘까요?

UX에의 집착

Remix 개발팀은 사용자 경험(UX)에 절대적으로 집착합니다.
우리가 주의 깊게 관찰하는 한 가지 주요 사항은 브라우저의 네트워크 탭입니다.
네트워크 탭이 엉망이라면 UX는 아마도 엉망일 것입니다.
즉, 이는 튀는 스피너, 느린 로드 시간을 의미합니다.
네트워크 탭이 깨끗하면, UX는 멋지고 앱의 반응성이 충분히 좋을 것입니다.
앱에서 데이터를 로드하는 방법은 네트워크 탭에 영향을 줍니다.
 
 
오늘날의 React 생태계에는 앱에 데이터를 로드하는 세 가지 방법이 있습니다.
 

1. Render-Fetch Waterfalls(렌더링 시 가져오기)

JavaScript 번들이 로드 및 컴포넌트가 렌더링된 후 브라우저에서 컴포넌트 내부의 데이터를 가져오는 것을 말합니다.

번들 로드, 렌더링 뒤에 데이터 가져오기를 시작한 후, 동일한 작업을 수행하는 하위 컴포넌트들을 렌더링하기 때문에 이를 "폭포"라고 부릅니다.

모듈 로드 → 렌더(스피너) → 가져오기 → 자식 렌더링(더 많은 스피너) → 자식 가져오기 → etc...

스피너를 렌더링하고 표시하는 것은 각각 폭포수의 각 단계입니다.

우리 홈페이지(take a scroll down our home page)를 아래로 스크롤하여 이 데이터 로드 방법이 UI에 미치는 영향을 확인하십시오.

이러한 리소스를 UI 계층 구조에 연결하여 인공적인 데이터 및 모듈 계층 구조를 생성합니다.

렌더링할 때까지 무엇을 가져올지 모르며 부모의 데이터를 가져올 때까지 렌더링할 수 없습니다!

이는 UI에 "버벅거림"을 생성하고 상위 뷰가 이미 렌더링된 후 하위 뷰가가 페이지에 팝업될 때

cumulative layout shift(CLS)를 일으키는 경향이 있습니다.
 

2. Fetch, Then Render(가져온 다음 렌더링)

페이지를 렌더링하기 전에 모든 데이터를 가져온 다음 전체 페이지를 한 번에 렌더링합니다.
이것은 Remix의 기본 동작입니다. 또한 대부분의 웹사이트가 수십 년 동안 동작해 온 방식이기도 합니다.
중첩 경로로 인해 Remix는 URL에서 페이지(JS 모듈, 데이터, 심지어 CSS까지)에 대한 모든 의존성을 인식하므로
모든 쿼리를 실행하고 리소스를 병렬로 로드할 수 있습니다.
사용자가 페이지를 방문할 것이라고 생각할 때 해당 리소스를 미리 가져올 수도 있습니다.
이 게시물에서 볼 수 있듯이 이는 초기 페이지 로드 및 후속 탐색에 긍정적인 영향을 미칩니다.
 

3.Render As You Fetch(렌더링 하면서 가져오기)

가져온 다음 렌더링과 마찬가지로 모든 로드를 병렬로 시작하지만 모든 리소스를 기다리지는 않습니다.
대신, 데이터가 준비되면 컴포넌트 조각을 렌더링합니다.
이는 또한 가져온 다음 렌더링할 수 없는 경우에는 불가능한 방법입니다.
빈 div 대신 사용자에게 최대한 빨리 뭔가를 보여주기 위한 최적화 입니다.
  
오늘날 React 생태계의 거의 모든 앱은 render-fetch 폭포수(waterfall)를 사용합니다.
이는 react-query, useSWR, Apollo Client 등과 같은 라이브러리를 포함하여
useEffect() 훅 내에서 실행되는 모든 데이터 가져오기의 기본 동작입니다.
기본적으로(Out of the box) React Server 컴포넌트는 render-fetch 폭포수입니다.
가져오기는 컴포넌트 내부에서 수행되기 때문에 컴포넌트가 렌더링될 때까지 앱은 무엇을 가져올지 모릅니다.
 
문제는 세 가지 중 render-fetch 폭포수가 최악의 UX를 제공한다는 것입니다.
그 이유를 알아보기 위해 몇 가지 테스트를 실행해 보겠습니다.

리액트 팀의 데모를 리믹스 버전으로 변경해 보았습니다.

Facebook의 핵심 React 팀의 the React Server Components demo를 가져와서
코드를 Remix의 경로 규칙에 맞게 조절한 다음
두 버전을 모두 호주의 서버에 배포하여 미국에서 경험했습니다.
 
Remix 버전은 React Server 구성 요소를 사용하지 않는 React 17의 평범한 Remix입니다.
제 목표는 각각에서 어떤 종류의 성능을 얻을 수 있는지 확인하고
Remix가 RSC의 이점을 얻을 수 있는 부분을 확인하는 것이었습니다.

RSC는 아직 실험적 API에 불과하고 이것은 장난감 앱에 불과하지만

초기 페이지 로드에서 Remix가 RSC보다 두 배 이상 빠르다는 사실에 놀랐습니다

 

네트워크 탭을 보면 RSC가 요청의 계단식 폭포를 생성하는 동안

Remix가 리소스를 병렬로 로드하는 방법을 볼 수 있습니다.

코드를 가져오고, 렌더링하고, 서버 컴포넌트를 가져오고, 렌더링합니다. 이 UI에는 중첩 구조도 없습니다.

Remix가 RSC를 훨씬 능가하는 것이 더욱 놀랍습니다.

중첩된 UI를 로드하는 것은 Remix가 RSC보다 더 빛나는 곳입니다. (렌더링 폭포수 때문)

 

하지만 저는 React 팀의 데모는 초기 서버 렌더링 중에 응답을 스트리밍하는 기능인 SSR Streaming.

즉, React Server Components의 킬러 기능을 실제로 활용하지 않는다는 것도 알고 있습니다.

스트리밍 렌더링 없이 RSC는 단지 컴포넌트 내부에서 데이터를 가져오는 하나의 방법일 뿐입니다.


SSR Streaming, Next.js Demo

따라서 스트리밍 서버 렌더링 기능을 포함하는 React Server Components 데모를 기준으로 측정해 보겠습니다.
몇 년 동안 이 기능을 상당히 기대하고 있었습니다.
Next.js Hacker News clone 클론 코드를 Remix의 데이터 로딩 규칙에 섞어서 두 가지가 나란히 어떤 느낌인지 확인했습니다.
그런 다음 두 앱을 모두 Vercel에 배포하여 동일한 서버에서 실행되도록 했습니다.
 
이번에는 정말 Remix가 질 꺼라고 예상했습니다.

Remix vs React Server Components Streaming + Next.js

다시 , Remix는 HTTP 캐싱 없이도 RSC 및 Next.js보다 2배 이상 빠릅니다.

(실제 HN에는 사용자 데이터가 캐싱되어 있으므로 요청이 날아가지 않습니다).

또한 Remix 버전에는 스피너가 표시되지 않았으며 콘텐츠 레이아웃 시프트도 없었습니다.

또한 여기에도 중첩된 UI가 없지만

Remix는 여전히 Next.js + RSC + SSR 스트리밍보다 2배 빠르게 로드됩니다. (stale-while-revalidate 캐싱의 경우 5배).


Remix는 RSC의 이점을 모두 누릴 수 있습니다.

RSC의 전략은 Render As You Fetch입니다. (렌더링 하면서 가져오기)
RSC 만으로는 렌더링 하면서 가져오기에 충분하지 않습니다. (어쨌든 폭포수가 존재한다는 말임.)
렌더링 전에 리소스의 병렬 로드를 시작하려면 프레임워크가 필요합니다.
이 데모 중 어느 것도 이것이 중요해지는 중첩이 없습니다.
 
Facebook에는 멋진 컴파일러, 백엔드 인프라, 렌더링하기 전에 무엇을 가져와야 하는지 알기 위해
당신과 나보다 훨씬 많은 급여를 받는 여러 엔지니어 팀이 있습니다.
 
하지만 당신에게는 리믹스가 있습니다 🤗
 
오늘날의 환경에서 Remix는 Suspense, RSC 및 SSR Streaming을 최대한 활용할 수 있는 고유한 위치에 있습니다.
URL(라우터)을 통해 페이지에 대한 모든 것을 이미 알고 있으며 이는 React가 렌더링 하면서 가져올 때에 필요한 것입니다.
 
또한 Remix는 이미 {name}.client.js 및 {name}.server.js 파일 규칙을 포함하여
서버와 클라이언트 코드를 함께 배치하는 이점이 있습니다.
필수는 아니지만 컴파일러에 어떤 파일이 어느 위치에서 실행하는지 알려줍니다.
 
개발자 경험을 위해 Remix 경로(route) 모듈은 이미 "서버 컴포넌트"입니다.
RSC를 사용하는 것은 Remix 자체의 구현 세부 사항일 뿐입니다.
 
Remix가 RSC를 채택될 준비가 되면 마이그레이션은 경로 파일 중 하나의 이름을 바꾸는 것만큼 쉬울 것입니다.
git mv routes/posts.tsx routes/posts.server.tsx
그러나 우리는 RSC가 안정화되고 여기에서 본 성능 및 UX 문제가 없을 때까지 기다릴 것입니다.
우리가 수행한 실제 테스트는 Remix + RSC vs Remix Alone입니다.
Remix + RSC가 더 나은 사용자 경험을 제공한다면 그것으로도 충분합니다.
그러나 이미 현재 데모를 2배 이상 이기고 있을 때 그러한 노력을 기울이는 것을 정당화하기 어렵습니다.
 
다시 말하지만, 이는 가짜 앱의 데모이므로 우리는 이 실험 결과를 엄청 진지하게 받아들이지는 않습니다.
그러나 우리는 RSC가 네트워크 탭에서 만드는 절충안과 관련하여 상당한 우려를 가지고 있습니다.

제로 사이즈 번들 vs 무한 크기 번들

오늘날 웹 개발 시대의 정신은 초기 페이지 로드와 TTFB(Time To First Byte)에 대한 집착인 것 같습니다.
그러나 여기에 똑같이 중요한 질문이 있습니다.
페이지 로드가 완료된 후 사용자 경험은요?
 
TTFB에만 집착하는 것은 운동하면서 식이요법을 무시하는 것과 같습니다.
M1X MacBook Pro를 닫고 학교에서 아이의 크롬북을 가져와서 CAT-6 케이블을 뽑고 시댁 WiFi에 접속하세요.
웹사이트가 완전히 다르다는 것을 느끼게 될 것입니다.
 
네트워크가 불규칙한 저전력 장치에서
상태 업데이트를 읽고, 레코드를 업데이트하고, 게시물을 만들고, 메시지를 보내는 데 한 시간을 소비하는 사용자 경험은
Remix엡의 모든 네트워크에 있는 장치의 초기 페이지 로드 개선만큼 중요합니다.
 
그렇다면 이것이 RSC와 어떤 관련이 있습니까?
앱이 각 페이지를 표시하는 데 걸리는 시간을 확인하기 위해 React Team의 데모의 "스피드런"을 해봤습니다.
어리석은 측정법이지만 느린 웹 사이트를 사용할 때 그 느린 느낌은
시간이 지남에 따라 복합되어 "이 웹 사이트는 그다지 좋지 않다"는 느낌을 줍니다.
아래 영상은 그 느낌을 잘 담아낸 것 같습니다.
이 데모에서 볼 수 있듯이 RSC 버전은 Remix 버전(!)보다 유선을 통해 34배 더 많은 JavaScript를 로드합니다.
두번째는 준비된 브라우저 캐시 덕택에 16배였습니다.

스트리밍 렌더링 외 React Server Components의 또 다른 강력한 기능 중 하나는 "Zero-Bundle"입니다.

이 아이디어는 속도를 높이기 위해 초기 페이지 로드에서 더 적게 전송하는 것입니다.

(이 데모에서는 이전에 그렇지 않음을 보았습니다). 아이디어는 다음과 같습니다.

리믹스가 아니라 리액트 서버 컴포넌트 입니다.
  1. 브라우저는 서버 컴포넌트를 렌더링하는 템플릿이 포함된 JavaScript 번들을 로드할 필요가 없습니다.
  2. 또한 이미 마크업에서 반복되는 JSON으로 가득 찬 일반적인 React SSR 인라인 수화 <script>에 대한 필요성을 제거합니다.
    • 이 사이트(https://remix.run/)는 리믹스로 개발되어 있으므로, 위 설명처럼 인라인 <script>로 가득차 있습니다.
겉보기에는 훌륭해 보이지만 사용자가 사이트와 상호 작용할 때마다 템플릿이 서버 컴포넌트 페이로드에서 반복됩니다.
즉, 서버에서 가져올 때마다 데이터뿐만 아니라 완전히 렌더링된 마크업을 얻게 됩니다.
서버 컴포넌트 데모에서 단일 아이템을 클릭하면 이에 대한 가져오기가 발생합니다.
M1:{"id":22,"chunks":[2],"name":""}
M2:{"id":20,"chunks":[0],"name":""}
S3:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]}]]}],["$","section","3",{"className":"col note-viewer","children":["$","$3",null,{"fallback":["$","div",null,{"className":"note skeleton-container","role":"progressbar","aria-busy":"true","children":[["$","div",null,{"className":"note-header","children":[["$","div",null,{"className":"note-title skeleton","style":{"height":"3rem","width":"65%","marginInline":"12px 1em"}}],["$","div",null,{"className":"skeleton skeleton--button","style":{"width":"8em","height":"2.5em"}}]]}],["$","div",null,{"className":"note-preview","children":[["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}],["$","div",null,{"className":"skeleton v-stack","style":{"height":"1.5em"}}]]}]]}],"children":"@5"}]}]]}]
M6:{"id":21,"chunks":[3],"name":""}
J4:["$","ul",null,{"className":"notes-list","children":[["$","li","1",{"children":["$","@6",null,{"id":1,"title":"Meeting Notes","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"This is an example note. It contains Markdown!"}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"Meeting Notes"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","2",{"children":["$","@6",null,{"id":2,"title":"A note with a very long title because sometimes you need more words","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"You can write all kinds of amazing notes in this app! These note live on the server in the notes..."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"A note with a very long title because sometimes you need more words"}],["$","small",null,{"children":"12/30/20"}]]}]}]}],["$","li","3",{"children":["$","@6",null,{"id":3,"title":"I wrote this note toda","expandedChildren":["$","p",null,{"className":"sidebar-note-excerpt","children":"It was an excellent note."}],"children":["$","header",null,{"className":"sidebar-note-header","children":[["$","strong",null,{"children":"I wrote this note toda"}],["$","small",null,{"children":"5:59 PM"}]]}]}]}]]}]
J5:["$","div",null,{"className":"note","children":[["$","div",null,{"className":"note-header","children":[["$","h1",null,{"className":"note-title","children":"I wrote this note toda"}],["$","div",null,{"className":"note-menu","role":"menubar","children":[["$","small",null,{"className":"note-updated-at","role":"status","children":["Last updated on ","3 Dec 2021 at 5:59 PM"]}],["$","@2",null,{"noteId":3,"children":"Edit"}]]}]]}],["$","div",null,{"className":"note-preview","children":["$","div",null,{"className":"text-with-markdown","dangerouslySetInnerHTML":{"__html":"<p>It was an excellent note.</p>\n"}}]}]]}]
4kb가 넘습니다. 세 가지 항목을 모두 클릭하면 각각에 대해 유사한 응답을 받습니다. (데이터를 포함한 마크업)
Remix와 대조해 보겠습니다. 링크를 처음 클릭하면 코드 스플리팅한 아이템 뷰(컴포넌트)의 JavaScript 템플릿을 다운로드 합니다.
import{a as i,c as a,d as p}from"/build/_shared/chunk-CDZR6LSD.js";import{a as m}from"/build/_shared/chunk-DQ7ZO7ZN.js";import"/build/_shared/chunk-XXRJHXMM.js";import{i as d}from"/build/_shared/chunk-2FSL4QX2.js";import{b as l,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();e();var f=l(p());function r(){let{id:n,title:s,body:u,updatedAt:o}=d();return o=new Date(o),t.createElement("div",{className:"note"},t.createElement("div",{className:"note-header"},t.createElement("h1",{className:"note-title"},s),t.createElement("div",{className:"note-menu",role:"menubar"},t.createElement("small",{className:"note-updated-at",role:"status"},"Last updated on ",i(o,"d MMM yyyy 'at' h:mm bb")),t.createElement(a,{noteId:n},"Edit"))),t.createElement(m,{body:u}))}export{r as default};

import{a as d}from"/build/_shared/chunk-XXRJHXMM.js";import{b as i,e as t,f as e}from"/build/_shared/chunk-AKSB5QXU.js";e();e();var n=i(d());function o({text:r}){return t.createElement("div",{className:"text-with-markdown",dangerouslySetInnerHTML:{__html:(0,n.default)(r)}})}function a({body:r}){return t.createElement("div",{className:"note-preview"},t.createElement(o,{text:r}))}export{a};
그러나 지금부터는 각 항목을 클릭할 때마다 이 작은 데이터만 전송됩니다.
{"id": 1, "createdAt": "2020-12-30T10:13:29.023Z", "updatedAt":
"2020-12-30T10:13:29.023Z", "title": "Meeting Notes", "body": "This is an
example note. It contains **Markdown**!"}

 

리액트 서버 컴포넌트는 데이터를 템플릿에 연결하므로
사용자는 해당 컴포넌트와 관련된 모든 단일 인터랙션과 함께 템플릿을 다운로드해야 합니다.
JavaScript는 "Zero-Bundle"이지만 리액트 서버 컴포넌트는 후속 탐색을 위한 "Infinite Bundle"입니다(탬플릿을 포함) 😟.
 
물론 이것은 작은 장난감 데모 앱이고 현실 세계에서는 항상 다르게 전개되지만
논리적으로 이러한 템플릿은 항상 데이터보다 큽니다.
내 경험상 1온스의 데이터에 대해 1파운드의 마크업이 있습니다.

우리의 교훈

나는 며칠 동안 이 두 가지 데모를 막 가지고 놀았습니다.
고속도로 위에서 내 폰에서 엡을 로드하고,
아이들이 학교에서 나올 때까지 기다리는 언덕에서도,
연결이 불규칙하고 전파가 거의 통과하지 않는 교회 건물 내부에서도 말입니다.
 
모든 경우에 예외 없이 Remix는 React Server Components보다 빠릅니다. 많이 빠릅니다.
 
RSC가 어떤 네트워크, 장치 및 서버 조건을 위해 구축되었는지는 확실하지 않습니다.
Remix 서버에서 전체 문서를 보내는 것은 네트워크가 빠르든 느리든 항상 RSC의 첫 번째 청크를 능가합니다.
 
이것은 RSC가 "나쁘다"고 말하는 것이 아닙니다. 아직 실험적입니다!
나는 지금은 RSC가 Remix에 매력적이지 않다고 말하는 것입니다.
RSC가 안정적이면 여기에서 시연한 것과 동일한 엄격한 테스트를 적용하였을 때,
더 나은 UX를 제공하는 경우 Remix 사용자에게 권장합니다.
 
내 직감은 사용자의 네트워크는 빠르지만 서버의 데이터 로딩이 느릴 때 RSC가 더 나은 UX를 제공할 가능성이 있다는 것입니다.
그것이 나의 다음 연구가 될 것입니다. (이 데모들은 모두 빠른 서버 데이터 로딩을 가졌습니다).
느린 서버가 Remix를 통해 사용자에게 전체 문서를 제공할 수 있는 것보다 더 빠른 첫 번째 청크가 사용자에게 유용할 것으로 기대합니다.
 
그러나 느린 서버가 문제라면 이 문제는 해결할 수 있습니다.
서버를 빠르게 만들 수는 있지만 사용자 네트워크에 대해서는 아무 것도 할 수 없습니다.
 
이것이 바로 Remix의 장점입니다.
최신 인프라를 활용하고 네트워크를 통해 더 적은 양의 데이터를 전송하세요.
 
백엔드 인프라가 정말 좋아지고 있습니다.
 
Remix는 Cloudflare Workers(demo) 및 (곧) Deno Deploy와 같은 플랫폼의 엣지(사용자와 가까운 위치)에서 전체 앱을 실행할 수 있습니다.
 
엣지에서 앱 서버를 실행할 수 있을 뿐만 아니라
데이터를 엣지로 가져올 수도 있습니다.
이러한 기술을 사용하면 사용자 데이터가 있는 경우에도 전체 페이지를 단 몇 밀리초 만에 렌더링할 수 있습니다.
 
사용자 데이터가 포함된 전체 문서를 Remix로 500ms 또는 50ms로 렌더링할 수 있는데,
스피너가 튀는 스트리밍을 원하는 이유가 무엇인가요?
(아직까지는 심지어 스트리밍보다 리믹스가 2배나 빠릅니다!)

Live Demos and Source Code

Hacker News Demos:

Hacker News Source Code:

Notes App Demos:

Notes App Source Code:

 
반응형