Conceptual Model of React Suspense

2022. 9. 12. 18:23Frontend

 

 

Overview

React v18의 정식 릴리즈가 나오면서 Automatic Batching, Transition등 여러 Feature들이 소개되었습니다. 그중에는 React v16.6에 Experimental Feature로 등장했다가 이번에 정식으로 탑재된 "Suspense"에 대한 내용도 추가되어 있는데, 이번 포스팅에서는 특별히 이 Suspense라는 기능에 대해서 살펴보려고 합니다.

 

What is Suspense?

Suspense lets your components “wait” for something before they can render. In this example, two components wait for an asynchronous API call to fetch some data: - React Official Docs

 

Suspense는 컴포넌트의 렌더링을 어떤 작업이 끝날 때까지 잠시 중단시키고, 다른 컴포넌트를 먼저 렌더링 할 수 있도록 도와주는 기능입니다. Suspense를 사용하면 Component Lazy Loading이나 Data Fetching 등의 비동기 처리를 할 때, 응답을 기다리는 동안 fallback UI(e.g Spinner)를 보여주고, 그 사이에 우선순위가 높은 다른 UI들을 먼저 렌더링 할 수 있습니다.

 

 

lazy loading에 사용되는 Suspense. OtherComponent가 로딩되기 전까지 Spinner를 보여준다.

 

 

React 공식문서에 따르면, 현재(v18) Suspense는 공식적으로 "Lazy Loading Component"(React.lazy를 사용한)를 기다릴 때 사용하고, Data Fetching에 사용되는 것이 가능은 하지만 아직까지 권장되지는 않는다고 이야기하고 있습니다. 하지만 이번 포스팅에서는 Suspense를 Data Fetching에 사용하는 경우에 대해서 집중적으로 살펴볼 텐데, 이는 React Core Team이 궁극적으로 Suspense를 Data Fetching 뿐 아니라 이미지 로딩 등 어떠한 형태의 "비동기 요청"에 대해서도 사용할 것이라는 목표를 가지고 있기 때문입니다. (실제로 모든 라이브러리들은 아니지만 Relay, SWR, Recoil와 같은 Library에서는 이미 Suspense를 사용해서 Data Fetching을 할 수 있도록 지원하고 있으며, 앞으로 대부분의 Data Fetching Library들이 Suspense에 대한 지원을 추가하게 될 것입니다.)

 

 

이어지는 문단들에서도 계속 언급하겠지만, Suspense는 React 18의 핵심 기능 중 하나인 Concurrency(동시성)과 깊은 관련이 있으며, 선언형 UI 라이브러리로서의 React가 에러 / 비동기 / 정상 상태를 조금 더 "선언적(Declarative)"으로 표현할 수 있도록 하는데 도움을 줍니다. 

 

As in previous versions of React, you can also use Suspense for code splitting on the client with React.lazy. But our vision for Suspense has always been about much more than loading code — the goal is to extend support for Suspense so that eventually, the same declarative Suspense fallback can handle any asynchronous operation (loading code, data, images, etc). - React Official Docs

 

 

Suspense Official Example (from React Team)

React 공식문서에서 제공한 아래의 예시[CodeSandbox]를 통해 Suspense를 Data Fetching과 함께 사용하는 방법에 대해 살펴보겠습니다. "ProfilePage" 안에는 "ProfileDetails"와 "ProfileTimeline"이라는 2개의 컴포넌트가 있고, 각 컴포넌트는 비동기 데이터를 가져와 화면에 렌더링 하게 되며, 비동기 데이터를 가져오는 동안 보여줄 FallbackUI를 컴포넌트를 감싼 Suspense에 넣어주었습니다.  ("resource.user.read()"와 같은 문법은 이후에 설명할 예정이니 지금은 "fetchData()" 정도의 역할을 수행한다고 이해하고 넘어가는 것이 좋습니다.)

 

 

Suspense With Data Fetching

 

 

