teklog

Fluent React 6 - React Server Component

2024/03/06

n°50

category : React

Fluent React에서 서버 컴포넌트에 대해 깊이있게 다루고 있어 학습한 내용을 정리. 책과 공식문서, 예시 코드를 작성해가며 학습했고, 유익한 내용이 많았다. 사이드 프로젝트를 RSC를 사용하여 개발했는데, 이 챕터를 학습하고 이해가 더 깊어진 것 같다.


(italic체는 Fluent React의 인용입니다)



React Server Component


리액트 컴포넌트는 서버에서 실행되고 클라이언트측의 자바스크립트 번들에서는 제외되는 새로운 타입의 컴포넌트를 도입한다. RSCs introduce a new type of component that “runs” on the server and is otherwise excluded from the client-side JavaScript bundle.


리액트 서버 컴포넌트는 말그대로 서버에서만 실행되는 컴포넌트이다. 도입된 이유는 성능, 코드의 효율성, 사용자 경험을 향상시키기 위한 목적이라고 한다. 결과적으로 서버 컴포넌트를 통해 SPA의 장점과 서버 렌더링된 MPA의 장점을 동시에 취할 수 있다고 한다.


Benefits


  • 성능 개선 : 서버 컴포넌트는 오직 서버 측에서 실행되며, 우리가 제어할 수 있는 컴퓨팅 파워를 가진 기계(서버)에서 실행됩니다. 이는 우리가 예측할 수 없는 클라이언트 장치에서 계산을 수행하지 않기 때문에 더 예측 가능한 성능을 의미합니다. (클라이언트로 전송할 번들에서 제외하기 때문에 오는 이점도 있다.)
  • 보안 : 서버 컴포넌트는 보안된 서버 환경에서 실행되므로, 보안 작업을 수행할 때 토큰과 기타 보안 정보가 유출될 걱정 없이 수행할 수 있습니다.
  • 효율성서버 컴포넌트는 비동기적일 수 있습니다. 우리가 서버에서 실행을 완료할 때까지 기다릴 수 있기 때문에, 그들을 네트워크를 통해 클라이언트와 공유하기 전에 기다릴 수 있습니다.


기본적으로 훅을 사용하는 오늘날의 리액트 컴포넌트를 떠올리면 서버 컴포넌트를 '서버에서 실행되는 함수’로 이해하면 낯설 것이 없다. 하지만 이 컴포넌트가 어떻게 브라우저에서 변환되고, 이 컴포넌트를 실행하는 서버에서는 어떤 일이 일어나는 것일까?



How It works?


서버 컴포넌트는 기본적으로 리액트 엘리먼트를 반환하는 함수이다. 서버 컴포넌트가 어떻게 서버에서 브라우저에서 렌더링 가능한 컴포넌트로 변환되는지 순서대로 살펴보자.


JSX의 트리:

	{
  $$typeOf: Symbol("react.element"),
  type: "div",
  props: {
    children: [
      {
        $$typeOf: Symbol("react.element"),
        type: "h1",
        props: {
          children: "hi!"
        }
      },
      {
        $$typeOf: Symbol("react.element"),
        type: "p",
        props: {
          children: "I like React!"
        }
      },
    ],
  },
}

다음은 서버 컴포넌트가 클라이언트에 전달되는 간략화한 과정이다.


  1. 서버에서 JSX의 트리는 리액트 엘리먼트 트리로 변환된다.
  2. 서버에서 이 엘리먼트 트리는 string 혹은 stream으로 변환된다.
  3. 이는 직렬화된 거대한 JSON object 형태로 클라이언트에 보내진다.
  4. 클라이언트 상의 리액트는 이 JSON을 parse하여 렌더링한다


이 과정을 코드와 함께 살펴보면 다음과 같다. 아래 예시 코드는 서버사이드에서 서버 컴포넌트를 변환하는 과정을 모사한 코드다. (*인용된 예시는 이해를 위한 code snippet이며, 실제 구현과 차이가 있다)

// server.js
const express = require("express");
const path = require("path");
const React = require("react");
const ReactDOMServer = require("react-dom/server");
const App = require("./src/App");


