들어가며

하이드레이션에 대해 알아보려면, 먼저 CSR과 SSR을 짧게 짚고 가야 합니다.


CSR (클라이언트 사이드 렌더링)

CSR은 렌더링이 클라이언트에서 발생하는 것을 말합니다. 순서는 다음과 같습니다.

  1. 유저가 웹사이트로 요청을 보냅니다.
  2. CDN이 HTML 파일과 JS로 접근할 수 있는 링크를 클라이언트로 보냅니다.
  3. 클라이언트는 HTML과 JS를 다운로드받습니다. 로딩되기 전까지는 아무것도 볼 수 없습니다.
  4. DOM이 그려지고, 다운로드가 완료된 JS가 실행됩니다.
  5. API가 있다면 요청 후 상호작용이 가능해집니다.

서버는 요청을 받으면 빈 HTML과 JS를 클라이언트에 넘겨주고, 클라이언트는 이를 바탕으로 렌더링을 합니다.

처음에 빈 HTML 파일을 받기 때문에 SEO에 적합하지 않고, HTML·CSS·스크립트를 한 번에 불러와 첫 로딩 속도가 느려 유저가 빈 화면을 오래 봐야 하는 단점이 있습니다. 대신 첫 로딩 이후에 발생하는 페이지 이동은 로딩이 짧고, 서버에 부하가 없다는 장점이 있습니다.


SSR (서버 사이드 렌더링)

이와 반대로 SSR은 렌더링이 서버에서 발생하는 것을 말합니다.

  1. 유저가 웹사이트로 요청을 보냅니다.
  2. 서버는 ‘Ready to Render’, 즉 즉시 렌더링 가능한 HTML 파일을 만듭니다.
  3. 클라이언트에 전달되는 순간 이미 렌더링 준비가 되어 있기 때문에 HTML이 즉시 렌더링됩니다.
  4. 클라이언트가 자바스크립트를 다운로드합니다.
  5. 브라우저가 자바스크립트 프레임워크 또는 코드를 실행합니다.

서버는 완성된 HTML을 클라이언트에게 전송하고, 유저는 빈 화면이 아닌 UI 콘텐츠를 볼 수 있습니다.

하지만 이 시점에는 아직 사용자 조작을 할 수 없는 상태입니다. 버튼 클릭 같은 조작을 했다면 그 동작을 기억해두었다가, JS까지 준비되면 기억하고 있던 조작이 실행됩니다.

CSR SSR
첫 화면 빈 화면 후 렌더링 완성된 HTML 즉시 표시
SEO 불리함 유리함
첫 로딩 느림 빠름
페이지 이동 빠름 상대적으로 느림
서버 부하 없음 있음

그래서 하이드레이션이란?

하이드레이션은 SSR에서 사용되는 개념입니다.

SSR의 경우 pre-rendering을 통해 완성된 HTML을 클라이언트에게 전달합니다. 이렇게 서버에서 렌더링된 정적 페이지를 클라이언트에게 보내고, 리액트는 번들링된 JS 코드를 클라이언트에게 보냅니다.

클라이언트는 전달받은 HTML과 JS 코드를 매칭하는 하이드레이션을 수행합니다. 먼저 도착한 HTML DOM 요소 위에서 한 번 더 렌더링하면서 각각 자기 자리를 찾아가며 매칭되는 것입니다. 하이드레이션 후에는 클릭 같은 이벤트나 모듈들이 적용되어 사용자 조작이 가능해집니다.

하이드레이션은 정적 HTML 요소에 이벤트 핸들러를 첨부하여 동적 웹 페이지로 변환하는 기술입니다.

SSR 덕분에 사용자는 UI를 먼저 볼 수 있고, 하이드레이션 덕분에 JS 코드가 매칭되어 이후에 사용자 조작이 가능한 것입니다.


여기서 잠깐, TTV와 TTI란?

  • TTV(Time To View): 사용자가 브라우저의 내용을 볼 수 있는 시점
  • TTI(Time To Interact): 사용자가 브라우저를 인터랙션 할 수 있는 시점

예를 들어 Next.js는 SSR이므로 pre-render가 되면 TTV가 가능해지고, 하이드레이션이 끝나면 TTI가 가능해집니다.

하지만 리액트(CSR)의 경우 HTML 로딩과 JS 로딩이 모두 끝나야 UI가 나타나므로, TTV와 TTI가 사실상 같은 시점에 도달합니다. SSR이 벌어들이는 것은 정확히 이 두 시점 사이의 간격입니다.


SSR과 하이드레이션의 문제점

여기까지 보면 SSR은 SEO에 좋고, 유저에게 빈 화면을 보여주지 않아 사용자 경험을 좋게 만들 수 있습니다. 그리고 하이드레이션을 이용해 그 화면에 이벤트 핸들러를 붙여 동적으로 동작하게 해줍니다.

두 번 렌더링하는 것이 비효율적으로 보일 수 있지만, 자바스크립트가 빠진 UI를 유저에게 빠르게 로딩시켜주는 장점이 있고, 두 번째 렌더에서는 하이드레이션만 수행하고 UI Paint는 하지 않기 때문에 꽤 좋아 보입니다.

하지만 이런 SSR과 하이드레이션에도 문제점이 있습니다.

1. 보여주기 전에 서버에서 다 가져와야 하는 문제

API에서 데이터를 받아 UI를 구성한 메인 페이지의 댓글을 사용자에게 빠르게 보여주고 싶다고 해보겠습니다. CSR은 자바스크립트가 완전히 불러와지기 전까지 빈 화면을 보여주니 SSR을 해야 합니다.

