<who's the next? 1탄 RSC>

강민수·2023년 9월 9일

저번 글에 이어서, 이번에는 과연 그렇다면 react 팀이 생각하는 앞으로의 front-end 프레임워크 들의 관련 방향성에 대해 살펴보려고 한다.

1. 리액트 18버전이 업데이트 되고 나서 지난 1년.

사실 리액트 팀이 18버전으로 업데이트 한 지 벌써 1년이나 지났다.

길다면, 길고 짤다면 짧은 시간인데, 기술적인 변화의 폭이 큰 프론트 입장에서는 사실 긴 시간이다.

그렇다면 그들이 왜 어떤 생각으로 18버전에서 어떤 시도를 하고 있는 것인지 먼저 살펴볼 필요성이 있다.

1) RSC

리액트 18버전의 가장 큰 특징 중 하나를 꼽자면, 단연코 RSC를 기반으로 한, 서버와 클라이언트의 분리다.

아래는 현재 리액트 훅스의 가장 큰 기여를 한 것으로 알려진, Dan Abramov라는 개발자의 기고글의 그림을 가져왔다.

그는 이렇게 주장했다.

RSC does not change that mental model, but it adds a new layer before any of that existing code runs:

-> 결국 RSC는 기존 리액트의 근본 모델 자체를 바꾼 것이 아니라, 그 위에 덧 붙이는 개념이 하나 더 생긴 것이다.

그러면 여기서 코드를 통해 어떻게 구성이 다른 것인지 살펴볼 필요가 있다.

2) Sever vs Client

이번에는 그렇다면 서버와 클라이언트 컴포넌트가 어떤 식으로 코드가 다른 지 살펴보자.

아래는 RSC에 관한 설명이 담긴 RFC 깃허브[https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#basic-example] 에서 가져온 코드다.

// Note.js - Server Component

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

async function Note(props) {
  const {id, isEditing} = props;
  // (B) Can directly access server data sources during render, e.g. databases
  const note = await 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>
  );
}

위의 코드는 말 그대로 서버 컴포넌트다.

가장 눈에 띄는 코드가 뭘까?

한 번 눈을 크게 뜨고 살펴봅시다. 그렇다. 이미 눈치 빠른 분들은 알아차렸겠지만, "db"다. 기존에는 node.js와 같은 백엔드 구조에서만 진짜 db에 접근할 수 있었다.

하지만, 지금은 이렇게 리액트에서도 node처럼 직접 서버 단에 코드에 접근할 수 있는 가능성이 열린 것이다. 다만, 여기서 주의해야 할 점은 있다.

하나를 얻었으면, 또 다른 하나를 내어줄 수 밖에 없지 않겠는가?

그렇다. 위의 컴포넌트에서는 기본적인 use~ 로 시작하는 hooks를 쓸 수 없다. 단순히 생각해서 그냥 서버단의 환경이라고 생각하면 좋겠다. 아마 next.js와 같은 프레임워크를 써보신 분들이라면 느낄 수 있겠지만, next에서도 역시 12버전까지는 getServerSideProps, getStaticProps와 같은 함수 내부에서만 이런 서버 코드를 적을 수 있었다.

그런데 이런 서버에서 쓸 수 있는 다양한 기능들을 서버 컴포넌트라는 제어권 내부에서 쓸 수 있게 된 것이다. 이렇게 되면서 얻게될 다양한 이점은 추후 다시 설명하겠다. 또한, next.js 13 역시 이를 토대로 새롭게 구성된 것이나 마찬가지다.(사실 개념은 거의 유사하다)


// NoteEditor.js - Client Component

'use client';

import { useState } from 'react';

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 => {
    setBody(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>
  );
}

이제는 클라이언트 코드다. 뭐 크게 기존에 우리가 늘 잘 사용하던 그런 리액트 컴포넌트 코드다.

다만, 여기서도 한 가지 차이를 두고 보면 뭔가 보일 것이다.

그렇다. "use client"

이게 뭘 의미할까? 이건 일종의 선언이다.

"리액트야! 나 이 컴포넌트는 클라이언트로 쓸거야!"

라는 느낌이다. 그러면 당연하게도 여기서는 use~로 시작하는 hooks들을 당연하게도 쓸 수 있다. 물론 반대로, "db"와 같은 서버에서 쓰는 것들은 못 쓴다.

2. 그래서 도대체 얻는 게 뭐야?

그렇다면, 리액트 팀이 이걸 도입했을 때 얻는 이점을 뭐라고 했을 지다시 하나 씩 살펴보자.

1) Zero-Bundle-Size Components

첫 번째 가장 큰 장점은 바로, 번들 사이즈에 대한 감소다.

이게 무슨 소리인가? 라고 들릴 수 있지만, 아래 코드를 살펴보자.

// NOTE: *before* Server Components
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}
// Server Component === zero bundle size

import marked from 'marked'; // zero bundle size
import sanitizeHtml from 'sanitize-html'; // zero bundle size

function NoteWithMarkdown({text}) {
  // same as before
}

위의 코드는 서버 컴포넌트 이전의 코드이고, 아래는 서버 컴포넌트 코드다. 거의 다른 점이 없어보이지만, 위의 예시에서는 기존 클라이언트 컴포넌트는 html의 압축을 할 수 있는 것에 한계가 있다. 하지만, 서버 컴포넌트로 넘어오면서 서버단 로직을 통해 이미 효율적인 압축을 수행할 수 있다. 따라서 이는 결국 전체 번들링 크기의 감소로 이어질 수 있다.