app.use(express.static(path.join(__dirname, "build")));
app.get("*", async (req, res) => {


  // 1 - 서버컴포넌트가 리액트 엘리먼트 트리로 변환된다
  const rscTree = await turnServerComponentsIntoTreeOfElements(<App 
  />);
  /* turnServerComponentsIntoTreeOfElements 
   * 함수 내부에서 서버 컴포넌트를 리액트 앨리먼트로 변환 */


  // 2 - await가 완료된 컴포넌트 트리를 HTML로 변환한다
  const html = ReactDOMServer.renderToString(rscTree);
  // renderToPipeableStream로 사용하는 것을 권장한다.
 -> 이부분에서 rscTree의 리액트 엘리먼트가 직렬화된다.
  // Send it
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>My React App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/static/js/main.js"></script>
      </body>
    </html>
  `);
});


app.listen(3000, () => {
  console.log("Server listening on port 3000");
});
  1. turnServerComponentsIntoTreeOfElements 비동기 함수 호출을 통해 서버 컴포넌트를 모두 엘리먼트로 변환
  2. ReactDOMServer.renderToString, renderToPipeableStream는 SSR을 위해 필요한 리액트DOM 서버 메서드이지만, 이 예시에서는 RSC와 함께 사용되는 것을 볼 수 있다. 이 부분에서 저자는 RSC가 SSR을 단순히 대체하기 위한 것이 아니며, 상호보완하기 위한 것임을 강조한다.
  3. root div 안에 직렬화된 html을 넣어 res.send를 통해 클라이언트로 보낸다
  4. 클라이언트에서는 HTML 마크업이 완성된 형태로 전달된다.


서버 컴포넌트를 리액트 트리로 변환하기

1번의 turnServerComponentsInto... 함수는 인자로 컴포넌트를 받고 있다. 이 함수에서 서버 컴포넌트를 전송 가능하게 변환하기 때문에 예시가 이어진다. (*마찬가지로 인용된 예시는 code snippet이다. RSC renderer를 이해하기 위한 코드이다.)

async function turnServerComponentsIntoTreeOfElements(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    // A - Don't need to do anything special with these types.
    return jsx;
  }
  if (Array.isArray(jsx)) {
    // B - Process each item in an array.
    return await Promise.all(jsx.map(renderJSXToClientJSX(child)));
  }


  // If we're dealing with an object
  if (jsx != null && typeof jsx === "object") {
    // C If the object is a React element,
    if (jsx.$$typeof === Symbol.for("react.element")) {
      // C-1 `{ type } is a string for built-in components.
      if (typeof jsx.type === "string") {
        // This is a built-in component like <div />.
        // Go over its props to make sure they can be turned into JSON.
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      }
      if (typeof jsx.type === "function") {
        // C-2 This is a custom React component (like <Footer />).
        // Call its function, and repeat the procedure for the JSX it returns.
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = await Component(props);
        return await renderJSXToClientJSX(returnedJsx);
      }
      throw new Error("Not implemented.");
    } else {
      // D - This is an arbitrary object (props, or something inside them).
      // It's an object, but not a React element (we handled that case above).
      // Go over every value and process any JSX in it.
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  }
  throw new Error("Not implemented");
}

이 복잡한 함수의 역할은 매개변수로 받은 안의 모든 서버 컴포넌트를 리액트 엘리먼트 트리로 변환하는 것이다. 조금 더 단순화하자면, 내의 모든 컴포넌트를 분기처리하여 그에 맞는 리엑트 엘리먼트를 반환하는 함수이다.


주석의 A, B, C-1, C-2, D로 나누어 설명하면 다음과 같다.


A - 기본 타입 처리

  • JSX가 기본 타입인 경우다. 기본 타입이란 문자열, 숫자, 불리언 또는 null을 의미. 이러한 유형은 추가 처리 없이 그대로 반환. <div>{'Hello'}</div>에서 'Hello'는 문자열로 직접 반환됨


B - 배열 처리

  • JSX가 배열인 경우. Promise.all을 사용하여 배열 내 각 JSX 항목을 동시에 처리하고, 결과적으로 모든 항목이 리액트 엘리먼트로 변환된 배열을 반환.
  • ex: Fragment로 감싸진 컴포넌트의 경우, children을 array로 감싼다.
[
  <div>hi</div>,
  <h1>hello</h1>,
  <span>love u</span>,
  (props) => <p id={props.id}>lorem ipsum</p>,
];

C-1 - 리액트 엘리먼트 / 내장 컴포넌트

  • JSX가 리액트 엘리먼트이며, 그 타입(jsx.type)이 문자열인 경우(예: <div /><span /> 등의 HTML 태그), 이 분기에서 처리됨. 여기서 해당 엘리먼트의 props를 JSON으로 변환할 수 있는지 확인하고, 모든 자식 컴포넌트 또한 리엑트 엘리먼트로 변환


C-2 - 사용자 정의 컴포넌트

  • SX가 함수형 또는 클래스 컴포넌트인 경우, 해당 컴포넌트를 호출하고, 반환된 JSX를 다시 처리. 그 결과로 반환된 JSX에 대해 재귀적으로 이 함수를 호출. 이 과정은 사용자 정의 컴포넌트가 반환하는 모든 자식 컴포넌트를 리액트 엘리먼트 트리에 포함. ex:


D - 임의 객체

  • (jsx가 객체이지만 리액트 엘리먼트가 아닌 경우) 주로 컴포넌트의 props 객체를 처리할 때 발생. props 객체 내의 모든 값에 대해 재귀적으로 함수를 호출하여, 모든 가능한 JSX를 리액트 엘리먼트로 변환.


리엑트 엘리먼트 트리를 직렬화하기 Serialization


