React Suspense

Daren Kim·2024년 2월 19일
0

저번 글에 이어서.. 리액트 18로 넘어오면서 리액트 팀이 구현하고자 했던 동시성의 개념과 컨셉에 대하여 알아보았다.
그 중 중심이라고도 할 수 있는 서스펜스에 대하여 더 깊이 학습하고자 이 글을 쓴다.

Suspense

서스펜스는 다시 정리해보자면 일정 작업이 수행되기 전까지 컴포넌트를 렌더링 시키지 않으며 fallback에 해당하는 컴포넌트를 대신 보여주는 동시성을 위한 기능이다.
먼저 서스펜스에대해 잘 이해하기 이전에 리액트가 어떻게 데이터를 fetching 해오는지 한번 살펴보자

  • Fetch-on-render (for example, fetch in useEffect): 컴포넌트 렌더링을 먼저 시작하고 useEffect나 componentDidMount로 비동기 처리를 합니다.
  • Fetch-then-render (for example, Relay without Suspense): useEffect나 componentDidMount로 화면을 그리는데 필요한 데이터를 모두 조회한 후 렌더링을 시작합니다.
  • Render-as-you-fetch (for example, Relay with Suspense): 비동기 작업과 렌더링을 동시에 시작합니다. 즉시 초기 상태를 렌더링(fallback rendering)하고, 비동기 작업이 완료되면 다시 렌더링합니다.
    react-suspense

Featch-on-Render

우리가 기존에 흔히 사용하던 방식은 Fetch on render. 즉 컴포넌트가 먼저 렌더링 된 이후에 useEffect 혹은 기타 라이브러리를 활용하여 데이터를 비동기적으로 fetch 해 렌더링 하는 방식이다.
컴포넌트가 data fetch 이전에 먼저 렌더링 되기 때문에 필연적으로 컴포넌트 내부에서 분기처리를 통해 명령적으로 로딩 상태를 표시해주곤 했다.

import { useEffect, useState } from "react";
import { fetchData } from "./fetchData";
import Card2 from "./Card2";

function Card() {
  const [data, setData] = useState("");

  useEffect(() => {
    async function getData() {
      const data = await fetchData("/fake-data");
      setData(data);
    }

    getData();
  }, []);

  // 데이터를 가져오기 이전에 로딩 상태를 표시하는 예시
  if (!data) {
    return <h1>...Loading</h1>;
  }

  console.log("Card component is Rendered");
  return (
    <>
      <h1>Vite + React</h1>
      <div className="card">
        <h3>{data.first.comment}</h3>
        <Card2 />
      </div>
    </>
  );
}

export default Card;

위 예시에서 처럼 컴포넌트는 먼저 렌더링 되며 그 이후 data가 서버로부터 전해지기 전까지 사용자는 분기처리된 loaing 상태를 바라보게 된다.
이렇게 작성하는건 코드 유지보수에도 어려움을 주며 가독성을 해치는 원인이기도 하다.
그러나 이보다 더 큰 문제가 있으니 바로 waterfall 문제이다.

위 예시중 Card2 컴포넌트를 살펴보자!

import { useEffect, useState } from "react";
import { fetchData } from "./fetchData";

function Card2() {
  const [data, setData] = useState("");

  useEffect(() => {
    async function getData() {
      const data = await fetchData("/fake-data");
      setData(data);
    }

    getData();
  }, []);

  if (!data) {
    return <h1>...Loading</h1>;
  }

  console.log("Card2 component is Rendered");
  return (
    <>
      <h1>Vite + React</h1>
      <div className="card">
        <h3>{data?.second?.comment}</h3>
      </div>
    </>
  );
}

export default Card2;

기존의 카드 컴포넌트와 거의 비슷하지만 내부에서 역시 data fetching을 진행하고 있다.
눈여겨 봐야 할 부분은 Card2 컴포넌트가 Card컴포넌트 내부에 있다느것.

즉, Card컴포넌트의 data fecting이 완료되기 전까지 Card2 컴포넌트는 어떤 행동도 하지 못한채 기다리게 된다.
뭔가 동시성이라는 개념이 필요하다 라는 느낌이 들지 않는가?


-> 렌더링 되기까지 한참을 기다리고 있는 Card2 컴포넌트...

Render-as-you-fetch

그러나 서스펜스를 통해 우린 데이터를 가져오는 비동기 작업과 렌더링을 동시에 할 수 있게 되었다. 여기서 중요한건 이러한 방식을 명령형이 아닌 보다 선언적으로 할 수 있게 되었다는 것이다.

서스펜스는 감싸고 있는 자식 컴포넌트가 지연되어야 하는 작업이 있다면 해당 작업이 끝날때까지, fallback 프롭으로 받는 컴포넌트를 렌더링한다.

여기서 중요한 부분은 자식 컴포넌트는 내부적으로 프로미스를 throw 해야 한다.
따라서 일반적으로 사용하던 fetch api 형식으로 suspense를 사용하게 되면 정상적으로 동작하지 않게 된다.
이는 프로미스의 pending, fulfilled, error 상태의 감지를 통해 데이터가 준비가 되었는지 안되었는지를 판단하기 위함이다.

  • 서스펜스를 통해 자식 컴포넌트가 렌더링
  • 렌더링 되는 자식 컴포넌트는 promise를 throw 하는 fetching 로직으로 데이터 요청
  • 서스펜스는 이를 감지하여 프로미스의 상태에 따라 fallback UI 혹은 자식 컴포넌트를 렌더링
  • 렌더링과 데이터 페칭이 동시성에 의해 동시에 일어남

위와 같은 흐름으로 진행되게 된다.


-> 서스펜스로 바꿔준 후 동시에 Card1과 Card2 컴포넌트가 요청을 시작하는걸 볼 수 있다.

