LeChuck

리액트 최신 동향 살펴보기

·22 min to read

CRA Is dead

Replace Create React App recommendation with Vite

CRA가 리액트 앱 제작에 있어 비적합한 도구인 것 같다는 의견에대한 리액트 공식개발팀의 답변을 정리해봤다. 리액트 18 공식문서에도 CRA에 대한 언급은 일절 없었고 Vite를 비롯한 툴 혹은 프레임워크가 몇 차례 거론되는 것을 확인했다. 따라서 앞으로는 Vite 혹은 Next.js로만 개발환경을 구축하지 않을까 싶다.

1) CRA 탄생배경

CRA는 2016년 통합되지 않고 파편화되어 있는 tooling landscape를 해결하기 위해 릴리즈되었다. 당시에는 새로운 React 프로젝트를 생성할 뚜렷하고 체계화된 방식이 없었다. 그래서 직접 수동으로 일일이 개발환경을 세팅해야만 했고, 이는 굉장히 복잡했다. 사람들은 설정을 끝마친 boilerplate 원격 저장소를 공유하고 이를 clone 함으로써 React App 환경을 구성했지만 이러한 방법으로는 빠르게 변화하는 React 생태계에 대응하기가 어려웠다.

CRA는 합리적인 기본 구성의 React 개발 환경을 제공함으로써 위 문제를 해결했다. CRA는 React 앱을 시작하는 가장 좋은 방법이 되는 쪽으로 발전해나갔다. 런타임 오류에 대한 오버레이를 지원하고 빠른 새로고침 기능, React Hooks 린트 규칙 등이 제공되었다.

2) CRA의 문제점

세월이 흐르면서 CRA는 정체되었다. 유용하고 인기있는 최신의 도구를 지원하지 못하고 있다. 또한 CRA는 client-side app을 생성하도록 디자인되었다. SSG/SSR등 client에서만 React를 실행하는 방식이 갖는 명확한 한계를 태생적으로 지닌다.

3) React Framework의 부상

React는 UI 라이브러리일 뿐이고, CRA도 별반 다르지 않다. 전문화되어 있는 많은 React Framework들이 경쟁하며 성장해나가고 있고, 개발자는 이들 중 상황에 맞는 Framework를 사용하면 된다. React는 중립적인 게이트웨이로 기능하고 각각의 Framework는 React에서 제공하는 기능을 확장하여 제공한다.

4) CRA의 방향성

CRA는 'launcher'로 전환될 가능성이 높다. CRA 명령을 입력하면 권장 프레임워크 옵션과 프레임워크를 사용하지 않는 classic 옵션이 함께 표시된다. 해당 옵션을 선택하면 관련된 개발환경이 제공되는 방식이다. 그중에서도 classic 옵션은 기존의 CRA와 유사한 최소한의 환경을 제공하지만 내부적으로 Vite를 이용하게 될 수 있다.

why Vite?

Esbuild

브라우저가 ESM(ES Modules)을 제공하기 전에는 bundling을 통해서 JavaScript 모듈화가 이루어졌다. Webpack, Rollup, Parcel과 같은 도구를 통해 번들링이 이루어졌다. 앱의 규모가 커지면서 기존의 번들링 방식으로는 서버 구동에 많은 시간이 소요되었고, Vite는 Esbuild를 통해서 기존 번들링 방식보다 10~100배 빠른 번들링 속도를 선보이고 있다.

image

Automatic Batching

리액트 18의 신기능 - 동시성 렌더링(Concurrent Rendering), 자동 일괄 처리(Automatic Batching) 등

batch update는 React 18 이전에도 있었다. React 17에서는 이벤트 핸들러의 상태 변화를 한 번에 처리했는데, Virtual DOM의 변경 내역을 재조정(Reconciliation)할 때 batch update로 한 번에 몰아서 처리함으로써 성능을 최적화하는 방식이었다.

const handleClick = () => {
    setCounter();
    setActive();
    setValue();
    // 마지막에 한 번에 리렌더링 된다
  };

그러나 React 17에서는 이벤트 핸들러 외부에서 수행된 상태 업데이트에는 batch update가 적용되지 않았다. Promise, setTimeouts, 콜백 등에서 비효율적인 방식으로 상태가 업데이트 되었다.