앞선 과정을 통해 직렬화serialization가 가능한 리액트 트리로 서버 컴포넌트를 변환하였다. 이제 이 엘리먼트 트리를 HTML 마크업으로 변환하여 서버에 보내주기 위해 직렬화를 해야한다. 직렬화serialization란 리액트 엘리먼트를 문자열로 바꾸는 과정이다. 이는 RSC 뿐만 아니라 일반적인 SSR에서도 필요한 과정이다. 서버에서 페이지를 HTML로 전달하는 것이 SSR의 핵심이기 때문이다. 직렬화는 ReactDOMServer의 다음 메서드로 구현한다.


  • renderToString : 리액트 컴포넌트를 HTML 문자열로 렌더링. SSR에서 이 메서드를 사용하여 초기 페이지 로드 시 필요한 전체 HTML 마크업을 서버에서 생성할 수 있다. 이렇게 생성된 HTML은 클라이언트로 전송되어 초기 페이지 렌더링에 사용됨.
  • renderToPipeableStream : React 18에서 새롭게 도입. 리액트 컴포넌트를 HTML로 변환하되, 결과를 Node.js의 스트림stream을 통해 비동기적으로 전송. 전체 페이지 대신 페이지의 일부분을 먼저 클라이언트에게 전송하여 빠르게 표시할 수 있게 해주어 사용자가 내용을 보기 시작하는 시간을 단축할 수 있다.


이 두 메서드에 대한 내용은 다음 글에서 더 자세히 살펴보도록 하자. 그렇다면 이같은 과정을 통해 서버에서 제공함으로써 얻는 이점은 무엇일까?


  • 페이지 로딩시간 개선서버가 가능한 한 빨리 클라이언트에게 완성되어 표시 준비가 된 HTML 페이지를 보낼 수 있게 해줍니다. 이는 사용자가 콘텐츠와 더 빨리 상호작용을 시작할 수 있게 함으로써 페이지의 인지된 로딩 시간을 개선합니다.
  • 일관된 초기 렌더링React 요소를 HTML 문자열로 직렬화하는 것은 환경에 관계없이 일관되고 예측 가능한 초기 렌더링을 가능하게 합니다. 생성된 HTML은 정적이며 서버나 클라이언트에서 렌더링되었을 때 동일하게 보입니다. 이러한 일관성은 부드러운 사용자 경험을 보장하는 데 필수적이며, 초기 렌더링이 최종 렌더링과 다를 경우 발생할 수 있는 깜빡임이나 레이아웃 이동을 방지합니다.
  • Hydration 과정 개선:마지막으로, 직렬화는 클라이언트 측에서의 hydration 과정을 용이하게 합니다. 자바스크립트 번들이 클라이언트에 로드될 때, React는 이벤트 핸들러를 부착하고 모든 동적 콘텐츠를 채워 넣어야 합니다. 초기 마크업으로 직렬화된 HTML 문자열을 갖는 것은 React가 작업을 시작할 수 있는 견고한 기반을 보장함으로써, 재수화 과정을 더 효율적이고 신뢰할 수 있게 만듭니다.


사실 이는 리액트의 일반적인 SSR버사이드 렌더링의 이점과도 동일하다. 여기서 한가지 의문점이 들었다.


  1. 서버 컴포넌트 또한 서버사이드에서 ReactDOMServer.renderToString 또는 renderToPipeableStream 같은 SSR에서 사용되는 함수를 사용하여 페이지 레벨에서 HTML을 직렬화하는 과정을 거친다면 결국 RSC는 SSR이 아닌가?
  2. 그렇다면 RSC와 SSR과 어떻게 구분되는가?


RSC & SSR


이러한 의문점을 해소해보자.


공통점

  • 서버사이드 렌더링: 두 방식 다 서버에서 React 컴포넌트를 HTML 문자열로 변환하는 과정을 포함. 이는 ReactDOMServer.renderToString 또는 renderToPipeableStream 함수를 사용하여 수행.
  • 성능 최적화: RSC와 SSR 모두 초기 로딩 시간 단축과 같은 성능 개선에 이점이 있음


차이점

  • 렌더링 단위와 목적의 차이
  • SSR: 페이지 전체 단위. 페이지 전체를 서버에서 렌더링하고 직렬화.
  • RSC: 컴포넌트 단위. 서버/클라이언트 컴포넌트 구분하고, 이 중 서버 컴포넌트만을 서버에서 렌더링하고 직렬화함
  • RSC는 개발자가 서버 컴포넌트와 클라이언트 컴포넌트를 명확하게 분리하는 "새로운 멘탈 모델"을 권장.
  • 서버 컴포넌트: 데이터 페칭과 같은 서버 상의 로직
  • 클라이언트 컴포넌트: 이벤트 등 동적 상호작용
  • 클라이언트 사이드 최적화: RSC는 클라이언트로 전송되는 자바스크립트의 양을 최소화하여 성능을 개선함. (서버 컴포넌트는 클라이언트 번들에 포함되지 않음)
  • 기술적 요구사항RSC는 차세대 번들러가 필요함.
  • (서버 / 클라이언트 컴포넌트를 위한 별도의 모듈 그래프 생성/관리 필요)


"차세대 번들러"라는 조금 낯선 내용을 포함하지만, 이어지는 챕터를 통해 더 명확히 이해할 수 있다.


