React Server Components 살펴보기

React 18에서 새롭게 등장한 React Server Components(RSC)는 서버 사이드 렌더링(SSR)을 넘어선 새로운 패러다임을 제시했습니다. 이번 글에서는 RSC의 구현 원리와 작동 방식을 살펴보겠습니다.

SSR과 RSC: 이름은 비슷하지만 다른 개념

"서버"라는 단어가 들어간다고 해서 SSR과 RSC를 같은 개념이라고 생각하기 쉽습니다. 하지만 이 둘은 전혀 다른 개념입니다.

SSR은 이렇게 동작합니다

  • 서버가 첫 페이지의 HTML을 미리 생성합니다.
  • 모든 JavaScript 코드는 여전히 클라이언트로 전송됩니다.
  • 클라이언트는 받은 JavaScript를 실행하며 페이지를 활성화합니다. (hydration)

RSC의 특별한 점

  • 컴포넌트 자체가 서버에서 실행됩니다.
  • 필요한 JavaScript 코드만 선별적으로 클라이언트로 전송합니다.
  • 클라이언트 컴포넌트와 자연스럽게 조합할 수 있습니다.

'클라이언트 컴포넌트'는 SSR에서 서버에서 실행될 수 있지만, '서버 컴포넌트'는 서버에서 실행됩니다.

서버 컴포넌트의 주요 특징

서버 컴포넌트의 핵심 특징들을 살펴보겠습니다.

  1. 컴포넌트 계층 구조

    • 서버 컴포넌트는 클라이언트 컴포넌트 내부에서 사용할 수 없습니다.
    • 이는 서버와 클라이언트의 명확한 경계를 만듭니다.
  2. 데이터 전달 규칙

    • 서버 컴포넌트에서 클라이언트 컴포넌트로 넘기는 prop은 직렬화가 가능한 값이어야 합니다.
  3. 비동기 처리 지원

    • 서버 컴포넌트에서는 async/await를 자유롭게 사용할 수 있습니다.
    • 데이터 fetching이 훨씬 간단해집니다.
  4. 번들 크기 최적화

    • 서버 컴포넌트의 코드는 클라이언트로 전송되지 않습니다
    • 결과적으로 사용자는 더 가벼운 JavaScript를 받게 됩니다.

이러한 특징들을 종합해보면, 서버 컴포넌트는 "JSX를 반환하는 서버 API" 역할을 한다고 볼 수 있습니다.

서버 컴포넌트의 등장 배경

SSR은 훌륭한 기능입니다. 첫 페이지 로딩이 빠르고, SEO에도 강점이 있죠. 하지만 몇 가지 근본적인 한계가 있었습니다.

  1. JavaScript 번들 크기 문제

    • 서버에서 HTML을 생성하지만, 결국 모든 JavaScript 코드를 클라이언트로 전송해야 합니다.
    • 인터랙티브한 기능을 위해서는 전체 번들을 다운로드하고 실행해야 합니다.
  2. 순차적인 렌더링과 블로킹

    • 서버는 모든 데이터를 가져온 후에야 HTML을 렌더링할 수 있습니다.
    • 클라이언트는 JavaScript 실행이 완료될 때까지 페이지와 상호작용할 수 없습니다.
    • 이런 순차적인 과정은 사용자 경험을 지연시킬 수 있습니다.
  3. 전체 페이지 Hydration

    • 인터랙티브한 부분이 일부더라도, 페이지 전체를 hydration해야 합니다.
    • 이는 불필요한 JavaScript 실행을 유발하고, 특히 큰 애플리케이션에서 성능 저하를 일으킵니다.

이런 한계로 인해 자연스럽게 떠오르는 질문이 있습니다. 정말 모든 컴포넌트를 클라이언트에서 실행해야 할까요?

예를 들어 보겠습니다.

const Card = () => {
  const { data } = useSuspenseQuery({
    queryKey: ["card"],
    queryFn: () => fetch("https://api.example.com/card").then((res) => res.json()),
  })
 
  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.description}</p>
      <button onClick={() => alert("clicked")}>{data.buttonText}</button>
    </div>
  )
}
const Card = () => {
  const { data } = useSuspenseQuery({
    queryKey: ["card"],
    queryFn: () => fetch("https://api.example.com/card").then((res) => res.json()),
  })
 
  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.description}</p>
      <button onClick={() => alert("clicked")}>{data.buttonText}</button>
    </div>
  )
}

이 컴포넌트에서 버튼을 제외한 나머지는 서버에서 그려서 마크업 구조만 전송하면 되지 않을까요?