fetch('/network').then(() => {
 setCounter(); // 한 번 리렌더링됨.
 setActive(); // 두 번 리렌더링됨.
 setValue(); // 세 번 리렌더링됨.
});
 
// 총 세 번 리렌더링 됨.

React 18부터는 이벤트 핸들러 외부에서도 batch update가 적용된다. createRoot() API를 적용하기만 하면 자동으로 상태 업데이트가 일괄 처리된다.

앞으로 소개할 동시성을 비롯한 React 18의 추가적인 기능을 활용하기 위해서는 createRoot() API를 통해 앱을 렌더해야 한다.

image

Concurrency (동시성)

Inside React(동시성을 구현하는 기술)

리엑트 동시성 매커니즘들은 어떻게 구현되어 있을까 - 01

Concurrency vs Parallelism

동시성이란 실제로는 여러 개의 스레드가 사용되지는 않지만, 겉으로 보기에는 마치 여러 개의 스레드가 사용되고 있는 것처럼 보이게 만드는 것을 의미한다.

image

동시성병렬성
싱글 코어에서도 작동멀티 코어가 필요
동시에 실행되는 것처럼 보임. <br/> 컨텍스트 스위칭 필요실제로 동시에 실행됨
최소 두 개의 논리적 통제 흐름최소 한 개의 논리적 통제 흐름

동시성 개념을 통해 동시에 두 가지 일이 진행되는 것처럼 작업을 처리할 수 있다. 그러나 여기서 중요한 건 둘 중 어떤 작업이 더 우선시되어 처리되어야 하는지(양보, 스케쥴링)를 결정할 수 있다는 의미다.

동시성은 Blocking Rendering 문제를 해결한다

브라우저의 main thread는 single thread다. 따라서 브라우저의 Critical Rendering Path는 직렬로 처리되며 도중에 중단할 수 없다. React 렌더링 과정도 동일한 방식으로 진행된다. 이때 비용이 큰 처리를 해야 할 경우 이후의 작업들의 처리가 지연되는데, 이를 Blocking Rendering 이라 한다.

image

이렇듯 React 18 이전의 렌더링이란 개입할 수 없는 하나의 동기적 처리였다. 하지만 동시성을 통해 React는 렌더링에 개입한다. 동시성은 리액트 렌더링 메커니즘의 근본적인 개선이다.

image

blocking

위 링크에서 Blocking Rendering 문제가 발생하는 예시를 볼 수 있다. Input의 입력값이 길어질수록 생성되는 블록의 개수와 렌더링에 필요한 계산량이 많아진다. 블록을 렌더링 처리 하느라 키보드 타이핑을 해도 input에 즉각적으로 반영되지 못하고 멈춘듯한 현상이 발생한다.

Blocking Rendering 문제는 React 18의 동시성 개념을 통해 해결할 수 있다. 위와 같은 경우에서는 블록이 생성되는 고비용 작업의 우선순위를 낮추면 input에 타이핑한 값이 끊김없이 렌더링되도록 한다. 즉, 블록 생성 렌더링을 덜 급한 작업(not urgent)로 분류하고 Input 렌더링을 급한 작업(urgent)으로 분류하여 main thread가 우선순위에 따라 처리할 수 있도록 하는 것이다.

useTransition으로 동시성 제어하기

image

  • isPending : pending transition 존재 여부를 알려주는 플래그

  • startTransition : state update를 transition으로 기능하게 해주는 함수

지금까지 설명한 동시성 개념을 startTransition 함수를 통해 코드에 적용할 수 있다. startTransition을 이용해서 일부 상태 업데이트를 긴급하지 않은 것(not urgent)로 표시한다. startTransition이 적용되지 않은 상태 업데이트 함수는 긴급한 것으로 간주된다. 긴급한 상태 업데이트는 긴급하지 않은 상태 업데이트를 중단할 수 있다.

image

image

concurrent

앞의 Blocking Rendering 문제를 동시성 개념으로 해결한 케이스다. 블록 렌더링을 덜 급한 작업으로 분류하기 위해 startTransition으로 처리하였다.

image