서버 컴포넌트의 규칙들


서버 / 클라이언트 컴포넌트의 구분


앞서 서버 컴포넌트와 클라이언트의 구분을 간략히 살폈다. 서버 컴포넌트를 도입하면서 이러한 구분이 왜 필요한게 됐는지 살펴보자.

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>Hello friends, look at my nice counter!</h1>
      <p>About me: I like pie! Sign my guest book!</p>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

버튼을 클릭하면 count의 숫자에 1이 더해지는 컴포넌트이다. 이 컴포넌트는 다음 이유에서 서버 컴포넌트가 될 수 없고, 클라이언트 컴포넌트여야만 한다.


로컬 상태의 사용

  • useState는 클라이언트 사이드에서만 사용하는 API입니다. 이는 서버가 count의 초기값을 알 수 없으므로 초기 HTML을 렌더링할 수 없다는 것을 의미합니다. 이는 클라이언트가 인터랙티브 UI를 렌더링하기 전에 서버가 초기 HTML을 렌더링해야 하기 때문에 문제가 됩니다.
  • 서버풀 환경에서는 "상태" 개념이 여러 클라이언트 간에 공유됩니다. 그러나 React에서 RSC 도입 이전까지, 상태는 현재 애플리케이션에 국한되었습니다. 이 차이는 위험을 초래합니다. 여러 클라이언트 간에 상태가 유출될 수 있으며, 이는 민감한 정보를 노출시킬 수 있습니다. 이러한 차이점과 관련된 보안 위험으로 인해 RSC는 서버 측에서 useState의 사용을 지원하지 않습니다. 이는 서버 측 상태가 클라이언트 측 상태와 근본적으로 다르기 때문입니다. 또한, useState에서의 디스패처(setState) 함수는 클라이언트로 전송하기 위해 네트워크를 통해 직렬화되어야 하지만, 함수는 직렬화할 수 없으므로 이는 불가능합니다.


브라우저 API 사용

  • onClick 또한 클라이언트 사이드에서만 사용하는 API입니다. 이는 서버가 인터랙티브하지 않기 때문입니다: 서버에서 실행 중인 프로세스를 클릭할 수 없으므로 서버 컴포넌트에서 onClick은 불가능한 상태입니다. 또한, 서버 컴포넌트의 모든 props는 직렬화 가능해야 합니다. 왜냐하면 서버가 props를 직렬화하여 클라이언트에게 전송해야 하고, 함수는 직렬화할 수 없기 때문입니다.


여러 이유가 있지만, 근본적으로 분리할 수 밖에 없는 이유가 충분히 설명된다. 위의 내용을 요약하자면 다음과 같다.


  1. 서버 상태와 클라이언트의 상태는 분리되어야 한다.
  2. 클라이언트에서만 사용되어야 하는 로컬 상태를 서버에서 공유하게 된다면, 여러 클라이언트에 상태가 노출될 수 있다.
  3. 서버에서는 브라우저 API를 사용할 수 없다.
  4. 상태를 업데이트하는 dispatch(state setter)나 브라우저 이벤트 핸들러 함수는 직렬화할 수 없다.


이같은 이유를 살펴보니 어느정도 납득이 된다. 그럼 이같은 구분이 가져오는 이점에 대해서도 살펴보자.


// Server Component
function ServerCounter() {
  return (
    <div>
      <h1>Hello friends, look at my nice counter!</h1>
      <p>
        About me: I like to count things and I'm a counter and sometimes I count
        things but other times I enjoy playing the Cello and one time at band
        camp I counted to 1000 and a pirate appeared
      </p>
      <InteractiveClientPart />
    </div>
  );
}


// Client Component
"use client";
function InteractiveClientPart() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

이 예시는 서버 컴포넌트와 클라이언트를 분리한다.


... 카운터 애플리케이션()의 작은 부분을 분리해내어 인터랙티브하도록 했습니다. 앱의 이 부분만이 실제로 사용자에게 JavaScript 번들의 일부로 전달될 것입니다. 나머지 부분은 전달되지 않습니다. 그 결과로, 우리는 네트워크를 통해 훨씬 작은 JavaScript 번들을 전송하게 되며, 이는 더 빠른 로딩 시간과 사용자에게 더 나은 성능을 의미합니다. 이는 CPU 측면에서, JavaScript를 파싱하고 실행하는 데 필요한 작업이 줄어들며, 네트워크 측면에서, 다운로드해야 하는 데이터가 줄어드는 측면 모두에서 해당됩니다. 이같은 이점에서 클라이언트 사이드 번들에서 코드를 제외하기 위해 서버에서 안전하게 렌더링할 수 있는 만큼 많이 렌더링하고자 하는 이유입니다.


서버/클라이언트 컴포넌트의 분리 장점을 요약하면 다음과 같다.


  • 기존 컴포넌트에서 서버 컴포넌트는 HTML로 클라이언트로 전달
  • 분리된 클라이언트 컴포넌트는 기존보다 더 작은 크기로 클라이언트 js 번들의 일부로 전달
  • 이를 통해 단축과 서버, 클라이언트 상의 이점이 생김
  • 서버에서 더 작은 크기의 JS 번들을 전송하여 다운받을 데이터의 크기가 줄어듬
  • 클라이언트에서 더 작은 크기의 JS 파싱하고 실행할 작업이 줄어듬


