Suspense?

Suspense는 React 17에서 공개된 기능이다.

// Lazy-loaded
const ProfilePage = React.lazy(() => import("./ProfilePage"));

// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>;

Suspense의 자식 컴포넌트에서 발생하는 렌더링의 유예상태를 탐지하고, 그 동안 자식 컴포넌트 대신 fallback에 선언된 컴포넌트로 대체하여 그려준다.

데이터나 자바스크립트 코드의 변경이 유예되는 경우, 이를 UI/UX 적으로 표현해주기 위해서는 컴포넌트 내에 임의의 상태값을 만들어 필요에 따라 초기화/변경해가면서 특정 JSX 요소를 렌더링해야 한다. 특히 하나의 UI를 표현하기 위해 필요한 여러 데이터가 동시에 유예되고 이를 각기 다르게 표현해주어야 하는 경우, 이는 아주 복잡해진다.

Suspense는 컴포넌트 안에서 유예 상태를 관리하기 위해 임의의 상태값을 추가/변경해야하는 모든 경우의 수를 제거하기 때문에 이 부분에서 아주 유용하다.

React의 공식 라이브러리인 Relay, Recoil 등은 Suspense를 공식적으로 지원하는 React 18이 릴리즈되기 전부터 이를 지원하고 있었고, 특히 우리가 사용하는 Recoil은 Suspense의 사용을 구조적으로 강제하고 있기 때문에 어쩔 수 없이 Suspense를 선제적으로 사용하고 있었다.

Suspense의 치명적 단점

반복하자면, Suspense는 자식 컴포넌트의 렌더링이 유예되는 경우, fallback 으로 선언된 컴포넌트를 대신 렌더링한다. 그 말은, 컴포넌트의 렌더링이 유예되는 경우 이전 상태의 컴포넌트를 보여주지 않는다는 뜻이다.

예를 들어 reports: Report[] 데이터로 그려지는 아래와 같은 <ReportList /> 컴포넌트가 있다고 해보자.

const ReportList = (): JSX.Element => {
  const [page, setPage] = useState(1);

  const reports: Report[] = useRecoilValue(reportsState({ page }));

  const handleChangePage = (newPage: number) => {
    setPage(newPage);
  };

  return (
    <Table>
      {reports.map(report => <ReportItem report={report} />)}
    </Table>
    <Pagination data={reports} onChange={handleChangePage} />
  );
}

const App = () => {
  <Suspense fallback={<Loading />}>
    <ReportList />
  </Suspense>
}

페이지를 변경할 때마다 reports: Report[]는 API를 통해 새로운 데이터를 요청할 것이고, 데이터가 로딩되는 동안 <ReportList />의 렌더링은 유예될 것이다.

<ReportList />의 렌더링이 유예되는 동안 Suspense는 자동으로 fallback 컴포넌트인 <Loading />을 렌더링한다.

이는 위처럼 사용자가 페이지를 이동할 때마다 화면 전체를 갈아끼운다.

다행히 네트워크가 느리면 위처럼 데이터의 변경을 유저가 알 수 있기 때문에 좋은 UX처럼 보여지기도 하지만, 바쁘다 바빠 현대사회에서는 네트워크가 아주 빠르기 때문에 대부분 아래처럼 보이게 된다.

이는 전통적인 SSR(Server Side Rendering)에서는 결코 일어나지 않았던 현상이었다. SSR에서는 어차피 전체 페이지를 새로 렌더링할 것이고, 브라우저는 다음 요청이 완료될때까지 이전 화면을 보여줬다.

CSR(Client Side Rendering) 이 처음 적용되던 시기에 이런 문제를 겪은 웹기획자/웹디자이너들은 기함을 금치 못했다. 화면 전체가 아닌 일부가 교체되는 동안 발생하는 이런 깜빡임은 사용자 입장에서 문제처럼 받아들여졌고, 이후 이 문제를 해결하기 위한 방법론들이 우후 죽순 등장했다. 그렇게 이 문제는 우리 기억 속에서 사라져가고 있었다.

결국 Suspense의 사용은 이런 오래된 문제를 다시 부활시키고 만 것이다.

어쩌면 쉽게 해결될지도?

이전 상태를 저장하기 위한 별도의 상태값을 선언한다면 생각보다 쉽게 해결되지 않을까?