서버 컴포넌트의 기본 구현 원리 유도해보기

1. 기본 마크업 처리

가장 기본적인 형태의 UI부터 시작해봅시다.

<div>
  <h1 className='card-title'>Card Title</h1>
  <p className='card-description'>Card Description</p>
</div>
<div>
  <h1 className='card-title'>Card Title</h1>
  <p className='card-description'>Card Description</p>
</div>

서버에서는 이 마크업을 다음과 같이 JSON 형태로 표현할 수 있습니다.

{
  "tag": "div",
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "card-title"
      },
      "children": ["Card Title"]
    },
    {
      "tag": "p",
      "props": {
        "className": "card-description"
      },
      "children": ["Card Description"]
    }
  ]
}
{
  "tag": "div",
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "card-title"
      },
      "children": ["Card Title"]
    },
    {
      "tag": "p",
      "props": {
        "className": "card-description"
      },
      "children": ["Card Description"]
    }
  ]
}

그럼 클라이언트에서는 이 응답을 가지고 그리는 로직만 있으면 되겠네요.

const renderServerDrivenUI = (json) => {
  const { tag, props, children } = json
 
  const element = document.createElement(tag)
  Object.entries(props).forEach(([key, value]) => {
    element.setAttribute(key, value)
  })
 
  children.forEach((child) => {
    if (typeof child === "string") {
      element.appendChild(document.createTextNode(child))
    } else {
      element.appendChild(render(child))
    }
  })
 
  return element
}
 
renderServerDrivenUI(jsonFromServer)
const renderServerDrivenUI = (json) => {
  const { tag, props, children } = json
 
  const element = document.createElement(tag)
  Object.entries(props).forEach(([key, value]) => {
    element.setAttribute(key, value)
  })
 
  children.forEach((child) => {
    if (typeof child === "string") {
      element.appendChild(document.createTextNode(child))
    } else {
      element.appendChild(render(child))
    }
  })
 
  return element
}
 
renderServerDrivenUI(jsonFromServer)
2. 클라이언트 컴포넌트

실제 애플리케이션에서는 다양한 상호작용이 필요합니다. 버튼 클릭과 같은 이벤트 핸들러는 어떻게 처리해야 할까요?

{
  "tag": "button",
  "props": {
    "className": "card-button",
    "onClick": "여기에 함수를 어떻게 전달할까요?"
  },
  "children": ["Click me"]
}
{
  "tag": "button",
  "props": {
    "className": "card-button",
    "onClick": "여기에 함수를 어떻게 전달할까요?"
  },
  "children": ["Click me"]
}

함수는 직렬화가 불가능하기 때문에 JSON으로 표현할 수 없습니다. 이 부분은 브라우저에서 직접 처리해야겠네요. 클라이언트 쪽에서 미리 버튼을 그리는 로직을 가지고 있고, 이를 매핑하면 어떨까요?

{
  "tag": "div",
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "card-title"
      },
      "children": ["서버에서 가져온 제목"]
    },
    {
      "tag": "p",
      "props": {
        "className": "card-description"
      },
      "children": ["서버에서 가져온 설명"]
    },
    {
      "type": "CLIENT_COMPONENT",
      "renderer": "renderButton",
      "props": {}
    }
  ]
}
{
  "tag": "div",
  "children": [
    {
      "tag": "h1",
      "props": {
        "className": "card-title"
      },
      "children": ["서버에서 가져온 제목"]
    },
    {
      "tag": "p",
      "props": {
        "className": "card-description"
      },
      "children": ["서버에서 가져온 설명"]
    },
    {
      "type": "CLIENT_COMPONENT",
      "renderer": "renderButton",
      "props": {}
    }
  ]
}

이제 클라이언트에서는 이렇게 처리할 수 있습니다.

const renderButton = () => {
  const element = document.createElement("button")
  element.addEventListener("click", () => alert("clicked"))
  element.appendChild(document.createTextNode("Click me"))
  element.setAttribute("className", "card-button")
  return element
}
 
/** 미리 정의된 컴포넌트 맵 */
const RENDERER_MAP = {
  renderButton,
}
 
const renderServerDrivenUI = (json) => {
  const { tag, props, children } = json
 
  const element = document.createElement(tag)
  Object.entries(props).forEach(([key, value]) => {
    element.setAttribute(key, value)
  })
 
  children.forEach((child) => {
    if (child.type === "CLIENT_COMPONENT") {
      const component = RENDERER_MAP[child.renderer]()
      element.appendChild(component)
    } else if (typeof child === "string") {
      element.appendChild(document.createTextNode(child))
    } else {
      element.appendChild(render(child))
    }
  })
 
  return element
}
 