서버 컴포넌트의 간략한 작동 원리, 도입 배경과 함께 살펴보니 더 납득이 간다. 이제 본격적으로 서버 컴포넌트를 사용하기 위한 방법과 지켜야할 규칙을 간략히 살펴보자.


하지만 다음 예제에서 ServerCounter 컴포넌트는 이미 클라이언트 컴포넌트를 포함하고 있다. InteractiveClientPart 클라이언트 컴포넌트가 서버사이드에서 이뤄지는 과정은 길지만 흥미롭다.


TLDR;

  • 서버에서 만든 렌더링 트리는 클라이언트 컴포넌트의 자리에 placeholder를 렌더링한다
  • 이 placeholder는 "모듈 참조"에 대한 내용이다.
  • 모듈 참조는 클라이언트 번들러가 참조할 모듈을 표시한다.
  • 서버 렌더링된 RSC가 클라이언트로 전달되면, 클라이언트의 React가 이를 참조된 모듈로 교체한다
  • 교체된 모듈(컴포넌트로) 클라이언트 상에서 렌더링이 일어난다.


How This Works?


...React는 언제 클라이언트 컴포넌트를 가져오고 실행해야 하는지 어떻게 알까요? 이를 이해하기 위해, 전형적인 React 트리를 고려해야 합니다. 우리의 카운터 예제를 사용하여 이를 해보겠습니다.

img

오렌지 컴포넌트는 서버에서 렌더링되고, 초록색 컴포넌트는 클라이언트에서 렌더링됩니다. 트리의 루트가 서버 컴포넌트인 경우, 전체 트리는 서버에서 렌더링됩니다. 그러나 InteractiveClientPart 컴포넌트는 클라이언트 컴포넌트이므로 서버에서 렌더링되지 않습니다. 대신, 서버는 클라이언트 컴포넌트를 위한 플레이스홀더를 렌더링하는데, **이는 클라이언트 번들러가 생성한 특정 모듈에 대한 참조입니다. 이 모듈 참조는 본질적으로 “트리의 이 지점에 도달하면, 이 특정 모듈을 사용할 시간이다”라고 말합니다.
RSC와 클라이언트 컴포넌트 그림 클라이언트와 서버 컴포넌트를 보여주는 컴포넌트 트리 모듈은 반드시 항상 지연 로딩되는 것만은 아니지만, 번들러가 사용자에게 전달하는 번들에 많은 모듈을 추가하기 때문에, 초기 번들에서도 로드될 수 있습니다. 실제로는 getModuleFromBundleAtPosition([0,4]) 또는 비슷한 것일 수 있습니다. 포인트는 서버가 올바른 클라이언트 모듈에 대한 참조를 보내고, 클라이언트 측 React가 이 공백을 채운다는 것입니다.
이 일이 발생하면, React는 모듈 참조를 실제 클라이언트 번들의 모듈로 대체합니다. 이것은 약간의 단순화이지만, 메커니즘을 충분히 이해하는 데 도움이 될 것입니다. 그런 다음 클라이언트 컴포넌트는 클라이언트에서 렌더링되며, 클라이언트 컴포넌트는 평소처럼 상호 작용할 수 있습니다. 이것이 RSC가 차세대 번들러를 요구하는 이유입니다: 서버와 클라이언트 컴포넌트를 위한 별도의 모듈 그래프를 생성할 수 있어야 합니다.



Serializability Is King 직렬화 가능성이 킹이다


서버 컴포넌트에서는 모든 props가 직렬화 가능해야 한다. 앞서 dispatch, 이벤트 핸들러가 서버 컴포넌트에 작성될 수 없는 이유와 동일하다. 서버에서 props를 직렬화하여 클라이언트에게 전송헤야하기 때문이다. 서버 컴포넌트에서 props는 함수나 다른 비직렬화 가능한 값일 수 없다.  

// error!
function ServerComponent() {
 return <ClientComponent onClick={() => alert("hi")} />;
}

이 컴포넌트는 오류를 발생시킨다. 이제 onClick prop을 ClientComponent 내부에 캡슐화하여 서버와 클라이언트 컴포넌트를 분리해야 한다.


+추가 내용

RSC에서 사용할 수 없는 컴포넌트 패턴들


*이 규칙은 5장에서 논의한 렌더 props 패턴을 사실상 구식으로 만듭니다.*
<ParentComponent>
 {props => <ChildComponent {...props} />}
</ParentComponent>

서버에서 클라이언트로 컴포넌트의 props를 전송하기 위해 모든 props가 직렬화 가능해야 하기 때문에 render props 패턴은 RSC에서 사용이 불가능하다. 함수는 직렬화할 수 없는 객체이고, render props 패턴에서는 함수를 prop으로 전달하기 때문이다.


