logo
Published on

[React] SSR과 Hydrate에 대해 알아보자

Authors
  • avatar

    jangth

﹒참고 도서 (모던 리액트 Deep Dive)

#Table Of Contents


과거 애플리케이션은 서버 사이드 렌더링(SSR)에 의존해, 페이지 접근 시마다 서버에서 새로운 HTML을 생성해 전달한다. 이 방식은 페이지 이동 시 지연을 발생시키고, 서버에 과부하를 초래할 수 있다. 이를 해결하기 위해 자바스크립트를 활용한 새로운 방식이 등장했다.

#SPA란

SPA(Single Page Application)란 렌더링과 라우팅을 서버가 아닌 브라우저의 자바스크립트에 의존하는 방식이다.

아래는 React를 사용해 만든 SPA의 첫 페이지를 소스맵에서 확인한 모습이다. <body>부분을 보면 아무런 내용이 없는데, 이는 <body> 내부의 모든 내용을 자바스크립트 코드로 삽입한 이후 렌더링하기 때문이다.

csr_sourcemap

이처럼 최초 페이지 접근 시 새로운 HTML 페이지를 요청하는 대신, 번들링된 자바스크립트를 다운로드받고 이 자바스크립트 리소스와 브라우저 API를 기반으로 모든 작동이 이뤄진다.

이 방식은 초기 로딩 시 자바스크립트 리소스가 커지는 단점이 있지만, 이후 필요한 리소스를 요청이 적어져 더 나은 UX/UI를 제공한다.


#SSR란

SSR(ServerSideRendering)이란 최초에 사용자에게 보여줄 HTML을 서버에서 렌더링해 사용자에게 화면을 제공하는 방식을 말한다.

아래 소스맵을 보면 CSR과 다르게 SSR은 웹페이지에 접근하면 <body> 내부에 콘텐츠를 확인할 수 있다. csr_sourcemap

이처럼 최초 웹 페이지에 접근 시 서버에서 HTML을 생성해 전달해주면 브라우저가 바로 파싱해 렌더링한다.

SSR은 최초 페이지 진입 시 비교적 빠르고, 사용자 기기 성능에 덜 영향을 받으며, 검색엔진 최적화(SEO) 등의 장점이 있다. 그러나 서버 리소스 사용과 코드 작성 시 서버를 고려해야 하는 등의 단점도 존재한다.


#왜 SSR을 적용하고 알아야 할까

SPA로 부드러운 사용자 경험을 제공하는 웹사이트를 만들 수 있는데, 왜 SSR이 요즘 들어 각광받고 있을까?

그 이유를 알기 위해서 먼저 아래 그래프를 보자! (모바일 부분을 집중해보자)

  • 아래 2016년부터 2024년까지 평균 자바스크립트 리소스 크기 그래프

    자바스크립트 리소스 크기는 약 250KB에서 500KB 이상으로 꾸준히 증가했으며, 특히 2020년부터 급격하게 증가하여 최근에는 1000KB까지 도달한 것을 볼 수 있다. csr_sourcemap

  • 아래 2018년부터 2024년까지 스크립트가 페이지당 소비하는 평균 CPU 시간

    2020년부터 급격하게 증가한 것으로 보이며, 최근에는 CPU 시간이 12초 이상에 도달한 것으로 보인다.

csr_sourcemap

위 그래프를 통해 알 수 있는 것은 사용자 기기와 네트워크 환경이 크게 개선되었음에도 불구하고, 자바스크립트 리소스 크기와 이를 처리하는 CPU 시간이 크게 증가했다는 점이다. 이로 인해 실제 사용자가 느끼는 웹 애플리케이션의 성능 및 로딩 속도는 오히려 더 느릴 수 있다.

다음으로 성능 탭을 보자.

동일한 코드로 작성한 CSR(React)과 SSR(Next.js)을 성능 탭에서 비교한 것이다. 참고로, 이 비교는 3G 환경과 캐시 비활성화 상태에서 이루어졌다.

  • CSR csr_sourcemap

위 CSR 성능 탭을 보면, main.tsx, react.js, react-dom_client.js, App.tsx 스크립트를 병렬적으로 다운로드받는 것을 확인할 수 있다. 여기서 main.tsx가 진입점 역할을 하여 ReactDOM.createRoot를 사용해 App.tsx를 렌더링하고 있다.

  • SSR csr_sourcemap