아래는 위의 예제를 실행한 결과입니다. user를 가져오는 API에는 1000ms의 딜레이를 주었고, post를 가져오는 API에는 1100ms의 딜레이를 주었습니다. 여기서 주목할 만한 점은 UserData가 도착한 뒤에 또 다른 1100ms를 기다렸다가 PostData가 로드되는 것이 아니라 UserData와 PostData가 동시에(정확하게는 Concurrent 하게) 요청되고 이에 따라 1100ms 안에 모든 데이터가 로드되어 모든 화면이 나타나게 된다는 것입니다. (UserData와 PostData는 서로 의존성을 가지고 있지 않아서 병렬 요청이 가능합니다)

 

 

 

두 API Call은 Concurrent 하게 일어난다. (1100ms안에 모든 요청이 끝난다)

 

 

What Problem Does it Solve?

React Core Team이 Suspense를 React 18의 Official Feature로 소개하고, 궁극적으로 Data Fetching이나 Image Loading과 같은 모든 비동기 요청을 처리하도록 만들 것이라고 한 이유는 Suspense가 기존에 비동기 요청을 처리하는데에서 발생하는 여러 문제들을 해결한다고 판단했기 때문 일 것입니다. 그렇다면 React Core Team에서는 Suspense를 사용해서 어떤 문제를 해결하려고 한 것일까요?

 

Declarative React

Suspense를 사용하기 전, React에서 비동기 데이터를 처리하는 과정을 살펴보면 하나의 컴포넌트 안에서 꽤나 많은 분기를 가지고 있었음을 알 수 있습니다. 아래 왼쪽의 예시에서도 이러한 분기가 드러나는데, 데이터를 가져오는 부분, loading을 처리하는 부분, 그리고 필요에 따라 error를 처리하는 부분까지 모두 하나의 컴포넌트에서 처리하고 있습니다. 이러한 분기를 생성하는 책임은 개발자에게 있었으며, 개발자는 코드를 작성해서 데이터가 로딩 중인지, 에러가 발생했는지, 정상적으로 로딩되었는지를 다소"명령적(Imperative)"인 방법으로 확인해야 했습니다.

 

 

이때, 오른쪽 예시와 같이 Suspense를 사용하면, 로딩을 처리하는 부분에 대한 책임과 데이터가 로딩되었는지를 판단하는 책임을 Suspense에 맡기고, 컴포넌트는 정상적으로 데이터가 로딩되었을 때의 UI만 "선언"할 수 있게 됩니다. 이때, React의 ErrorBoundary를 같이 사용하면, "정상 / 로딩 중 / 에러 발생 "의 3가지 상태를 분리해서 선언할 수 있게 됩니다. 즉, Suspense를 사용함으로써 개발자는 데이터의 로딩 여부를 판단하고 로딩 중일 때의 UI를 보여주는 책임을 Suspense에게 위임하고, 원래 보여야 하는 UI(정상 상태)를 로딩 중 / 에러 발생과 같은 상태와 분리해서 생각할 수 있게 합니다. 그리고 이는 개발자로 하여금 비즈니스 로직과 UI에 조금 더 집중할 수 있도록 도와줍니다. (선언형 UI 라이브러리로써의 React에 대한 내용은 이 글을 참고해주세요)

 

https://codesandbox.io/s/react-suspense-test-z5lvrq
Without Suspense With Suspense

 

Concurrent React (fix Waterfall Rendering)

앞선 React 공식 문서의 예시에서 두 번의 API요청(각각 1000ms, 1100ms가 소요됩니다)이 모두 Resolve 되어 화면에 데이터가 나타날 때까지 걸린 시간은 대략 "1100ms + a"입니다. 같은 레이아웃을 Suspense를 사용하지 않고 요청하게 되면(위의 왼쪽 예제 참고), 1000ms 요청이 Resolve 된 후, loading 상태가 변경되어야지만 Child Component가 렌더 되고, 해당 컴포넌트가 렌더 되기 시작한 후에야 두 번째 요청(1100ms) 요청이 시작되기 때문에 동일한 화면을 그리는데 필요한 시간이 "2100ms + a"가 됩니다. 당연히 Suspense를 사용한 첫 번째 케이스가 더 나은 사용자 경험을 제공하게 되며, React 공식문서에서는 이를 "Render-as-You-Fetch"라고 부르고 있습니다.

 

