React Hydration의 내부 동작 원리

우혁·6일 전
45

React

목록 보기
10/10
post-thumbnail

리액트에서 DOM트리가 구성되는 단계

1. Backing DOM Node가 필요한 각 Fiber 노드는 stateNode라는 속성을 통해 해당 DOM 노드를 참조한다.

💡 Backing DOM Node란?
React의 가상 DOM 구조에서 실제 브라우저의 DOM 요소와 연결되는 노드를 의미한다.

  • 실제 DOM과의 연결: React의 내부 구조와 실제 웹 페이지에 렌더링되는 DOM 요소 사이의 연결 고리이다.
  • HostComponents와의 관계: React Fiber 구조에서 HostComponents라고 불리는 특정 유형의 Fiber Node가 Backing DOM Node를 가진다.
  • stateNode 속성: HostComponents 타입의 Fiber Node는 stateNode라는 속성을 통해 실제 DOM 노드를 참조한다.

2. React는 Fiber Tree를 처리할 때 두 가지의 주요 단계를 사용한다.

beginWork(), completeWork() 이 과정은 깊이 우선 탐색 방식(DFS)으로 진행된다.

- Fiber Tree 처리 순서 4단계 요약

➔ 자신에 대한 beginWork() 수행

  • 현재 Fiber 노드에 대한 초기 작업을 수행한다.

➔ 자식에 대한 beginWork() 수행

  • 자식 노드가 있다면 해당 노드로 이동하여 beginWork()를 수행한다.

➔ 자신에 대한 completeWork() 수행

  • 모든 자식 노드의 처리가 끝나면 현재 노드의 작업을 완료한다.

➔ 형제 노드로 이동 또는 부모의 completeWork() 수행

  • 형제 노드가 있다면 해당 노드로 이동하여 beginWork() 수행
  • 형제 노드가 없다면 부모 노드로 돌아가 completeWork() 수행

3. completeWork() 단계

➔ 실제 DOM 노드 생성: 해당 Fiber 노드에 대응하는 실제 DOM 요소를 만든다.

➔ stateNode 설정: 생성된 DOM 노드를 Fiber 노드의 stateNode 속성에 연결한다.

➔ 자식 노드 추가: 이미 생성된 자식 DOM 노드들을 현재 DOM 노드에 추가한다.

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  switch (workInProgress.tag) {
    case HostComponent: {
      if (wasHydrated) { // hydration 관련
        ...
      } else {
        const rootContainerInstance = getRootHostContainer();
        const instance = createInstance( // DOM 생성
          type,
          newProps,
          rootContainerInstance,
          currentHostContext,
          workInProgress
        );
        appendAllChildren(instance, workInProgress, false, false); // 자식 추가
        workInProgress.stateNode = instance; // stateNode 연결
        ...
      }
    }
  }
}

React의 Fiber Tree에는 실제 DOM 노드가 필요한 노드와 그렇지 않은 노드가 모두 존재한다.

DOM 노드가 필요한 Fiber 노드: div, span 등의 HTML 요소를 나타내는 노드
DOM 노드가 필요 없는 Fiber 노드: Context, Fragment 등의 추상적인 React 컴포넌트

appendAllChildren() 함수

Fiber 트리를 순회하면서 실제 DOM 노드를 생성하고 연결한다.

1. Fiber Tree 순회: Fiber Tree를 깊이 우선 탐색 방식(DFS)으로 순회한다.

2. DOM 노드 필요 여부 확인: 각 Fiber 노드를 방문할 때마다 해당 노드가 실제로 DOM 노드를 필요로 하는지 체크한다.

3. DOM 노드 생성 및 연결: DOM 노드가 필요한 경우 새로운 DOM 노드를 생성하고 부모 DOM 노드에 추가한다. 만약 DOM 노드가 필요없는 경우 해당 노드를 건너 뛰고 자식 노드들을 계속 탐색한다.

4. 최상위 DOM 노드 찾기: DOM 노드가 필요없는 Fiber 노드를 만나면, 해당 노드의 자식들 중 DOM 노드가 필요한 최상위 노드를 찾아 현재의 DOM 트리에 추가한다.


Hydration이란

서버에서 렌더링된 정적 HTML을 클라이언트에서 동적인 React 컴포넌트로 전환하는 과정이다.

이 개념은 서버 사이드 렌더링(SSR)을 가능하게 한다.
1. 서버는 상호작용이 불가능한(Dehydrate) HTML을 생성하여 클라이언트에 전송한다.
2. 클라이언트에서 이 HTML에 Hydration을 적용하면 상호작용이 가능한 상태가 된다.

💡 Hydration은 기존 DOM 구조를 최대한 재사용하여 성능을 최적화한다!

  • 서버에서 렌더링된 내용과 클라이언트의 React 컴포넌트가 일치하면, React는 새로운 DOM을 생성하지 않고 기존 구조를 그대로 활용한다.