다행히 Recoil은 Suspense를 사용하지 않기 위한 Loadable 기능을 제공하고 있다. 하지만 이 역시도 데이터의 변경이 유예되는 동안 Loadable.contents를 빈 상태로 전달하기 때문에, 이전 상태를 그려주기 위해서는 별도의 상태값을 선언하여 이전 상태를 저장해야 한다.

const ReportList = (): JSX.Element => {
  const [page, setPage] = useState(1);

  // Suspense를 사용하지 않기 위해 Loadable로 변경
  const reportsLoadable = useRecoilValueLoadable(reportsState({ page }));
  // 이전 상태를 저장하기 위한 로컬 상태값 추가
  const [reports, setReports] = useState<Report[]>([]);
  // Recoil 상태값이 변경되면 reports 변경
  useEffect(() => {
    if (reportsLoadable.state === 'hasValue') {
      setReports(reportsLoadable.contents);
    }
  }, [
    reportLoadable.state,
    reportLoadable.contents,
  ]);

  const handleChangePage = (newPage: number) => {
    setPage(newPage);
  };

  return (
    <Table>
      {reports.map(report => <ReportItem report={report} />)}
    </Table>
    <Pagination data={reports} onChange={handleChangePage} />
  );
}

// Suspense의 사용을 제거
const App = () => {
  // <Suspense fallback={<Loading />}>
  <ReportList />
  // </Suspense>
}

그러나 이런 수정은 꽤나 고통스럽다.

  • Recoil과 Suspense가 가져다주는 유려한 관심사 분리의 장점을 모조리 깨트린다.
  • 데이터를 사용하는데에 Boilerplate를 추가함으로 코드의 유지보수성을 저하시킨다.
  • useEffect를 사용한 상태 변경은 언젠가 분명히 고통으로 되돌아온다.

이럴거라면 그냥 Recoil과 Suspense를 모두 없던 이전 상태로 돌아가는게 나을 것만 같다. 하지만 이미 맛본 Recoil과 Suspense의 달콤함을 이렇게 떠나보낼 수는 없다.

Suspense를 구하러온 영웅, Transition

이러한 문제를 해결하기 위해 React 18에서는 Transition을 새로이 추가했다.

function App() {
  const [isPending, startTransition] = useTransition();
  const [count, setCount] = useState(0);

  function handleClick() {
    startTransition(() => {
      setCount((c) => c + 1);
    });
  }

  return (
    <div>
      {isPending && <Spinner />}
      <button onClick={handleClick}>{count}</button>
    </div>
  );
}

Transition을 사용하면 특정 값의 변경으로 인한 React 컴포넌트의 리렌더링을 해당 값이 완전히 변경된 이후로 유예시킬 수 있다.

Recoil 역시 이 문제를 심각하게 여기고 있었기 때문에 발빠르게 대응하였고, 비록 아직 공식은 아니지만 0.6.0 버전부터 TRANSITION_SUPPORT_UNSTABLE을 사용할 수 있게 되었다.

기존 <ReportList />에 이를 적용해 보자.

const ReportList = (): JSX.Element => {
  const [page, setPage] = useState(1);

  // useTransition 추가
  const [, startTransition] = useTransition();
  // transition을 지원하는 새로운 recoil 훅 사용
  const reports: Report[] = useRecoilValue_TRANSITION_SUPPORT_UNSTABLE(reportsState({ page }));

  const handleChangePage = (newPage: number) => {
    // 데이터 변경을 일으키는 함수에 Transition 적용
    startTransition(() => {
      setPage(newPage);
    });
  };

  return (
    <Table>
      {reports.map(report => <ReportItem report={report} />)}
    </Table>
    <Pagination data={reports} onChange={handleChangePage} />
  );
}

const App = () => {
  // 다른 상태값의 변경을 대응하기 위한 Suspense 유지
  <Suspense fallback={<Loading />}>
    <ReportList />
  </Suspense>
}

이로서 보다 적은 변경과, 데이터의 유예 상태의 관리를 여전히 컴포넌트 외부에 유지하면서 문제를 개선할 수 있게 되었다. 결과적으로 아래처럼 어플리케이션은 개선되었다.

✨✨ 완벽하다. ✨✨

이제 즐겁고 안전하게 Suspense, Recoil, 그리고 Transition을 사용할 수 있게 되었다!