In the previous approach, we fetched data before we called setState:
- Start fetching
- Finish fetching
- Start rendering

With Suspense, we still start fetching first, but we flip the last two steps around:
- Start fetching
- Start rendering
- Finish fetching

With Suspense, we don’t wait for the response to come back before we start rendering. In fact, we start rendering pretty much immediately after kicking off the network request - React Official Docs

 

 

즉, 아직 데이터 로딩이 완료되지 않아 Fallback UI를 보여주게 되더라도, React는 'hidden' 모드로 Child Component를 렌더 하게 되며, 이로 인해 아직 부모 컴포넌트가 로딩 중이더라도 자식 컴포넌트는 Data Fetching을 Concurrent 하게 수행할 수 있게 되는 것입니다. 아래의 코드는 실제 React Implementation 중 Suspense가 Fallback과 Children 렌더링을 위해 사용하는 로직의 일부인데, (Suspense의 실제 Fiber 구현에 대한 Detail은 이후 DeepDive 시리즈를 통해 살펴볼 예정입니다.) 로직을 살펴보면 해당 함수가 리턴하는 것은 "fallbackChildFragment"이지만, 실제로 Offscreen에서 primaryChildFragment도 "hidden" 모드로 렌더링하고 있는 것을 확인할 수 있습니다. 

 

function mountSuspenseFallbackChildren(
  workInProgress,
  primaryChildren,
  fallbackChildren,
  renderLanes,
) {
  const mode = workInProgress.mode;
  const progressedPrimaryFragment: Fiber | null = workInProgress.child;

  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };

  let primaryChildFragment;
  let fallbackChildFragment;

  primaryChildFragment = mountWorkInProgressOffscreenFiber(
     primaryChildProps,
     mode,
     NoLanes,
  );
  fallbackChildFragment = createFiberFromFragment(
     fallbackChildren,
     mode,
     renderLanes,
     null,
  );

  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;
  return fallbackChildFragment;
}

 

 

How Suspense Works (Brief Concept)

지금까지 살펴본 내용을 정리해보면, Suspense는 React를 사용해서 UI를 개발하는 개발자가 Data Loading에 대한 상태를 선언적으로 관리할 수 있는 방식을 제공하며, 이로 인해 메인 컴포넌트가 "데이터가 성공적으로 불러와졌을 때"에 대한 상태만 신경 쓸 수 있게 도와주는 역할을 한다는 것을 살펴보았습니다. 그렇다면, 실제로 Suspense는 어떻게 Child Component의 데이터 로딩에 대한 상태를 확인할 수 있는 것일까요? 이에 대한 React Core Team의 idea들을 살펴보도록 하겠습니다.(FYI. React Core Team은 Twitter를 적극적으로 사용하는 것으로 보입니다. 이들을 Follow 해두면 유용한 Insight들을 얻을 때가 많습니다.)

 

 

먼저 React Core Team이자 Concurrent React를 위한 Lane Model을 개발한 Andrew Clark의 Suspense에 대한 트윗을 확인해보도록 하겠습니다. 여기서 그는 Suspense가 동작하는 방식을 다음과 같이 소개하고 있습니다.

 

  1. render method에서 캐시로부터 값을 읽기를 시도한다
  2. value가 캐시 되어 있으면 정상적으로 렌더 한다
  3. value가 캐시 되어 있지 않으면 캐시는 "Promise를 throw 한다"(important part)
  4. promise가 resolve 되면, React는 "Promise를 throw 한 곳으로부터" 재시작한다.

 

 

