본문 바로가기
프로그래밍 개발/NextJs

NextJs - 3편: NextJs에서의 서버 컴포넌트 RSC(React Server Component)

by Jinseok Kim 2023. 9. 14.
반응형

3편: NextJs에서의 서버 컴포넌트 RSC(React Server Component) 

 

 

2편에서 기록하면서 알아가게 된 NextJs 13 ver.에서 등장한 서버 컴포넌트 RSC(React Server Component)에 대하여 개인적으로 많이 생소하여 좋은 블로그 글을 발견하여 제 블로그에 기록하게 되었습니다.

 

NextJs - 2편: NextJs 버젼 12에서 13으로 넘어오면서

2편: NextJs 버전 12에서 13으로 넘어오면서 NextJs는 12 버젼에서 13으로 넘어오면서 사용방법이 바뀔 정도로 많은 변화가 있었습니다. 당연히 최신 버전인 13을 바로 사용해도 되지만 아직도 많은 프

k0502s.tistory.com

 

 

 

RSC(React Server Component) 서버 컴포넌트

 

서버 컴포넌트, 클라이언트 컴포넌트 서로 다른점이 명확하기 때문에 AppDir로 개발하기 전에 차이를 잘 알아야한다.

 

RCC에서는 기존 ReactJs에서 사용했던 hooks, 이벤트 리스너와 같은  것들을 사용할 수 있고 RSC에서는 사용이 불가능합니다. 당연히 RSC에서  hooks, 이벤트 리스너와 같은  것들을 사용할 수 없는 대신 많은 이점들이 존재합니다.

그리고 RSC, RCC 이 둘중 뭐가 더 좋은 성능을 가지고 있다는 것이 아니라 서로 적재적소에 배치하여 개발하려는 접근이 아주 중요합니다.

 

 

 

 

 

RSC(React Server Component)의 동작 방식

 

RSC의 특징을 이해하고 RSC와 RCC를 적재적소에 배치하기 위해서는 실제로 RSC가 어떻게 렌더링되는지 이해할 필요가 있습니다.


아래는 같이 RSC와 RCC를 적절하게 혼합하여 구성한 스크린이 있다고 가정한 구성 이미지 입니다.

사용자는 해당 페이지를 띄우기 위해 서버로 요청을 날린다. 그러면 서버는 이때부터 컴포넌트 트리를 root부터 실행하며 직렬화된 json형태로 재구성하기 시작합니다.

 

직렬화란.
직렬화란 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정

 

쉽게 말해서 특정 개체를 다른 컴퓨터 환경으로 전송하고 재구성할 수 있는 형태로 바꾸는 과정이라고 할 수 있습니다.

우리가 흔히 사용하는 JSON.stringify함수가 바로 직렬화를 수행하는 함수이며, JSON.parse가 역직렬화를 수행하는 함수입니다. 주의할점은 모든 객체를 직렬화할 수는 없다는 것입니다.

대표적으로 function은 직렬화가 불가능한 객체입니다. function이 실행코드와 실행 컨텍스트를 모두 포함하는 개념이기 때문인데, 함수는 자신이 선언된 스코프에 대한 참조를 유지하고, 그 시점의 외부 변수에 대한 참조를 기억하고 있습니다. js의 클로저가 바로 이런 현상을 가리키는 용어이기도 합니다.

const a = 100;
    
const sample = ()=>{
    console.log(a)
}

sample() //100

이처럼 함수의 실행 컨텍스트, 스코프, 클로저까지 직렬화할 수는 없기 때문에 function은 직렬화가 불가능한 객체로 분류되는 것입니다.

직렬화 과정은 모든 서버 컴포넌트 실행하여 json 객체 형태의 트리로 재구성할 때까지 진행다.

 


예를들면 다음과 같습니다.

<div style={{backgroundColor:'green'}}>hello world</div> //JSX 코드는 createElement의 syntax sugar다.

> React.createElement(div,{style:{backgroundColor:'green'}},"hello world")

> {
  $$typeof: Symbol(react.element),
  type: "div",
  props: { style:{backgroundColor:"green"}, children:"hello world" },
  ...
} //이런 형태로 모든 컴포넌트를 순차적으로 실행한다.

다만 이 과정을 모든 컴포넌트에 대하여 진행하는게 아니라, RCC일 경우 건너뛰게 된다. 하지만 RCC를 서버에서 해석하지 않고 건너 뛴다고해서 비워 둔다면 실제 컴포넌트 트리와 괴리가 생기게 됩니다.

 


