RSC에 대해

1. 그전에 먼저 CSR

React Server Component에 대해 탐구해보기 전에 워밍업으로 CSR를 다시 짚고 넘어가야 한다.

아주 전통적인 방식의 CSR를 다시 만들어보면서 무엇이 어떻게 변경되었고 왜 RSC까지 가게 되었는지 알아보자.

1.1 예제

간단한 애플리케이션이 있다고 가정하자.

이 애플리케이션은 번들러를 사용하지 않은 완전 원시적인 앱이다. 번들러를 사용하지 않아 바벨도 없고 createElement를 직접 사용해야 한다.(여기서 h는 hyperscript의 h다) React 조차도 importmap을 통해 CDN으로 불러온다.

이미 UI와 데이터 페칭 코드는 작성되었다고 했을 때, 초기에 브라우저 서버간 정적파일 서빙 관련 코드만 작성해보자.

이 애플리케이션은 좌측 검색바가 있고 우측에는 검색 결과에 대한 이미지가 나온다. Input에 검색어를 입력하면 <form>을 통한 GET 요청을 하게 된다.

검색을 한뒤

<a> 태그인 요소를 클릭하게 되면 배id와 함께 searchParams가 변경되며 페이지 새로 고침이 일어난다.

전통적인 CSR 방식이므로, 브라우저가 GET /:id?search=star로 HTTP 요청 보내고

서버는 그럼 index.html을 반환한다. (번들러가 없으므로 Express나 Hojo 같은 웹 프레임워크을 사용해야 한다)

이제 브라우저는 받은 HTML을 파싱하고,
<script type="module" src="/index.js"></script> 스크립트 태그를 실행한다.

스크립트 호출로 인해 /index.js에 있는 React가 다시 실행되는데, 앱은 드디어 api/:something검색어로 새로운 데이터를 받아 리렌더링 하게 된다.

여기까지가 간략한 과정이다. 그럼 처음부터 살펴보자.

1.2 /public/index.html

먼저 리액트 관련 코드를 작성하자.

서버 관련은 조금 뒤에서 살펴볼테지만, 일단 서버에서 정적 파일 서빙은 가능하다고 가정하고 앱을 실행시키면

요런 에러가 나온다. 즉, 브라우저가 react를 인식하지 못해서 나오는 에러다.

위처럼 import something from 'react'를 할 경우, 브라우저가 절대 경로도, 상대 경로도 아닌 이 모듈 경로를 이해해야 하는 것

그래서 importmap을 사용해야 한다.

<script type="importmap">을 이용해 react가 어떤 URL인지 명시해주면 된다.

그런데 가만 생각해보면 그냥

<script src="https://unpkg.com/react@18/umd/react.development.js"></script>

이런 식으로 umd로다가 cdn 호출을 해도 되지 않을까 싶은데,

importmap을 사용하여 ESM 모듈 시스템으로 진행하는 것이 트리 쉐이킹이나 모듈 import, export 이점이 있기 때문에 위와 같은 방식이 더 좋다.

이러한 작업들을 번들러에선 node_modules/react에서 react 패키지를 찾은 뒤 js 파일에 포함시키면서 내부적으로 처리한다. 번들러를 사용하지 않기 때문에 importmap으로 브라우저에게 React 패키지의 위치를 알려줘야 한다.

1.3 /server/app.js

자, 그럼 서버 코드를 살펴보자

지금은 아무 것도 없는 404 빈 페이지다. 정적 파일을 서빙해야한다.

기본적인 서버 세팅을 해준다. 웹 프레임워크는 HonoJs를 사용한다.

port랑 app instance를 생성하고 정적 파일을 서빙하기 위한 코드를 작성한다.

serveStatic은 정적 파일을 클라이언트에 넘기는 미들웨어다. (지금은 자세히 알 필요는 없다.)

이제./public 폴더에 있는 정적 파일을 브라우저 요청에 맞게 서빙해준다.

그럼 브라우저는 서빙받은 HTML을 파싱하다 스크립크 태그를 호출할 것이다.

서버에 js 파일을 요청했지만 js 파일을 서빙해주고 있지 않아 에러가 나온다.

이제 js 파일도 서빙해보자.

