use Hook의 내부 동작 원리

우혁·2024년 11월 26일
22

React

목록 보기
16/19
post-thumbnail

use Hook이란?

use는 Promise나 context와 같은 데이터를 참조하는 React 훅이다.

다른 React Hook과 달리 use는 if와 같은 조건문과 반복문 내부에서 호출할 수 있다.

💡 use Hook은 현재 React Canary 및 실험적 채널에서만 사용할 수 있다.

  • React 19 버전에서 정식 출시될 예정이다.

Promise와 함께 호출될 때 use Hook은 Suspense, ErrorBoundary와 통합하여 사용할 수 있다.

use에 전달된 Promise가 pending되는 동안 use를 호출하는 컴포넌트는 suspend가 된다.

이 때 컴포넌트가 Suspense로 감싸져 있다면 Fallback이 렌더링되고, Promise가 resolve된다면 Suspense 내부 컴포넌트가 렌더링된다.

만약 reject된다면 가장 가까운 ErrorBoundary의 Fallback이 렌더링된다.

주의 사항

  • use Hook은 컴포넌트나 Hook 내부에서 호출되어야 한다.

  • 서버 컴포넌트에서 데이터를 fetch할 때는 use보다 async/await을 사용한다.

    • async/awaitawait이 호출된 시점부터 렌더링을 시작하는 반면, use는 데이터가 리졸브된 후에 컴포넌트를 리렌더링한다.
  • 클라이언트 컴포넌트에서 Promise를 생성하는 것보다 서버 컴포넌트에서 Promise를 생성하여 전달하는 것이 좋다.

    • 클라이언트 컴포넌트에서 생성된 Promise는 렌더링할 때마다 다시 생성되지만, 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된 Promise는 리렌더링 전반에 걸쳐 안정적이다.
  • usetry-catch 블록에서 호출할 수 없다. 대신 Error Boundary로 래핑하거나 Promise의 catch 메서드를 사용하여 대체 값을 제공해야 한다.

  • use는 렌더링 중에 생성된 Promise를 지원하지 않는다.

    • 렌더링 중에서 생성된 Promise는 매 렌더링마다 새로운 Promise가 생성될 수 있어 예측 불가능한 동작을 발생시킬 수 있으며, React 렌더링 모델의 순수성과 일관성을 해칠 수 있다.
    • 현재는 Suspense 호환 라이브러리나 프레임워크에서 제공하는 캐시 기능을 사용하여 Promise를 캐싱해야 한다.(추후 React팀은 렌더링 중 Promise를 더 쉽게 캐싱할 수 있는 기능을 제공할 계획이라고 한다)

use Hook의 내부 동작 원리

export type Usable<T> = Thenable<T> | ReactContext<T>;
function use<T>(usable: Usable<T>): T {
  if (usable !== null && typeof usable === 'object') {
    if (typeof usable.then === 'function') {
      // thenable 객체인 경우
      const thenable: Thenable<T> = (usable: any);
      return useThenable(thenable);
    } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
      // Context 객체인 경우
      const context: ReactContext<T> = (usable: any);
      return readContext(context);
    }
  }

  // Promise, Context가 아닌 경우
  throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

인자로 들어온 usable 객체가 thenable 객체인 경우 useThenable 함수를 호출하고, context 객체인 경우에는 readContext 함수를 호출하는 것을 볼 수 있다.


thenable 객체 처리

let thenableIndexCounter: number = 0;
let thenableState: ThenableState | null = null;

export function createThenableState(): ThenableState { // Thenable 상태 초기화
  return [];
}