Blocking 예시에서는 고비용 블록 렌더링 연산 처리에 Input 렌더링이 밀렸지만, Concurrent 예시에서는 자잘한 Input 렌더링이 높은 우선순위 덕택에 먼저 렌더링되는 모습이다.

image

위는 React Docs: useTransition 에서 확인할 수 있는 예제인데, setTab을 startTransition으로 처리하고있다. 따라서 Posts(slow) 버튼을 눌러서 1초가 넘게 걸리는 렌더링 와중에도 다른 버튼을 클릭하면 자연스럽게 상태 업데이트 및 해당 UI로 전환된다. 렌더링 중 다른 사용자 상호작용을 차단하지 않는다.

startTransition으로 setTab을 감싸지 않은 다른 예제에서는 Posts를 눌러서 1초가 넘는 렌더링이 수행되는 와중에 다른 동작을 수행할 수 없는 모습을 확인할 수 있다.

Suspense

React 18에서는 업데이트 된 Suspense를 통해서 Streaming HTML, Selective Hydration이라는 기능을 적용할 수 있다. 이 두 가지 기능은 기존 SSR의 단점을 보완한다.

React 16 vs React 18

React.lazy 및 Suspense를 사용한 코드 분할

React 16에서 Suspense는 React.lazy와 함께 사용하는 사례만 존재했다. 큰 JavaScript 페이로드를 로드해야 하는 경우 초기 성능 향상을 목적으로 코드 분할 및 지연 로딩처리를 하는 것이다. React.lazy를 통해 동적으로 import한 컴포넌트에 필요한 코드가 아직 다운로드 되지 않았거나 DOM 트리에 표시되지 않았을 경우 로딩 fallback을 제공한다.

import React, { lazy, Suspense } from 'react';
 
const AvatarComponent = lazy(() => import('./AvatarComponent'));
 
const renderLoader = () => <p>Loading</p>;
 
const DetailsComponent = () => (
  <Suspense fallback={renderLoader()}>
    <AvatarComponent />
  </Suspense>
)

Suspense와 lazy로 처리된 코드가 chunk.js로 코드 분할 처리된 모습이다.

image

React 18에서 Suspense는 코드 스플리팅 목적 외에도 Data fetching 상태에 따른 로딩 fallback을 표시하는 기능이 추가되었다. Relay, Next.js, React-Query와 같이 Suspense-enabled한 data fetching의 경우에만 해당된다. 아직까지 data fetching에 suspense를 도입하는 방식은 프레임워크 의존적이다.

더 중요한것은, React 18 Suspense는 SSR에도 적용할 수 있고 앞으로 자세히 살펴볼 Streaming ,Selective Hydration 과 같은 SSR 전용 내부 최적화가 Suspense에 포함되어있다.

image

기존 SSR의 문제점 (waterfall)

New Suspense SSR Architecture in React 18

기존의 SSR은 다음의 단계를 거쳐야만 사용자가 페이지와 인터랙션할 수 있게 된다.

  1. 서버에서 data fetch를 수행

  2. 서버에서 HTML 생성 (with data)

  3. HTML, CSS, JS파일을 클라이언트로 전송

  4. 클라이언트에서 HTML, CSS를 이용해서 non-interactive UI 렌더링

  5. React가 Hydrate를 통해 페이지를 interactive 하게 만듦

fetch -> render -> load -> hydrate (waterfall)

이러한 단계는 절차적이다. 또한 'all or nothing'이다. 이전 절차가 완전히 수행되어야만 뭐라도 보여줄 수 있기 때문이다. 이전 절차가 수행되지 않았다면 아무것도 보여줄 수 없다.

image

1) You have to fetch everything before you can show anything

서버에서 HTML을 전달하기 전에 모든 데이터 페칭을 기다려야 한다. 즉, 데이터 페칭이 끝나기 전에는 아무것도 할 수 없다. 이는 상당히 비효율적이고 부당하다. 만약 DB, API 통신의 속도가 느리면 정말이지 속수무책이다.

댓글이 있는 게시물을 렌더링하는 상황을 가정해보자. DB, API 통신의 속도가 느려서 CSR로 전환했더니 SSR의 초기 로딩 속도 경감의 장점이 사라지고 사용자는 빈 화면을 보는 시간이 늘었다.어쩔 수 없이 SSR을 유지한다. 이러면 댓글 데이터 페칭이 완료될 때까지 HTML 전송이 불가능하기 때문에 댓글이 있는 게시물 컴포넌트 때문에 페이지 내 다른 모든 컴포넌트의 렌더링도 지연되는 것이다.