React에서 Hydration이 동작하는 방식

hydrateRoot()

react-dom/server를 통해 사전에 만들어진 HTML로 그려진 브라우저 DOM 노드 내부에 React 컴포넌트를 렌더링한다.

- 이 메서드는 Hydration 과정의 시작점이고 아래와 같은 과정을 거친다.

1. 서버에서 렌더링된 HTML의 루트 요소를 식별한다.

2. React 컴포넌트 트리를 생성하고, 기존 HTML 구조와 매칭시킨다.

3. 각 컴포넌트에 대한 이벤트 리스너를 연결하고 상태를 초기화한다.

4. 불일치가 발견되면 경고를 발생시키고, 필요한 경우 DOM을 업데이트한다.


새로운 DOM 트리를 만드는 프로세스와 서버에서 렌더링된 HTML이 있기 때문에 추가로 필요한 것은 다음과 같다.

  • 기존 DOM 트리에 커서를 둔다. 새로운 DOM 노드를 만들어야 할 때 이 커서와 비교한다.
    - 비교 결과가 일치하면 새 노드를 만들지 않고 기존 노드를 그대로 사용한다.
    - 이 재사용된 노드는 React Fiber 노드의 stateNode로 설정된다.

위에서 살펴봤듯이 React Fiber Tree의 각 노드는 beginWork(), completeWork() 두 번 순회되기 때문에 기존 DOM 트리의 커서도 이에 맞춰 동기화해줘야 한다.

beginWork()에서의 Hydration 과정

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  ...
  switch (workInProgress.tag) {
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
  }
  ...
}
  
function updateHostComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
) {
  pushHostContext(workInProgress);
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
  ....
  return workInProgress.child;
}

💡 HostComponent는 <div>, <span> 등 브라우저 네이티브 DOM 요소를 나타낸다.

tryToClaimNextHydratableInstance() 함수는 이름에서 알 수 있듯이, 서버에서 미리 렌더링된 HTML 페이지에 이미 존재하는 DOM 요소를 재사용하려고 시도한다.

function tryToClaimNextHydratableInstance(fiber: Fiber): void {
  if (!isHydrating) { // Hydration 중이 아니라면 return
    return;
  }

  // 현재 렌더링 환경에 대한 정보를 가져온다.
  const currentHostContext = getHostContext();
  // 현재 Fiber 노드가 Hydration에 적합한지 검사
  const shouldKeepWarning = validateHydratableInstance(
    fiber.type,
    fiber.pendingProps,
    currentHostContext,
  );

  // 다음 Hydration 대상 DOM 요소를 가져온다.
  // 이 요소를 현재 Fiber 노드와 연결하려고 시도한다.
  const nextInstance = nextHydratableInstance;

  // 더 이상 Hydrate 할 노드가 없거나 Hydration이 불가능한 경우 
  if (
    !nextInstance ||
    !tryHydrateInstance(fiber, nextInstance, currentHostContext)
  ) {
    if (shouldKeepWarning) { // Hydration이 실패했고, 경고를 유지하는 경우
      warnNonHydratedInstance(fiber, nextInstance); // Hydration 실패에 대한 경고 발생
    }
    throwOnHydrationMismatch(fiber); // Hydarion 불일치가 발생했을 때 오류 발생
  }
}

function tryHydrateInstance(
  fiber: Fiber,
  nextInstance: any,
  hostContext: HostContext,
) {
  // DOM 요소가 현재 Fiber 노드와 일치하여 Hydration이 가능한지 확인한다.
  const instance = canHydrateInstance(
    nextInstance,
    fiber.type,
    fiber.pendingProps,
    rootOrSingletonContext,
  );
    
  if (instance !== null) { // 가능하다면 DOM 요소를 Fiber 노드의 stateNode로 설정
    fiber.stateNode = (instance: Instance);
    // 현재 Fiber를 Hydration 부모로 설정
    // 다음 Hydration 대상을 현재 요소의 첫 번째 자식으로 설정(기존 DOM의 커서가 자식으로 이동)
    // rootOrSingletonContext 플래그를 false로 설정
    hydrationParentFiber = fiber;
    nextHydratableInstance = getFirstHydratableChild(instance);
    rootOrSingletonContext = false;
    return true; // 성공
  }
    
  return false; // 실패
}

completeWork()에서의 Hydration 과정

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
   switch (workInProgress.tag) {
    case HostComponent: {
      ...
      if (current !== null && workInProgress.stateNode != null) {
       ... // 기존 노드 업데이트 로직
      } else {
        ... // 새 노드 생성 로직
        
        // 이 노드가 Hydration 과정을 거쳤는지 확인
        const wasHydrated = popHydrationState(workInProgress);
        if (wasHydrated) { 
            prepareToHydrateHostInstance(workInProgress, currentHostContext);
        } else { // Hydration 되지 않은 경우
          const rootContainerInstance = getRootHostContainer();
          
          // 새로운 DOM 인스턴스 생성, 자식들을 추가, stateNode 설정
          const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
          );
          appendAllChildren(instance, workInProgress, false, false);
          workInProgress.stateNode = instance;
        }
      }
      return null;
    }
   }
  ...
}

