Web/React

[React🌀] React 서버 컴포넌트 / RSC의 도입 배경과 장점

서상혁 2021. 9. 20. 17:36

들어가며

 프론트엔드 세계에는, 시간이 갈수록 정말 많은 변화를 겪고, 많은 유용한 프레임워크와 라이브러리들이 생겨나고 있습니다. 그 중에 중요한 요소중 하나가 data를 fetching 및 rendering하는 부분인데요, 그래프QL이라던가 react-query 와 같은 기술들도 모두 서버로부터 데이터를 받아오는 데에 더 효율적이고 프로젝트에 적합한 구조를 도입할 수 있도록 해주는 역할을 합니다.

 

최근에는 Next JS와 같은 프레임워크를 이용해 필요한 곳에서만 부분적으로 SSR을 채용하면서, CSR과 SSR의 각각의 이점을 가져가고있는 추세이죠. 하지만 이런 구조 또한 완전하다거나 안정적이 방식이라고 하기에는 단점이 명확하게 존재하고, 꾸준히 변화하고 있는 부분입니다. 제 개인적인 생각으로는 State Management와 마찬가지로, 새로운 큰 혁신이나 변화가 필요한 과도기에 놓여있다고 생각합니다. 이런 상황에서 리액트 개발팀은 리액트 18을 공개하기에 앞서 작년 React Server Component라는 새로운 방식의 도입을 발표했습니다. 계속해서 연구중이고 개발중인 부분이고, 계속해서 많은 사람들의 의견과 피드백을 거쳐서 나올 것이라고 하는군요.

 

https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html

 

Introducing Zero-Bundle-Size React Server Components – React Blog

2020 has been a long year. As it comes to an end we wanted to share a special Holiday Update on our research into zero-bundle-size React Server Components. To introduce React Server Components, we have prepared a talk and a demo. If you want, you can check

reactjs.org

 

왜 RSC(React Server Component) 일까?

 