2) You have to load everything before you can hydrate anything

Hydration을 수행하려면 먼저 브라우저의 컴포넌트에서 생성된 트리와 서버에서 생성된 트리가 일치해야한다. 그렇지 않으면 React가 Hyrdation을 수행할 수 없다. 이때문에 클라이언트에서 모든 컴포넌트에 대한 자바스크립트 코드를 로드해야만 Hydration을 시작할 수 있다는 문제가 있다.

만약 댓글 컴포넌트의 자바스크립트 코드를 로드하는 데 오랜 시간이 걸린다고 가정해보자. React 18 이전의 Hydration은 단 한번에 전체적으로 수행되기 때문에 댓글 컴포넌트의 자바스크립트 코드가 지연되는 만큼 다른 컴포넌트의 Hydration 또한 늦어진다.

3) You have to hydrate everything before you can interact with anything

앞서 언급했듯 지금까지의 Hydration은 단 한 번 전체적으로 수행된다. React는 트리를 한 번에 수화한다. 이 말은 한 번 hydration이 시작되면 모든 트리에 대한 hydration이 끝날때 까지 멈출 수 없다는 의미다. 따라서 모든 컴포넌트의 Hydration이 끝날때까지 어떤 컴포넌트와도 상호작용할 수 없다.

solution : 앱 전체가 아닌 컴포넌트 단위로 분리

지금까지 살펴본 문제는 fetch -> render -> load -> hydrate 과정이 waterfall이기 때문에 이전 동작이 완료되기 전까지 그 어떤 동작도 수행될 수 없다는 점이었다.

이에 대한 해결책은 앱 전체가 아닌 화면 일부 단위로 각 단계가 적용될 수 있도록 분리하는 것이다. <Suspense>로 페이지를 일부 단계로 쪼개고 Suspense 의 Streaming HTML 기능과 Selective Hydration 기능을 활용한다.

Streaming HTML and Selective Hydration

Streaming HTML은 데이터 페칭이 완료되기도 전에 HTML을 클라이언트에 먼저 보내고, 이후 페칭된 데이터는 stream을 통해 추가적으로 전송해주는 최적화 기법이다.

image

Selective Hydration을 활용하면 나머지 HTML, 자바스크립트 코드가 완전히 로드되기도 전에 Hydration을 수행할 수 있다. 또한 사용자가 상호작용한 부분에 우선적으로 Hydrate 해준다.

image

1) Streaming HTML before all the data is fetched

기존의 SSR은 앞서 언급한 바와 같이 all or nothing이었다. 따라서 데이터 페칭이 완료되어야 아래 그림과 같이 render -> load -> hydration을 수행할 수 있었다.

image

하지만 React 18 <Suspense>를 활용하면 React에게 Suspense로 감싼 부분의 데이터 페칭을 기다리느라 다른 페이지의 HTML Streaming 까지 지연될 필요가 없음을 명시할 수 있다. Suspense는 데이터 페칭이 완료될 동안 스피너를 띄우고, 그 동안 페이지의 나머지 부분은 HTML Streaming을 문제없이 수행한다.

image

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- data는 제외된 initial HTML-->
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

이후 server에서 data fetching 작업이 끝나면 React는 same stream을 통해 data 관련 최소한의 추가적인 HTML을 client로 전송한다. 이는 <script> 태그로 이루어진다.

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

이제 streaming 기능을 통해서 fetch가 완료되기도 전에 HTML을 생성하고 render 할 수 있게 되었다. 이제 예전처럼 all or nothing이 아니게 되었다. 화면의 일부가 초기 HTML render를 지연시킨다고 모든 페이지가 지연될 필요가 없다. 만약 사이드바에서 데이터 페칭이 필요하면 사이드바를 <Suspense>로 감싸주어 Streaming 처리하라. 사이드바는 다른 컴포넌트를 지연시키지 않고 동시적으로 처리될것이다.

image

2) Hydrating the page before all the code has loaded