따라서 RCC의 경우 직접 해석하는 것이 아니라 “이곳은 RCC가 렌더링되는 위치입니다”라는 placeholder를 대신 배치해줍니다.

{
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default", //export default를 의미
    filename: "./src/ClientComponent.js" //파일 경로
  },
  props: { children: "some children" },
}

아까도 언급했듯이 RCC는 곧 함수이므로, 직렬화를 할 수 없습니다.

따라서 함수를 직접 참조하는 것이 아니라 “module reference” 라고 하는 새로운 타입을 적용하고, 해당 컴포넌트의 경로를 명시함으로써 직렬화를 우회하고 있습니다.



이러한 직렬화 작업을 마친 후 생성된 JSON Tree를 도식화하면 다음과 같은 형태를 띠고 있습니다.

 

 

이제 이렇게 도출된 결과물을 Stream 형태로 클라이언트가 전달받게 되고, 함께 다운로드한 js bundle을 참조하여, module reference 타입이 등장할 때마다 RCC를 렌더링해서 빈 공간을 채워놓은 뒤, DOM에 반영하면 실제 화면에 스크린이 보여지게 되는 것입니다.

 

 

 

 

RSC의 제약사항

 

이 일련의 과정으로부터 우리는 RSC와 RCC를 함께 사용할 때 몇가지 제약사항을 도출해낼 수 있습니다.

 

 

1.  RSC에서 RCC로 function과 같이 직렬화 불가능한 객체를 prop으로 넘겨줄 수 없다.

 

RSC는 서버에서 해석되어 직렬화된 JSON 형태로 변환됩니다.

 

그때문에 서버 컴포넌트를 설명하는 모든 요소는 ‘직렬화 가능해야 한다’는 전제조건이 붙습니다.

 

만약 RSC가 child에게 function을 prop으로 넘겨주면 위와같이 JSON에 이 사실이 명시되어야 하므로, 에러가 발생할 수 있습니다.

 {
   $$typeof: Symbol(react.element),
   type: "div",
   props: { 
 		children: {
 		  $$typeof: Symbol(react.element),
           type: {
             $$typeof: Symbol(react.module.reference),
             name: "default",
             filename: "./src/ClientComponent.js"
           },
 		  props: {callback:function}, // 이처럼 JSON에 function이 명시되어야만 한다.
 		  ...
 		}
 	},
   ...
 }

하지만 RSC에서 다른 RSC로 function을 넘기는건 아무 문제 없이 가능합니다. 정확한 이유는 모르겠지만 어차피 서버에서 렌더링되는 RSC간의 함수 전달을 굳이 client로 넘기는 스트림에 서술할 필요가 없기 때문에 생략하거나 placeholder로 대신한게 아닐까 하고 짐작하고 있습니다.

const ServerComponent = ()=>{
  const add = async (a:number,b:number) =>{
    'use server'
    return a+b
  }

  return <div>
        <ClientComponent addFunc={add}/>
    </div>
}

또한, next의 server action을 사용하면 RSC에서 RCC로 함수를 전달할 수도 있는데, 아래와 같이 RSC에서 'use server' directive와 함께 함수를 정의하면 RCC로 넘겨줄 수 있습니다.

다만, 해당 function의 params와 return은 모두 직렬화 가능해야한다는 조건이 붙습니다.

 

server action으로 선언한 함수를 RSC에서 RCC로 넘겨줄 때는 function 자체를 넘겨준다기보다, api의 명세를 넘겨주고, 함수를 호출하면 서버에 api를 호출하고 그 결과값을 받아오는 것처럼 동작하여 제약사항을 우회하는 것이 아닌가 추측하고 있습니다.

 

 

 

 

2. RCC는 RSC를 직접 return해줄 수 없으며, 반드시 children prop의 형태로 넘겨주어야 한다.

 

서버에서 모든 RSC가 순차적으로 실행되며, 중간에 RCC를 만나면 placeholder로 표시해두고 넘어갑니다.

 

즉, RCC는 실행되지 않기 때문에 RCC 내부에서 반환되는 RSC또한 (서버 컴포넌트임에도 불구하고) 서버에서 실행되지 못합니다. 이러한 경우 해당 RSC는 RCC와 동일하게 클라이언트에서 동작하게 됩니다.

 

하지만 children prop을 통해 RSC를 넘기게 되면, 사실상 공통 부모가 렌더링 되는 시점에 RSC가 실행이 되고, 그 결과값을 children으로 전달할 수 있습니다.

function ParentClientComponent({children}) {
	...
  return <div onChange={...}>{children}</div>;
}

