Doeun Kim

Engineer

RSC 그리고 최적화된 디자인 시스템

최근에 회사에서 RSC에 대한 언급이 많아지면서, 빠르게는 번들링된 파일 상단에 'use client' 지시문을 붙이는 전략으로 대응을 했는데요. 이렇게 단순할 수 있었던건가? 좀 더 RSC 이점을 활용할 수 있지 않을까? 하는 생각이 들었던 것 같아요. 그래서 이번 글에서는 RSC에 대해 알아보고, 어떻게 하면 효과적으로 지원할 수 있을지 직접 구현도 해보려고 합니다.

RSC(React Server Component)

React Server Component는, 서버에서 리액트 컴포넌트를 직접 실행해 완성된 결과(JSON)를 주고 브라우저는 받아서 바로 렌더링 할 수 있도록 하는 컴포넌트라고 합니다. 덕분에 브라우저는 Server Side Rendering과는 다르게 Hydration 없이도 완성된 결과를 바로 렌더링한다고 해요.

RSC가 추가된 React 환경 이해하기

Server Components RFC 문서와 Dan Abramov의 Why do Client Components get SSR'd to HTML?라는 글에서 알 수 있듯이, 서버 컴포넌트라는 개념이 추가되면서 기존 리액트 컴포넌트 개념을 클라이언트 컴포넌트로 구별해서 이해하면 된다고 해요.

기존 리액트 컴포넌트를 클라이언트 컴포넌트라고 생각하면 된다

어떻게 구분할 수 있을까?

정확하게는 컴포넌트를 구분한다기보다 컴포넌트 트리의 서브 트리를 클라이언트/서버로 구분한다라는 표현이 맞다고 해요. 특정 컴포넌트를 기준으로 컴포넌트 트리를 구분하고, 이 때 'use client' 지시문을 선언하면 해당 컴포넌트를 기준으로 클라이언트 트리로 취급합니다.

컴포넌트 단위가 아니라 트리 단위로 구분한다

서버 컴포넌트에서 클라이언트 컴포넌트 쓰기

서버 컴포넌트가 트리의 상위 레벨에서 렌더링을 하고 그 안에 클라이언트 컴포넌트를 children으로 포함할 수 있습니다.

// Button.tsx
'use client';
export default function Button() {
  return <button onClick={() => alert('clicked!')}>Click me</button>;
}
 
// Page.tsx
export default async function Page() {
  const data = await fetch('https://api.example.com/posts').then((res) => res.json());
 
  return (
    <div>
      <h1>{data.title}</h1>
      {/* 서버 컴포넌트 안에서 클라이언트 컴포넌트 사용 */}
      <Button />
    </div>
  );
}

클라이언트 컴포넌트에서 서버 컴포넌트 쓰기

클라이언트 컴포넌트에서 직접 서버 컴포넌트를 사용하는 것은 불가능하지만, composition을 사용해 클라이언트 트리 내부에 서버 컴포넌트를 렌더링할 수 있습니다. composition은 컴포넌트를 children이나 props를 이용해 조합해서 트리를 만드는 방식입니다.

// ClientWrapper.tsx
'use client';
import { useState } from 'react';
 
export default function ClientWrapper({ children }) {
  const [open, setOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setOpen(!open)}>Toggle</button>
      {open && children} {/* composition으로 children을 렌더링 */}
    </div>
  );
}
 
// ServerComponent.tsx
export default async function ServerComponent() {
  const data = await fetch('https://api.example.com/data').then((r) => r.json());
  return <div>Server says: {data.message}</div>;
}
 
// Page.tsx (Server Component)
import ClientWrapper from './ClientWrapper';
import ServerComponent from './ServerComponent';
 
export default function Page() {
  return (
    <ClientWrapper>
      {/* ClientWrapper 안에 ServerComponent를 "조합" */}
      <ServerComponent />
    </ClientWrapper>
  );
}

클라이언트 컴포넌트는 그저 받은 children을 렌더링하는 구조로 만들면, 클라이언트 트리 내부에 서버 컴포넌트를 렌더링할 수 있습니다.

'use client'를 쓴다는 것

