Suspense가 Hydration중에 동작하는 방식

우혁·2024년 10월 28일
35

React

목록 보기
11/19
post-thumbnail

Suspense가 동작하는 방식

Suspense 톺아보기 🔎 요약하기

💡 Suspense의 기본 구조

  • DidCapture 플래그: Suspense가 현재 일시 중단(Suspended) 상태인지 아닌지를 나타낸다.
  • Offscreen 컴포넌트: Suspense의 자식 요소들을 감싸는 특별한 컴포넌트

일시 중단되지 않은 상태

  • 일반적인 상황에서는 Offscreen 컴포넌트 내부의 내용이 렌더링된다.

일시 중단된 상태

  • Suspense가 일시 중단되면, Offscreen 내부의 내용 대신 Fallback이 렌더링된다.

  • 여기서 중요한 점은 Offscreen 컴포넌트가 Fiber 트리에서 제거되지 않고 유지된다는 점이다.
    ➔ 그 이유는 상태를 보존하기 위함이다.

Promise 처리 과정

Promise(thenable)가 발생하면 다음과 같은 과정을 거친다.

  1. React는 Fiber 트리에서 가장 가까운 상위 Suspense 컴포넌트를 찾는다.

  2. 해당 Suspense 컴포넌트에 ShouldCapture 표시를 한다.

  3. 언와이딩 과정(완료 과정)동안 이미 설정된 ShouldCapture플래그를 확인하고 처리한다.

  4. 이 시점에서 React는 부모 노드로 올라가지 않고, Suspense 컴포넌트에서 다시 reconciliation(재조정) 과정을 시작한다.

  5. ShouldCapture에서 DidCapture 플래그로 변경되고, Suspense는 Fallback을 렌더링한다.
    원래의 자식 컴포넌트들은 Fiber 트리에서 제거되지 않고 Offscreen 컴포넌트 내부에 유지되어 상태를 보존한다.

  6. React는 throw된 Promise가 resolve되면, React는 해당 Suspense 경계에서 렌더링을 다시 시도하여 Offscreen에 유지되었던 원래의 자식 컴포넌트들을 다시 렌더링한다.

const fetcher = createFetcher("완료", 8000)

function AsyncComponent() {
  return fetcher.fetch()
}

function App() {
  return <>
    <span>결과 값: </span>
    <Suspense fallback={<span>loading...</span>}>
      <AsyncComponent/>
    </Suspense>
  </>
}


Suspense의 서버 사이드 렌더링(SSR)

Suspense는 React의 특별한 컴포넌트로 일반적인 HTML 요소와 달리 직접적으로 대응되는 HTML 태그가 없다.

그래서 React는 Suspense를 HTML로 직렬화할 때 특별한 방법을 사용한다.

/* React의 SSR에서 Suspense 경계는 HTML 주석으로 인코딩된다. */

// 완료된 Suspense 경계의 시작을 나타낸다.
// 자식 컴포넌트가 모두 로드되어 정상적으로 렌더링된 상태이다.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');

// 대기 중인 Suspense 경계의 시작 부분이다.
// <template> 태그의 사작과 함께 사용되며 아직 렌더링되지 않은 컨텐츠를 위한 것이다.
// Progressive(점진적) Hydration과 관련이 있다.
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
  '<!--$?--><template id="',
);

// 대기 중인 Suspense 경계의 <template> 태그를 닫는다.
const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>');

// 클라이언트에서 렌더링될 Suspense 경계의 시작을 나타낸다.
// 이는 서버에서 fallback이 렌더링된 경우를 의미한다.
const startClientRenderedSuspenseBoundary =
  stringToPrecomputedChunk('<!--$!-->');

// 모든 유형은 Suspense 경계의 끝을 나타낸다.
const endSuspenseBoundary = stringToPrecomputedChunk('<!--/$-->');