물론 클라이언트 컴포넌트 사이에선 여전히 사용 가능할 것이다. 개발하려는 앱의 구조에 따라 서버, 클라이언트 컴포넌트의 구성에 따라 더 적절한 패턴이 있을 것이기에, 단정짓기는 어렵다. 다만 앞으로 RSC가 훅이 처음 도입되어 오늘날 자리잡은 것만큼 일반적으로 사용된다면, 여러 컴포넌트 설계 패턴에서도 변화가 있을 것이 예상된다.



부수효과를 일으키는 훅 금지 No Effectful Hooks


앞서 살펴본 것처럼 컴포넌트가 실행되는 서버와 클라이언트는 환경이 다르다. 서버에는 window 객체, DOM, 그에 기반한 이벤트와 사용자의 인터렉션이 없다. 따라서 이를 처리하는 훅(useState, useEffect) 또한 서버 컴포넌트에서 사용할 수 없다.


Next.js와 같은 일부 프레임워크는 서버 컴포넌트에서 모든 hooks의 사용을 완전히 금지하는 린트 규칙을 가지고 있지만, 이것이 완전히 필요한 것은 아닙니다. RSC는 상태useState, 효과useEffect 또는 브라우저 전용 API에 의존하지 않는 hooks를 사용할 수 있습니다. 예를 들어, useRef hook는 상태, 효과 또는 브라우저 전용 API에 의존하지 않기 때문에 서버 컴포넌트에서 사용할 수 있습니다. 그러나 이러한 제한이 모두 나쁜 것은 아니며, 이는 우리가 컴포넌트를 더 안전하게 다루게끔 유도합니다.


useRef는 서버 컴포넌트에서 사용할 수 있다고는 한다! 몰랐지만.. 사용할 일이 있을까 싶다.


(서버) 상태가 (클라이언트)상태는 아니다 State is Not State 


앞서 살펴봤듯이 서버 컴포넌트의 상태는 클라이언트 컴포넌트의 상태와 동일하지 않다. 그 이유 또한 살펴봤다. 각 컴포넌트가 렌더링되는 환경이 다르기 때문이다. 또한 서버 상의 상태가 여러 클라이언트에 공유될 수 있기 때문에, 보안 상의 이유로도 두 컴포넌트는 분리되야 한다고 했다.


서버 컴포넌트는 서버에서 렌더링되고, 클라이언트 컴포넌트는 클라이언트(브라우저)에서 렌더링됩니다. 이는 서버 컴포넌트의 상태가 여러 클라이언트에 공유될 수 있음을 뜻합니다. 서버와 클라이언트의 관계는 유니캐스트(하나의 클라이언트, 하나의 상태) 대신 브로드캐스트 관계이기 때문에, 클라이언트 간에 상태가 유출될 위험이 높습니다.


여러 클라이언트가 결국 동일한 리소스로 같은 서버 컴포넌트를 요청하기 때문에 상태가 공유될 위험성이 있다는 말이다. 따라서 상태와 관련된 useState 또는 useReducer 또는 상태state가 필요한 모든 컴포넌트는 클라이언트 컴포넌트가 적합하다.



클라이언트 컴포넌트는 서버 컴포넌트를 import 할 수 없다.

// error!
"use client";
import { ServerComponent } from "./ServerComponent";

function ClientComponent() {
 return (
  <div>
   <h1>Hey everyone, check out my great server component!</h1>
   <ServerComponent />
  </div>
 );
}

import { readFile } from "node:fs/promises";

export async function ServerComponent() {
 const content = await readFile("./some-file.txt", "utf-8");
 return <div>{content}</div>;
}

 - 서버 컴포넌트는 Node.js API `"node:fs/promises"` 모듈에서 `readFile` 함수를 import한다.

 - 클라이언트 컴포넌트는 서버 컴포넌트를 import하여 브라우저에서 사용할 수 없는 서버 컴포넌트의 import가 불가능하다. 서버에서 브라우저 API를 사용할 수 없는 것과 마찬가지다.

 - Node.js API 사용만이 아니더라도, 서버에서 먼저 실행되는 서버 컴포넌트를 클라이언트 컴포넌트에서 import 하는 것은 서버/클라 분리의 원칙상으로도 어긋난다. (서버 컴포넌트를 클라이언트 번들에 포함하려는 것이기 때문에)


Workaround

"use client";

function ClientComponent({ children }) {
 return (
  <div>
   <h1>Hey everyone, check out my great server component!</h1>
   {children}
  </div>
 );
}

import { ServerComponent } from "./ServerComponent";

async function TheParentOfBothComponents() {
 return (
  <ClientComponent>
   <ServerComponent />
  </ClientComponent>
 );
}

이는 클라이언트 컴포넌트에서 서버 컴포넌트를 명시적으로 import하지 않기 때문에 작동한다.

- 부모 서버 컴포넌트가 서버 컴포넌트를 클라이언트 컴포넌트에 props.children으로 전달. 

- (클라이언트) 번들러는 import 문에만 주의를 기울이고 prop 구성에는 주의를 기울이지 않기 때문에 가능하다



클라이언트 컴포넌트 나쁘지만은 않다


