리액트서버 API와 hydration이 뭔데?

2025. 2. 20. 18:08프론트엔드

WHY?

 

요새 대체 SSR과 Hydration이 뭔지는 알고는 있지만 정확하게 어떻게 돌아가는지 몰라서 이 글을 작성하게 됐습니다.

React Server API

리액트에서는 서버 환경(node) 에서 사용할 수 있는 API가 존재합니다. 이를 통해 서버환경에서 리액트를 렌더링할 수 있습니다.

왜 이게 나왔을까요 ??
- 클라이언트에서 모든 렌더링을 해결하는(CSR)은 여러 단점이 존재했습니다.
- SEO, 보안문제, 더 늦은 초기 로딩 속도 등등 ..
- 따라서 서버에서 리액트를 서버렌더링을 해줘야하는 상황이 필요해져서 그렇습니다.

 

그렇다면 어떤 API들이 있을까요 ?

renderToString

React Tree를 string으로 변환해줍니다.

import { renderToString } from 'react-dom/server';

const html = renderToString(<App />);

 

이제 리액트 서비스를 서버에서 HTML로 만들어서 처리할 수 있게 되었습니다 !


물론 프론트개발자 입장에서 서버를 구축하는것은 매우 큰 일이겠지만 사용자가 더 좋은 서비스를 이용할 수 있으면 더할나위 없이 좋겠죠!!

그런데 조금 문제가 있어보입니다. 엄청 큰 HTML은 서버에서 다르기엔 조금 부하가 있어보입니다. 이는 어떻게 처리할까요 ? 요즘 리액트 풀스택 프레임워크는 다들 아래의 기능을 사용합니다.

renderToReadableStream

renderToReadableStream React tree를 Readable Web Stream. 로 변환해줍니다.

const stream = await renderToReadableStream(reactNode, options?)
import { renderToReadableStream } from 'react-dom/server';

async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/main.js']
  });
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

 

여기서 Stream 이란 큰 데이터를 작은 단위인 chunk로 쪼개서 서빙할 수 있는 node의 자료구조 입니다.

 

그렇다면 장점은 ???

이로서 저희 서비스는 다음과 같은 장점을 얻게 되었습니다.

 

  1. 초기 로딩 성능 향상됩니다. 서버에서 React 컴포넌트를 HTML 문자열로 렌더링하여 클라이언트에 전송함으로써, 브라우저가 JavaScript를 다운로드하고 실행하기 전에 초기 콘텐츠를 빠르게 표시할 수 있습니다.

  1. 검색 엔진에(SEO) 친화적입니다 :
서버에서 렌더링된 HTML은 검색 엔진 크롤러가 쉽게 읽을 수 있어, 웹 페이지의 검색 엔진 노출도를 높입니다.


3. 사용자 경험 향상을 향상시킵니다: 사용자는 초기 콘텐츠를 더 빨리 볼 수 있어, 특히 느린 네트워크 환경에서 사용자 참여도를 유지하는 데 도움이 됩니다. (참고로 이벤트루프 글을 보셨으면 아시겠지만 초기 스크립트(js파일)가 없으면 렌더링이 더 빠르게 된답니다. 다들 아시죠? )

 

그렇다면 단점은???

이로서, 저희 서비스는 다음과 같은 단점을 얻게 되었습니다.

1. 서버에서는 브라우저 API를 사용할 수 없습니다. 즉 window와 document, localhost를 사용하지 못합니다. 프론트엔드 개발자들은 이 때문에 골머리가 아플 수도 있습니다.

 

2. 서버에서는 컴포넌트 상태와, 생명주기를 사용할 수 없습니다. 즉 클라이언트에서 로딩되기 전까지 훅이나 이벤트가 동작하지 않습니다.

 

왜 냐구요 ? react server API 이것들의 결과물엔 JS가 없어요 단지 HTML만 존재할 뿐입니다. 그러면 어떻게 클라이언트에서 JS 파일을 읽으란 말입니까 ?

이 외에도 많은 react/server API가 존재하지만 중요한 두 가지만 소개하고 넘어가겠습니다.


그것을 해결해주는것이 바로 Hydration입니다.

Hydration 이란?

 

목마른 우리 String 또는 (ReadableStream)에게 물(JS)을 줘서 목마름을 해소 시켜 주는 과정이 되겠습니다.

 

그런데 이 과정이 그렇게 호락호락하지 않습니다. 말만 들어도 어렵죠 HTML에 어떻게 JS를 넣는다는거야 ..

 

리액트 공식문서에서 ..