function useThenable<T>(thenable: Thenable<T>): T {
  // 각 Promise 객체에 고유한 인덱스를 할당하여 구분
  const index = thenableIndexCounter;
  thenableIndexCounter += 1;
  if (thenableState === null) {
    // Thenable 상태가 없다면 초기화(단순히 배열을 생성하는 것이다)
    thenableState = createThenableState();
  }
  const result = trackUsedThenable(thenableState, thenable, index);
  const workInProgressFiber = currentlyRenderingFiber; // 현재 렌더링 중인 Fiber
  const nextWorkInProgressHook =
    workInProgressHook === null
      ? workInProgressFiber.memoizedState
      : workInProgressHook.next; // 다음 작업 중인 Hook을 식별

  if (nextWorkInProgressHook !== null) {
  } else {
    // 훅의 디스패처 설정
    const currentFiber = workInProgressFiber.alternate;
    // 컴포넌트가 마운트, 업데이트 되는지에 따라 적절한 디스패처 선택
    ReactSharedInternals.H =
      currentFiber === null || currentFiber.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
  return result; // Promise에서 해결된 값이거나 아직 해결되지 않은 값일 수 있다.
}
  1. 각 thenable 객체에 고유한 인덱스를 부여해서 구분하고, thenableState가 없다면 배열로 초기화한다.
  2. 그 후 thenable 객체를 시작하고 추적할 수 있는 trackUsedThenable 함수를 호출한다.
  3. 컴포넌트가 초기 마운트이거나 업데이트인지 상태에 따라 적절한 디스패처를 설정한다.
  4. 마지막으로 결과를 반환하는데 이 때 결과는 Promise가 해결된 값일 수도 있고 아직 해결되지 않는 값일 수도 있다.

💡 thenableIndexCounter와 thenableState는 각 렌더링마다 초기화된다.
컴포넌트가 렌더링할 때 실행되는 renderWithHooks 함수 끝에 finishRenderingHooks 함수를 매번 호출하여 상태를 초기화시킨다.

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  finishRenderingHooks(current, workInProgress, Component);
  return children;
}
// 상태 초기화
function finishRenderingHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
): void {
  thenableIndexCounter = 0;
  thenableState = null;
}

thenable 객체 추적

function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
  // dev mode code..
  const prodState = (state: any);
  return prodState;
}

function noop(): void {} // 빈 함수(핸들러 추가용, 메모리 누수 방지)

export function trackUsedThenable<T>(
thenableState: ThenableState,
 thenable: Thenable<T>,
 index: number
): T {
  // 인덱스에 해당하는 thenable를 추적한다.
  const trackedThenables = getThenablesFromState(thenableState);
  const previous = trackedThenables[index];
  if (previous === undefined) {
    // 새로운 thenable이라면 추적한다.
    trackedThenables.push(thenable);
  } else {
    if (previous !== thenable) {
      // 기존과 다르면 이전 thenable를 유지한다.
      // noop 함수는 빈 함수로, thenable에 핸들러를 추가하여 메모리 누수를 방지한다.
      thenable.then(noop, noop); 
      thenable = previous;
    }
  }

  switch (thenable.status) {
    case "fulfilled": {
      // 완료된 상태라면 그 값을 반환한다.
      const fulfilledValue: T = thenable.value;
      return fulfilledValue;
    }
    case "rejected": {
      // 거부된 상태라면 오류를 검사한 후 던진다.
      const rejectedError = thenable.reason;
      checkIfUseWrappedInAsyncCatch(rejectedError);
      throw rejectedError;
    }
    default: {
      // 대기 상태 등등...
      if (typeof thenable.status === "string") {
        // 상태가 문자열이라면 noop 핸들러를 추가한다.
        thenable.then(noop, noop);
      } else {
        const root = getWorkInProgressRoot(); // 트리 가져오기
        if (root !== null && root.shellSuspendCounter > 100) {
          // suspense 관련 작업의 수가 100을 초과한 경우 에러 처리
          throw new Error(
            "async/await is not yet supported in Client Components, only " +
            "Server Components. This error is often caused by accidentally " +
            "adding `'use client'` to a module that was originally written " +
            "for the server."
          );
        }

        // 상태를 pending으로 설정하고 성공과 실패에 대한 핸들러 추가
        const pendingThenable: PendingThenable<T> = (thenable: any);
        pendingThenable.status = "pending";
        pendingThenable.then(
          (fulfilledValue) => {
            if (thenable.status === "pending") {
              const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
              fulfilledThenable.status = "fulfilled";
              fulfilledThenable.value = fulfilledValue;
            }
          },
          (error: mixed) => {
            if (thenable.status === "pending") {
              const rejectedThenable: RejectedThenable<T> = (thenable: any);
              rejectedThenable.status = "rejected";
              rejectedThenable.reason = error;
            }
          }
        );
      }

      switch ((thenable: Thenable<T>).status) {
        case "fulfilled": {
          // 성공한 경우 값 반환
          const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
          return fulfilledThenable.value;
        }
        case "rejected": {
          // 실패한 경우 오류 처리 및 throw
          const rejectedThenable: RejectedThenable<T> = (thenable: any);
          const rejectedError = rejectedThenable.reason;
          checkIfUseWrappedInAsyncCatch(rejectedError);
          throw rejectedError;
        }
      }

      // Suspense 예외를 throw
      suspendedThenable = thenable;
      throw SuspenseException;
    }
  }
}
  1. 해당 thenable이 추적 배열에 존재하면 이전 thenable로 대체하고 존재하지 않다면 추적 배열에 추가한다.

  2. thenable 상태가 fulfilled라면 값을 반환하고, rejected라면 에러를 던진다.

  3. pending 상태라면 빈 핸들러를 추가하여 메모리 누수를 방지한다.

  4. 위 조건에 부합하지 않는 경우(커스텀 thenable 객체)라면 현재 트리의 루트를 가져와 shellSuspendCounter(suspense 관련 작업의 수)를 체크하고 100을 초과한다면 에러를 던진다.

  5. 커스텀 thenable에는 상태가 없기 때문에 상태를 추가하고 성공 or 실패했을 때 동작할 핸들러(상태 변경, 값 반환 등)를 추가한다.

  6. 상태가 fulfilled라면 값을 반환하고, rejected라면 에러를 던진다.

  7. 위 조건에 부합하지 않는 경우(pending 상태) Suspense 상태로 간주하여 suspendedThenable에 thenable을 저장하고 SuspenseException를 throw한다.