이제 Streaming 기능 덕분에 initial HTML을 일찍 전송할 수 있게 되었다. 하지만 아직 문제가 남았다. 특정 컴포넌트(댓글 컴포넌트)의 자바스크립트 코드가 로드되지 않았다면 hydration을 시작할 수 없었다. 코드의 크기가 클수록 이 동작은 지연된다.

React.lazy와 같은 code splitting 기법을 통해서 load->hydration으로 이어지는 속도를 높일 수 있다. 일부 코드를 동기적으로 로드할 필요가 없음을 명시한다. 번들러는 이를 보고 해당 코드를 다른 <script> 태그로 메인 번들로부터 분리할 것이다.

import { lazy } from 'react';
 
const Comments = lazy(() => import('./Comments.js'));
 
// ...
 
<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

이전에는 SSR에서 위와 같은 기능이 작동하지 않았다. 그러나 React 18부터는 Suspense로 감싼 부분의 코드가 로드되기 전에도 Hydration이 시작된다.

image

initial HTML을 받는다. 이후 React에 Hydrate를 요청한다. 아직 Suspense 부분의 코드가 로드되진 않았지만 상관없다.

이것이 Selective Hydration이다. Comments 부분을 <Suspense>로 감싸줌으로써, 해당 부분이 나머지 다른 부분의 streaming, 더 나아가서는 hydrating까지 block하면 안 된다고 요청하는 것이다. 이후 Comments 부분의 코드 불러오기가 완료되면 해당 부분의 Hydration까지 완료될 것이다.

image

Selective Hydration 덕분에 무거운 컴포넌트의 JS 코드가 나머지 페이지 컴포넌트의 상호작용(interactive)을 방해하지 않게 되었다.

You no longer have to wait for all JavaScript to load to start hydrating. Instead, you can use code splitting together with server rendering. The server HTML will be preserved, and React will hydrate it when the associated code loads.

3) Interacting with the page before all the components have hydrated

Suspense로 특정 컴포넌트를 감싸주었을 때 추가적으로 적용되는 기능이 있다. 이제 해당 컴포넌트의 Hydration이 브라우저의 다른 일을 방해하지 않는다는 점이다. Suspense로 감싸진 댓글 컴포넌트가 Hydration 중이라고 가정해보자. 이때 사용자는 사이드바를 클릭한다.

image

Hydration이 진행중임에도 클릭 이벤트는 정상적으로 처리된다! 이 덕분에 저사양 기기에서 Hydration이 장기화되는 동안 브라우저가 멈추는 현상이 발생하지 않는다. 예를 들면, Hydration이 장기화 될 때 사용자는 다른 페이지로 이동할 수 있다.

image

이번에는 아래와 같이 사이드바, 댓글 컴포넌트가 Suspense로 감싸진 경우를 가정해보자. 리액트는 트리의 앞 부분에 위치한 컴포넌트부터 Hydrating을 수행할 것이다. 이 경우에는 사이드바가 먼저 수화되고 있다.

만약 이때 사용자가 댓글 컴포넌트를 클릭하면 React는 댓글 컴포넌트를 우선적으로 Hydrating한다.

이제 인터랙션을 위해서 모든 Hydration이 끝나기를 기다릴 필요가 없다. Suspense를 이용한 Selective Hydration이 적용된 컴포넌트라면 인터랙션시 Hydration이 우선적으로 수행되어 금새 인터랙션이 가능해진다.

image

image

You no longer have to wait for all components to hydrate to start interacting with the page. Instead, you can rely on Selective Hydration to prioritize the components the user is interacting with, and hydrate them early.

Suspense를 적극 활용하자

위에서 설명한 기능들은 모두 <Suspense>를 적용하면 React 내부에서 자동적으로 최적화되어 처리되는 기능들이다.

if(isLoading)이라는 명령형 코드를 <Suspense>라는 선언형 코드로 변경하는 게 코드의 유지보수성을 증진시킬 뿐 아니라 UX적으로도 굉장히 많은 이점을 준다는 걸 명심하자. Suspense는 React 18의 useDeferredValue, startTransition과 함께 사용될 수 있다.

React Server Components(RSC)