hydrateRoot에 전달한 React 트리는 서버에서 만들었던 React 트리 결과물과 동일해야 합니다.

  • 이는 사용자 경험을 위해서 중요합니다. 사용자는 서버에서 만들어진 HTML을 자바스크립트 코드가 로드될 때까지 둘러보게 됩니다. 앱의 로딩을 더 빠르게 하기 위해 서버는 일종의 신기루로서 React 결과물인 HTML 스냅샷을 만들어 보여줍니다. 갑자기 다른 컨텐츠를 보여주게 되면 신기루가 깨져버리게 됩니다. 이런 이유로 서버에서 렌더링한 결과물과 클라이언트에서 최초로 렌더링한 결과물이 같아야 합니다.
  • React는 Hydration 오류에서 복구됩니다, 하지만 다른 버그들과 같이 반드시 고쳐줘야 합니다. 가장 나은 경우는 그저 느려지기만 할 뿐이지만, 최악의 경우엔 이벤트 핸들러가 다른 요소Element에 붙어버립니다.
정리하자면 hydration이 일치하지 않으면 !!!
  1. 서버에서 사전으로 렌더링 된 결과물과 리액트에서 hydration한 결과물이 다르면 신기루가 깨져요. 깜빡이면서 다른 화면이 렌더링 되겠죠 ? (즉 전혀 다른 초기 화면이 렌더링 될것이다.)
  2. 최악의 경우 이벤트 핸들러가 다른 element에 붙을 수도 있습니다.
  3. 불일치가 일어나면 그냥 CSR로 바뀌어 버립니다. ( 서버를 사용하는 이유가 사라집니다. )

hydration 코드 까보기

지금 부터 두루뭉실한 hydration 개념을 코드로 직접 hydration해 봅시다. ( ㅋㅋ )

 

Remix를 이용해서 리액트 풀스택 프레임워크 프로젝트를 생성해봅니다. 그러면 아래와 같은 파일을 볼 수 있습니다. 

 

 이 파일은 클라이언트 도입부를 담당하는 파일인데요, 코드를 살펴봅시다.

remix : entry.client.ts

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <RemixBrowser />
    </StrictMode>
  );
});

startTransition : 파이버에 우선순위가 낮은 트랜지션(콜백함수)을 추가한다.
hydrateRoot : 하이드레이션 루트를 만든다. 여기서는 document 객체와 RemixBrowser를 인자로 넣었다.

 

hydrateRoot 함수는 리액트돔 패키지에 있습니다.

바로 여기 : https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOMClient.js

 

여기서 hydrateRoot 코드를 까보도록 하지요.

 

packages/react-dom/src/client/ReactDOMRoot.js

export function hydrateRoot(
  container: Document | Element,
  initialChildren: ReactNodeList,
  options?: HydrateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }

  // 하이드레이션 초기화 시, DEV 모드 에러 발생 시, 에러를 발생시킵니다.
  warnIfReactDOMContainerInDEV(container);
  if (__DEV__) {
    if (initialChildren === undefined) {
      console.error(
        'Must provide initial children as second argument to hydrateRoot. ' +
          'Example usage: hydrateRoot(domContainer, <App />)',
      );
    }
  }

- // hydration option 처리
- // ...중략

- // hydration root를 만든다.
- // 이때 서버에서 만든 string과 HTML을 비교한다.
  const root = createHydrationContainer(
    initialChildren,
    null,
    container,
    ConcurrentRoot,
    hydrationCallbacks,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    transitionCallbacks,
    formState,
  );

- // 이 하이드레이션을 root로 설정합니다.
  markContainerAsRoot(root.current, container);

- // 모든 이벤트 리스너 부착
  listenToAllSupportedEvents(container);

- // HydrationRoot 객체 리턴
  return new ReactDOMHydrationRoot(root);
}

간단하죠 ? 리액트 측에서도 추상화를 아주 잘 해놔서 읽기가 매우 쉽습니다.

 

hydrationRoot 함수

  1. hydrationRoot의 다양한 콜백 옵션을 처리합니다.
  2. 리액트컴포넌트와 초기 HTML (서버에서 렌더링한 결과물)을 이용해서 루트를 생성합니다.
  3. 리액트 루트 컴포넌트에 이밴트 리스너를 부착합니다.
  4. hydrationRoot를 반환합니다.
    startTrasition 함수
  5. hydrationRoot를 리액트 파이버 작업단위인 트랜지션에 넣습니다.

리액트컴포넌트와 초기 HTML (서버에서 렌더링한 결과물)을 이용해서 루트를 생성합니다.

그렇다면 createHydrationContainer는 어떻게 동작할까요? 이는 reconciler 패키지에 있습니다.

 

packages/react-reconciler/src/ReactFiberReconciler.js

