서버 컴포넌트(RSC; React Server Component) 이해하기

김소연·2025년 8월 31일
post-thumbnail

RSC 등장 배경

1. CSR

서버는 빈 HTML(<div id="root">)과 자바스크립트 번들(<script>)을 보내고, 브라우저는 자바스크립트 번들을 다운로드 및 파싱한 후 React 앱을 실행한다.

따라서 자바스크립트 번들이 로딩되기 전까진 빈 화면이 나타나며, 번들 크기가 커질수록 초기 로딩 시간이 길어지는 문제점이 있다.

이에 대해선 React.lazy, Suspense, Code Splitting 등으로 초기 로딩 부담을 줄일 수 있다. 처음에 전체 번들을 다 로딩하지 않고, 필요할 때 JS를 다운로드를 하고 그 동안 미리 지정한 UI를 보여줄 수 있다.

2. SSR

CSR의 단점을 개선하고자 서버 사이드 렌더링이 등장했다.

서버에서 React 컴포넌트를 실행하여 채워진 HTML을 보내어 사용자는 빈 화면이 아닌 완성된 HTML을 볼 수 있다. 이후 자바스크립트 번들을 로딩하여 hydration을 진행하면, 사용자와의 상호작용이 가능해진다.

사용자는 빈 화면 대신 빠르게 콘텐츠를 볼 수 있지만, JS 번들이 모두 다운로드되기 전까진 hydration을 진행하지 않기 때문에 인터랙션이 불가하다는 문제가 있다.

3. Streaming SSR과 Selective Hydration

React 18에서 Streaming SSRSelective Hydration이 새롭게 도입되었다.

React의 내장 함수 renderToNodeStream을 사용하여 HTML을 조각 단위로 스트리밍할 수 있게 되었다. 즉 전체 HTML을 만들고 보내는 것이 아니라(renderToString), 준비된 HTML을 순차적으로 보낸다. 따라서 사용자는 부분적으로 완성된 HTML을 미리 볼 수 있다.
또한 선택적 하이드레이션(Selective Hydration)을 통해 우선순위가 높은 컴포넌트에 대해 먼저 hydration을 적용하여 사용자의 상호작용 시점을 개선할 수 있다.

Streaming SSR에서도 Suspense을 사용하여 아직 준비되지 않는 부분에 대해 fallback UI를 보여줄 수 있다.

💡 CSR(Code Splitting)와 Streaming SSR에서의 Suspense 역할 차이

1. CSR (Code Splitting + Suspense)
사용자가 어떤 컴포넌트를 렌더링하려는 런타임 시점(클라이언트에서 모듈을 import하는 시점)에 해당 컴포넌트에 대한 JS 번들을 서버에 요청한다. JS 번들을 로딩하는 동안 Suspense fallback을 표시한다.

2. Streaming SSR + Suspense
사용자가 페이지를 열 때 서버에 페이지 전체의 HTML을 요청하고, 순차적으로 HTML 조각을 스트리밍 전송한다. 특정 컴포넌트가 전송되기 전까지 Suspense fallback을 표시한다.

하지만 SSR도, Streaming SSR도 hydration을 위해서는 자바스크립트 번들을 다운로드 받아야 한다. (JS에 대한 의존성은 그대로!)

그래서 나온 RSC

React는 18버전에서 RSC의 개념을 제안했다.

서버 컴포넌트란?

서버 컴포넌트는 오직 서버에서만 실행되는 컴포넌트이다.
해당 코드는 클라이언트로 전송되지 않아서 번들 크기를 줄여 초기 로딩 속도를 개선하고, 서버 자원(DB, 파일 시스템, 내부 API)을 직접 활용할 수 있다.
서버 컴포넌트는 빌드 시 CI 서버에서 한 번 실행되거나(static) 또는 요청을 받을 때마다 웹 서버를 통해 실행된다(dynamic).

서버 컴포넌트를 도입한 동기

리액트 rsc 관련 문서에서는 서버 컴포넌트를 도입하게 된 동기를 다음과 같이 말했다.