SSR 성능 탭에서는 CSR과 다르게 main-app.js, page.js와 같은 스크립트만 확인할 수 있는데, 이 스크립트들은 SSR 이후 하이드레이션 과정에 사용된다

"✔️ FCP는 렌더링 이후 사용자가 첫 번째 콘텐츠를 볼 수 있는 시점을 측정하는 지표로, CSR과 SSR의 성능을 비교하는 데 사용했다.”

위 성능 탭 비교에서 하단에 표시된 빨간색 박스의 FCP 부분을 보면, CSR은 8.24s인 반면 SSR은 1.38s로 차이가 크다. CSR에서는 자바스크립트를 모두 다운로드받아 렌더링하기 때문에, 서버에서 HTML을 생성해 바로 렌더링하는 SSR이 더 빠르다.

결과적으로, 사용자가 처음으로 리소스를 볼 수 있는 시간이 SSR이 CSR보다 빠르기 때문에, 사용자 경험에 크게 영향을 미친다고 판단할 수 있다. 이를 통해 SSR을 도입함으로써 초기 로딩 속도를 개선하고, 궁극적으로 더 나은 사용자 경험을 제공할 수 있다.


#React SSR과 Hydrate

SSR을 깊이 이해하기 위해, React에서 SSR이 어떻게 구현되는지, 그리고 이를 위해 어떤 API가 사용되는지 살펴보려 한다. 이를 통해 Next.js를 공부할 때 더욱 탄탄한 기초를 다질 수 있다.

아래는 React에서 SSR을 구현한 코드이다. React에는 SSR을 구현하기 위한 다양한 API가 존재하지만, 여기서는 React 18에서 서버 사이드 렌더링을 제공하는 renderToPipeableStreamhydrateRoot API를 사용한 예제이다.
정확한 내용은 공식문서를 참고하자

React 17 버전에서는 renderToNodeStreamhydrate를 사용했으나, 18 버전부터는 deprecated되었고, 대신 renderToPipeableStreamhydrateRoot API를 사용한다.

Hydrate

const container = document.getElementById('root')
hydrateRoot(container, <App />)

hydrateRoot는 SSR으로 생성된 HTML에 JavaScript 핸들러와 이벤트를 연결하는 역할을 한다. 이는 이미 렌더링된 HTML이 존재한다는 전제하에 실행되며, 해당 HTML을 기반으로 JavaScript 기능을 활성화하는 작업만 수행한다.

더 자세한 내용은 아래 성능 탭 이미지에서 설명할 예정이다.

hydrate의 또 다른 중요한 특징은 단순히 이벤트나 핸들러를 추가하는 것에 그치지 않고, 클라이언트에서 한 번 더 렌더링을 수행하여 그 결과물과 SSR로 생성된 HTML을 비교한다는 점이다. 이 비교 과정에서 불일치가 발생하면 아래와 같은 에러가 발생할 수 있다.

“Hydration failed because the initial UI does not match what was rendered on the server.”

이 에러가 발생하더라도 hydrateRoot가 렌더링한 결과를 기준으로 웹 페이지는 정상적으로 동작하지만, 이는 SSR의 장점을 포기한 것으로 반드시 확인하고 수정해야 할 부분이다.

다음으로 코드를 살펴보자

SSR과 Hydrate

참고로 웹팩과 바벨 설정은 생략했다.

그럼 이제 본격적으로 아래코드를 통해 SSR를 구현하고 Hydrate를 적용해보자

  • index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SSR</title>
  </head>
  <body>
    __placeholder__
    <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
    <script src="/browser.js" defer></script>
  </body>
</html>

<body /> 내부를 보면 __placeholder__와 React CDN, 그리고 browser.js 스크립트가 있다.

__placeholder__는 SSR로 생성된 HTML이 삽입될 부분이며, browser.js는 클라이언트 측 코드를 실행할 스크립트다.

  • index.jsx

import { hydrateRoot } from 'react-dom/client'
import App from './App.jsx'

export function main() {
  const container = document.getElementById('root')
  // container에는 SSR에서 제공한 HTML이 존재해야한다.
  hydrateRoot(container, <App />)
}

main()

index.jsx의 목적은 hydrateRoot로 하이드레이션을 실행하고, SSR로 생성된 HTML에 자바스크립트 헨들러 및 이벤트를 연결시켜준는 역할을 하는 것이다.

  • App.jsx

import React, { useState } from 'react'