기존 SSR 방식은 다음과 같이 동작했습니다. ( 아래 부분은 다음 글을 인용했습니다 : https://nookpi.tistory.com/35)

// ES modules
import ReactDOMServer from 'react-dom/server';
// CommonJS
var ReactDOMServer = require('react-dom/server');


ReactDOMServer.renderToString(element)

 

`renderToString` 함수를 통해 초기 렌더링 결과를 HTML String으로 반환하고, 이를 바탕으로 첫 요청의 응답으로 마크업을 포함한 HTML 문서를 빠르게 사용자에게 보여줍니다. 그리고 클라이언트단에서 ReactDOM.hydrate 함수를 통해 바뀐부분만 수분을 공급해줍니다.

 

 

사용 예시

 

일반적인 컴포넌트

1. 검색창에 뭔가 입력

2. onChange => 검색 Fetch => 검색결과 받아옴

3. 받은 검색 결과 리액트에 넘겨서 컴포넌트 렌더링

 

RSC

1. 검색창에 뭔가 입력

2. onChange => 렌더링 서버에 키워드 Fetch =>

3. 서버에서 검색 Fetch 요청 보냄 => 검색결과 받아서 비 Json/비 HTML 형식으로 마크업 빌드해서 클라이언트에 보냄

4. 클라이언트에서 마크업 받아서 정적 UI로 렌더링(React 컴포넌트가 아니므로 컴포넌트 처리에 드는 시간 절약)

 

(인용 : https://nookpi.tistory.com/35)

 

 

그렇다면 리액트팀이 이런 새로운 구조를 도입하려는 이유가 무엇일까요?

기존 방식에 어떠한 단점이 있었을까요?

 

기존의 SSR 방식에는 몇가지 단점이 있었습니다. SSR 방식에서는 페이지가 아닌 컴퍼넌트를 정적으로 export 할 수가 없습니다. 실질적으로 리액트 개발자가 조작하는 코드는 컴퍼넌트 단위로 구성이 되는데, 이는 불편한 점이죠. Next JS를 다뤄보신 분이라면, pages 하위에 있는 컴퍼넌트가 아닌 이상, SSR 관련 함수들(예컨대 getSeverSideProps) 을 사용할 수 없다는 것을 알고 계실겁니다. 어쩔 수 없이 최상위가 되는 pages 컴퍼넌트에서 서버단에서 fetch해온 데이터를 props 등으로 하위 컴포넌트로 내려주거나, 그렇지 않다면 하위 컴퍼넌트에서 CSR 방식으로 데이터를 가져오는 방법을 택해야 했죠.

 

뿐만 아니라, UI를 렌더링하는 데에는 필요하지 않은 데이터 처리 과정에서 사용되는 모듈까지 함께 번들링 되기 때문에, 큰 규모의 프로젝트인 경우 브라우저가 받아와야 하는 파일의 용량이 매우 높아지게 되죠. 이 불필요한 청크 파일들을 받아오는 것을 막기 위해, 코드 스플리팅이나 lazy loading과 같은 기술을 이용하지만, 이 또한 결국 시간을 투자해야 되는 하나의 불편함으로 작용했습니다.

 

 

어떤식으로 사용될까?

 

RSC가 도입되면, 클라이언트 컴포넌트, 서버 컴포넌트 두 종류로 분리가 되게 됩니다.

 

*.client.js(jsx, ts, tsx) : 클라이언트용 컴포넌트
*.sever.js(jsx, ts, tsx) : 서버용 컴포넌트
// Note.server.js - Server Component

import db from 'db.server'; 
// (A1) We import from NoteEditor.client.js - a Client Component.
import NoteEditor from 'NoteEditor.client';

function Note(props) {
  const {id, isEditing} = props;
  // (B) Can directly access server data sources during render, e.g. databases
  const note = db.posts.get(id);
  
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
      {/* (A2) Dynamically render the editor only if necessary */}
      {isEditing 
        ? <NoteEditor note={note} />
        : null
      }
    </div>
  );
}

리액트에서 제공하는 예시중 하나인데요, 위에서 보시는 바와 같이, 서버 컴포넌트는 api 형태의 방식을 쓰지 않고, 직접DB에 접근하여 note 데이터를 받아오고 있죠. 이는 RSC의 장점 중 하나입니다. 그리고 받아온 데이터를 바탕으로 NoteEditor라는 클라이언트 컴포넌트를 구성합니다. 여기서 NoteEditor는 클라이언트 컴포넌트인데, 서버 컴포넌트가 이 컴포넌트를 import할 때는, 자동적으로 필요로 할 때 dynamic하게 import를 하게 됩니다. 기존 방식처럼 직접 불편한 과정들을 거칠 필요가 없죠.

 

export default function NoteEditor(props) {
  const note = props.note;
  const [title, setTitle] = useState(note.title);
  const [body, setBody] = useState(note.body);
  const updateTitle = event => {
    setTitle(event.target.value);
  };
  const updateBody = event => {
    setTitle(event.target.value);
  };
  const submit = () => {
    // ...save note...
  };
  return (
    <form action="..." method="..." onSubmit={submit}>
      <input name="title" onChange={updateTitle} value={title} />
      <textarea name="body" onChange={updateBody}>{body}</textarea>
    </form>
  );
}

앞 서 예시에 나온 NoteEditor 코드인데요,

클라이언트 컴포넌트는 우리가 기존에 사용하던 컴포넌트와 거의 동일합니다. state, effects, DOM 접근 등을 할 수 있습니다. 또한 중요한 사실은, 서버 컴퍼넌트가 다시 렌더링된다고 하더라도, 클라이언트 컴포넌트가 기존에 가지고 있었던 DOM과 state들은 유지가 된다는 점입니다. (정확히 말하자면, 서버에서 내려주는 props를 바탕으로 머지됩니다.)

 

 

요약

 

어떤 상황에 어떤 컴포넌트를 써야할까?

  • 서버 컴포넌트 : 데이터를 받아오는 부분, 전처리 과정, 파일 시스템이 필요한 부분
  • 클라이언트 컴포넌트 : UI 위주의 부분, 빠른 interaction이나 사용자 입력이 필요한 부분

 

RSC 의 장점은?

 

Zero-Bundle-Size Components

 

  • 서버 컴포넌트는 번들에 포함되지 않기 때문에 브라우저로 가는 번들 사이즈가 현저하게 작아진다.
  • 서버에서만 사용되는 패키지 모듈들은 서버에서만 유지하면 된다.

 

 

Full Access to the Backend

 

  • API 형식으로 불러올 필요 없이, 파일시스템, DB 등에 편하게 접근할 수 있다. 물론 클라이언트 단에서 api 를 통해 패치하는 방식도 가능하다.

 

 

Automatic Code Splitting

// PhotoRenderer.js
// NOTE: *before* Server Components

import React from 'react';

// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />; 
  } else {
    return <PhotoRenderer {...props} />;
  }
}