Suspense 컴포넌트는 서버 사이드 렌더링 시 3가지 상태로 렌더링될 수 있다.

  1. 완료된 상태(<!--$-->): 모든 자식 컴포넌트가 성공적으로 렌더링된 경우

  2. 대기 중인 상태(<!--$?--><templaate id="...">): 자식 컴포넌트의 데이터가 아직 준비되지 않은 경우

  3. 클라이언트 렌더링 상태(<!--$!-->): 서버에서 fallback을 렌더링한 경우


Suspense in Hydration의 내부 동작 원리

첫 번째 렌더링 패스(mount 단계)

updateSuspenseComponent 함수에서 Hydartion에 대한 분기를 찾아볼 수 있다.

function updateSuspenseComponent(
  current: null | Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // another conde...
  if (current === null) { // 초기 마운트
    if (getIsHydrating()) { // Hydration중인지 확인
      // fallback을 보여줄지 여부에 따라 적절한 Suspense 핸들러 설정
      if (showFallback) {
        pushPrimaryTreeSuspenseHandler(workInProgress);
      } else {
        pushFallbackTreeSuspenseHandler(workInProgress);
      }
      // 서버에서 렌더링된 Suspense 인스턴스를 찾아 재사용하려고 시도
      tryToClaimNextHydratableSuspenseInstance(workInProgress);
      
      const suspenseState: null | SuspenseState = workInProgress.memoizedState;
      if (suspenseState !== null) { // Hydration 상태를 확인하고 
        const dehydrated = suspenseState.dehydrated;
        if (dehydrated !== null) { // 성공적으로 Hydration이 된 경우 컴포넌트를 마운트
          return mountDehydratedSuspenseComponent(
            workInProgress,
            dehydrated,
            renderLanes,
          );
        }
      }
      // Hydration 실패 처리
      popSuspenseHandler(workInProgress);
    }
  }
}

이 함수는 Suspense 컴포넌트의 초기 마운트 과정을 처리한다.


기존 DOM의 Suspense DOM 노드(주석 노드) 찾기

function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
  if (!isHydrating) { // Hydration 중이 아니라면 return
    return; 
  }
  
  const nextInstance = nextHydratableInstance;
  // 핵심 로직은 tryHydrateSuspense 함수이다.
  if (!nextInstance || !tryHydrateSuspense(fiber, nextInstance)) {
    // Hydration 불일치 시 에러 발생
    warnNonHydratedInstance(fiber, nextInstance);
    throwOnHydrationMismatch(fiber);
  }
}

// Suspense 컴포넌트를 Hydration(서버에서 렌더링된 HTML을 React 컴포넌트로 활성화)한다.
function tryHydrateSuspense(fiber: Fiber, nextInstance: any) {
  // DOM 인스턴스가 Suspense 컴포넌트로 Hydration이 될 수 있는지 확인
  // canHydrateSuspenseInstance 함수는 DOM 인스턴스가 주석 노드인지를 확인한다.
  const suspenseInstance = canHydrateSuspenseInstance(
    nextInstance,
    rootOrSingletonContext,
  );
  
  // Hydartion 가능한 경우
  if (suspenseInstance !== null) {
    // Suspense 상태 생성
    const suspenseState: SuspenseState = {
      dehydrated: suspenseInstance,
      treeContext: getSuspendedTreeContext(),
      retryLane: OffscreenLane, // 재시도할 때 사용할 레인
    };
    fiber.memoizedState = suspenseState; // Fiber 노드의 memoizedState로 설정
    // dehydrated 상태의 Fragment를 나타내는 Fiber 노드를 생성
    const dehydratedFragment =
      createFiberFromDehydratedFragment(suspenseInstance);
    dehydratedFragment.return = fiber;
    // Suspense Fiber의 자식으로 설정
    // 나중에 호스트 형제 노드를 찾거나 노드를 삭제할 때 코드를 단순화
    fiber.child = dehydratedFragment; 
    hydrationParentFiber = fiber; // 현재 Suspense Fiber를 Hydration 부모로 설정
    // 다음 Hydration 대상을 null로 설정
    // Suspense 내부의 자식들을 첫 번째 패스에서 처리하지 않고 나중에 다시 진입
    nextHydratableInstance = null; 
    return true; // Hydration 성공
  }
  return false; // Hydration 실패
}
  • 서버에서 렌더링 된 Suspense DOM 노드를 찾아 재사용하려고 시도한다.
  • Suspense DOM 노드는 특별한 주석 형태이다.(<!--$-->, <!--$!-->)
  • 이러한 주석 노드를 찾아 memoizedState를 설정한다.
  • suspenseState 객체는 Suspense의 Hydration 상태를 추적하는 데 중요한 역할을 한다.
  • dehydratedFragment는 Suspense의 자식 컴포넌트들을 나중에 Hydrate하기 위한 Placeholder 역할을 하고 Progressive(점진적) Hydration을 가능하게 한다.

