← cd ..

RSC 뜯어보기 1: 빌드부터 런타임까지

$ stat rsc-build-and-runtime.md
date: 2025-12-31  |  tags: react, rsc  |  5min read
$ cat rsc-build-and-runtime.md

회사에서는 인터랙션이 많은 편집 툴을 주로 개발하다 보니, 서버 사이드 렌더링보다는 클라이언트 사이드 렌더링을 다룰 일이 많았고, SSR이나 RSC를 깊게 파볼 기회는 적었다.

그러던 중 최근 Waku라는 RSC 프레임워크를 접하며 RSC의 내부 동작 원리에 대해 깊이 파헤쳐 보고 싶은 욕심이 생겼다. 공부하며 정리한 RSC의 빌드부터 런타임 과정, 그리고 SSR과의 결정적인 차이점을 정리해보겠다.


RSC란?

RSC(React Server Component)는 말 그대로 서버에서만 실행되는 리액트 컴포넌트를 의미한다.

기존의 리액트 컴포넌트가 브라우저에서 실행되어 UI를 그렸다면, RSC는 서버 환경에서 실행되어 그 결과물(데이터)을 브라우저에 전달한다. 이를 통해 클라이언트가 다운로드해야 할 자바스크립트 번들 크기를 줄이고 데이터 페칭 성능을 최적화할 수 있다.


RSC 빌드 과정

RSC 프로젝트를 빌드하면 번들러는 클라이언트와 서버라는 두 가지 환경을 위해 분주하게 움직인다.

RSC를 지원하는 주요 번들러로는 Webpack, Turbopack, Vite가 있다.

  • Turbopack은 Rust 기반 번들러로, Webpack 대비 빌드/HMR 속도가 매우 빠르다.
  • Vite는 Tanstack Start, Waku 등 신생 프레임워크들이 대부분 채택하고 있는 번들러다.

클라이언트 빌드

먼저 번들러는 프로젝트 전체를 스캔하며 use client 지시어가 붙은 파일들을 찾아낸다. 이 파일들은 브라우저에서 실행될 클라이언트 컴포넌트들이다. 번들러는 이 컴포넌트들과 그에 필요한 의존성들을 묶어 브라우저용 자바스크립트 번들을 생성한다.

이때 중요한 파일이 하나 만들어지는데, 바로 react-client-manifest.json이다. 이 매니페스트 파일은 어떤 컴포넌트가 어떤 자바스크립트 번들 파일에 위치해 있는지 기록한 지도와 같다. 서버가 브라우저에게 "이 컴포넌트가 필요해"라고 말할 때 참조하는 근거가 된다.

서버 빌드

이어서 서버 컴포넌트 코드를 빌드한다. 특이한 점은 서버 빌드 결과물에는 클라이언트 컴포넌트의 실제 코드가 포함되지 않는다는 것이다. 대신 앞서 만든 매니페스트의 참조 정보로 그 자리를 대체한다. "여기는 나중에 브라우저가 실행할 Counter 컴포넌트가 들어갈 자리야"라는 표식만 남겨두는 셈이다.

다시 정리해보면,

번들러가 use client 파일들을 모아 브라우저용 번들과 매니페스트(react-client-manifest.json)를 만들고, 서버 빌드에서는 그 매니페스트 참조로 클라이언트 컴포넌트 자리를 대신 채워 넣는다.


RSC 런타임 과정

초기 진입 시

사용자가 브라우저를 통해 처음 접속하면 다음과 같은 순서로 화면이 그려진다.

  1. RSC Payload 생성: 서버는 서버 컴포넌트를 실행하여 RSC Payload라는 특수한 형태의 데이터를 만든다. 이 과정에서 컴포넌트 트리를 텍스트로 바꾸는 직렬화가 일어난다.
  2. HTML 생성 (SSR): 사용자가 빈 화면을 보지 않도록, 생성된 RSC Payload를 바탕으로 리액트 엔진이 HTML을 미리 렌더링한다.
  3. 스트리밍 전송: 서버는 완성된 HTML과 클라이언트 자바스크립트 번들, 그리고 RSC Payload를 브라우저로 보낸다. 보통 모든 데이터가 준비될 때까지 기다리지 않고 조각 단위로 스트리밍한다.
  4. Hydration: 브라우저는 HTML을 먼저 띄워준 뒤, 자바스크립트 번들을 실행해 이벤트 리스너를 붙인다. 마지막으로 RSC Payload를 해석해 서버와 클라이언트 컴포넌트가 결합된 최종 리액트 트리를 완성한다.

페이지 이동 시

사용자가 링크를 클릭해 다른 페이지로 이동할 때는 HTML을 다시 받아오지 않는다.

  1. 브라우저는 서버에 새 페이지의 RSC Payload만 요청한다.
  2. 서버는 해당 서버 컴포넌트만 실행해 결과값을 직렬화해서 응답한다.
  3. 브라우저는 현재 메모리에 있는 상태를 유지하면서 서버에서 받은 데이터로 UI 트리를 비교하여 갱신한다(Reconciliation).

이를 통해 싱글 페이지 애플리케이션(SPA) 같은 부드러운 전환이 가능해진다.


직렬화와 제약 사항

서버 컴포넌트의 실행 결과는 네트워크를 타고 브라우저로 이동해야 하므로 문자열 형태인 RSC Payload로 변환되어야 한다. 이 과정을 직렬화(Serialization)라고 한다.