기존의 lazy loading 방식. 반드시 React.lazy를 통한 콜백으로 import를 감싸줘야 했다.

 

import React from 'react';

// one of these will start loading *once rendered and streamed to the client*:
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';

function Photo(props) {
  // Switch on feature flags, logged in/out, type of content, etc:
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <PhotoRenderer {...props} />;
  }
}

client 컴포넌트들은 자동적으로 코드 스플리팅이 적용되어 렌더링이 필요한 시점에 lazy하게 import 된다.

 

 

 

No Waterfalls

 

아마 여기서 얘기하는 waterfall이란 것은, 렌더링과 로딩이 한번에 진행되는 것이 아니라, 렌더링 이후 데이터가 로딩이 되는 방식으로 인해 불필요하게 시간이 소요되는 것을 말하는 것 같습니다. 추가적으로, 이 현상은 부모 -> 자식 계층적으로 데이터가 내려가는 컴포넌트 형태가 꾸려져 있을 때 더 악화되죠.

 

// Note.js
// NOTE: *before* Server Components

function Note(props) {
  const [note, setNote] = useState(null);
  useEffect(() => {
    // NOTE: loads *after* rendering, triggering waterfalls in children
    fetchNote(props.id).then(noteData => {
      setNote(noteData);
    });
  }, [props.id]);
  if (note == null) {
    return "Loading";
  } else {
    return (/* render note here... */);
  }
}

위 예시를 보면, useEffect의 디펜던시에 props 가 있기 때문에, 이 Note 컴포넌트는 렌더링이 끝난 후, 부모에서 전달해주는 props가 전부 로딩이 되고 난 이후에야 fetchNote를 통해 추가적으로 로딩을 시작하게 됩니다. 그리그 그 동안 UI에서는 과도하게 길게 'Loading'을 보여줘야 하는 문제가 생기죠. 이는 리액트에서 최근에 중점적으로 도입하려고 하는 Concurrent Mode, Suspense와도 연관된 문제입니다.

 

// Note.server.js - Server Component

function Note(props) {
  // NOTE: loads *during* render, w low-latency data access on the server
  const note = db.notes.get(props.id);
  if (note == null) {
    // handle missing note
  }
  return (/* render note here... */);
}

렌더링을 함과 동시에 data를 로드해오기 때문에, 렌더링 자체가 데이터를 가져오기를 시작하는 시간에 영향을 주지 않습니다. 자동적으로 성능이 향상이 되겠죠. 

 

 

Avoiding the Abstraction Tax

 

이 부분은 솔직히 말해서 정확히 이해가 잘 안되네요.. 서버 컴포넌트를 도입함으로서 컴파일 단계에서 추상화로 부터 소모되는 시간을 줄인다는 것 같은데 잘 모르겠습니다 ㅎㅎㅎ
공식 문서 링크를 참고할테니 이해되시는 분 있으면 댓글 남겨주시면 감사하겠습니다 !

https://github.com/reactjs/rfcs/blob/bf51f8755ddb38d92e23ad415fc4e3c02b95b331/text/0000-server-components.md#avoiding-the-abstraction-tax

 

마치며

 

 리액트 생태계는 정말 끝없이 바뀌고, 발전해 나아가는 것 같아서 좋습니다. 물론 한편으로는 공부해도 공부해도 계속해서 공부할 것들이 생기니까 쉽지만은 않네요 😢

 

함수형 컴포넌트가 보편적인 형태로 자리잡은 것 처럼 RSC 또한, 언젠가 모두가 사용하는 컴포넌트로 자리잡을 수 있겠죠. 아마 Concurrent Mode 와 행보를 함께할 것으로 보입니다. 현재까지는 구현 복잡도를 늘린다는 점에서 필요성을 굳이 찾기는 힘들지만, 더 연구가 되고 기능이 추가되고, 성능도 벤치마킹이 되면서 실제 프로젝트에도 도입할만한 때가 오기를 기다려야겠습니다.

 

글 읽어주셔서 감사합니다.

 

 

출처

 

 

728x90