자식들을 탐색하지 않고 중단, 두 번째 렌더링 패스 예약

function mountDehydratedSuspenseComponent(
  workInProgress: Fiber, // 현재 작업중인 Fiber 노드
  suspenseInstance: SuspenseInstance, // 서버에서 렌더링된 Suspense 인스턴스
  renderLanes: Lanes // 렌더링 우선순위
): null | Fiber {
  // Suspense 인스턴스가 Fallback 상태인지 확인
  if (isSuspenseInstanceFallback(suspenseInstance)) {
    // 클라이언트 전용 경계
    // 서버에서 컨텐츠를 받지 않아서, 더 높은 우선순위로 스케줄링
    // DefaultHydrationLane를 사용하여 우선순위 설정
    // 별도의 커밋에서 렌더링될 작업을 남겨둔다.
    workInProgress.lanes = laneToLanes(DefaultHydrationLane);
  } else { // Fallback 상태가 아닌 경우
    // 서버에서 이미 올바른 콘텐츠를 받았기 때문에 급하게 처리할 필요가 없다.
    // OffscreenLane를 사용하여 낮은 우선순위로 설정
    workInProgress.lanes = laneToLanes(OffscreenLane);
  }
  return null;
}

const SUSPENSE_START_DATA = "$"; // 시작
const SUSPENSE_END_DATA = "/$"; // 끝
const SUSPENSE_PENDING_START_DATA = "$?"; // 대기 중 시작
const SUSPENSE_FALLBACK_START_DATA = "$!"; // Fallback 시작
export function isSuspenseInstanceFallback(
  instance: SuspenseInstance
): boolean {
  // Suspense 인스턴스가 Fallback 상태인지 확인
  return instance.data === SUSPENSE_FALLBACK_START_DATA;
}
  • Suspense의 초기 마운트 단계에서 호출되며 실제 컨텐츠 렌더링은 나중 단계로 미룬다.
  • Fallback 상태와 일반 상태에 따라 다른 우선순위를 설정한다.
  • 이 우선순위 설정은 React의 동시성 모드와 관련이 있으며, 렌더링 우선순위를 조절한다.
  • DefaultHydrationLane은 높은 우선순위로 빠른 Hydration이 필요한 경우에 사용한다.
  • OffscreenLane는 낮은 우선순위로 당장 화면에 표시되지 않는 컨텐츠에 사용된다.

Suspense 컴포넌트를 만나면 React는 해당 Suspense에 대한 Fiber 노드를 생성하고 Suspense의 자식 컴포넌트나 Fallback에 대한 Fiber 노드를 생성하지 않는 대신 dehydrateFragment라는 특별한 Fiber 노드를 생성한다.

💡dehydratedFragment란?
Suspense의 실제 자식 컴포넌트나 Fallback 콘텐츠를 나중에 Hydrate하기 위한 Placeholder 역할을 한다.
이는 Suspense 컴포넌트의 상태를 초기화하는 데 도움을 준다.