RSC Payload의 실제 형태를 예시로 살펴보면 다음과 같다. JSON과 유사하지만 리액트만의 특수한 기호가 섞인 텍스트 데이터이다.

M1:{"id":"./src/Counter.client.js","chunks":["client0"],"name":"Counter"} // 클라이언트 컴포넌트 정보
J0:["$","div",null,{"children":[
  ["$","h1",null,{"children":"방명록"}],
  ["$","@$M1",null,{"initialCount":0}] // M1(클라이언트 컴포넌트)을 여기에 끼워넣어라!
]}]

이러한 직렬화 과정 때문에 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 함수를 넘길 수 없다는 중요한 제약이 생긴다. (단, 'use server'로 선언된 서버 액션(Server Action) 함수는 예외로, 직렬화 가능한 참조로 변환되어 클라이언트에 전달될 수 있다.)

// 불가능! 함수는 직렬화할 수 없음.
<ClientComponent onClick={() => console.log('서버에서 정의한 함수')} />

왜 함수는 직렬화할 수 없는데?

함수는 코드를 통째로 문자열로 바꿀 경우 보안 이슈가 생길 수 있고, 브라우저에서 이를 다시 실행하는 과정도 까다롭기 때문이다. 따라서 서버에서 클라이언트로 데이터를 보낼 때는 기본적으로 숫자, 문자열, 객체처럼 JSON으로 표현 가능한 데이터를 사용하는 것이 안전하다. (스트리밍되는 Promise나 프레임워크별 확장, 그리고 앞서 언급한 서버 액션 참조는 예외다.)


Waku 프레임워크로 본 실제 빌드 결과물

지금까지 설명한 개념이 실제 프레임워크에서 어떻게 나타나는지 살펴보자. RSC 전용 프레임워크인 Waku를 통해 빌드된 구조를 살펴보았다.

dist/
├── public/ # 브라우저가 접근하는 정적 자원 (Client-side)
│   ├── assets/
│   ├── RSC/
│   └── [page]/index.html
└── server/ # Node.js 환경에서 실행되는 서버 로직 (Server-side)
    ├── __vite_rsc_assets_manifest.js
    ├── server-node.js
    └── index.js
  • dist/server/server-node.js: 서버 엔트리 포인트
  • dist/server/__vite_rsc_assets_manifest.js: 서버 컴포넌트가 참조하는 클라이언트 컴포넌트 ID가 실제 어떤 자바스크립트 파일에 있는지 매핑한 정보가 담겨 있다.
  • dist/public/RSC/: 빌드 타임에 미리 생성된 정적 RSC Payload 조각들이 저장되어 있다.

RSC와 SSR의 차이

지금까지 살펴본 RSC는 "컴포넌트가 어디서 실행되는가"의 문제라면, SSR은 "HTML이 언제 그려지는가"의 문제다. 이 둘은 혼동하기 쉬우므로 차이를 짚고 넘어가자. RSC와 SSR는 서로 '보완 관계'이지 대체 관계가 아니다.

전통적인 SSR 방식 (RSC 미사용)

  1. 서버에서 모든 컴포넌트를 실행해 HTML을 만든다.
  2. 이후 브라우저는 HTML을 받고 나서 리액트 런타임이 모든 컴포넌트 함수를 다시 실행하며 메모리에 Fiber 노드를 생성한다.
  3. 이렇게 생성한 Fiber 트리와 이미 그려진 HTML의 DOM을 비교하면서 이벤트 리스너를 붙인다. (Hydration)

브라우저가 Fiber 노드를 생성하기 위해서 자바스크립트 번들에 모든 컴포넌트 코드가 포함되어야 하므로 번들이 무거워진다. 또한 컴포넌트 함수가 서버와 클라이언트에서 두 번 실행되는 비효율이 발생한다.

현대적인 방식 (RSC + SSR)

  1. 서버에서 서버 컴포넌트를 실행해 RSC Payload를 만든다. 브라우저는 이 Payload를 읽어 트리를 구축하는데, 서버 컴포넌트들은 이미 실행이 완료된 결과값 상태이므로 클라이언트에서 다시 함수를 실행할 필요가 없다.
  2. 따라서 자바스크립트 번들에는 클라이언트 컴포넌트의 코드만 담기게 되어 용량이 획기적으로 줄어든다. Fiber 트리 역시 소스 코드를 가진 클라이언트 노드와 결과값만 가진 서버 노드가 공존하는 가벼운 형태가 된다.

두 방식의 차이를 표로 정리하면 다음과 같다.

항목전통적인 SSR (RSC 미사용)RSC + SSR
번들 크기모든 컴포넌트의 소스 코드 포함클라이언트 컴포넌트의 코드만 포함
컴포넌트 함수 실행 횟수서버 1회 + 클라이언트 1회 (총 2회)서버 컴포넌트는 서버에서 1회만 실행
Fiber 트리 구성모든 노드를 소스 코드 기반으로 재구성클라이언트 노드(소스 코드) + 서버 노드(결과값)가 공존

정리하자면 RSC는 컴포넌트가 어디서 실행될 것인가에 대한 문제이고, SSR은 초기 HTML을 어떻게 빠르게 보여줄 것인가에 대한 문제다.

그렇다면 실무에서는 어떤 기준으로 서버/클라이언트 경계를 나눠야 할까? 상태나 이벤트 핸들러가 필요 없는 컴포넌트는 최대한 서버 컴포넌트로 남겨두고, 인터랙션이 꼭 필요한 최소 단위만 use client로 감싸는 것이 번들 크기와 성능을 동시에 챙기는 방법이다.