React 18: 리액트 서버 컴포넌트 준비하기

  • RSC는 서버에서 동작하는 리액트 컴포넌트를 일컫는다.

리액트 data fetching의 한계

  • 한 페이지에서 여러 정보를 보여주는 컴포넌트는 흔히 아래와 같이 작성된다.
function KakaopayHome({ memberId }) {
  return (
    <MemberDetails memberId={memberId}>
      <MoneyBalance memberId={memberId}></MoneyBalance>
      <PaymentHistory memberId={memberId}></PaymentHistory>
    </MemberDetails>
  );
}
  • 부모 컴포넌트인 <MemberDetails>와 자식 컴포넌트인 <MoneyBalance>, <PaymentHistory> 컴포넌트 모두에서 API 응답값인 memeberId에 의존하고 있다.

  • 이때, 개발자는 아래 두 가지 방법 중 하나를 선택해야만 한다

    1. 부모 컴포넌트에서 한 번 받아온 API 정보를 자식에 내려준다.

    2. 컴포넌트에 필요한 API를 각 컴포넌트에서 매번 호출한다.

  • 첫 번째 방법은 API 요청 횟수를 줄일 수 있지만 부모와 자식 컴포넌트 간 결합도가 높아져서 유지보수가 어려워진다.

  • 두 번째 방법은 API 요청 횟수가 늘어난다. 또한 부모 컴포넌트가 렌더링 된 후 API 호출을 통해 데이터를 받아올 동안 자식 컴포넌트의 렌더링 및 API 호출이 지연된다. 이런식의 waterfall은 UX를 저하시킨다.

  • RSC는 이와 같은 data fetching의 단점을 보완한다.

Next.js 13에서는 2번과 같은 방식의 data fetching을 권장하는 것 같다. 같은 API가 반복적으로 호출될 경우 background에서 cache and dedupe을 통해 API 호출 횟수를 한 번으로 제한해주기 때문이다. 물론 Next.js의 fetch() API를 사용해야 한다.

Data Fetching

image

RSC 사용시의 이점

1) No Client-Server waterfall

  • 서버에서 컴포넌트를 렌더링할 수 있기 때문에, 서버-클라이언트 간 비동기 API 호출로 인한 워터폴을 방지할 수 있다. 서버에서 데이터 페칭을 하고, 렌더링까지 수행하기 때문이다.
// Note.server.js - 서버 컴포넌트의 파일 네임 컨벤션
import { fetch } from 'react-fetch';
 
function Note(props) {
  // 아래 요청은 client-to-server가 아닌 server-to-server로 진행됩니다.
  const note = fetch(`https://api.example.com/notes/${props.id}`).json();
 
  if (note == null) {
    return <div>노트가 존재하지 않습니다.</div>;
  } else {
    return (/* render note here... */);
  }
}

2) 자유로운 서버 리소스 접근

  • RSC는 서버에서 동작하기 때문에 DB, File System 같은 서버 리소스에 자유롭게 접근할 수 있다.

  • 서버에서 fetching한 데이터는 JSON 형식으로 props에 전달할 수 있다.

// Note.server.js - 서버 컴포넌트
import fs from 'react-fs';
import db from 'db.server';
 
function Note(props) {
  // NOTE: loads *during* render, w low-latency data access on the server
  const note = db.notes.get(props.id); // 데이터베이스 접근
  const noteFromFile = JSON.parse(fs.readFile(`${id}.json`)); // 파일 접근
 
  if (note == null) {
    // handle missing note
  }
  return (/* render note here... */);
}

3) 제로 번들 사이즈 컴포넌트

  • 기존의 클라이언트 컴포넌트에서는 패키지 import시 번들에 추가된다.
// NoteWithMarkdown.client.jsx - 클라이언트 컴포넌트 = 기존의 리액트 컴포넌트
 
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
 
function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}
  • RSC 코드는 브라우저에 다운로드되지 않고 서버에서 렌더링 된 static content를 브라우저에 전달하는 형식이기 때문에 패키지의 추가가 번들 사이즈에 영향을 끼치지 않는다.

  • 위 컴포넌트처럼 유저 인터랙션이 없는 컴포넌트를 RSC로 마이그레이션하면 동일한 뷰를 제공하면서도 번들 사이즈와 초기 로딩 시간을 감소시킬 수 있다.\