💡 noop 함수를 핸들러에 추가하는 이유
Promise에 .then() or .catch() 핸들러가 없으면, 그 Promise는 가비지 컬렉션이 지연될 수 있어 메모리 누수의 원인이 될 수 있다.

  • noop이라는 빈 함수를 핸들러에 추가함으로써 Promise가 해결되거나 거부될 때 처리된 것으로 간주하고 필요 없어졌을 때 가비지 컬렉션의 대상으로 만들 수 있다.

이 함수의 동작을 크게 5가지로 나눌 수 있다.

  • 새로운 thenable이라면 추적 배열에 추가하고, 아닌 경우 이전 thenable로 현재 thenable을 대체한다.

  • thenable 객체 상태에 따라 값을 반환하거나 에러를 던지거나 빈 핸들러를 추가한다.

  • 현재 작업중인 루트 트리를 가져와 shellSuspendCounter 체크해서 100을 초과하면 에러를 던진다.

  • 커스텀 thenable 객체에 상태를 추가하고 이 후 동작에 대한 핸들러(상태 변경, 값 반환 등)를 추가하고 상태를 확인하여 값을 반환하거나 에러를 던진다.

  • 아직 thenable이 해결되지 않은 경우 Suspense 상태로 간주하여 suspendedThenable에 thenable을 저장하고 SuspenseException를 throw한다.


thenable 객체와 Promise 객체

처음에 Promise 객체를 다른 말로 thenable 객체라고 부르는 줄 알았다..

Promise 객체

  • ECMAScript 6에서 표준화된 객체이다.
  • then, catch, finally 등의 표준 메서드를 가진다.
  • pending, fulfilled, rejected 중 하나의 상태를 가진다.

thenable 객체

  • then 메서드를 가진 객체를 의미한다.
  • Promise보다 유연한 구조를 가질 수 있다.
  • 사용자 정의 객체로 Promise와 유사한 동작을 가질 수 있지만 Promise의 모든 기능을 구현할 필요는 없다.

thenable은 Promise의 개념은 일반화한 것으로 Promise보다 더 넓은 범위의 객체를 포함할 수 있다.(React의 내부 구현에서 thenable을 사용하는 것은 더 유연한 비동기 처리를 위함이다)

➔ Promise 객체가 thenable한 객체이지만, thenable 객체가 무조건 Promise 객체인 것은 아니다.


Context 객체 처리

export function readContext<T>(context: ReactContext<T>): T {
  return readContextForConsumer(currentlyRenderingFiber, context);
}