Fiber 노드가 성공적으로 Hydartion이 된다면 prepareToHydrateHostInstance() 함수를 호출한다.

Hydration이 되지 않은 경우, React는 새로운 DOM 인스턴스를 생성하고, 자식 노드들을 추가한 후, 이를 Fiber 노드의 stateNode에 설정한다.

이 과정은 렌더링 단계의 일부로, 실제 DOM 업데이트는 이후의 커밋 단계에서 이루어진다.

여기서 prepareToHydrateHostInstance() 함수의 역할은 다음과 같다.

function prepareToHydrateHostInstance(
  fiber: Fiber,
  hostContext: HostContext,
): void {
  if (!supportsHydration) { // 현재 환경이 Hydration을 지원하는지 확인
    throw new Error(
      'Expected prepareToHydrateHostInstance() to never be called. ' +
        'This error is likely caused by a bug in React. Please file an issue.',
    );
  }

  // Fiber 노드에 연결된 실제 DOM 요소를 가져온다.
  const instance: Instance = fiber.stateNode;
  // 실제 Hydration 작업 수행(DOM 요소의 속성 업데이트, 이벤트 리스너 연결 등)
  const didHydrate = hydrateInstance(
    instance,
    fiber.type,
    fiber.memoizedProps,
    hostContext,
    fiber,
  );

  // Hydration이 실패하고, 안전성을 성능보다 우선시하는 설정이 켜져있다면 에러를 발생시킨다.
  if (!didHydrate && favorSafetyOverHydrationPerf) {
    throwOnHydrationMismatch(fiber);
  }
}

hydrateInstance() 내부에서 hydrateProperties()를 호출하여 Hydration 과정을 수행한다.

💡 prepareToHydrateHostInstance()는 실제 Hydration을 수행하는 함수이다.


서버에서 렌더링된 트리와 클라이언트에서 생성되는 트리를 동기화

function popHydrationState(fiber: Fiber): boolean {
  if (!supportsHydration) { // Hydration을 지원하지 않는 경우
    return false;
  }
  
  if (fiber !== hydrationParentFiber) { // 현재 Fiber가 Hydarion의 부모가 아닌 경우
    return false;
  }
  
  if (!isHydrating) { // 현재 Hydration 중이 아니면
    popToNextHostParent(fiber); // 다음 호스트 부모로 이동
    isHydrating = true; // Hydration 상태 true 설정
    return false; // false return
  }

  // 노드 정리 여부 결정
  let shouldClear = false;
  if (supportsSingletons) { // 싱글톤 지원 여부
    if (
      fiber.tag !== HostRoot &&
      fiber.tag !== HostSingleton &&
      !(
        fiber.tag === HostComponent &&
        (!shouldDeleteUnhydratedTailInstances(fiber.type) ||
          shouldSetTextContent(fiber.type, fiber.memoizedProps))
      )
    ) {
      shouldClear = true;
    }
  } else { // 싱글톤 지원 X
    if (
      fiber.tag !== HostRoot &&
      (fiber.tag !== HostComponent ||
        (shouldDeleteUnhydratedTailInstances(fiber.type) &&
          !shouldSetTextContent(fiber.type, fiber.memoizedProps)))
    ) {
      shouldClear = true;
    }
  }
  
  // 노드 정리 실행
  if (shouldClear) {
    const nextInstance = nextHydratableInstance;
    if (nextInstance) {
      warnIfUnhydratedTailNodes(fiber);
      throwOnHydrationMismatch(fiber);
    }
  }
  
  // Hydration 컨텍스트 업데이트
  popToNextHostParent(fiber); // 다음 호스트 부모로 이동
  if (fiber.tag === SuspenseComponent) { // Suspense 처리
    nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
  } else { // 다음 Hydartion 대상 설정
    nextHydratableInstance = hydrationParentFiber
      ? getNextHydratableSibling(fiber.stateNode)
      : null;
  }
  return true;
}

이 함수는 각 Fiber 노드에 대해 Hydration 상태를 관리하고, 필요한 경우 정리 작업을 수행한다.

또한 Hydration 과정에서 발생할 수 있는 불일치를 감지하고 처리한다. 이를 통해 서버에서 렌더링된 콘텐츠와 클라이언트에서 생성되는 React 트리를 효과적으로 동기화한다.

popToNextHostParent() 함수는 React의 컴포넌트 트리 현재 위치에서 위로 올라가면 가장 가까운 실제 DOM 요소와 연관된 컴포넌트를 찾는다.