리액트 훅을 사용하지 않더라도 'use client'를 붙이면 클라이언트 컴포넌트로 취급됩니다. 궁금해서 테스트를 진행해봤는데, 아래처럼 동일한 소스코드 파일 기준으로 'use client' 지시어 유무에 따라 클라이언트 js 번들에 포함되는 것을 직접 확인할 수 있었어요.


'use client' X
'use client' O

그렇지만 'use client' 지시어를 붙인다고 해도 서버 사이드 렌더링은 가능합니다. 단, 클라이언트 js 번들에도 포함되게 됩니다.

use client를 사용하면, 서버 사이드 렌더링이 가능하다. 단, 클라이언트 js 번들에는 포함된다.

반면 서버 컴포넌트는 js 번들에 포함되지 않으며, 서버에서 렌더링된 결과가 script 태그를 통해 JSON 형태로 브라우저에 전달됩니다.

script 태그를 까보면 확인할 수 있었다. js 번들이 아니라 이미 렌더링 되어져 JSON 형태로 전달된다.

이를 통해 알 수 있는 사실은, 컴포넌트 트리에서 'use client' 지시어를 리프에 가깝게 사용할 수록 클라아인트가 로드해야 하는 js 번들량면에서 이점을 얻을 수 있겠습니다.

RSC 렌더링의 라이프 사이클

서버 컴포넌트의 렌더링 즉, 서버에서 그려지는건 알겠는데 이게 결국 브라우저에서 보여지기까지 어떤 과정들이 일어날까요? 그 과정들에 대해 알아보려고 합니다.


1️⃣ 서버 컴포넌트를 RSC Payload로 렌더링

서버 컴포넌트를 렌더링하면 RSC Payload라는 직렬화된 트리 형태의 데이터로 변환하게 되는데요. 이 데이터에는 트리 형태로, 서버 컴포넌트 렌더링 데이터와 클라이언트 컴포넌트의 렌더링 위치와 Javascript 참조 위치 그리고 서버 컴포넌트에서 클라이언트 컴포넌트로 전달되어야 하는 props를 담게 됩니다.


2️⃣ HTML 렌더링

서버에서는 RSC Payload와 Javascript instruction을 이용해 HTML을 렌더링합니다. Javascript instruction은 클라이언트에서 클라이언트 컴포넌트를 렌더링하기 위해 필요한 정보를 담고 있는데요. 서버에서 사용하는 instruction은 .next/server/app/**/page.js 파일에 있어요.

라우트 별로 다른 instruction이 있는 모습


3️⃣ 클라이언트에서 리액트 트리 재조정(reconciliation)

HTML은 바로 보여주게 되고, 이제 리액트 트리를 재조정하게 됩니다. RSC Payload을 통해 리액트 트리를 재조정하게 되는데, 클라이언트 컴포넌트는 typemodule reference로 표시되어 있으며 참조가 직렬화 되어 있다고 해요.