renderServerDrivenUI(jsonFromServer)
const renderButton = () => {
  const element = document.createElement("button")
  element.addEventListener("click", () => alert("clicked"))
  element.appendChild(document.createTextNode("Click me"))
  element.setAttribute("className", "card-button")
  return element
}
 
/** 미리 정의된 컴포넌트 맵 */
const RENDERER_MAP = {
  renderButton,
}
 
const renderServerDrivenUI = (json) => {
  const { tag, props, children } = json
 
  const element = document.createElement(tag)
  Object.entries(props).forEach(([key, value]) => {
    element.setAttribute(key, value)
  })
 
  children.forEach((child) => {
    if (child.type === "CLIENT_COMPONENT") {
      const component = RENDERER_MAP[child.renderer]()
      element.appendChild(component)
    } else if (typeof child === "string") {
      element.appendChild(document.createTextNode(child))
    } else {
      element.appendChild(render(child))
    }
  })
 
  return element
}
 
renderServerDrivenUI(jsonFromServer)

JSON을 만들어 내는 것을 서버 사이드에서 동작하는 React, renderServerDrivenUI 를 클라이언트 사이드에서 동작하는 React라고 생각하면 서버 컴포넌트의 기본 원리를 이해할 수 있습니다.

실제로 RSC를 사용할 때는 '어디서부터 클라이언트 컴포넌트인지'를 명시해주어야 번들러가 구분지을 수 있습니다. 이 때문에 클라이언트 컴포넌트는 모두 "use client" 라는 키워드를 붙여주어야 합니다.

실제 React에서의 서버 컴포넌트

RSC Payload

서버에서 클라이언트로 데이터를 넘겨주려면 직렬화가 가능해야 합니다. 간단하게 '직렬화'를 구현한다면 JSON.stringify를 사용할 수 있겠지만 실제 React에서는 이보다 더 많은 것을 유연하게 직렬화 할 수 있는 유틸을 직접 만들어 사용합니다.

resolveToJSON이라는 함수입니다.

React에서 사용하는 유틸은 Set, Map 등과 Promise(thenable들)까지도 직렬화 할 수 있습니다. 이 함수로 직렬화가 가능한 데이터는 모두 서버 컴포넌트에서 클라이언트 컴포넌트로 넘겨줄 수 있습니다.

이렇게 직렬화된 데이터를 React는 'RSC Payload'라는 특수한 형태로 전달합니다.

스트리밍

HTTP 프로토콜에서 스트리밍은 클라이언트가 요청을 보내면 서버가 응답을 조금씩 보내주는 방식입니다. 이를 HTML에 적용할 경우 브라우저는 준비된 부분부터 렌더링할 수 있습니다.

Node.js 스트리밍 예제

Node.js에서 스트리밍으로 HTML을 전송하는 예시를 간단하게 구현해보겠습니다.

const http = require("http")
 
const server = http.createServer(async (req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" })
  res.write("<html><body>")
  res.write("<div>STREAMING START</div>")
 
  await new Promise((resolve) => setTimeout(resolve, 5_000))
 
  res.write("<h1>Hello, World!</h1>")
  res.write("</body></html>")
  res.end()
})
 
server.listen(3000, () => {
  console.log("Server is running on port 3000")
})
const http = require("http")
 
const server = http.createServer(async (req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" })
  res.write("<html><body>")
  res.write("<div>STREAMING START</div>")
 
  await new Promise((resolve) => setTimeout(resolve, 5_000))
 
  res.write("<h1>Hello, World!</h1>")
  res.write("</body></html>")
  res.end()
})
 
server.listen(3000, () => {
  console.log("Server is running on port 3000")
})

HTML을 한번에 모두 전송하는 것이 아니라, 조금씩 붙여나가면서 전송합니다. 이렇게 하면 브라우저에서는 스트리밍 받는 족족 렌더하게 됩니다.

위 예제에서는 <div>STREAMING START</div> 부분을 먼저 전송하고, 5초 후에 <h1>Hello, World!</h1> 부분을 전송하므로, 실제로 서버를 실행해서 접속해보면 이렇게 동작합니다.

스트리밍 예제

인라인으로 적힌 스크립트도 스트리밍으로 받아오는대로 바로 실행됩니다. 따라서 이를 활용하면 스트리밍으로 돔 요소를 조작하는 것도 가능합니다.