// NoteWithMarkdown.server.jsx - 서버 컴포넌트
import marked from 'marked'; // ZERO IMPACT on bundle size
import sanitizeHtml from 'sanitize-html'; // ZERO IMPACT on bundle size
 
function NoteWithMarkdown({ text }) {
  // client component와 동일
}

4) 자동 코드 분할

  • Code Splitting이란 하나의 거대한 자바스크립트 번들을 여러 개의 작은 번들로 쪼개어 필요할 때마다 클라이언트로 전송하는 방식이다.
// PhotoRenderer.js
// NOTE: *before* Server Components
import React from 'react';
 
// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));
 
function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}
  • 코드 스플리팅의 두 가지 단점
  1. lazy loading이 필요한 컴포넌트에 일일이 React.lazy와 dynamic import를 적용해야 한다.

  2. 부모 컴포넌트가 렌더링 된 이후 로딩을 시작하기 때문에 화면에 보이기 까지의 딜레이가 존재한다

  • RSC는 위 두 가지 단점을 해결한다.
  1. RSC에서 import되는 모든 클라이언트 컴포넌트는 code splitting 포인트로 간주되기 때문에 일일이 React.lazy를 명시할 필요가 없다.
// PhotoRenderer.server.js - Server Component
import React from 'react';
 
// one of these will start loading *once rendered and streamed to the client*:
import NewPhotoRenderer from './NewPhotoRenderer.client.js';
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
 
function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

세 가지 종류의 컴포넌트

image

  • 미래에는 목적에 따라 컴포넌트를 분리할 수 있을 것이다. 서버 컴포넌트(data fetchin), 클라이언트 컴포넌트(유저 인터랙션) 등.

  • 관심사에 따라 서버/클라이언트 로직을 분리할 수 있을 것이다.

// Note.server.jsx
import { format } from 'date-fns'; // 번들사이즈에 영향 없음
import { readFile } from 'react-fs'; // 번들사이즈에 영향 없음
import path from 'path';
 
import NotePreview from './NotePreview'; // 공유 컴포넌트
import EditButton from './EditButton.client'; // 클라이언트 컴포넌트, 자동 코드 분할됨
 
export default function Note({ selectedId }) {
  const note = readFile(path.resolve(`./notes/${selectedId}.md`), 'utf8'); // 파일 시스템에서 data fetching
 
  if (note === null) {
    return (
      <div className="note--empty-state">
        <span className="note-text--empty-state">노트를 찾을 수 없어요 🥺</span>
      </div>
    );
  }
 
  // 노트가 존재하지 않을 시 아래의 코드는 클라이언트에 전달되지 않음
  let { id, title, body, updated_at } = note; // serializable한 props 클라이언트 컴포넌트로 전달 가능
  const updatedAt = new Date(updated_at);
 
  return (
    <div className="note">
      <div className="note-header">
        <h1 className="note-title">{title}</h1>
        <div className="note-menu" role="menubar">
          <small className="note-updated-at" role="status">
            마지막 변경 시간 {format(updatedAt, "yyyy MMM d 'at' h:mm bb")}
          </small>
          <EditButton noteId={id}>수정</EditButton>
        </div>
      </div>
      <NotePreview body={body} />
    </div>
  );
}

RSC는 SSR의 대체재가 아니다

  • SSR의 주요 목적은 non-interactive한 HTML을 클라이언트에 가능한 빨리 전달하여 초기 페이지의 First Contentful Paint, Largest Contentful Paint 속도를 향상시키는 것이다.

  • RSC의 코드는 클라이언트로 전달되지 않지만, SSR 컴포넌트 코드는 자바스크립트 번들에 포함되어 클라이언트로 전송된다.

  • RSC는 클라이언트의 상태를 유지한 채 refetch될 수 있다. 반면 SSR은 refetch가 필요한 경우 새로고침을 통해 전체 HTML을 리렌더링해야 하므로 클라이언트 상태 유지가 불가능하다.

  • SSR로 초기 렌더링 속도를 단축시키고, RSC로 클라이언트에 전송되는 자바스크립트 번들 사이즈를 감소시키는 방향으로 활용할 수 있을 것.