서버 컴포넌트가 도입되기 전까지, 클라이언트 컴포넌트는 우리가 React에서 가지고 있던 유일한 유형의 컴포넌트였습니다. 이는 우리의 기존 컴포넌트들이 모두 클라이언트 컴포넌트라는 것을 의미하며, 그것은 괜찮습니다. 클라이언트 컴포넌트는 나쁘지 않으며 사라지지 않을 것입니다. 그들은 여전히 React 애플리케이션의 핵심이며, 우리가 작성할 가장 일반적인 유형의 컴포넌트입니다. 이 주제에 대해 어느 정도 혼란이 있었고, 서버 컴포넌트가 클라이언트 컴포넌트의 우월한 대체물로 일부에 의해 인식되었기 때문에 여기서 이를 언급합니다. 이것은 사실이 아닙니다. 서버 컴포넌트는 클라이언트 컴포넌트에 추가하여 사용할 수 있는 새로운 유형의 컴포넌트이지만, 클라이언트 컴포넌트의 대체물은 아닙니다.


이번 챕터에서 서버 컴포넌트 만을 깊게 다루었지만, 사실 아직 과도기의 느낌도 받는다. 뒤에 이어지는 서버 액션의 경우 이를 구현하는 새로운 훅 api가 (비공식적으로) 공개되었고, 리액트 컴파일러, use 훅 등등.. 변경 사항은 많아 보인다. 하지만 리액트의 강력한 점은 이전 버전의 코드와 호환이 가능한게 아닌가. 안정화가 될 때까지는 여전히 SPA, SSR 등을 사용하고 있을 것으로 예상된다.


서버 액션 Server Action 


서버 액션과 use server는 아직 Canary이다. (관련한 PR도 계속 추가되고 있고, 많은 예측과 논의가 오가고 있다.) 아직은 실험적인 내용이기에, 먼저 기본적인 개념을 간략히 이해하는데 집중해보자.


 RSC는 클라이언트 측 코드에서 호출할 수 있는 서버 측 함수를 표시하는 새로운 지시어 "use server"와 함께 작동합니다. 우리는 이러한 함수들을 서버 액션이라고 부릅니다. ...어떤 비동기 함수든 그 몸체의 첫 줄에 "use server"를 가질 수 있으며, 이는 React와 번들러에게 이 함수가 클라이언트 측 코드에서 호출될 수 있지만 서버에서만 실행되어야 한다는 것을 알립니다. 클라이언트에서 서버 액션을 호출할 때, 전달된 모든 인수의 직렬화된 복사본을 포함하는 네트워크 요청을 서버로 만듭니다. 서버 액션이 값을 반환하면, 그 값은 직렬화되어 클라이언트에 반환됩니다.


"use server" - Canary


"use server"와 함께 사용된 함수를 Server Action이라고 한다.

"use server"

export const getSampleData = async () => ...
export const postFormSampleData = async () => ...
  • RSC에서 서버와 클라이언트 간의 통신을 간소화하고, 서버 측 연산의 재사용성을 높이기 위해 도입
  • 'use server'는 함수가 클라이언트 측에서 호출되지만, 서버에서만 실행되어야 할 때 사용
  • 해당 함수(서버 액션)는 클라이언트에서 서버로 인수를 전달하며 호출될 수 있으며, 실행 결과만 클라이언트로 반환
  • 서버 액션의 매개 변수와 반환값은 직렬화될 수 있음
  • 동적인 데이터 처리나 민감한 데이터의 처리를 서버에서 수행할 수 있음
  • 클라이언트에 필요한 데이터를 효율적으로 요청 (useEffect와 state, 데이터 상태 관리 툴 없이)
  • 파일 전체에 'use server'를 적용하여, 해당 파일의 모든 export가 서버 액션으로 표시됨



먼저 "use server" 디렉티브는 서버 컴포넌트를 만들기 위한 명령어가 아니다(Next.js의 "use client"의 반대가 아니다). "use server"는 함수에 적용하기 위해 사용한다. 이 함수는 클라이언트 컴포넌트에서 호출한다. 그러므로 Next.js라면, 한 컴포넌트 최상단에 "use client", 함수 안에는 "use server" 두 명령어가 모두 있을 수 있다. 


목적 : 함수를 "서버에서만" 실행되게끔 한다. 클라이언트는 호출 -> (서버) -> 반환만 받는다.
클라이언트: 클라이언트에서 서버 액션 호출 (=서버로 요청) -> 
서버: (서버에서 함수가 실행 -> 서버에서 결과를 반환 ) ->
클라이언트: 클라이언트 컴포넌트에서 응답을 받음 -> 함수가 반환한 값에만 접근할 수 있음.


앞서 살핀 것처럼 컴포넌트를 서버로 옮긴 것의 이점을, 클라이언트의 함수에 적용한다고 이해하면 되겠다.



Server Action in Forms

// App.js  
async function requestUsername(formData) {
	'use server';  
	const username = formData.get('username');  
	const address = formData.get('address');  
	console.log(username)  
	// ... 
	return response
	}  