이를 통해 리액트는 다양한 부분에서 성과를 거뒀다.

  • 컴포넌트들이 각자 데이터 fetching을 동시에 시작하게 되어 waterfall 문제가 발생하지 않는다.
    --> 이제 data fetching이 컴포넌트 트리의 구조적인 부분의 흐름보다 컴포넌트 각자의 영역이 되어 독립적으로 시행 될 수 있게 되었다.

  • ErrorBoundary와 함께 사용시 정상적으로 로드되었을 때”의 UI만 고려하면 되고, 로딩 중이나 에러의 상태에서의 UI는 위임하면 된다.
    --> 하나의 컴포넌트에서 각각의 상태에 대한 관리를 명령적으로 관리할 필요가 없다.

  • 컴포넌트들의 역할이 아주 명확하게 분리되고 결합도가 낮아진다.

적용해보니..

실제로 간단하게 Suspense 활용 예제를 구현해보니 꽤나 흥미로운게 여러가지 있었다.
일단 코드를 보자!

import React, { Suspense } from "react";
import Posts from "./Posts";

function User({ resource }) {
  console.log("찍힌다");
  const user = resource.user.read();
  console.log("안찍힌다");

  return (
    <div>
      <p>
        {user.name}({user.email}) 님이 작성한 글
      </p>
      <Suspense fallback={<p>글목록 로딩중...</p>}>
        <Posts resource={resource} />
      </Suspense>
    </div>
  );
}

export default User;

서스펜스는 위처럼 간단하게 적용이 가능하다. fallback과 children을 잘 전달만 해주면 된다. 최상단에 있는 read 메소드를 통해 데이터를 가져온다.(react 문서를 보며 자체 구현한 fetch 메소드이니 양해부탁드립니다..)
해당 User 컴포넌트 역시 서스펜스로 감싸져 있는데 서스펜스가 어떻게 렌더링을 중단시키고 fallback을 보여주는지, 특히 렌더링을 실제로 시키는지 안시키는지가 궁금해져 콘솔에 기록을 해봤더니...

동시적으로 fetching이 일어나는건 예상했던 부분이지만 data fetching이 완료되기까지 수없이 많이 찍힌 "찍힌다"와 데이터 페칭이 완료되자, read 메소드 아래 콘솔이 찍힌 과정이 흥미로워 조사를 해보았다. 물론 정상적인 동작이 아니라고 생각했다.

이는 바로 suspense가 참조하는 promise가 지속적으로 다른값을 참조하기 때문이다.

그럼 promise 호출을 컴포넌트 바깥으로 빼면 되려나?

이게 무슨소리인고.. 하면 서스펜스를 통해 렌더링되는 자식 컴포넌트는 fulfilled가 되기 전까지 마운트와 언마운트를 반복한다. 즉 그 과정에서 리액트는 계속해서 새로운 프로미스 객체를 만드는 과정을 반복한다.

해당 가설을 증명하고자 useRef를 통해 실험해보았다.

function User({ resource }) {
  const ref = useRef(Math.ceil(Math.random() * 100));
  console.log(ref.current);
  const user = a.user.read();
  console.log("안찍힌다");

  return (
    <div>
      <p>
        {user.name}({user.email}) 님이 작성한 글
      </p>
      <Suspense fallback={<p>글목록 로딩중...</p>}>
        <Posts resource={a} />
      </Suspense>
    </div>
  );
}


--> 지속적으로 새로운 값이 찍히는 ref...

리렌더링이 아니었다! 그럼 반복적으로 새로운 promise객체를 어떻게 고정시킬 수 있을까?
당연히 이를 해결하기 위해선 캐싱을 통해 해당 루프를 끊어 낼 수 있겠지만 현재 간소하게 구현된 fetch 함수에선 promise를 캐싱해주는 기능이 없기에 .. 이론적으로만 이해하고자 한다.

결론적으로, 서스펜스는 프로미스를 감지하며 이때마다 새롭게 컴포넌트를 마운트, 언마운트 해가며 fallback을 렌더링 할지, 하위 컴포넌트를 렌더링 할지 판단하고 있었다.
실제로 소스코드에서도 확인할 수 있듯, 리액트는 둘다 렌더링을 시키지만 하위 컴포넌트의 경우 display 속성을 none으로 설정해 레이아웃에서 제외키셔 렌더링에 영향을 끼치지 않게 하는걸 확인할 수 있다.

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;
}
export function hideInstance(instance: Instance): void {
  // TODO: Does this work for all element types? What about MathML? Should we
  // pass host context to this method?
  instance = ((instance: any): HTMLElement);
  const style = instance.style;
  // $FlowFixMe[method-unbinding]
  if (typeof style.setProperty === 'function') {
    style.setProperty('display', 'none', 'important');
  } else {
    style.display = 'none';
  }
}

suspense deepdive
실제 깃허브에서 해당 내용을 찾고 싶었으나.. 소스코드를 보는 연습이 부족해 찾지 못했다..

마치며

이처럼 지금까지의 다소 복잡? 할 수 있던 data fetching 메커니즘에서 서스펜스와 동시성을 통해 간결하고 선언적인 UI로의 색채를 더해가는 리액트의 흐름을 엿볼 수 있는 좋은 기회였다. 관련하여 서스펜스와 소스코드 역시 깊게 파보고자 했으나, 들여다 보면 볼수록..이해가 어려운 부분이 많아 난항을 겪었다.
마음속에 약간은 큰 숙제를 남기며... 추후에는 리액트 서버 컴포넌트에 대하여 학습하고자 한다.

참고자료

React Dev
카카오 FE 기술 블로그
콴다 서스펜스 글
리액트 suspense DeepDive

profile
안녕하세요!여기저기관심많은FE개발자지망생입니다.

0개의 댓글