🎯 첫 번째 패스에서 React는 dehydrateFragment와 Suspense 주석 노드만 보존하고 commit 단계로 넘어간다. 이 과정에서 Hydration 상태를 설정하고 다음 렌더링 패스를 위한 준비를 한다.

이 단계에서 React는 서버에서 렌더링된 HTML 구조를 유지하면서, Suspense 컴포넌트의 상태를 초기화한다.

이는 Progressive(점진적) Hydration을 가능하게 하며, 전체 애플리케이션의 Hydration을 지연시키지 않고 부분적으로 상호작용 가능한 UI를 빠르게 제공할 수 있다.


두 번째 렌더링 패스(클라이언트 측 Hydration 과정)

다시 updateSuspenseComponent 함수로 들어가고 이번에는 current Fiber 트리가 존재하기 때문에 update 분기로 진입한다.

function updateSuspenseComponent(
  current: null | Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // another conde...
  if (current === null) {
    // 초기 마운트
    ...
  } else {
    // 업데이트
    const prevState: null | SuspenseState = current.memoizedState;
    if (prevState !== null) {
      // 이전에 매칭된 dehydrated supense 상태가 있다면
      const dehydrated = prevState.dehydrated;
      if (dehydrated !== null) {
        return updateDehydratedSuspenseComponent(
          current,
          workInProgress,
          didSuspend,
          nextProps,
          dehydrated,
          prevState,
          renderLanes,
        );
      }
    }
    // another conde...
  }

첫 번째 렌더링 패스에서 memoizedState를 설정하였기 때문에 존재하므로 updateDehydratedSuspenseComponent 함수를 호출한다.


Suspense 내부 콘텐츠 렌더링

function updateDehydratedSuspenseComponent(
  current,
  workInProgress,
  didSuspend,
  didPrimaryChildrenDefer,
  nextProps,
  suspenseInstance,
  suspenseState,
  renderLanes,
) {
  /*
  didSuspend의 목적
  true: 현재 렌더링 과정에서 컴포넌트가 중단되었음을 나타낸다.(후속 렌더링)
  false: 컴포넌트가 중단되지 않은 경우(초기 렌더링)
  */
  if (!didSuspend) { // 초기 렌더링
    if (isSuspenseInstanceFallback(suspenseInstance)) {
      // Suspense 인스턴스 상태가 fallback이라면 Hydrate를 시도하지 않고 클라이언트 렌더링으로 전환
      return retrySuspenseComponentWithoutHydrating(
        current,
        workInProgress,
        renderLanes,
      );
    }

    // 컴포넌트가 업데이트를 받았거나 컨텍스트가 변경된 경우
    if (didReceiveUpdate || hasContextChanged) {
      // Hydarte 불가능 상황 처리
      const root = getWorkInProgressRoot();
      if (root !== null) {
        const attemptHydrationAtLane = getBumpedLaneForHydration(
          root,
          renderLanes,
        );
        if (
          attemptHydrationAtLane !== NoLane &&
          attemptHydrationAtLane !== suspenseState.retryLane
        ) {
          // 더 높은 우선순위로 Hydrate 재시도
          suspenseState.retryLane = attemptHydrationAtLane;
          // 업데이트 스케줄링
          throw SelectiveHydrationException;
        }
      }
      
      // Hydrate 실패 시 클라이언트 렌더링으로 전환
      return retrySuspenseComponentWithoutHydrating(
        current,
        workInProgress,
        renderLanes,
      );
    } else if (isSuspenseInstancePending(suspenseInstance)) {
      // 대기 중인 Suspense 인스턴스 처리
      workInProgress.flags |= DidCapture;
      workInProgress.child = current.child;
      // 재시도 함수 등록
      return null;
    } else {
      // Hydrate 성공 시 주 자식 컴포넌트 마운트
      reenterHydrationStateFromDehydratedSuspenseInstance(
        workInProgress,
        suspenseInstance,
        suspenseState.treeContext,
      );
      const primaryChildFragment = mountSuspensePrimaryChildren(
        workInProgress,
        nextProps.children,
        renderLanes,
      );
      // Hydrating 플래그를 설정하여 Hydrate 과정임을 표시
      primaryChildFragment.flags |= Hydrating;
      return primaryChildFragment;
    }
  } else { // 컴포넌트가 이미 한 번 렌더링되었고, 중단되었거나 오류가 발생한 경우
    if (workInProgress.flags & ForceClientRender) {
      // Hydrate 중 오류 발생 시 클라이언트 렌더링으로 전환
      return retrySuspenseComponentWithoutHydrating(
        current,
        workInProgress,
        renderLanes,
      );
    } else if ((workInProgress.memoizedState: null | SuspenseState) !== null) {
      // 컴포넌트가 중단된 상태라면 현재 자식을 유지하고 DidCapture 플래그 설정
      workInProgress.child = current.child;
      workInProgress.flags |= DidCapture;
      return null;
    } else {
      // Hydrate가 실패하고 중단 상태가 아닌 경우 fallback 컨텐츠 렌더링
      const fallbackChildFragment =
        mountSuspenseFallbackAfterRetryWithoutHydrating(
          current,
          workInProgress,
          nextProps.children,
          nextProps.fallback,
          renderLanes,
        );
      // 상태 및 레인 설정
      return fallbackChildFragment;
    }
  }
}

Hydration 초기 단계

  • suspense 인스턴스의 상태에 따라 Hydrate 또는 클라이언트 렌더링 결정
  • 컨텍스트 변경이나 업데이트 발생 시 Hydrate 재시도 또는 클라이언트 렌더링으로 전환
  • 대기 중인 Suspense 인스턴스 처리
  • Hydrate 성공 시 자식 컴포넌트 마운트

Hydration 후속 단계(주로 Hydration 과정에서 문제가 발생했거나 컴포넌트가 중단된 경우를 처리)

  • Hydrate 중 오류가 발생한 경우 클라이언트 렌더링으로 전환
  • 중단된 상태 처리
  • Hydrate 실패 시 Fallback 콘텐츠 렌더링

클라이언트 렌더링으로 전환

function retrySuspenseComponentWithoutHydrating(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes
) {
  // 기존 자식의 Fiber들을 삭제 목록에 추가하여 제거
  reconcileChildFibers(workInProgress, current.child, null, renderLanes);
  const nextProps = workInProgress.pendingProps;
  // Suspense의 주요 자식들(Fallback이 아닌 실제 내용)을 새로 마운트
  const primaryChildren = nextProps.children;
  const primaryChildFragment = mountSuspensePrimaryChildren(
    workInProgress,
    primaryChildren,
    renderLanes
  );
  // 새로 생성된 자식에 플래그 설정, 이는 React에게 Fragment를 DOM에 새로 삽입해야 함을 알린다.
  primaryChildFragment.flags |= Placement;
  workInProgress.memoizedState = null; // Suspense 컴포넌트의 이전 상태를 제거한다.
  return primaryChildFragment; // 새로 생성된 자식 Fragment를 반환한다.
}
  • 기존 DehydratedFragment 자식을 제거한다.
  • mountSuspensePrimaryChildren 함수로 새로운 자식들을 마운트한다.
  • Placement 플래그를 통해 새로 생성된 자식 Fragment를 DOM에 삽입해야 한다는 것을 알린다.
  • workInProgress.memoizedState를 null로 설정한다.

이 함수는 Suspense 컴포넌트의 Hydration이 실패했을 때 호출되는 함수이다. 즉 서버에서 렌더링된 내용을 삭제하고 클라이언트에서 새로 렌더링한다.


정리하기

Suspense의 서버 사이드 렌더링(SSR) 과정과 HTML 생성

  • 서버에서 React 애플리케이션을 렌더링할 때, Suspense 컴포넌트를 포함한 전체 컴포넌트를 렌더링한다.
  • 이 과정에서 Suspense 내부의 실제 콘텐츠로 렌더링된다.(데이터가 준비된 경우)
  • 렌더링된 HTML은 Suspense 주석 노드(<!--$-->)와 함께 실제 콘텐츠를 포함한다.
    • 데이터가 준비되지 않은 경우, 서버는 Fallback 콘텐츠를 렌더링하고 주석 노드(<!--$!-->)를 사용하여 이를 표시한다.

첫 번째 렌더링 패스(mount 단계, Hydration 준비 단계)

  • tryToClaimNextHydratableSuspenseInstance 함수를 통해 서버에서 렌더링된 HTML의 Suspense 주석 노드를 찾는다.
  • 주석 노드를 찾으면 workInProgress.memoizedStateSuspenseState 객체를 설정한다.
  • mountDehydratedSuspenseComponent 함수를 호출하여 Suspense 내부의 요소를 탐색하지 않고 중단하고 두 번째 패스를 예약한다.

첫 번째 패스에서는 Suspense 컴포넌트를 만나면 React는 해당 Suspense에 대한 Fiber 노드를 생성하고 Suspense의 자식 컴포넌트나 Fallback에 대한 Fiber 노드를 생성하지 않는 대신 dehydrateFragment라는 특별한 Fiber 노드를 생성한다.

React는 dehydrateFragment와 Suspense의 주석 노드만 보존하고 commit 단계로 넘어간다. 이 과정에서 Hydration 상태를 설정하고 다음 렌더링 패스를 위한 준비(우선순위 설정 등)를 한다.


두 번째 렌더링 패스(클라이언트 측 Hydration 과정)

  • updateDehydratedSuspenseComponent 함수에서 Suspense 컴포넌트의 현재 상태를 확인하고, 적절한 렌더링 경로를 선택한다.

올바른 콘텐츠를 받은 경우

  • 서버에서 Suspense 컴포넌트가 실제 콘텐츠와 함께 렌더링되었다면
  • 클라이언트에서는 일반적인 Hydration 과정을 진행한다.
  • 이는 서버에서 렌더링된 HTML을 그대로 사용하면서, React의 이벤트 핸들러와 상태를 연결하는 과정이다.
  • Hydrate 성공 시 자식 컴포넌트를 마운트한다.

Fallback 상태일 때

  • 서버에서 Suspense 컴포넌트가 Fallback 상태로 렌더링되었다면(실제 콘텐츠가 준비되지 않았다면)
  • retrySuspenseComponentWithoutHydrating 함수를 호출하여 클라이언트 렌더링으로 전환한다.
  • 이 함수는 서버에서 렌더링된 콘텐츠를 버리고, 클라이언트에서 Suspense 컴포넌트를 새로 렌더링한다.
  • 이는 서버와 클라이언트 간의 불일치를 해결하고, 클라이언트에서 올바른 콘텐츠를 렌더링하기 위한 것이다.

두 번째 패스에서는 클라이언트의 현재 상태와 서버에서 렌더링된 내용과 비교한 후, 클라이언트와 서버 상태 모두 실제 콘텐츠를 가지고 있는 경우 Hydartion을 수행하고, Fallback 상태이거나 불일치하는 경우 클라이언트 렌더링으로 전환하여 일관된 UI를 제공한다.

이 과정에서 React는 사용자 상호작용이나 네트워크 상태 변화에 따라 우선순위를 동적으로 조절할 수 있다.

💡 Suspense의 Hydration 과정은 React의 점진적 Hydration 전략의 일부로, 전체 애플리케이션의 Hydration을 지연시키지 않고 부분적으로 상호작용 가능한 UI를 빠르게 제공할 수 있게 한다.


🙃 도움이 되었던 자료들

How hydration works with Suspense internally in React?
Upgrading to React 18 on the server #22
New Suspense SSR Architecture in React 18 #37

profile
🏁

2개의 댓글

comment-user-thumbnail
2024년 10월 29일

좋은 내용인데 전부다 폰트를 굵게 하셔서 그런지 읽기가 쉽지가 않네요;;

1개의 답글