ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React 18 - Suspense 분석하기
    Study/개발 2024. 2. 21. 22:26

    TL;DR

    1. Suspense는 내부에서 throw된 Promise를 감지할 수 있음.
    2. 이를 통해 data loading 로직과 ui 로직을 분리할 수 있음.
    3. Tanstack Query나 Suspend React 등을 통해 간단한 예를 만들어 볼 수 있다.


    소개

    기존의 Suspense

    이전의 Suspenselazy 로드된 코드를 불러올 때 fallback UI를 노출하는데에 사용되었다.
    이를 통해 code splitting을 할 수 있었다.

    const LazyComponent = lazy(() => import('path/to/component'));
    
    <Suspense fallback={<Spinner />}>
        <LazyComponent />
    </Suspense>

    React 18 버전

    React 18부터는 Data fetch에 대해서도 Suspense UI를 노출할 수 있도록 하였다. 공식 문서

    공식 문서를 참고하면 다음과 같은 사용성을 가지는 것을 알 수 있다.

    <Suspense fallback={<Loading />}>
        <Albums />
    </Suspense>

    위와 비슷하지만, Albumslazy 로드된 컴포넌트가 아니다.

    대신 내부적으로 Suspense의 fallback을 트리거할 수 있는 data fetching을 사용한다.

    이런 data fetching 방식은 프레임워크들을 통해 사용할 수 있다.
    (직접 만들 수 있지만, 권장되지 않는다.)

    Suspense를 활용할 수 있는 데이터 소스

    1. Next
    2. Relay
    3. Tanstack Query
    4. use를 통해 Promise 읽기 등

     

    구현

    Children 내부의 data fetching에 대해 Suspense는 어떻게 알 수 있을까?

    여기에 대해 몇가지 힌트를 찾을 수 있다.

    1. RFC

    React 18의 RFC를 보면 이런 내용이 있다. RFC

    Conceptually, you can think of Suspense as being similar to a catch block. However, instead of catching errors, it catches components "suspending"

    In JavaScript, when you throw, the closest catch above "wins", even if it's several function calls higher. Although Suspense works differently under the hood, the mental model is similar: if a component suspends, the closest Suspense component above the suspending component "catches" it, no matter how many components that are in between.

    요약하자면,

    - Suspense는 try/catchcatch블록과 유사하게 동작한다.
        - 아래에서의 throw를 가장 가까운 상위의 catch가 받아서 처리한다.
    - 하위의 컴포넌트가 Suspend하면, 가장 가까운 Suspense가 이를 캐치한다.

    정확히 일치하지는 않지만 리액트는 이런 방식으로 Suspense에서 Data를 "Catch"하고 있음을 알 수 있다.

    2. React Lazy

    그럼 실제로 Suspense의 코드를 살펴볼 수 있다. Suspense 사용의 가장 대표적인 예인 React Lazy와 Suspense 찾아봤다. lazy

    lazyThenable을 리턴하는 함수를 인자로 받는다.

    export function lazy<T>(
      ctor: () => Thenable<{default: T, ...}>,
    ): LazyComponent<T, Payload<T>> {
      const payload: Payload<T> = {
        // We use these fields to store the result.
        _status: Uninitialized,
        _result: ctor,
      };
    
      const lazyType: LazyComponent<T, Payload<T>> = {
        $$typeof: REACT_LAZY_TYPE,
        _payload: payload,  // Promise가 완료되면 then에서 payload의 값을 바꿔준다.
        _init: lazyInitializer,
      };
    
      return lazyType;
    }

     

    이 인자는 내부에서 lazyInitializer로 전달된다.

    layzyInitializer를 간추려보면 다음과 같다. 상세는 생략했다.

    type Payload = {
      _status: Uninitialized | Pending | Rejected | Resolved,
      _result: ...,
    };
    
    function lazyInitializer<T>(payload: Payload<T>): T {
      // 초기 상태
      if (payload._status === Uninitialized) {
        const ctor = payload._result; // lazy의 인자로 받은 함수
    
        // ctor은 Promise같은 Thenable을 리턴. 간단히 Promise라고 생각해도 됨.
        const thenable = ctor();
    
        thenable.then(
          // Promise resolve 케이스
          moduleObject => {
            if (payload._status === Pending || payload._status === Uninitialized) {
              const resolved: ResolvedPayload<T> = (payload: any);
              resolved._status = Resolved;
              resolved._result = moduleObject;
            }
          },
          // Promise reject 케이스
          error => {
            if (payload._status === Pending || payload._status === Uninitialized) {
              const rejected: RejectedPayload = (payload: any);
              rejected._status = Rejected;
              rejected._result = error;
            }
          },
        );
        // 아직 초기상태인 케이스
        if (payload._status === Uninitialized) {
          const pending: PendingPayload = (payload: any);
          pending._status = Pending;
          pending._result = thenable;
        }
      }
    
      if (payload._status === Resolved) {
        const moduleObject = payload._result;
        return moduleObject.default;
      } else {
        // ** Important! Pending, Rejected일 경우 throw **
        throw payload._result;
      }
    }

    여기서 마지막 else 구문을 보면 throw Promise로 구현한 것을 알 수 있다.

    payload의 상태가 Pending일 경우에 _resultPromise이다. 이 때 Promise를 throw한다.

    사실 좀 어색하긴 하지만 JS는 Promisethrow할 수 있다. 관련 글

     

    You Can throw() Anything In JavaScript - And Other async/await Conside

    Ben Nadel changes the way he looks at throw() statements in JavaScript after thinking about React Suspense; and, looking at how he uses errors in async/await Functions.

    www.bennadel.com

     

    이번엔 throw된 부분을 catch하는 방법을 찾아보면 아래와 같다.

    throw 된 값은 워크루프에서 처리한다. catch를 통해 throw을 잡아내고, Suspense의 fallback이 렌더될 수 있도록 적절히 처리한다.

    // ReactFiberWorkLoop.new.js
    
    // work loop
    function renderRootConcurrent(...) {
      ...
         
      do {
        try {
          workLoopConcurrent();
          break;
        } catch (thrownValue) {
          handleError(root, thrownValue);
        }
      } while (true);
    }
    
    // handle thrown value
    function handleError(root, thrownValue) {
        ...
        
        throwException(
            root,
            erroredWork.return,
            erroredWork,
            thrownValue,
            workInProgressRootRenderLanes,
        );
        completeUnitOfWork(erroredWork);
    }

     

    // ReactFiberThrow.new.js
    function throwException(...) {
      if (
        value !== null &&
        typeof value === 'object' &&
        typeof value.then === 'function'
      ) {
        // This is a wakeable. The component suspended.
        const wakeable: Wakeable = (value: any);
        resetSuspendedComponent(sourceFiber, rootRenderLanes);
        
        // Schedule the nearest Suspense to re-render the timed out view.
        const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
        if (suspenseBoundary !== null) {
          suspenseBoundary.flags &= ~ForceClientRender;
          markSuspenseBoundaryShouldCapture(
            suspenseBoundary,
            returnFiber,
            sourceFiber,
            root,
            rootRenderLanes,
          );
          attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
          return;
        } else {
          // No boundary was found. Unless this is a sync update, this is OK.
          // We can suspend and wait for more data to arrive.
          ...
        }
      }
    }

    이 방법을 통해 Suspense는 상위에서 pending 상태를 캐치할 수 있다.

    이런 방식을 React에서는 Algebraic Effects라고 말한다. (상세 설명은 더 보기에)

     

    cf) Algebraic Effects
    Algebraic Effect는 함수형 언어에서 사용되는 용어로, 현재 진행하던 작업을 상위 환경으로 위임 했다가, 완료되면 다시 돌아와서 작업을 이어나갈 수 있는 연산을 말한다.

    Dan Abramov의 글 - https://overreacted.io/algebraic-effects-for-the-rest-of-us/
    관련 블로그 글(한글) - https://blog.mathpresso.com/algebraic-effects-of-react-suspense-157b49807ea0
     

    Algebraic Effects for the Rest of Us — overreacted

    Have you heard about algebraic effects? My first attempts to figure out what they are or why I should care about them were unsuccessful. I found a few pdfs but they only confused me more. (There’s something about academic pdfs that makes me sleepy.) But

    overreacted.io

    더보기

    Note)

    "Algebraic Effects"의 번역인 "대수적 효과"라는 단어는 나에겐 직관적으로 이해되지 않았다.
    "숫자를 기호로 대신한다"는 뜻의 "대수"는 Algebraic Effects가 나타내는 것과 큰 관련이 없어보인다.

    Algebraic이라는 단어에 대해 찾아본 후 뜻이 조금 명확해졌다.
    Algebra는 아랍어 al-jabr 에서 나온 말이다.
    이 단어는 "Reunion of broken parts"라는 뜻을 가지고 있다.

    함수가 여러 부분으로 나눠져 상위에 전달 되었다가, 다시 원래 함수로 돌아와서 진행한다는 의미와 꽤 잘 들어맞는다.
    이런 어원이 있다는걸 알게된 후 저 단어가 조금 덜 낯설게 느껴졌다.

    물론 Algebraic Effects는 해당 어원에서 직접적으로 파생된 단어는 아니다.
    다만 내가 그랬던 것 처럼, 의미를 이해하는 데에 도움이 되길 바라며 첨언했다.

     

    3. Suspend-React

    구현을 살펴보았으니 data fetching과 함께 사용하는 예를 알아보자.

    suspend react라는 간단한 라이브러리를 사용할 것이다.

    이 라이브러리에서도 내부에서 Promisethrow하는 방식을 볼 수 있다.

    이 방식을 통해 다른 프레임워크들 처럼 ReactSuspense를 사용할 수 있다.

    코드를 작성해보자. fetch가 성공하는 케이스, 실패하는 케이스에 대해 간단한 예시를 만들 수 있다.

    1. 데이터 로딩 성공

    데이터를 1초간 가져올 때 로딩을 노출한다.

    성공 시에는 화면을 띄우는 것을 볼 수 있다.

    Suspense 로딩 성공 케이스

    const fetchData = (): Promise<Data> => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({ title: "TITLE", by: "ME" });
        }, 1000);
      });
    };
    
    const SuspenseLoad: React.FC = () => {
      const data = suspend(fetchData);
    
      // suspend를 사용하면 여기서 로딩 관련 처리가 필요없다
      // if (loading) { ... }
    
      // 에러 처리 필요
      // ex) if (error) { ... }
    
      return (
        <div>
          {data.title} by {data.by}
        </div>
      );
    };
    
    
    
    function App() {
      return (
        <div className="card">
          <Suspense fallback={<div>Loading...</div>}>
            <SuspenseLoad />
          </Suspense>
        </div>
      );
    }
    
    1. 데이터 로딩 실패

    실패 케이스에는 ErrorBoundary도 넣어서 대응해볼것이다.

    이를 통해서 데이터의 로딩, 실패 처리를 모두 컴포넌트와 분리할 수 있다.

    Suspense 로딩 에러 케이스

    const fetchError = (): Promise<Data> => {
      return new Promise((_, reject) => {
        setTimeout(() => {
          reject("ERROR");
        }, 1000);
      });
    };
    
    const SuspenseError: React.FC = () => {
      const data = suspend(fetchError);
    
      // if (loading) { ... }
    
      // error 처리도 필요없다.
      // if (error) { ... }
    
      return (
        <div>
          {data.title} by {data.by}
        </div>
      );
    };
    
    function App() {
      return (
        <div className="card">
          <ErrorBoundary fallback={<div>Something went wrong</div>}>
            <Suspense fallback={<div>Loading...</div>}>
              <SuspenseError />
            </Suspense>
          </ErrorBoundary>
        </div>
      );
    }

    장점 및 한계

    이 방식을 통해서 데이터 로딩 및 에러에 관련된 로직과 UI 로직을 분리할 수 있다. 이로 인해 컴포넌트는 더 간결해지고, 명확한 로직을 가질 수 있다.

    또한 에러와 서스펜스의 바운더리가 명확하기 때문에 일관된 에러 및 로딩 처리를 할 수 있다는 장점도 있다.

    하지만 공식 문서에서도 언급하고 있듯이, Suspense 내에서 직접적으로 Promise를 던지는 방식은 production에서는 조심할 필요가 있다.

    대신에 ReactQuerySuspense를 지원하는 라이브러리가 등장했기 때문에 그런 걸 사용하는 것이 더 좋다.

     


     

    728x90

    'Study > 개발' 카테고리의 다른 글

    Tmux 마우스 드래그 오류  (0) 2024.07.01
    Regular Expression 종류, 동작 방식, 성능  (2) 2024.03.23
    Neovim  (2) 2023.12.28
    React 18 - Transition과 외부 상태 관리 도구  (2) 2023.12.10
    React 18 - Transition  (0) 2023.12.09
Designed by Tistory.