export function createHydrationContainer(
  initialChildren: ReactNodeList,
  callback: ?Function,
  containerInfo: Container,
  tag: RootTag,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onUncaughtError:  => void,
  onCaughtError: => void,
  onRecoverableError:  => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
  formState: ReactFormState<any, any> | null,
): OpaqueRoot {
  const hydrate = true;
- // 리액트 Fiber 루트를 생성한다.
  const root = createFiberRoot(
    containerInfo,
    tag,
    hydrate,
    initialChildren,
    hydrationCallbacks,
    isStrictMode,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    transitionCallbacks,
    formState,
  );

-  // 루트와 서브트리를 연결한다.
  root.context = getContextForSubtree(null);

- // fiber 노드의 current는 fiber 이다.
- // 이는 createHostRootFiber로 정의하는데 tag와 mode로 구분한다.
- // hydration Root의 경우 ConcurrentRoot = 1;
  const current = root.current;

- // 업데이트의 우선순위를 정한다. (lane)
- // suspense인 경우 렌더링 우선순위가 밀려난다. (대신 fallback 렌더)
  let lane = requestUpdateLane(current);
  if (enableHydrationLaneScheduling) {
    lane = getBumpedLaneForHydrationByLane(lane);
  }

- // 업데이트의 객체를 만들고 콜백 옵션을 넣는다.
  const update = createUpdate(lane);
  update.callback =
    callback !== undefined && callback !== null ? callback : null;

- // root 업데이트를 진행한다.
  enqueueUpdate(current, update, lane);

- // 초기 하이드레이션에 불필요한 많은 단계를 건너뛰어 최적화시킨다.
- // createRoot에서는 모든 렌더링(초기, 후속)이 동일하다
- // 하지만 hydrationRoot에서는 초기 children과 후속 업데이트를 구별해야한다.
- // scheduleInitialHydrationOnRoot이 그 역할을 해준다.
  scheduleInitialHydrationOnRoot(root, lane);

  return root;
}
  • hydrationRoot의 정체는 파이버 루트 ! 였습니다만, 여기서 fiberRoot와의 차이점은
  • 초기 하이드레이션 결과물을 띄워주기위해 scheduleInitialHydrationOnRoot를 사용합니다.

여기서 말하는 HTML 스냅샷을 만들어 보여줍니다. << 이것의 역할을 하게 됩니다.

 

그리고 파이버의 업데이트 큐에 넣게 되면 fiber에서 - beginWork()가 실행됩니다.

react/packages/react-reconciler/src/ReactFiberBeginWork.js

beginWork() {

// current를 업데이트 시키는 과정
// 하이드레이션을 하는 과정에서 dom에 id를 부착
//...
.
.
.
// 조건별로 업데이트하는 로직 시작
//...
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
//...
}

fiber가 노드를 순회하면서 노드 컴포넌트 타입이HostComponent이면 updateHostComponent가 실행됩니다.

function updateHostComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }

 //.. 중략
}

( fiber - beginWork 중 ... )
그리고 여기서 current(fiber)가 없으면 tryToClaimNextHydratableInstance가 실행됩니다.

 

여기서 업데이트를 하는 과정 중 hydration의 불일치를 확인하게 됩니다.

react/packages/react-reconciler/src/ReactFiberHydrationContext.js

// 전역으로 관리되는 변수들
let hydrationParentFiber: null | Fiber = null;
let nextHydratableInstance: null | HydratableInstance = null;
let isHydrating: boolean = false;
let hydrationDiffRootDEV: null | HydrationDiffNode = null;
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
  if (!isHydrating) {
    return;
  }

  // 현재 호스트 컨텍스트를 가져옴
  const currentHostContext = getHostContext();
- // fiber의 타입과 props가 hydration에 적합한지 검증
  const shouldKeepWarning = validateHydratableInstance(
    fiber.type,
    fiber.pendingProps,
    currentHostContext,
  );

- // 다음으로 hydration할 DOM 인스턴스를 가져옴
  const nextInstance = nextHydratableInstance;
  if (
    !nextInstance ||
    // `tryHydrateInstance`를 호출하여 실제 hydration을 시도하는 함수
    !tryHydrateInstance(fiber, nextInstance, currentHostContext)
  ) {
    if (shouldKeepWarning) {
      warnNonHydratedInstance(fiber, nextInstance);
    }
    // 미스매치 에러가 있으면 경고를 발생시킵니다.
    throwOnHydrationMismatch(fiber);
  }
}
function tryHydrateInstance(
  fiber: Fiber,
  nextInstance: any,
  hostContext: HostContext,
) {
  // 다음 하이드레이션을 하기위해 인스턴스를 생성
  const instance = canHydrateInstance(
    nextInstance,
    fiber.type,
    fiber.pendingProps,
    rootOrSingletonContext,
  );
  if (instance !== null) {
    // fiber - stateNode에 다음 인스턴스 삽입
    fiber.stateNode = (instance: Instance);

    // 개발 모드일 때, 하이드레이션 불일치를 확인한다.
    // 차이점이 있으면 warnings를 날린다.
    if (__DEV__) {
      if (!didSuspendOrErrorDEV) {
        const differences = diffHydratedPropsForDevWarnings(
          instance,
          fiber.type,
          fiber.pendingProps,
          hostContext,
        );
        if (differences !== null) {
          const diffNode = buildHydrationDiffNode(fiber, 0);
          diffNode.serverProps = differences;
        }
      }
    }

    // hydrationParentFiber를 업데이트 한다. (파이버가 채워지는 과정)
    hydrationParentFiber = fiber;
    // nextHydratableInstance 업데이트 한다. (다음 파이버 인스턴스를 넣음)
    nextHydratableInstance = getFirstHydratableChild(instance);
    rootOrSingletonContext = false;
    // 성공적으로 완료되면 true를 리턴한다.
    return true;
  }
  return false;
}