export default App() {  
	<form action={requestUsername}>   
		<input type="text" name="username" />   
		<input type="text" name="address" />    
		<button type="submit">Request</button>  
	</form> 
	}`


서버액션을 사용한 Form 컴포넌트의 예시이다.


  1. 사용자가 form을 작성하고 submit 버튼을 눌러 제출한다. 
  2. requestUserName이 서버에서 실행된다. 실행 중인 함수 내부의 변수 username, address는 서버에서만 접근할 수 있다. (콘솔은 서버사이드에서 프린트된다)
  3. 함수가 끝나고 난뒤 클라이언트 측에서는 requestUserNamed의 반환값인 response를 받게된다.


클라이언트에서 기존대로 폼 데이터를 제출하는 것과는 다른 패턴이라 낯설다. form 제출에 서버 액션을 사용하여 다음과 같은 이점을 볼 수 있다고 한다.


  • 서버 액션은 **폼 액션**으로 사용될 수 있으며, 폼 제출 시 서버 함수를 호출
  • 폼에서 서버 액션을 사용하면, 폼 데이터는 자동으로 서버 액션에 전달되어 서버에서 처리 후 결과를 클라이언트로 반환
  • 이 방식을 통해, JavaScript 번들이 로드되기 전에도 폼을 제출할 수 있어, 폼의 점진적 개선이 가능


앞으로도 변경사항이 있을 수 있는 내용인 것 같다. 기본적인 개념을 인지하고 넘어가자.


Outside of Form

// LikeButton.jsx
"use client";

import incrementLike from "./actions";
import { useState, useTransition } from "react";

function LikeButton() {
 const [isPending, startTransition] = useTransition();
 const [likeCount, setLikeCount] = useState(0);

 const incrementLink = async () => {
  "use server";
  return likeCount + 1;
 };

 const onClick = () => {
  startTransition(async () => {
   // 서버 액션 반환 값을 읽으려면, 반환된 프로미스를 await 해야함
   const currentCount = await incrementLike(); // 서버 액션 호출
   setLikeCount(currentCount);
  });
 };

 return (
  <>
   <p>Total Likes: {likeCount}</p>
   <button onClick={onClick} disabled={isPending}>
    Like
   </button>;
  </>
 );
}


// actions/incrementLike.js

// db를 호출하지 않는 간단한 예시. 
let likeCount = 0;  
export default async function incrementLike() {  
likeCount++;  
return likeCount;  
}

// 데이터베이스로 직접 요청을 보낼 수도 있다
  const pool = new Pool({ user: 'dbuser', host: 'database.server.com', database: 'mydb', password: 'secretpassword', port: 5432, });

async function incrementLike(postId) {
 'use server'; // 서버 액션 지시어
 try {
  // 백엔드 api로 요청을 보낸다고 가정 시 
  const response = await fetch(`... `, {
   method: 'POST', ...
  }); 
  // 데이터 베이스에 직접 접근을 가정할 시 (db는 PostgreSQL)
  const client = await pool.connect();
  const { rows } = await client.query('SELECT likes FROM posts WHERE id = 
  ... 
   

  const { likeCount } = await response.json();

  return likeCount; // 증가된 좋아요 수 반환
 } catch (error) {
  console.error('Error incrementing like:', error);
	...
 }
}


이 예시를 통해, form 태그 뿐만 아니라 다양한 클라이언트 컴포넌트의 이벤트에서도 사용가능하다.

  • 서버 액션은 폼 외부에서도 사용될 수 있으며, 클라이언트 코드 어디에서나 서버 엔드포인트로 호출될 수 있다.
  • useTransition훅을 사용하여 서버 액션 호출 시 로딩 인디케이터 표시, 낙관적 UI 업데이트, 예상치 못한 오류 처리 등의 기능을 구현할 수 있다.
  • ex) 좋아요 버튼 클릭 시 서버 액션을 호출하여 좋아요 수를 증가시키고, 결과를 클라이언트 상태에 반영


리액트 공식문서에는 가장 간단한 예시를 제공하지만, 이론적으로 서버 액션으로 SQL을 작성하여 직접 db에 호출하는 것까지 가능하다. (얼마나 실용적인지는 모르겠다) 이 때문에 서버 액션은 처음 공개되었을 시 많은 반발을 샀던 걸로 기억한다. 그럼에도 가장 큰 변화 중 하나가 아닐까 싶다.


 서버 액션을 통해 비동기 데이터를 관리하는 클라이언트 컴포넌트가 더 직관적이고 단순해졌다. 서버 액션으로 백엔드 api를 호출하는 방식이 BFF와 생각도 들기도 하여 이점이 없을 것 같진 않다. 하지만 아직은 예측만 해볼 뿐이다. 

 

 올해 말에 리액트 19가 나온다고 하니, 귀추가 주목된다!


관련한 흥미로운 글들


3rd party 상태 관리 라이브러리는 무용해질 것

- 라우팅 기반으로 상태 관리가 바뀔 것이다

tanstack-router

- 라우팅 기반의 상태 관리, 캐싱