function App() {
  const [count, setCount] = useState(0)

  return (
    <>
      <h1>React</h1>
      <p>SSR 구현하기</p>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
      </div>
    </>
  )
}

export default App

App.jsx는 SSR의 대상이 되는 컴포넌트이다.

  • server.js

import '@babel/register'
import { createElement } from 'react'
import { renderToPipeableStream } from 'react-dom/server'
import { createReadStream } from 'fs'
import { createServer } from 'http'
import App from './src/App.jsx'

const PORT = 3000

function serverHandler(req, res) {
  const { url } = req

  switch (url) {
    case '/': {
      res.setHeader('Content-Type', 'text/html')
      res.write(`<!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>SSR</title>
        </head>
        <body>
          `)

      const rootElement = createElement('div', { id: 'root' }, createElement(App))

      const { pipe } = renderToPipeableStream(rootElement, {
        bootstrapScripts: ['/browser.js'],
        onShellReady() {
          pipe(res)
        },
        onShellError(error) {
          res.statusCode = 500
          res.setHeader('Content-Type', 'text/plain')
          res.end('An error occurred during server-side rendering')
          console.error(error)
        },
        onAllReady() {
          res.write(`
            <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
            <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
            <script src="/browser.js"></script>
          </body>
          </html>`)
          res.end()
        },
        onError(error) {
          console.error(error)
        },
      })

      return
    }
    case '/browser.js': {
      res.setHeader('Content-Type', 'application/javascript')
      createReadStream('./dist/browser.js').pipe(res)
      return
    }
    default: {
      res.statusCode = 404
      res.end('Not Found')
      return
    }
  }
}

function main() {
  createServer(serverHandler).listen(PORT, () => {
    console.log(`Server started on port ${PORT}`)
  })
}

main()

server.js에서는 다음과 같은 주요 부분을 확인할 수 있다.

  • createServer
    • http는 Node.js의 기본 라이브러리로, 간단히 서버를 만들 수 있다.
  • serverHandler
    • createServer에 전달되는 함수로, HTTP 서버가 각 라우트에서 어떻게 동작할지를 정의한다.
  • renderToPipeableStream
    • HTML을 <body>를 기준으로 나눈 후, <body> 내부에 rootElementApp.jsx를 생성하여 삽입하고 있다. 이는 renderToPipeableStream데이터를 스트림 형태로 청크 단위로 전송하고, 이 데이터가 클라이언트에 즉시 전달되어 순차적으로 렌더링되기 때문이다.
  • browser.js
    • webpack에 의해 index.jsx를 기점으로 번들링된 스크립트로, React 클라이언트 코드를 실행하기 위한 것이다.

결과 확인

위에서 작성한 서버를 실행하면 성능 탭에서 다음과 같은 결과를 확인할 수 있다. csr_sourcemap

위 이미지에서 확인할 수 있듯이, SSR을 통해 HTML이 생성되고 파싱된 후, 화면에 즉시 렌더링되는 모습을 볼 수 있다. 특히 LCP와 FCP가 빠르게 측정된 점이 눈에 띈다.

또한, SSR로 생성된 HTML이 빠르게 파싱되어 콘텐츠가 즉시 화면에 나타나며 그후 browser.js를 다운로드된다. 이는 hydrateRoot가 동작하여 React 클라이언트 코드를 다운로드하고 실행하는 과정이다.

이처럼 서버 사이드에서 HTML이 렌더링된 후, 이 생성된 HTML을 기반으로 브라우저에서 JavaScript 핸들러와 이벤트가 연결되는 과정을 명확히 볼 수 있다.

#마무리

SSR이 항상 장점만을 제공하는 것은 아니다. 서버 리소스가 추가로 필요하며, 서버 설계에 대한 부담도 크다. 또한, 코드 설계를 제대로 하지 않으면 SSR의 이점을 충분히 활용하지 못할 수 있다. 특히, SSR 과정에서 API 통신이나 렌더링 시간이 길어지면, 사용자는 빈 화면을 보게 되어 CSR에 비해 사용자 경험이 나빠질 가능성도 있다.

따라서 SSR을 도입할 때는 요구사항, 기획, 그리고 다양한 환경과 상황을 종합적으로 고려해야 한다.

모던 리액트 Deep Dive 책을 여러번 읽고 직접 서버사이드 렌더링을 구현해보면서, 아직 완벽하진 않지만 SSR과 Hydrate에 대해 더욱 깊이 이해할 수 있었다.