Hydration 과정에서 현재 처리 중인 컴포넌트의 가장 가까운 DOM 관련 부모를 찾아주는 역할을 한다.


Hydration 에러 처리

Hydrtion 에러(Hydration Missmatch)는 주로 다음과 같은 상황에서 발생할 수 있다.

  • 서버와 클라이언트의 시간 차이로 인한 데이터 불일치
  • 동적 콘텐츠로 인한 서버와 클라이언트 렌더링 결과 차이
  • 브라우저 확장 프로그램이 DOM을 수정하는 경우

이러한 에러를 해결하기 위해 React는 아래와 같은 전략을 사용한다.

  • 경고 메세지를 통해 개발자에게 불일치를 알린다.
  • 클라이언트 사이드 렌더링으로 전환하여 불일치 해결
  • suppressHydrationWarning prop을 사용하여 의도적인 불일치 허용

💡 suppressHydrationWarning이란?
특정 엘리먼트에 대해 Hydration Missmatch 경고를 억제할 수 있다.

  • 불가피하게 서버와 클라이언트의 렌더링 결과가 다를 수밖에 없는 경우
  • 차이가 사용자 경험에 중요하지 않는 경우
function CurrentTime() {
  return (
    <div suppressHydrationWaring>
      현재 시간: {new Date().toLocaleTimeString()}
    </div>
  );
}

이 예시 코드는 서버에서 렌더링된 시간과 클라이언트 렌더링된 시간이 다르기 때문에 suppressHydrationWaring를 사용함으로써 이에 대한 경고를 억제할 수 있다.

  • suppressHydrationWaring는 해당 엘리먼트와 그 직계 자식에만 적용된다.
  • 남용하면 실제 문제를 숨길 수 있으므로 꼭 필요한 경우에만 사용해야 한다.
  • 가능하면 서버와 클라이언트의 렌더링 결과를 일치시키는 것이 더 좋은 해결책이다.

React 18과 Hydration

React 18에서 도입된 Concurrent(동시성) 렌더링을 Hydration 과정에도 영향을 미친다.

  • Suspnese와의 통합: Suspense 경계를 사용하여 Hydration 우선순위를 조정할 수 있다.

    • 중요한 컨텐츠는 Suspense로 감싸 먼저 로드하고, 덜 중요한 부분은 나중에 로드할 수 있다.

    • 이를 통해 Progressive(점진적) Hydration, Selective(선택적) Hydration이 가능하다.

  • Streaming SSR: 서버에서 HTML을 점진적으로 전송하고 클라이언트에서 부분적으로 Hydrate할 수 있다.

    • 전체 페이지가 로드되기 전에 일부 콘텐츠를 먼저 표시하고 상호작용할 수 있게 한다.


정리하기

1. Hydration 시작

  • hydrateRoot() 함수를 호출하여 서버에서 렌더링된 HTML과 React 컴포넌트 트리를 매칭시키는 과정을 시작한다.

2. Fiber Tree 순회

  • beginWork() 함수에서 각 Fiber 노드를 처리한다.

  • tryToClaimNextHydratableInstance() 함수를 통해 서버에서 렌더링된 DOM 요소와 현재 Fiber 노드를 연결하려고 시도한다.

3. DOM 요소 연결

  • tryHydrateInstance() 함수에서 DOM 요소가 현재 Fiber 노드와 일치하는지 확인하고, 일치하면 Fiber 노드의 stateNode로 설정한다.

4. Hydration 완료 및 정리

  • completeWork() 함수에서 각 Fiber 노드의 Hydartion 작업을 마무리한다.

  • prepareToHydrateHostInstance() 함수를 통해 실제 Hydration 작업(속성 업데이트, 이벤트 리스너 연결 등)을 수행한다.

5. 트리 동기화

  • popHydrationState() 함수에서 서버에서 렌더링된 트리와 클라이언트에서 생성되는 트리를 동기화한다.

  • 불일치를 감지하고 처리하며, 필요한 경우 정리 작업을 수행한다.

6. 다음 Hydartion 대상 설정

  • popToNextHostParent() 함수를 사용하여 다음 Hydration 대상을 설정한다.

이를 통해 초기 렌더링 성능을 최적화하고, 서버 사이드 렌더링의 이점을 극대화하면서도 완전한 인터렉티브 React 애플리케이션으로 전환을 할 수 있다.


🙃 도움이 되었던 자료들

hydrateRoot - React 공식 문서(v18.3.1)
How basic hydration works internally in React?
React의 hydration missmatch 알아보기
Rendering Pattern - Progressive Hydration
Rendering Pattern - Selective Hydration

profile
🏁

4개의 댓글

직접 분석하신건가요? 챗gpt 도움없이?

1개의 답글