The fundamental challenge was that React apps were client-centric and weren’t taking sufficient advantage of the server.

기본적으로 좋은 성능을 낼 수 있고 데이터 페칭을 쉽게 할 수 있는 다양한 솔루션을 내보았지만, 가장 근본적인 문제는 서버의 이점을 충분히 누리지 않는 클라이언트 중심적인 리액트 앱이었다는 것이다.

1. Zero-Bundle-Size Components

  • 작은 기능에 사용되는 third-party 라이브러리를 가져오면 코드 크기가 커진다.
  • tree-shaking, lazy loading으로 줄일 수는 있으나 여전히 JS를 다운로드해야 한다.
  • 해결: 서버 컴포넌트라면 라이브러리를 서버에서만 실행하고 클라이언트 번들로 보내지 않는다. → 번들 크기 감소

2. Full Access to the Backend

  • 데이터를 가져오기 위해 API 엔드포인트를 추가적으로 만들어야 하거나 UI용이 아닌 엔드포인트를 억지로 사용해야한다.
  • 해결: 서버 컴포넌트는 백엔드 자원(DB, 파일 시스템, 내부 API)에 직접 접근할 수 있다. → 데이터 접근 단순화

3. Automatic Code Splitting

  • 기존에는 코드 스플리팅을 위해서는 React.lazy를 사용해야 하고, 클라이언트 렌더링 시에 로딩이 이루어져 지연이 발생한다.
  • 해결: 서버 컴포넌트는 자동으로 코드 분할을 처리한다. 또한 어떤 클라이언트 컴포넌트를 사용할지 결정이 서버에서 이루어지므로, 클라이언트는 필요한 코드를 미리 다운로드할 수 있다.

4. No Client-Server Waterfalls

  • 기존 useEffect 기반 데이터 페칭 방식은 waterfall 문제를 만든다. (parent 로딩 완료 전까지 child는 로딩 시작 X → 느린 속도)
  • 해결: 서버 컴포넌트는 데이터 요청을 서버에서 처리하여, 네트워크 latency(클라이언트-서버)를 줄여 성능을 개선할 수 있다. → waterfall 해결

5. Avoiding the Abstraction Tax

  • 강력히 추상화된 JS(컴포넌트 합성, HOC, Hooks 등)는 개발자에겐 편리하지만, 실행될 땐 불필요한 오버헤드를 발생시킨다.
  • 리액트는 AOT(Ahead-of-Time) 최적화를 통해 빌드 타임에 코드를 분석하여 해결하려했으나 실패했다. 동적 언어 JS의 예측 불가능함과 신뢰할 수 없는 최적화 결과 때문이다.
  • 해결: 서버에서 컴포넌트의 추상화 계층을 풀어내고 단순화된 결과만 클라이언트에 전송한다. → 추상화 비용을 서버에서 처리하여 추상화 오버헤드 제거

RSC 작동 원리

1. 서버가 렌더링 요청 수신

RSC를 사용하는 페이지는 항상 서버 요청에서 시작하며, 루트 컴포넌트는 서버 컴포넌트이다. 서버는 요청에 담긴 정보를 바탕으로 어떤 서버 컴포넌트를 사용할지, 어떤 props를 전달할지 결정한다.

2. 루트 컴포넌트를 JSON 형태로 직렬화

서버는 루트 서버 컴포넌트를 직렬화(serialize)하여 브라우저로 보낸다.
html로 보내는 것보다 직렬화된 형식으로 보내는 것이 리액트 엘리먼트를 재구성하기에 유리하다.

리액트 엘리먼트는 결국 객체이기 때문에 직렬화할 수 있다.
이렇게 기본 html 태그로 이루어진 경우 다음과 같이 type 필드가 문자열이므로 직렬화가 가능하다.

// <div>oh my</div>
{
  $$typeof: Symbol(react.element),
  type: "div",
  props: { title: "oh my" },
  ...
}