Twitter - React Core Team (Andrew Clark)

 

 

이를 User Profile을 로드하는 위의 예제를 통해 살펴보면 다음과 같습니다.

  1. Suspense가 Child Component를 렌더 할 때, 데이터(user profile)를 캐시로부터 읽으려고 시도한다
  2. 데이터가 없으므로, ChildComponent의 "캐시(의 역할을 하는 것)"는 promise를 throw 한다.
  3. Suspense는 이 promise를 받아 fallback을 처리하고 정상적으로 resolve 되었다면 다시 ChildComponent를 보여준다.

 

 

중요한 포인트는 데이터가 로딩 중일 때, "컴포넌트는 Suspense(가장 가까운 Parent에 위치한)에게 promise를 throw" 한다는 것과, 데이터가 "로딩되고 나면(Promise가 Resolve 되고 나면), Suspense는 Fallback UI를 보여주는 것을 멈추고, 다시 정상적으로 컴포넌트를 로딩한다 " 는 점입니다. 익숙하지 않은 내용인만큼, 이에 대한 React Core Team의 개념적 구현(실제 구현은 이보다 조금 더 복잡합니다.)을 통해 이 과정을 다시 한번 살펴보도록 하겠습니다.

 

 

[Prev React Core Team Sebastian Markbåge Example] - Suspense Mechanism

위의 Andrew Clark가 설명한 모델의 개념적 구현입니다. fetchTextSync라는 메서드는 실제 url로부터 데이터를 fetch 하는 역할을 합니다. 이 함수의 구현을 보면, fetch 요청의 완료 여부에 따라 리턴하는 값이 다른데, 아직 로딩 중이면 아직 resolve 되지 않은 promise를 리턴하고, 값이 도착하면 promise가 resolve 되면서 Set 한 캐시의 value를 리턴합니다.

 

 

이는 Suspense의 개념적 구현에서 중요한 의미를 갖는데, 동일한 함수를 동일한 url을 가지고 여러 번 호출했을 때 promise의 resolve 여부에 따라서 리턴(혹은 throw)하는 값이 다르므로 이에 대한 처리가 가능하게 됩니다. 즉, promise를 throw 했을 때는 Suspense에서 이를 잡아서 resolve 될 때까지 fallback UI를 보여주다가  resolve가 되면, 다시 throw 한 곳으로부터 정상적인 컴포넌트 로딩을 처리할 수 있게 되는 것입니다. (아래 runPureTask라는 함수도 이와 개념적으로 동일합니다.)

 

// Infrastructure.js
let cache = new Map();
let pending = new Map();

function fetchTextSync(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
  if (pending.has(url)) {
    throw pending.get(url);
  }
  let promise = fetch(url).then(
    response => response.text()
  ).then(
    text => {
      pending.delete(url);
      cache.set(url, text);
    }
  );
  pending.set(url, promise);
  throw promise;
}

async function runPureTask(task) {
  for (;;) {
    try {
      return task();
    } catch (x) {
      if (x instanceof Promise) {
        await x;
      } else {
        throw x;
      }
    }
  }
}

 

React Official Example Recap

지금까지 살펴본 내용을 바탕으로 위의 React 공식문서 예제를 다시 한번 보겠습니다. Suspense로 감싼 ProfileDetails 라는 컴포넌트 안에 const user = resource.user.read() 라는 메서드 호출이 보이는데, 이 메서드를 가지고 있는 resource는 fetchProfileData 라는 함수의 호출로 가져오게 됩니다.

 

이 fetchProfileData 라는 함수는 비동기 요청을 wrapPromise 라는 함수로 한번 감싸고, 이렇게 감싸진 user 라는 리소스는 read() 라는 메서드를 제공합니다. ProfileDetails 에서는 주기적으로 .read() 메서드를 호출해서 값을 가져오는데, 아직 비동기 요청이 resolve가 되지 않았다면 .read() 메서드는 promise를 부모 Suspense로 Throw하게 되고, resolve가 되었다면 정상적으로 화면을 로드하게 되는 것입니다. 

 

 