이 경우 HTML에 렌더할 때 서버에서 컴포넌트에 필요한 데이터를 모두 준비해놔야 합니다. 결국 댓글을 위한 데이터를 불러오고 렌더하기까지, 나머지 HTML을 전송하는 것까지 지연됩니다.

2. 하이드레이션을 하려면 모두 불러와야 하는 문제

메인 페이지에는 댓글 외에도 네비게이션 바, 사이드바 등에 사용된 자바스크립트 코드가 있을 것입니다. 댓글에 대한 코드가 불러와지기 전까지 다른 부분들도 하이드레이션을 할 수 없게 됩니다.

3. 상호작용 전에 모두 하이드레이션을 해야 하는 문제

한 번 하이드레이션이 시작되면 전체 트리가 완전히 하이드레이션되기 전까지 유저는 네비게이션 바, 사이드바, 본문과 상호작용할 수 없습니다.

특히 네비게이션이 문제입니다. 유저는 이 페이지를 떠나고 싶은데, 클라이언트가 열심히 하이드레이션을 진행하고 있기 때문에 더 이상 보고 싶지 않은 페이지에 남아 있어야 합니다.


해결 방법은?

위의 문제점을 해결하기 위해 React 18에서 Suspense가 업그레이드되어 나왔습니다.

문제의 원인은 이 파이프라인에 있습니다.

데이터 가져오기(서버) → HTML로 렌더링(서버) → 코드 불러오기(클라이언트) → 하이드레이션(클라이언트)

이 과정에서 어떤 단계도 이전 단계가 전체 애플리케이션에 대해 끝나기 전까지는 시작되지 못합니다. 이것이 위 세 가지 문제를 유발하는 원인입니다.

해결책은 작업을 쪼개서, 전체 애플리케이션이 아니라 각각의 부분들에 대해 이 단계들을 수행할 수 있게 하는 것입니다.

1번 해결 — HTML 스트리밍

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

댓글 부분인 Comments 컴포넌트를 Suspense로 감싸주면, 리액트는 댓글 부분을 기다리지 않고 나머지 페이지에 대해 HTML을 스트리밍합니다. Comments 대신 placeholder에 해당하는 Spinner를 보내줍니다.

<main>
  <nav>
    <a href="/">Home</a>
  </nav>
  <aside>
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <img width="400" src="spinner.gif" alt="Loading..." />
  </section>
</main>

Comments에 대한 HTML이 없는 것을 볼 수 있습니다.

서버에서 댓글 데이터가 준비되면, 리액트는 동일한 스트림에 HTML을 추가로 보내고, 그 HTML을 “올바른 장소”에 위치시키기 위한 작은 인라인 script 태그를 함께 보내줍니다.

<div hidden id="comments">
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // 실제 구현은 이보다 조금 더 복잡합니다
  document
    .getElementById('comments-spinner')
    .replaceChildren(document.getElementById('comments'))
</script>

결과적으로 클라이언트에서 리액트 자체가 불러와지기도 전에 늦게 도착한 댓글 HTML이 제자리에 들어오게 됩니다.

2번 해결 — 코드 스플리팅

댓글 위젯을 위한 자바스크립트 코드가 로딩되기 전에는 애플리케이션을 하이드레이션할 수 없다는 문제는, 코드 스플리팅으로 해결할 수 있습니다.

lazy import를 사용하면 메인 번들에서 분리시킬 수 있습니다.

import { lazy, Suspense } from 'react'

const Comments = lazy(() => import('./Comments.js'))

// ...
;<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

이제 댓글에 해당하는 코드가 아직 불러와지지 않아도 다른 부분들이 먼저 하이드레이션되고, 그 후에 Comments도 하이드레이션됩니다.

3번 해결 — 선택적 하이드레이션

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

NavBar와 Post는 하이드레이션이 되었고, Sidebar와 Comments는 HTML 스트리밍만 된 상태라고 가정해보겠습니다.

이제 사이드바와 댓글 코드를 가진 번들이 불러와집니다. 리액트는 둘 모두를 하이드레이션하는데, 트리상에서 더 먼저 발견되는 사이드바부터 시작합니다.

하지만 이때 유저가 댓글 부분을 클릭한다면, 리액트는 해당 클릭을 기록해둔 후 댓글 항목의 하이드레이션에 우선순위를 부여합니다.

먼저 수행하려 했던 사이드바의 하이드레이션을 미루고, 댓글에 대한 하이드레이션을 먼저 하는 것입니다.

이제 리액트는 최대한 빨리 모든 것을 하이드레이션하되, 유저의 상호작용을 기반으로 화면상에서 가장 급한 부분에 우선순위를 부여합니다.


정리

  • CSR은 첫 화면이 느리고 SEO에 불리하지만 이동이 빠릅니다. SSR은 그 반대입니다.
  • 하이드레이션은 서버가 보낸 정적 HTML에 이벤트 핸들러를 붙여 동적 페이지로 만드는 과정입니다.
  • SSR이 벌어주는 것은 TTV와 TTI 사이의 간격입니다. 화면을 먼저 보여주고 조작 가능 시점을 뒤로 미루는 것입니다.
  • 기존 SSR의 세 가지 문제는 전부 “전체가 끝나야 다음 단계가 시작된다” 는 하나의 원인에서 나옵니다.
  • React 18의 Suspense는 그 파이프라인을 부분 단위로 쪼개서 데이터 로딩, 코드 로딩, 하이드레이션을 각각 독립적으로 진행할 수 있게 만들었습니다.