동일하게 /client/*로 들어온 요청도 /client 하위 파일로 매핑해주면 된다.

들어온 요청 경로를 파일 시스템의 경로와 일치하기 위해서
rewriteRequestPath: (path) => path.replace('/client', '') 이처럼 replace로 경로를 변경해줘야 한다.

이제 HTML을 포함한 정적 파일과 js 스크립트 파일 모두 브라우저가 받을 수 있게 되었다.

번들러를 사용한다면 이 과정을 번들러의 개발 서버에서 알아서 해준다.

여기까지 하면 애플리케이션이 잘 구동된다. (data api 설명은 생략함)

하지만, 살짝쿵 문제가 있다. 상세 페이지 같은 동적인 URL에 대해서 파일을 서빙할 수 없는 것이다.

가령, 이런 페이지에서 새로고침을 한다고 했을 때 URL은 /:shipId/search?=star인데 실제 서버엔 이 경로에 맞는 HTML 파일이 없다.

(사실 요소를 클릭하고 상세 페이지에 들어오기 전에 이미 404다 나온다)

우리에겐 단 하나의 index.html 뿐이니, 동적 경로에 대해 index.html 로 떨어지게 하여 다시 브라우저 렌더링을 수행할 수 있도록 CSR 느낌의 라우팅을 만들어야 한다.

/server/app.js에 새로운 라우팅 하나를 추가하고, 동적인 요청 URL에 대하여 직접 index.html 파일을 불러와서 반환한다.

이제 브라우저는 모든 경로에 대해 HTML을 서빙 받게 되고 진정한 CSR 라우팅이 된다.

이 문제를 번들러에선 historyApiFallback 옵션값으로 처리하고 있다.

드디어 CRS의 브라우저 HTML 요청 -> 서버 서빙 -> 클라이언트 렌더링의 사이클이 완성되었다.

1.4 정리

마지막으로 SPA 기반 CSR 흐름을 다시 살펴보면

1. 브라우저에 "/"로 접속
-> 서버에 index.html 요청

2. 서버가 index.html 응답
-> 이 HTML 안에는 React 앱을 로딩하는 <script> 태그만 있음

3. 브라우저가 js 번들 요청
-> ex) /client/index.js

4. js 번들이 로드되면 react 앱 시작됨
-> ReactDOM이 #root에 mount
-> 이 시점에서 로딩이나 스켈레톤 UI

5. react 앱이 API 호출로 데이터 요청
-> ex) /api/ships

6. 데이터를 받아서 React 컴포넌트들과 함께 React.createElement() 호출

7. ReactDOM이 이를 진짜 DOM으로 렌더링

2. 이제 RSC

RSC에 들어가기 전에 왜 RSC인지 살짝쿵 살펴보자

2.1 왜 RSC?

RSC의 등장은 리액트가 해결하지 못 한 두 가지 난제와 연관이 있다.

첫 번째, 클라이언트에서 데이터를 다룰 때 Cascading waterfallsProp drilling 같은 이슈에 노출되어 있는 것

Prop drilling은 워낙 유명하니 생략하고 Cascading waterfalls은 여러 데이터가 순차적으로 요청되면서 렌더링이 지연되거나 병목 현상이 생기는 이슈다.

두 번째, 상호작용이 필요 없는 컴포넌트까지 Hydration을 위해 불필요한 js가 클라이언트로 전송되는 것

RCS는 이 두 문제를 해결하기 위해 서버에서 UI를 생성해 클라이언트로 전달하여 클라이언트로 전송되는 js를 상호작용이 필요한 부분만으로 줄이고, 컴포넌트 내부에서 직접 데이터 패칭도 할 수 있게 된다.

2.2 RSC Payload

일반적 SSR에서 서버 렌더링할 때는 HTML 문자열을 렌더링 한다.

하지만 RSC는 클라이언트에서 상호작용이 필요한 컴포넌트와 서버에서만 렌더링되는 컴포넌트를 섞어야 하므로 조금 다른 방식을 선택하는데 이를 RSC Payload라 부른다.

위와 같은 구조를 가진 데이터 형식인데, 서버에서 렌더링된 결과를 클라이언트에 보낼 때 사용한다.

이 데이터는 스티리밍 방식으로 나눠서 클라이언트에 보내질 수도 있고, 클라이언트에서 사용할 함수에 대한 참조 등이 있다.

예를들어 1:D{"name":"App","env":"Server"}는 서버 컴포넌트에 대한 정의고, $L1는 참조와 관련있다.

2.3 RSC 플로우차트

이제 RSC의 개념을 대략적으로 알았으니 클라이언트와 서버 간 플로우를 알아보자.

출처 : https://x.com/kentcdodds/status/1775181726094110835

이름하여 슈퍼 심플 리액트 서버 컴포넌트 플로우 차트다...

2.3.1 RSC 흐름 정리

간단하게 요약해서 정리해보면

1. 브라우저가 '/'로 접속
-> 여전히 index.html을 요청함.

2. index.html이 최소한의 클라이언트 코드만 포함
-> ex) index.client.js만 요청 (전체 SPA 번들 아님)

3. index.client.js 안에서 createFromFetch('/RSC') 호출
-> 클라이언트는 React 컴포넌트를 요청하지 않고, 서버에서 미리 조립된 UI 조각을 요청

4. 서버는 renderToPipeableStream()을 이용해 JSX 직렬화 데이터를 스트리밍으로 응답

5. 클라이언트는 createFromFetch()로 이 스트림을 받아서 React.createElement() 리액트 엘리먼트 트리로 복원

6. ReactDOM이 이걸 실제로 DOM에 렌더링

7. JSX 안에 client component가 있으면 클라이언트는 필요한 js 모듈만 로드하고 hydrate

휴.. 생소한 개념들과 단어는 차차 설명하고 이제 처음 만들었던 예제를 하나씩 RSC로 변경해보자.

2.4 /server/app.js

먼저 서버 코드를 변경해보자. 기존에 JSON 데이터를 반환했던 api에서 이제 RSC Payload를 반환해야 한다.

query param을 읽어서 db에 접근하여 JSON data를 반환하는 api다. RSC Payload를 반환한다는 걸 명시하기 위해 end point를 rcs로 변경하고 필요한 서버 컴포넌트 메서드를 추가한다.

하나씩 살펴보면 기존의 JSON data를 props 객체로 묶은 뒤, createElement(App, props)renderToPipeableStream에 넘겼다.

즉, 클라이언트에서 생성해야할 root react element 객체를 서버에서 생성한 것이다.

renderToPipeableStream는 이 객체를 RSC 포맷의 스트림으로 직렬화하여 pipe라는 함수를 반환한다.

pipe는 RSC 페이로드를 Node.js의 writable 스트림에 스트리밍하는 메서드다. 말이 조금 어려운데, 쉽게 설명해서 pipe를 호출하면, 서버는 준비된 부분부터 RSC 페이로드를 순차적으로 클라이언트에 전송하는 것이다.

2.4.1 RSC는 꼭 서버가 필요한가?

그럼 여기서 의문, RSC는 항상 서버를 띄워야 하는지 궁금증이 생긴다.

RSC는 서버 컴포넌트라는 이름과 달리, 항상 실행 중인 서버가 있어야만 동작하는 것은 아니다.빌드 시점에서 미리 RSC를 렌더링해 정적 파일로 배포하는 것도 가능하다.

하지만 personalization, dynamic fetching 등 서버에서 RSC를 했을 때 누릴 수 있는 이점들이 있어서 서버 환경이 필요하다.

이제 서버 코드는 끝났으니 한번 앱을 실행시켜보자

2.5 React Server 환경

실행과 동시에 서버 에러 로그가 나온다.

 throw new Error()
...
Error: The React Server Writer cannot be used outside a react-server environment. 
You must configure Node.js using the `--conditions react-server` flag.

이유인즉슨 서버 컴포넌트 관련 모듈들은 React Server 환경에서 실행되어야 하는데, 이에 대한 설정이 없었던 것

찐 React 패키지를 살펴보면

{
	"exports": {
		".": {
			"react-server": "./react.react-server.js",
			"default": "./index.js"
		},
		"./package.json": "./package.json",
		"./jsx-runtime": {
			"react-server": "./jsx-runtime.react-server.js",
			"default": "./jsx-runtime.js"
		},
		"./jsx-dev-runtime": "./jsx-dev-runtime.js"
	}
}

환경에 따라 다르게 모듈을 export하는 걸 확인할 수 있다.

(찐 React는 "use client" 지시어로 번들러가 모듈을 분리한다.)

기본적으로 서버 컴포넌트는 useState, useEffect 같은 훅을 사용할 수 없다. 한 번만 렌더링되는 템플릿 같기 때문에 브라우저 이벤트 핸들러도 사용할 수 없다.

조금 더 생각해보면 useState의 상태라는 것은, React 내부의 지역변수인 fiber 객체에(메모리 어딘가)에 존재하는 것인데, 서버의 요청은 stateless하기 때문에 상태를 메모리에 저장하는 것은 불가능하다.

그래서 서버 컴포넌트의 경우에 실행 스크립트에 --conditions react-server라는 조건을 줘서 RSC가 동작할 수 있는 React Server 모듈을 가져와야 한다.

"scripts": {
  "dev": "node --conditions=react-server --watch server/app.js"
}

2.6 /client/index.js

이제 클라이언트 코드를 수정해보자. 먼저 서버에서 받은 직렬화된 RSC Payload를 해제해야 한다.

createFromFetch으로 RSC Payload를 리액트 엘리먼트로 변환한다.

content 를 로그로 확인해보면

리액트 엘리먼트 객체가 된 것을 확인할 수 있다. 이걸 index.js에서 렌더링 해주면 된다.

서버 컴포넌트가 잘 렌더링 된다.

그리고 App.js에서 훅을 사용하려고 하면

에러가 잘 나온다. 이로서 RSC의 첫 단계를 수행했다!

참고 : epic-react

profile
프론트엔드 개발

0개의 댓글

Powered by GraphCDN, the GraphQL CDN