2) Full Access to the Backend

아까도 살짝 언급했지만, 서버 컴포넌트는 백엔드에서 쓸 수 있는 부분을 활용 가능하다.

예를 들어, 어떤 기존과 다르게 파일 시스템 모듈을 통해서 직접적인 파일 시스템들에 대한 접근이 가능하다.

import fs from 'fs';

async function Note({id}) {
  const note = JSON.parse(await fs.readFile(`${id}.json`));
  return <NoteWithMarkdown note={note} />;
}

만약, 더 큰 범위의 db에 대한 접근이 필요하다며, 그것 역시 가능하다. 직접 백엔드 액세스를 활용하여 데이터베이스, 내부 (마이크로) 서비스 및 기타 백엔드 전용 데이터 소스를 사용할 수 있다.


import db from 'db';

async function Note({id}) {
  const note = await db.notes.get(id);
  return <NoteWithMarkdown note={note} />;
}

3) Automatic Code Splitting

기존에도 자동 코드 분할이 react에서 없던 기능은 아니었다.

다만, 구현이 조금 귀찮고, 개발자가 일일이 신경써 줘야만 했다.

아래 코드처럼, 서버 컴포넌트 이전에는 react는 자동 코드 분할을 하지 못해 개발자가 만약 코드 분할을 통해 로딩 시점을 일일이 처리해 줘야 했다.

그래서 lazy처리를 아래처럼 해줬던 것 이다. 그리고 그 시점 역시 유저가 직접적인 접근을 통해 해당 컴포넌트를 랜더링하는 시점이어서 다소 클라이언트에 코드가 다운되는 시점도 느린 감이 있었다.

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

import { lazy } from 'react';

// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = 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 <OldPhotoRenderer {...props} />;
  }
}

하지만, 서버 컴포넌트는 사실 그런 걸 애초에 신경쓰지 않아도 된다. 일단, 서버 컴포넌트는 클라이언트와 분리되었기 때문에 자동으로 코드 분할이 된다. 별도의 lazy 설정 X.

추가로, 속도 역시 랜더링보다 이전인 시점에 클라이언트 코드를 다운받기 때문에 더 빠르다.

// PhotoRenderer.js - Server Component

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

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

3. No Client-Server Waterfalls

다음은 이제는 더 이상 클라이넌트와 서버 간의 워터폴(폭포수 방식)의 흐름이 아니게 된 것이다.

이게 무슨 말이냐면, 결국 기존의 리액트 코드를 살펴보면 알 수 있다.

아래 코드를 보면, 우리가 흔히 알고 있는 데이터 패칭 후, 그에 따라 컴포넌트가 그려지는 예시다.

이 예시를 통해서 결국 useEffect를 통해 우리는 데이터를 fetch하고, 이후 그 데이터를 다시 자식 컴포넌트에 내려준다. 이게 바로 전형적인 워터폴 방식(폭포수 방식)인 것이다.

이렇게 되면 성능 저하를 가져올 가능성이 높다. 왜냐면, 서버의 응답 처리 속도에 따라 이후 클라이언트 단이 랜더링 되는 구조이기 때문이다. 즉, 흔히 얘기하는 블로킹되는 시점이 길어지면 길어질수록 유저가 보는 화면의 랜더링 속도도 같이 느려질게 뻔하다.


// 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... */);
  }
}

그에 반해, 서버 컴포넌트는 다르다.

"어! 다르다!"

아래 코드를 살펴보면, 서버 컴포넌트 쪽으로 데이터 패칭이 전혀 다른 구조라는 것을 알 수 있다.

물론 이건 필자가 생각하기에, 아직 서버 api요청을 통한 데이터 패칭이 아직은 안정적이고 많이 쓰이는 방법이다. 다만, 그냥 react팀에서는 서버에서 직접 데이터를 받아서 서버단에서 받아서 오면 데이터 응답처리 속도나 기존의 위와 같은 워터폴 방식과 같은 연쇄적인 처리 속도 지연을 막을 수 있다고 주장한다.

이 부분에 대해서는 아직 갈 길이 좀 남아는 있는 거 같아서 추후 지속적으로 살펴봐야 할 부분이다.


// Note.js - Server Component

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

위의 내용들이 RSC에 대한 RFC문서에 나온 주요 특장점이다. 물론 이외에도 더 많기는 한데... 더 필요하다면 해당 링크로 들어가서 더 살펴보길 권장한다.

3. 1편 마무리.

RSC에 대해서는 이정도로 일단 간략하게 마무리 하겠다. 우리는 앞서 react팀이 어떻게 18버전 이후로 갈 지에 대해 살펴본다고 했다. 첫 번째로 가장 중요한 기능 중 하나인 RSC에 대해 살펴봤다.

이 다음 편은 그렇다면 Next, React-query 등의 주요 프레임워크들은 어떻게 그걸 녹여내고 있는 지 그리고 그에 대한 필자의 견해는 어떤 지 적어보겠다.

긴 글 읽어주셔서 감사하며, 오늘은 이걸로 마친다.

출처

https://github.com/reactwg/server-components/discussions/4

https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components

https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#basic-example

profile
개발도 예능처럼 재미지게~

0개의 댓글