let lastContextDependency: ContextDependency<mixed> | null = null;
function readContextForConsumer<T>(
consumer: Fiber | null, // context를 사용하는 컴포넌트(Fiber)
 context: ReactContext<T> // context 객체
): T {
  const value = isPrimaryRenderer
  ? context._currentValue
  : context._currentValue2; // 렌더러에 맞는 값 설정

  if (lastFullyObservedContext === context) {
  } else {
    // 새로운 context 의존성 객체 생성
    const contextItem = {
      context: ((context: any): ReactContext<mixed>),
      memoizedValue: value,
      next: null,
    };

    if (lastContextDependency === null) {
      // 첫 번째 context 의존성인 경우
      if (consumer === null) {
        // 잘못된 위치에서 Context를 읽으려 할 때 에러 발생
        throw new Error(
          "Context can only be read while React is rendering. " +
          "In classes, you can read it in the render method or getDerivedStateFromProps. " +
          "In function components, you can read it directly in the function body, but not " +
          "inside Hooks like useReducer() or useMemo()."
        );
      }

      lastContextDependency = contextItem;
      consumer.dependencies = {
        // consumer의 dependencies 설정
        lanes: NoLanes,
        firstContext: contextItem,
      };
    } else {
      // 이미 의존성이 있는 경우 새 의존성을 체인의 끝에 추가
      lastContextDependency = lastContextDependency.next = contextItem;
    }
  }
  return value; // context 값 반환
}

readContext 함수만으로는 React Context의 내용을 이해하기 어려울 것 같은데 이전에 따로 React Context의 내부 동작 원리에 대해 정리했던 게 있어서 이걸 참고해도 좋을 것 같다.

간단하게 설명하면 Context Provider를 만나면 리액트 내부적으로 스택 자료구조에 value를 푸쉬하고 Provider 범위를 벗어나면 pop하여 이전 상태로 돌아간다.

컴포넌트 내부에서 readContext 함수가 호출되면 해당 Context 객체의 value 값을 가져온다.


정리하기

use Hook은 thenable 객체와 Context 객체를 처리할 수 있다.

thenable 객체

  • thenable 객체를 추적 배열에 추가하여 같은 thenable 객체인 경우 이전 thenable을 사용하여 불필요한 상태 업데이트를 방지한다.
  • thenable 객체 상태에 따라 적절한 동작을 한다(값 반환, 에러 처리 등)
  • thenable 객체가 해결되지 않았다면 SuspenseException를 throw한다.

Context 객체

  • 해당 Context의 가장 가까운 Provider Value를 가져온다.

use Hook을 조건문이나 반복문 내부에서 호출할 수 있는 이유

React에서 제공하는 대부분의 기본 Hook들은 Fiber 노드 내에 연결 리스트(Linked List)로 구성되어 각 노드는 하나의 Hook에 해당하며, Hook의 상태와 관련 정보를 포함한다.

useState로 예를 들면 초기 마운트 단계에서 mountWorkInProgressHook를 호출하여 새로운 Hook 노드를 생성하고 연결 리스트에 추가한다. 이 Hook 노드에 초기 상태 값을 저장한다.

업데이트 단계에서 updateWorkInProgressHook를 호출하여 기존 Hook 노드를 찾아 업데이트한다. 이 과정에서 이전 상태 값을 읽고 새로운 상태 값을 설정한다.

이처럼 React는 호출된 순서대로 연결 리스트에 저장하고 관리한다. 매 렌더링마다 같은 순서로 Hook이 호출되어야 React가 올바른 Hook 노드를 찾아 데이터를 관리할 수 있기 때문에 useState와 같은 React Hook들은 호출 순서가 중요한 것이다.

하지만 useThanble은 데이터가 Promise 자체에 붙어있고, readContext도 데이터는 Context Provider의 가장 가까운 Fiber 노드에서 가져온다.

그렇기 때문에 use Hook은 useThanblereadContext를 호출하기 때문에 조건문이나 반복문에서 호출될 수 있는 것이다.

🤷‍♂️ 그러면 readContext를 호출하는 useContext도 조건문이나 반복문에서 사용할 수 있지 않을까?
useContext도 조건문이나 반복문 내부에서 사용해도 동작하는데 문제가 없지만 Lint Rule에서 경고를 표시한다.


🙃 도움이 되었던 자료들

use 훅이 바꿀 리액트 비동기 처리의 미래 맛보기
리액트의 신규 훅, "use"
use - React 공식 문서
React 19 RC - React 공식 문서
RFC: First class support for promises and async/await
text/0000-first-class-support-for-promises.md
How does use() work internally in React?

profile
🏁

0개의 댓글