function ChildServerComponent() {
	...
  return <div>server component</div>;
}

function ContainerServerComponent() {
  return <ParentClientComponent>
			<ChildServerComponent/>
	</ParentClientComponent>;
}

위와 같이 “ChildServerComponent”는 “ParentClientComponent”의 자식 컴포넌트이지만, 사실상 “ContainerServerComponent”를 공통부모로 갖고있기 때문에 “ContainerServerComponent”가 렌더링되는 시점에 “ChildServerComponent”도 함께 렌더링되어 그 결과값이 “ParentClientComponent”에 넘겨지고 있습니다.

{
  // The ClientComponent element placeholder with "module reference"
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ParentClientComponent.js"
  },
  props: {
    children: {
      $$typeof: Symbol(react.element),
      type: "div",
      props: {
        children: "server component"
      }
    }
  }
}

JSX와 children prop에 대한 더 자세한 내용은 children prop에 대한 고찰 참고

 

 

 

 

RSC vs SSR(Server Side Rendering)

실제로 RSC와 SSR은 서버에서 처리한다는 공통점 외에는 각각 해결하고자하는 목표도 다르고, 일어나는 시점과 최종 산출물도 다른 완전히 별개의 개념입니다.

 

따라서 반드시 둘 중 하나를 선택할 필요도 없고 필요에 따라 RSC와 SSR을 함께 사용하면 큰 시너지를 낼 수도 있습니다. 우리가 작성한 소스코드가 브라우저에 보여지기 위해서는 우선 컴포넌트가 실행되어 데이터가 해석되어야하고, 그 해석된 데이터가 다시 html로 변환하는 과정을 거쳐야합니다.

더 확실한 이해를 위해서는 NextJs 프레임워크 프로젝트에서 SSR을 어떻게 사용하고 있는지 이해해야합니다.

 

 

 

NextJs의 SSR(Server Side Rendering)

 

간단하게 CSR과 SSR의 개념을 소개합니다.

 

 


CSR - Client Side Rendering은 말 그대로 클라이언트에서 컴포넌트를 렌더링하는 것을 의미합니다.

 

서버에서 빈 html과 js bundle을 다운로드 받고, 이 js 소스코드를 클라이언트에서 해석해서 처음부터 그려나가게 된다. 때문에 초기 로딩속도가 느리지만, 스크린간 이동이나 인터렉션에 강점이 있습니다.

 

 




SSR -  Server Side Rendering은 서버에서 컴포넌트를 해석하여 최종 결과물인 html 파일을 내려주는 것을 의미합니다.

 

CSR과는 반대로 초기 로딩속도가 빠르지만, 페이지를 이동할때마다 새로운 html을 요청해서 받는 시간이 필요하고, 현재 화면에서도 작은 변경사항이 발생하면 처음부터 html을 다시 로드해야하기 때문에 스크린간 이동이나 인터렉션에 약점이 있습니다.




 

NextJs에서 우리가 사용하는 ssr은 전통적인 의미의 ssr은 아닙니다. 

SSR과 CSR 의 장점만을 취하기 위해 일종의 절충점을 찾은 형태라고 할 수 있습니다. 즉, 초기 로딩속도가 느리다는 CSR의 단점을 보완하기 위해 초기 로딩시에는 html파일을 SSR을 통해 빠르게 받아오고, 이와 병렬적으로 js번들도 함께 가져와서 미리 받아온 html과 병합하는 hydration과정을 거치는 구조입니다.

그 결과 빠른 로딩에 강점이 있는 SSR과 인터렉션에 강점이 있는 CSR의 장점을 모두 취할 수 있게 됩니다.


NextJS의 SSR 뿐만 아니라 CSR의 특징도 많이 가지고 있으므로 RSC를 함께 사용했을 때 그 이점이 더욱 크게 극대화될 수 있습니다.





RSC의 장점

 

1. Zero Bundle Size

RSC는 서버에서 이미 모두 실행된 후 직렬화된 JSON 형태로 전달되기 때문에 어떠한 bundle도 필요하지 않습니다.

즉, RSC의 컴포넌트 소스파일 뿐만아니라, RSC에서만 사용하는 외부 라이브러리의 경우에도 번들에 포함될 필요가 없기 때문에 번들사이즈를 획기적으로 감량할 수 있습니다.