const server = http.createServer(async (req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" })
  res.write("<html><body>")
  res.write("<div id='streaming-start'>STREAMING START</div>")
 
  await new Promise((resolve) => setTimeout(resolve, 5_000))
 
  res.write(`<script>
    const streamingStart = document.getElementById('streaming-start')
    streamingStart.textContent = 'STREAMING END'
  </script>`)
 
  res.write("<h1>Hello, World!</h1>")
  res.write("</body></html>")
  res.end()
})
const server = http.createServer(async (req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" })
  res.write("<html><body>")
  res.write("<div id='streaming-start'>STREAMING START</div>")
 
  await new Promise((resolve) => setTimeout(resolve, 5_000))
 
  res.write(`<script>
    const streamingStart = document.getElementById('streaming-start')
    streamingStart.textContent = 'STREAMING END'
  </script>`)
 
  res.write("<h1>Hello, World!</h1>")
  res.write("</body></html>")
  res.end()
})

스트리밍 예제-인라인 스크립트

RSC와 스트리밍

SSR만 사용하던 때에는 Streaming SSR이라는 개념이 있었습니다. 앞서 저희가 살펴본 스트리밍을 통해 initial HTML을 준비하는 시간을 줄일 수 있었습니다.

다만, Streaming SSR은 SSR의 한계를 여전히 가지고 있었습니다.

  • initial HTML 렌더시에만 활용됨
  • 클라이언트 사이드에서 전체 JS 번들을 로드하고 hydration이 완료될 때까지 상호작용이 불가함

RSC의 등장으로 일부만 선택적으로 hydration 할 수 있게 되면서 스트리밍이 더 빛을 보게 됐습니다.

const getServerData = async () => {
  const data = await fetch("https://api.example.com/data")
  return data.json()
}
 
const Page = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <ClientComponent data={getServerData()} />
      </Suspense>
    </div>
  )
}
 
// client component
const ClientComponent = ({ data }) => {
  const { userName } = use(data)
 
  return <div>Hello, {userName}</div>
}
const getServerData = async () => {
  const data = await fetch("https://api.example.com/data")
  return data.json()
}
 
const Page = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <ClientComponent data={getServerData()} />
      </Suspense>
    </div>
  )
}
 
// client component
const ClientComponent = ({ data }) => {
  const { userName } = use(data)
 
  return <div>Hello, {userName}</div>
}

서버 컴포넌트인 Page는 완벽히 서버 사이드에서 렌더링되고, getServerData 함수 또한 클라이언트로 내려가지 않습니다.

페이지에 접근하면 HTML 스트리밍이 시작되고, Suspense fallback이 먼저 렌더됩니다. 이 때 스트리밍 되는 HTML 조각은 이런 모양입니다.

<div>
  <!--$?-->
  <template id="B:0"></template>
  <div>Loading...</div>
  <!--/$-->
</div>
<div>
  <!--$?-->
  <template id="B:0"></template>
  <div>Loading...</div>
  <!--/$-->
</div>

getServerData Promise가 resolve 되는 순간 이런 HTML 조각이 스트리밍을 통해 전달됩니다.

<div hidden id="S:0">
  <div>
    Hello,
    <!-- -->
    Shi Woo
  </div>
</div>
<script>
  $RC = function (b, c, e) {
    // ...
    // b, c 아이디를 가진 요소를 갈아끼우는 로직이 있음
  }
  $RC("B:0", "S:0")
</script>
<div hidden id="S:0">
  <div>
    Hello,
    <!-- -->
    Shi Woo
  </div>
</div>
<script>
  $RC = function (b, c, e) {
    // ...
    // b, c 아이디를 가진 요소를 갈아끼우는 로직이 있음
  }
  $RC("B:0", "S:0")
</script>

$RC 의 실제 구현은 React 코드베이스에서 확인할 수 있습니다. 링크

인라인 스크립트가 실행되면 스트리밍을 통해 받아온 내용을 Suspense fallback과 바꾸게 되는 것입니다.

최초 접근시에는 HTML이 스트리밍 되고, 이후 페이지 이동시에는 RSC Payload가 스트리밍 됩니다.

스트리밍은 선택적 hydration이 가능하다는 RSC의 장점과 시너지를 발휘해 사용자 경험을 개선시켰습니다. SSR이였다면 getServerData Promise가 끝나기 전까지는 hydration이 이뤄지지 않아 상호작용이 불가능 했을겁니다.