여기까지 하이드레이션을 하는 과정과 불일치를 검사하는 과정을 알아봤습니다.

listenToAllSupportedEvents

그렇다면 리액트가 이벤트를 부착하는 과정은 어떻게 될까요 ?

여기서 새로 알게된 사실은 리액트에서 이벤트를 넣는 방식이 기존의 HTML가 다르다는것입니다.

 

packages/react-dom-bindings/src/events/DOMPluginEventSystem.js

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {

. // rootContainerElement 마킹이 되어 있지 않으면
  if (!(rootContainerElement: any)[listeningMarker]) {
    // 최적화를 위해서 마킹
    (rootContainerElement: any)[listeningMarker] = true;
    // 모든 네이티브 이벤트를 부착한다.
    allNativeEvents.forEach(domEventName => {
      // selectionchange은
      // 버블링이 일어나지 않기 때문에 document 환경에서 부착한다.
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });
    const ownerDocument =
      (rootContainerElement: any).nodeType === DOCUMENT_NODE
        ? rootContainerElement
        : (rootContainerElement: any).ownerDocument;
    if (ownerDocument !== null) {
      // selectionchange 또한 중복처리를 해준다.
      // but it is attached to the document.
      if (!(ownerDocument: any)[listeningMarker]) {
        (ownerDocument: any)[listeningMarker] = true;
        listenToNativeEvent('selectionchange', false, ownerDocument);
      }
    }
  }
}

리액트에서 이벤트를 다루는 방법

  • rootContainerElement에 allNativeEvents (미리지정된 이벤트 배열) 을 반복하면서 부착시켜 이벤트를 위임시킨다.
  • 리액트에서는 직접 node element에 이벤트를 붙이는게 아니라 루트 컴포넌트에서 모든 이벤트를 위임해서 처리한다.
  • 이를 통해 리액트 컴포넌트 트리 수준으로 이벤트가 격리되어 이벤트 버블링을 막을 수 있다.

즉 리액트에서는 루트 컴포넌트에서 모든 네이티브 이벤트를 위임 처리해서 부착시켜 왔던것입니다. ( 진짜 몰랐다. )

 

그러면 어떻게 리액트에서 이벤트를 감지할 수 있을까? 는 이 글로 ..
https://velog.io/@pakxe/React-How-to-Manage-Events-Globally-in-React

결론

  1. react 에는 server side rendering을 지원하기 위해 reactDom/server API를 제공한다.
  2. 하지만 이 API들은 단순 문자열이나 문자열 stream을 제공하기 때문에 react에서는 이를 hydration 하는 과정을 거친다.
  3. hydrationRoot는 root 생성 (HTML과 컴포넌트 비교) - 이벤트 부착을 하는 과정으로 일어난다.
  4. createRoot와의 차이점은 초기렌더링을 비교해야하기 때문에 scheduleInitialHydrationOnRoot를 통해 스케줄링을 시켜놓는다.
  5. 서버에서 렌더링된 HTML과 클라이언트에서 fiber가 렌더링 하는 결과물이 불일치 하면 다음과 같은 단점이 있다.
    1. UI가 다르기 때문에 화면이 깜빡이게 된다.
    2. 리액트가 루트에서 이벤트를 위임하는 과정에서 불일치가 발생하면 다른 요소에 이벤트가 부착될 가능성이 있다.
    3. 만약 불일치가 발생한다면 개발환경에서는 warning 오류가 발생하고, 프로덕션에서는 CSR(클라이언트 사이드 렌더링)로 전환된다.
  6. 리액트에서는 이벤트를 루트 컴포넌트에서 위임시켜서 attach 한다. 이는 하이드레이션이 끝나고 발생한다.
  7. 따라서 하이드레이션 불일치가 발생하면 이벤트가 요상한 엘리먼트로 갈 가능성이 있다는것이다.

참고

https://github.com/facebook/react
https://ko.react.dev/reference/react-dom/client/hydrateRoot
https://velog.io/@njt6419/React-Hydration-%EA%B9%8A%EA%B2%8C-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0
https://velog.io/@pakxe/React-How-to-Manage-Events-Globally-in-React