이러한 부분은 Next의 TTI(Time To Interactive) 개선에 크게 기여할 수 있는데, 이전에 살펴봤듯이 Next에서 SSR을 사용한다고 하더라도 초기 로딩속도에 이점이 있을 뿐 CSR과 동일한 사이즈의 js 번들을 다운받아야하기 때문에 TTI는 여전히 CSR 대비 큰 메리트가 없었기 때문입니다.

하지만 RSC를 도입하면 다운받아야하는 번들 사이즈가 줄어들게 되므로, TTI에 개선에 기여할 수 있습니다.

 

 

2. No More getServerSideProps / getStaticProps (app directory)

기존 next에서는 getServerSideProps / getStaticProps라는 함수를 이용해서 서버에 접근했었습니다.

때문에, Data fetch등을 수행할때는 반드시 getServerSideProps(or getStaticProps)함수를 page 최상단에서 수행하고, 이를 page에 prop으로 넘겨서 사용했어야 했습니다.

하지만 이 과정은 순수 React와는 괴리가 있어 처음 next를 사용하는 사람들에게 낯설 뿐만 아니라, 무조건 최상단에서 fetch 후 page에 prop으로 넘겨줄 수밖에 없는 구조 때문에, 실제 data를 사용하는 하위 컴포넌트의 depth까지 props drilling이 불가피했습니다.

반면 RSC는 그 자체가 서버에서 렌더링되므로, 컴포넌트 내부에서 Data Fetch를 실행해도 무방하다. 즉, data가 필요한 컴포넌트에서 직접 data fetch가 가능해졌고, next13의 app directory에서는 기본적으로 모든 컴포넌트가 RSC이기 때문에 더이상 getServerSideProps / getStaticProps는 불필요한 함수가 되었습니다.

 

 

3. Automatic Code Splitting

본래 code splitting을 하기 위해서는 React.Lazy나 dynamic import를 사용했어야했습니다.

import dynamic from 'next/dynamic'
            
const DynamicComponent = dynamic(() => import('../components/hello'))

하지만 RSC에서 RCC를 import 하는 케이스에서는 자동적으로 RCC를 dynamic import가 적용됩니다.

이 장점은 어떻게 보면 굉장히 당연한 사실입니다. 서버에서 RSC가 렌더링될 때 RCC는 실행되지 않기 때문에 굳이 RCC를 즉시 import 할 필요가 없기 때문입니다.

 

 

 

4. Progressive Rendering

위에서 살펴봤듯, next13부터는 컴포넌트가 서버에서 한차례 렌더링 되며, 그 결과물로 직렬화된 JSON이 생성된다고 했다. 그리고 client는 그 결과물을 스트림의 형태로 수신합니다.

// Tweets.server.js
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.client.js
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.server.js
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

위 문자열은 클라이언트가 수신하는 스트림의 한 예시를 나타낸 것이다. 여기서 짚고 넘어갈 부분은 데이터가 ‘스트림’ 형태로 전달된다는 사실입니다.

즉, 스크린의 모든 화면정보를 수신할 때까지 기다릴 필요 없이, 클라이언트는 먼저 수신된 부분부터 반영하기 시작하여 화면에 띄워줄 수 있게 됩니다.

 

위 스트림 문자열을 보면 S2 지점에 suspense가 서술되어 있습니다. 그리고 J0를 보면 뒤쪽에 children으로 “@3”이 참조되어 있는 것을 볼 수 있다. 하지만 스트림의 어디를 봐도 “@3”에 대한 정의는 나와있지 않습니다. 이는, 아직 data fetch가 완료되지 않았기 때문에 fallback이 보여지는 상황이기 때문에 @3를 placehoder로 사용하고 있기 때문입니다.

 

만약 data fetch가 완료되면 “@3”이 “J3”로 대체되고, “J3”는 참조하고 있던 “M4”에 해당하는 client component에 data를 넘겨주면서 화면에 보여지게 됩니다. 따라서 RSC를 React.Suspense와 함께 사용한다면 모든 데이터를 기다릴 필요 없이 먼저 그릴 수 있는 부분을 반영하여 뷰를 로드한 뒤, data fetch가 완료되면 그 결과가 즉각적으로 스트림에 반영됨을 알 수 있습니다.





2ast님의 블로그 포스팅 글을 대거 참고하였습니다.

 

Next) 서버 컴포넌트(React Server Component)에 대한 고찰

이번에 회사에서 신규 웹 프로젝트를 진행하기로 결정했는데, 정말 뜬금 없게도 앱 개발자인 내가 이 프로젝트를 리드하게 되었다. 사실 억지로 떠맡게 된 것은 아니고, 새로운 웹 기술 스택을

velog.io

 

반응형

댓글