하지만 서버/클라이언트 컴포넌트의 경우 type 필드 값이 컴포넌트 함수의 참조가 되는데, 함수는 직렬화할 수 없기 때문에 다른 방식으로 처리되게 된다.

 function MyComponent({children}) {
     return <div>{children}</div>;
 }

// <MyComponent>oh my</MyComponent>
{
  $$typeof: Symbol(react.element),
  type: MyComponent  // reference to the MyComponent function
  props: { children: "oh my" },
  ...
}

1) 서버 컴포넌트
type에 있는 함수(컴포넌트)를 props와 함께 호출하여 그 결과를 직렬화한다. 즉 실제 HTML 태그들로 렌더링해버린다.

2) 클라이언트 컴포넌트
번들러(webpack)은 함수 참조를 파일 이름과 export 이름을 포함한 모듈 참조 객체로 변환한다.

{
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default", // default export
    filename: "./src/ClientComponent.js"
  },
  props: { children: "oh my" }
}

클라이언트 컴포넌트는 서버에서 호출되지 않고 참조 정보만 남기므로, placeholder라고 표현한다.

결과적으로 다음과 같이 하나의 트리(JSON)가 만들어진다.

서버는 이 직렬화한 트리(JSON)를 브라우저로 보낸다.

실제 서버가 브라우저로 스티리밍하는 데이터

다음과 같이 한 줄에 하나의 JSON blob과 ID가 있는 형식이다.

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]

브라우저는 한 줄씩 읽으며 JSON을 파싱해 점진적으로 UI를 갱신하게 된다.
M으로 시작하는 줄은 클라이언트 컴포넌트 모듈 참조를 정의하는데, 클라이언트 번들에서 해당 컴포넌트를 어떻게 찾아야하는지에 대한 정보를 제공한다.
J로 시작하는 줄은 실제 리액트 엘리먼트를 정의한다. @1M으로 정의된 클라이언트 컴포넌트에 대한 참조를 의미한다.

Next.js에선 RSC Payload라고 부른다.

3. 클라이언트에서 JSON을 받아 React 트리로 복원

브라우저는 렌더링을 위해 리액트 엘리먼트 트리로 복원한다.

서버에서 번들러가 클라이언트 컴포넌트 함수를 모듈 참조로 바꾸었듯, 브라우저에서 번들러는 모듈 참조를 실제 클라이언트 컴포넌트 함수로 되돌리는 역할을 한다.

이 과정을 통해 재구성된 리액트 트리는 기본 HTML 태그와 클라이언트 컴포넌트로 구성되고, 이후 렌더 & 커밋을 통해 DOM에 반영된다.

서버 컴포넌트의 사용

React 19에서 서버 컴포넌트가 공식화되었지만, 실제로는 Next.js 같은 프레임워크를 사용하는 것이 일반적이다.
물론 프레임워크를 사용하지 않고 직접 서버 로직을 구현해 볼 수도 있다: https://itnext.io/react-server-components-without-frameworks-7c61c1ce0561

서버 컴포넌트는 언제 사용하는가?

별도의 지시어("use client")가 없는 컴포넌트는 기본적으로 서버 컴포넌트가 된다.
따라서 다음과 같이 꼭 클라이언트 컴포넌트로 구현해야하는 경우에만 지시어를 명시하면 되겠다.

  1. 브라우저 API가 필요한 경우
  • 예: window, document, localStorage, addEventListener 등
  1. 상태와 사이드이펙트가 필요한 경우
  • useState, useReducer, useEffect 같은 hook 사용
  • 폼 입력, 모달 토글, 스크롤 위치 제어 등
  1. 사용자 이벤트 핸들러가 필요한 경우
  • onClick, onChange, onSubmit 같은 이벤트 바인딩

참고자료

https://saengmotmi.netlify.app/react/what-is-rsc/
https://react.dev/reference/rsc/server-components
https://nextjs.org/docs/app/getting-started/server-and-client-components
https://www.plasmic.app/blog/how-react-server-components-work#what-are-react-server-components

0개의 댓글