fetchProfileData 와 wrapPromise 의 구현은 다음과 같습니다. (아래 주석에도 쓰여있듯, 이는 Suspense와 이를 둘러싼 Data Fetching에 대한 개념적 모델을 구현한 프로토타입이므로 실제로 React가 이렇게 Suspense와 Data Fetching을 처리하지는 않는다는 것을 주의해야 합니다.)

 

export function fetchProfileData() {
  let userPromise = fetchUser();
  let postsPromise = fetchPosts();
  return {
    user: wrapPromise(userPromise),
    posts: wrapPromise(postsPromise)
  };
}

// Suspense integrations like Relay implement
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}

function fetchUser() {
  console.log("fetch user...");
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fetched user");
      resolve({
        name: "Ringo Starr"
      });
    }, 1000);
  });
}

function fetchPosts() {
  console.log("fetch posts...");
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fetched posts");
      resolve([
        {
          id: 0,
          text: "I get by with a little help from my friends"
        },
        {
          id: 1,
          text: "I'd like to be under the sea in an octupus's garden"
        },
        {
          id: 2,
          text: "You got that sand all over your feet"
        }
      ]);
    }, 1100);
  });
}

 

 

Conclusion

Suspense의 Basic Idea를 이해하기 위해 먼 길을 달려온 것 같습니다. 위의 내용들을 요약해서 bullet list로 정리하며 이번 포스팅을 마무리하도록 하겠습니다. 이후 포스팅에서는 이러한 Basic Idea를 가진 Suspense가 Fiber Architecture 안에서 실제로 어떻게 구현되어 있는지를 살펴보도록 하겠습니다.

 

  • React Suspense는 컴포넌트가 무언가를 렌더하기 위해 "wait"할 수 있도록 도와주며, 여기서의 "wait"은 데이터 로딩을 포함한 모든 비동기 요청이 해당될 수 있다.
  • (Error Boundary와 함께) Suspense를 사용하면 개발자는 컴포넌트에서 사용할 데이터가 "정상적으로 로드되었을 때"의 UI만 고려하면 되고, 로딩 중이나 에러의 상태에서의 UI는 위임하면 된다. (하나의 컴포넌트에서 각각의 상태에 대한 관리를 명령적으로 관리할 필요가 없다)
  • Suspense는 Fallback UI를 보여주는 동안에도 Child Component를 렌더한다(단지 보이지 않을 뿐.) 따라서 보다 Concurrent하게 데이터를 요청할 수 있고, 결과적으로 그렇지 않을 때 보다 더 나은 사용자 경험을 제공한다.
  • Suspense가 Fallback UI를 보여주기 위해 사용하는 개념적인 방식은 "promise를 Throw하는 것이다." promise가 throw되면 이 promise가 resolve되기 전까지 Fallback UI를 보여주고 resolve되면, hidden 상태였던 Child Component를 "보여준다"

 

React Core Team의 Dan Abramov는 Suspense가 사용하는 이러한 방식은 "대수적 효과"에서 기인한 것이라고 이야기했습니다. 다음 포스팅에서는 이 대수적 효과가 무엇인지, Suspense가 이 대수적 효과와 어떤 연관이 있는지를 간단하게 살펴보도록 하겠습니다.

 

Reference

https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md

https://17.reactjs.org/docs/concurrent-mode-suspense.html

https://dev.to/darkmavis1980/a-practical-example-of-suspense-in-react-18-3lln

https://maxkim-j.github.io/posts/suspense-argibraic-effect

https://overreacted.io/ko/algebraic-effects-for-the-rest-of-us/

An Introduction to Algebraic Effects and Handlers Paper

https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md

 

 

 

 

반응형