{
  // ClientComponent 엘리먼트를 `module reference` 와 함께 placeholder로 배치
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // 자식으로 ServerComponent가 넘어간다.
    children: {
      // ServerComponent는 바로 html tag로 렌더링됨
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}

재조정 과정에서 module reference 엘리먼트를 만날 때마다 실제 클라이언트 컴포넌트 함수로 대체하는 작업을 진행하게 됩니다.

🤔

클라이언트에서는 RSC Payload를 어떻게 활용할까?

react-server-dom-webpack 패키지를 활용해서 역직렬화하고 리액트 엘리먼트 트리로 전환한다고 해요. 소스 코드를 조금 살펴보면, createResponse로 RSC Payload로 리액트 엘리먼트 트리를 만들고 있습니다.


저는 RSC Payload가 트리 형태라서 클라이언트에서 리액트 트리를 재조정할 때 더 빠르게 처리할 수 있을 거라고 생각했는데요. 실제로는 RSC Payload가 서버 컴포넌트의 렌더링 결과물로, 클라이언트에서는 이를 역직렬화한 뒤 클라이언트 컴포넌트 참조를 찾아 엘리먼트 트리로 대체하는 과정이 이루어집니다. 다만 서버에서 일부는 이미 데이터 요청과 렌더링이 완료된 상태이므로, 클라이언트에서는 실행해야 하는 컴포넌트의 양이 줄어듭니다. 그 결과, 초기 렌더가 빨라지고, 네트워크 비용과 클라이언트 JS 실행량이 줄어들어 브라우저의 처리 부하도 감소한다는 점에서 성능상 유리하겠습니다.


4️⃣ Hydration

이제 클라이언트 용 JS 번들에서 런타임 코드들을 사용해 상호작용이 가능하도록 Hydration을 진행하게 됩니다. 그럼 이제 리액트가 관리할 수 있는 트리가 될 것입니다.


이제 렌더링 과정에 대해 알아보았으니, 최적화된 컴포넌트를 만들기 위해 더 알아보려고 합니다.

RSC에서 css in js

결론부터 말하자면, RSC에서는 runtime css in js(예: styled-components, emotion)를 사용할 수 없어요. 서버 사이드 렌더링은 가능하지만, 런타임 의존성 때문에 서버 컴포넌트에서 직접 사용하는 것은 불가능합니다.


때문에 'use client' 지시어를 붙여 클라이언트 컴포넌트임을 명시해야 합니다. 서버 컴포넌트 렌더링을 할 때 RSC Payload를 생성하게 될텐데 이때 실행되지 않아 에러가 발생하지 않을 것이며 서버용 js 번들로 HTML을 생성할 것 입니다.


emotion에서는 서버 사이드 렌더링을 위한 방법을 제공하고 있는데요. Next.js에서는 별다른 설정 없이도 서버 사이드 렌더링이 되고 그렇지만 'use client'를 붙여야 합니다. emotion 소스 코드에서 !isBrowser 부분이 있는데요, 실행되는 환경이 브라우저가 아닌 경우 style 태그를 반환해요. 이를 이용해서 서버 사이드에서 삽입하여 렌더링하는 것으로 보이네요.

SSR된 HTML에서 이미 style 태그가 삽입되어 있는 모습'use client' 지시어 없이는 에러가 발생하는 모습

서버 컴포넌트에서 emotion을 사용할 수 없는 이유는 런타임에 동작하는 코드가 포함되어 있기 때문이였어요. 따라서 서버 컴포넌트에서도 스타일링을 처리하려면 zero-runtime 방식의 스타일링 접근법을 택하는 것이 필요합니다. 스타일링은 서비스 전반에 걸쳐 중요한 요소이기 때문에, 서버 컴포넌트를 최대한 활용하면서 클라이언트 컴포넌트 트리를 최소화하기 위해서는 zero runtime 스타일 방식이 필요할 것 같네요.

RSC에서 Context API

Context API는 createContext를 통해 생성한 context를 Provider를 통해 값을 내릴 수 있으며 useContext 훅을 통해 하위에서 값을 사용할 수 있습니다. 그런데, 아래와 같이 Provider 자체도 'use client' 지시문을 필요로 하는 것을 확인할 수 있었어요.

import { TestContext } from '@/context/TestProvider';
 
export default function Page({ children }: PropsWithChildren) {
  return <TestContext.Provider value={{ type: 'A' }}>{children}</TestContext.Provider>;
}

createContext 함수 자체가 클라이언트 컴포넌트에서만 작동한다

많은 디자인 시스템에서 Compound Pattern을 활용해 각 구성 요소의 역할을 구분하고 있는 것 같은데요! 사용자는 자유롭게 조합하고, 필요한 부분을 선택적으로 제어할 수 있어 유연함을 줄 수 있는 좋은 방법이기 때문일 것 같아요. 또, 시스템 적으로 사이즈 매핑이나 간격, 상태에 따른 스타일을 조정하기 위해 상위에서 특정 속성을 전달하면 내부에서 여러 컴포넌트들의 스타일을 제어하게 됩니다. 그리고 Controlled와 Uncontrolled를 모두 제공해야 할 때 내부에서 state 관리가 필요할 수 있습니다. 이럴 때 Context API를 필수적으로 사용하게 되는 것 같아요.


이런 점들을 종합해보면, 디자인 시스템을 RSC에 맞춰 최적화하는 것이 사실 쉽지 않다는 걸 알 수 있었어요. 실제로 컴포넌트를 Controlled 방식으로 사용하게 되면 상태 관리를 위해서 자연스럽게 클라이언트 컴포넌트 트리 안에 위치하게 되고, 이 경우 컴포넌트 단위로 RSC 최적화를 시도하는 것이 큰 의미를 가지지 못한다는 생각이 들었던 것 같아요.

Reference