NextJs에 React18 Suspense 적용하기 (with react-query 적용)

sy u·2022년 7월 13일
56

🚪 들어가기

React Suspense는 React 16에서 릴리즈된 기능이다. React 18에서는 추가된 기능을 제공하고 있고 Error Boundary와 합친 기능으로도 사용할 수 있다고 하여 적용해보려한다.
이 글에서는 React 16에서의 Suspense와 18에서의 Suspense를 다룰 것이고 실제로 적용해 볼 것이다.
또한 react-query를 사용하여 Data를 fetching하고 있기 때문에 react-query에서 Suspense를 처리하려고 한다.

🔎 React 16에서 (legacy) Suspense

React 16에서 Suspense는 React.lazy와 함께 사용하는 사례 하나만 존재했다.
React.lazy는 코드 스프리팅을 위한 API이다.

React.lazy를 통해 동적으로 import한 컴포넌트에 필요한 코드가 아직 다운로드 되지 않았거나 DOM 트리에 표시되지 않았을 경우 Lodaing Indicator가 제공된다.

✏️ 정의

Suspense의 핵심은 Promise를 throw 하는 것이다.
Promise가 resolve(성공) 하거나 reject(실패) 할 때까지 컴포넌트 트리의 실행을 연기한다.

컴포넌트 트리의 실행이 연기되는 동안 컴포넌트 트리는 UI에서 숨겨진다.
DOM 트리에서 삭제되는 것이 아니라 해당 컴포넌트에 display: none 스타일을 추가하는 것이다.

🏃 실행 순서

  1. React 엘리먼트는 DOM 트리에 마운트 된다.
  2. 라이프사이클 메서드나 Effects Hook이 실행된다.
  3. Suspense가 트리거 될 때, DOM 트리에서 숨겨진다.
  4. 컴포넌트가 모두 로드되면 DOM 트리에서 보이며 화면에도 보인다.

🛑 문제점

- Server-side Rendering 에서는 사용할 수 없다.

- 라이프사이클 이벤트가 불일치하는 일이 발생한다.

이러한 작동은 Promise가 해결되기 전에 컴포넌트가 실행되어 라이프사이클 이벤트가 불일치하는 일이 발생한다.
컴포넌트가 브라우저 상에서 보이진 않지만 해당 컴포넌트는 rendering 단계를 거쳐 커밋 된다. 이는 컴포넌트가 실제 DOM에 업데이트되는 것을 의미한다. 만약 컴포넌트에서 useEffect (컴포넌트가 브라우저 화면에 업데이트되면 실행되는 Hook)를 사용하고 있다면 컴포넌트는 우리에게 보이지 않지만 해당 useEffect는 실행될 것이다.

사용방법

아래의 예제는 React 공홈에서 제공하고 있다.

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    // Displays <Spinner> until OtherComponent loads
    <React.Suspense fallback={<Spinner />}>
      <div>
        <OtherComponent />
      </div>
    </React.Suspense>
  );
}
  • React.lazy를 사용하여 컴포넌트를 동적으로 import 한다.
  • import 한 컴포넌트를 Suspense 컴포넌트로 감싼다.
  • Suspense 컴포넌트에 fallback prop으로 Lodaing Indicator UI를 전달한다.
  • Suspense 컴포넌트를 중첩하여 배치할 수 있다.
    - 이런 경우 가장 가까운 Suspense 컴포넌트에 포착되고 해당 Suspense 컴포넌트의 fallback UI를 화면에 표시한다.

🔎 React 18에서 (Concurrent) Suspense

React 18에서 Suspense는 이전 Suspense의 단점을 보완하며 새로운 기능도 추가되었다.

✏️ 정의

Suspense의 핵심은 Promise를 throw하는 것이다.
Promise가 resolve 하거나 reject할 때 까지 컴포넌트의 실행을 연기한다.

컴포넌트 트리의 실행이 연기되는 동안 해당 컴포넌트는 DOM 트리에 존재하지 않는다.
실행이 완료되지 않는 컴포넌트는 커밋 되지 않는다. 따라서 컴포넌트가 완료되고 DOM 트리에 배치되며 브라우저 화면에 업데이트되기 때문에 라이프사이클 이벤트가 불일치하는 일이 발생하지 않는다. (우리가 화면에서 해당 컴포넌트를 볼 때, useEffect Hook이 실행되는 것이다.)

🏃 실행 순서

  1. React 엘리먼트는 컴포넌트가 로드되기 전에 DOM에 마운트되지 않는다.
  2. DOM에 업데이트 되고 Effects Hook이 실행된다.

🚀 달라진 점

New Suspense SSR Architecture in React 18
글을 읽고 이해한 내용을 작성한 부분입니다.

1. Server-side Rendering (SSR) 지원

먼저 React에서 SSR 과정을 살펴보면
1. 전체 app에 대한 data를 서버에서 fetching 한다.
2. data fetching이 완료되면 전체 app을 HTML로 렌더링하고 클라이언트에 응답으로 전달한다.
3. HTML을 전달받은 후 클라이언트는 app에 대한 모든 JavaScript 코드를 로드한다.
4. JavaScript 코드를 로드한 후, 서버에서 렌더링 된 HTML에 JavaScript 로직을 연결한다.

여기서 문제가 되는 점은 위의 각 단계가 한 번에 완료되고 다음 단계로 넘어간다는 것이다. 따라서 발생하는 문제점에 대해 자세히 알아보자.

기존 SSR 방식 문제점

1. HTML을 렌더링 하기 전에 app에 필요한 모든 데이터를 가져와야한다.
서버에서 HTML 렌더링을 시작하려면 모든 데이터가 준비되어 있어야 한다.
즉, 데이터가 수집되지 않으면 HTML 렌더링을 시작하지 못해 클라이언트에 어떠한 HTML로 전송할 수 없다는 것을 의미한다.

만약 초기 화면 일부에 필요한 데이터가 준비되는 시간이 늦어지면 초기 화면의 나머지 HTML 렌더링도 늦어지며 전송도 지연된다.

2. hydrate를 시작하기 전에 필요한 모든 JavaScript 코드가 로드되어야 한다.
서버에서 렌더링된 HTML이 전달되면 정적인 HTML을 상호작용 가능하게 하기 위해 이벤트 핸들러를 연결해야 한다. 이때 브라우저 컴포넌트에 의해 생성된 컴포넌트 트리가 서버에서 생성된 트리와 일치해야 한다. 일치하지 않는다면 React는 HTML과 JavaScript 코드를 매치할 수 없다. 이 때문에 hydrate를 진행하기 전 모든 JavaScript를 로드해야 하는 것이다.

화면의 일부분(컴포넌트)에 상호작용할 로직이 많이 존재할 때, 이에 따른 JavaScript 로직을 다운로드하는데 시간이 많이 걸릴 경우 비교적 상호작용 로직이 적은 화면(컴포넌트) HTML에도 hydrate를 시작할 수 없다.

3. 상호작용을 시작하기 전, 모든 HTML에 hydrate가 완료되어야 한다.
hydrate가 시작되면 React는 hydrate가 완료될 때까지 멈출 수 없으며 다른 작업을 할 수 없다.
즉, 화면 일부에 필요한 코드 다운로드가 느리다면 navigation 바 또는 사이드 바와 같은 부분도 hydrate가 되지 않아 해당 페이지에서 벗어나거나 다른 컨텐츠와 상호작용할 수 없다. 만약 느린 네트워크를 사용하는 사용자가 있다면 그 사용자는 HTML만 보고 있을 뿐 어떠한 작업도 할 수 없다는 것을 의미한다.

React 18에서 개선된 점

Streaming HTML: 모든 데이터를 가져오기 전 HTML 스트리밍

Suspense로 감싸진 컴포넌트는 필요한 데이터가 수집되지 않아도 화면의 다른 부분이 해당 데이터 수집을 기다리지 않고 데이터가 수집된 다른 부분부터 스트리밍 된다.
이후, Suspense로 감싸진 컴포넌트에 필요한 데이터가 수집된다면 해당 컴포넌트는 인라인 script 태그와 함께 스트리밍 된다.

예를 들어 아래와 같은 코드가 있다고 가정해보자

<AppLayout>
  <MenuBar />
  <SideBar />
  <Suspense fallback={<div>loading...</div>}>
    <PostList />
  </Suspense>
</AppLayout>

PostList 컴포넌트는 Suspense 컴포넌트 하위에 위치하고 있다. 이는 MenuBar나 SideBar HTML을 스트리밍 하기 위해서 PostList에 필요한 모든 데이터를 받아오기를 기다릴 필요가 없다는 것을 의미한다. 대신에 React는 fallback UI를 PostList 위치에 표시한다.

아래와 같이 초기 HTML에는 PostList가 존재하지 않는다.

<main>
  <nav>
    <!--MenuBar -->
    <a href="/"></a>
  </nav>
  <section>
    <!-- SideBar -->
    <a href="/profile">프로필</a>
    <a href="/profile">정보 수정</a>
  </section>
  <section id="postlsit-fallback">
    <!-- fallback UI -->
    <div>loading...</div>
  </section>
</main>

PostList에 필요한 Data가 수집되면 React는 PostList HTML을 해당 위치(PostList가 배치되어야 할)에 배치하기 위해 인라인 script 태그와 함께 HTML을 스트리밍 한다.

<div hidden id="post-list">
  <!-- Post Items -->
  <p>Post1</p>
  <p>Post2</p>
</div>
<script>
  ...
</script>

Selective Hydration: 모든 JavaScript 코드가 로드되기 전에 hydrate를 시작한다.

번들 사이즈가 커지는 것을 방지하기 위해 코드 스플리팅을 사용한다. 분리된 코드 조각은 동기적으로 로드될 필요가 없고 번들러는 이를 별도의 script 태그로 분할한다.

코드 스플리팅을 사용하기 위해 React에서는 React.lazy로 코드를 분할할 수 있고 Next에서는 next/dynamic 을 사용하여 주 번들에서 코드를 분할할 수 있다.

우리는 위에서 살펴본 예제 PostList를 코드 스플리팅 해보자.

import React, { Suspense } from 'react'

const PostList = React.lazy(() => import('./PostList.js'));

<AppLayout>
  <MenuBar />
  <SideBar />
  <Suspense fallback={<div>loading...</div>}>
    <PostList />
  </Suspense>
</AppLayout>

Suspense 컴포넌트는 PostList 코드가 로드되기 전에 다른 부분에 hydrate를 시작한다.


PostList에 대한 코드가 로드되지 않았지만 다른 부분들은 상호작용이 가능해졌다.

즉, Suspense가 감싸진 컴포넌트가 다른 컴포넌트의 HTML 스트리밍이나 hydrate를 막을 수 없다는 것이다.

이후 PostList에 대한 코드가 로드되면 React는 PostList에도 hydrate를 시작한다.

Selective Hydration: 모든 HTML이 스트리밍 되기 전에 이미 스트리밍 된 부분의 hydrate를 시작할 수 있다.

만약 PostList의 HTML이 아직 스트리밍 되지 않았을 때, 다른 부분의 스트리밍이 완료되고 JavaScript 코드 또한 로드 되었다면 PostList의 HTML 스트리밍을 기다리지 않아도 된다.

다른 부분의 코드가 로드되었다면 hydrate를 시작한다.

PostList의 HTML이 스트리밍 되면 아직 JavaScript 코드는 로드되지 않았기 때문에 정적인 HTML 화면만 나타난다.

PostList의 JavaScript 코드가 로드되었다면 hydrate를 시작한다.

Selective Hydration: 모든 컴포넌트가 hydrate 되기 전 페이지와 상호작용할 수 있다.

위에서 hydrate 작업을 시작하면 React는 다른 작업은 실행하지 못하고 hydrate를 완료해야 한다고 했었다.
하지만 이제 hydrate 작업이 진행 중이어도 브라우저는 다른 작업을 할 수 있다.
PostList의 hydrate 작업이 진행 중일 때, 사용자가 Menubar의 홈을 클릭하여 페이지를 벗어날 수 있다는 것을 의미한다.

Selective Hydration: 다른 부분의 hydrate 작업이 실행 중일 때 아직 코드가 로드되지 않은 부분을 사용자가 상호작용 시도했을 때, 하던 작업을 멈추고 상호작용을 시도한 부분의 hydrate를 시작한다.

React는 사용자 상호작용을 기준으로 화면의 가장 급한 부분을 우선시하여 hydrate를 시작한다.

다른 부분도 Suspense로 감싸보자.

import React, { Suspense } from 'react'

const PostList = React.lazy(() => import('./PostList.js'));
const SideBar = React.lazy(() => import('./SideBar.js'));

<AppLayout>
  <MenuBar />
  <Suspense fallback={<div>loading...</div>}>
    <SideBar />
  </Suspense>
  <Suspense fallback={<div>loading...</div>}>
    <PostList />
  </Suspense>
</AppLayout>


위 코드를 확인하면 SideBar도 Suspense 컴포넌트 하위에 위치한다.
SideBar와 PostList는 HTML은 스트리밍 됐지만 JavaScript 코드는 아직 로드되지 않았다.

이후 SideBar와 PostList의 코드가 포함된 번들이 로드된다.

React는 트리에 더 가까운 SideBar 먼저 hydrate를 시작한다.
이때 사용자는 PostList 중 하나의 Post를 클릭했다고 가정하면

React는 클릭 이벤트의 capture 단계에서 PostList의 hydrate를 동기적으로 실행한다.

이벤트가 타깃 요소인 PostList에 전달될 때, hydrate는 완료되면 이벤트 핸들러를 실행할 것이다.
그런 다음 긴급하게 처리할 일이 없는 React는 다시 SideBar의 hydrate를 실행한다.

2. Data Fetching 지원

서버에서 많은 양의 데이터를 가져오거나 낮은 네트워크 환경을 사용하고 있는 사용자는 데이터를 가져오는 시간 아무런 컨텐츠를 보지 못할 수 있다.
이는 지금 사용자가 어떤 일이 벌어지고 있는지 모른다는 것을 의미한다.

Suspense 컴포넌트를 사용하면 데이터를 가져오는 동안 fallback UI를 표시하여 사용자에게 더 좋은 경험을 제공할 수 있다.

Suspense에 관해 알아보았으니 실제로 적용하려고 한다.
당시 나는 React17과 NextJs11을 사용하고 있었다. Suspense의 더 많은 이점을 누리기 위해 버전업을 결정하였다.

⬆️ NextJS 12, React 18 버전 업

설치

npm install next@latest react@latest react-dom@latest

버전업 후 발생한 오류

걱정한 것보다 많은 오류가 발생하지 않았다.
NextJs12는 React17부터 지원하기 때문에 17보다 하위 버전을 사용 중이라면 버전을 업그레이드해야한다.
나는 17을 사용하고 있었기 때문에 필수적으로 버전업을 할 필요는 없었지만 React18이 제공하는 Concurrent 모드와 추가적으로 지원하는 Suspense 기능을 사용하기 위해 React18 (react-dom도 마찬가지)로 업그레이드했다.

1. peerDependencies

peerDependencies란?
사용하고 있는 라이브러리에서 권고하는 패키지 버전
node_modules/[라이브러리]에 들어가면 package.json에서 확인할 수 있다.


위 콘솔 에러를 살펴보면 어떠한 이유에서 이러한 에러가 발생했는지 몇 가지 예시를 보여준다. 이에 따라 오류의 원인을 파악해 보자!

  1. React 및 렌더러의 버전이 일치하지 않을 수 있다.(예: React DOM).
    ➡️ npm show react version 명령어를 사용하여 react18 과 react-dom18버전이 제대로 설치되었는지 확인했다. 결과는 제대로 된 버전이다.
  2. Hooks 규칙을 위반했을 수 있다.
    ➡️ Hooks 규칙을 위반했다면 버전업을 하기 전에도 발생했을 오류이기 때문에 패스하기로 했다.
  3. 같은 app에 React 복사본이 두 개 이상 있을 수 있다.
    ➡️ 1번 2번 항목이 아니란 걸 확인한 나는 에러가 발생하는 컴포넌트를 확인하고 싶었다. 따라서 컴포넌트를 하나씩 지워보며 테스트해 본 결과 redux의 state를 참조하는 컴포넌트가 존재할 경우 이러한 에러가 발생했다.
    node_modules/react-redux 폴더의 package.json을 확인한 결과 내가 사용하는 react-redux가 react18을 지원하지 않고 있었다.
    함께 사용하는 redux-toolkit 또한 마찬가지였고 나는 이 라이브러리들을 최신 버전으로 업그레이드 했다. 그러나 계속해서 에러는 발생했다. host 컴포넌트에서는 에러가 발생하지 않는 것으로 봐서 styled-components를 사용하는 컴포넌트에서 오류가 발생한 것이라고 판단한 후, 이 라이브러리 또한 버전을 업그레이드하였다.

2. suspense를 사용하는 서버 컴포넌트(page 컴포넌트)에서 script 태그 사용

🧑‍💻 Suspense 적용하기

1. react-query에 Suspense 적용하기

react-query에 suspense를 적용하는 방법은 간단하다. suspense 옵션을 true로 설정해 주면 된다.

suspense 옵션 설정하기

1. 전역으로 설정하기

import { QueryClient, QueryClientProvider } from 'react-query'
 
const App = ({ Component, pageProps }: AppProps) => {
 const queryClientRef = useRef<QueryClient>();
  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient({
      defaultOptions: {
        queries: {
          suspense: true,
        },
      },
    });
  }
   return (
     <QueryClientProvider client={queryClientRef.current}>
       <Component {...pageProps} />
     </QueryClientProvider>
   )
 }

2. 개별 쿼리에 설정

 import { useQuery } from 'react-query'
 
 useQuery(queryKey, queryFn, { suspense: true })

React.Suspense 컴포넌트 사용하여 data 로드 중 fallback UI 표시하기

Suspense 모드를 사용하게 되면 status 상태나 isLoading은 필요하지 않다.
React.Suspense 컴포넌트 사용으로 대체할 수 있다.

suspense 적용 전

사용하는 컴포넌트의 일부분을 요약하여 가져온 코드이다.
useQuery가 반환하는 isLoading 을 사용하여 데이터를 가져오는 중에는 fallback UI가 표시되도록 설정하였다.

import { useQuery } from 'react-query';
import { AxiosError } from 'axios';
import LoadingFallback from './LoadingFallback';

const UserList = () => {
  const { isLoading, data: users } = useQuery<
    Users[] | undefined,
    AxiosError
  >(queryKey, queryFn);

  return (
    <div>
        {isLoading ? (
        	<LoadingFallback />
        ) : (
        	users?.map((user) => (
            <Card key={friend.id} user={user}>
          )
       )}
    </div>
  );
};

export default UserList;

suspense 적용 후

import { useQuery } from 'react-query';
import { AxiosError } from 'axios';
import LoadingFallback from './LoadingFallback';

const UserList = () => {
  const { data: users } = useQuery<Users[] | undefined,
    AxiosError
  >(queryKey, queryFn, { suspense: true });

  return (
    <div>
        {isLoading ? (
        	<LoadingFallback />
        ) : (
        	users?.map((user) => (
            <Card key={friend.id} user={user}>
          )
       )}
    </div>
  );
};

export default UserList;
import { useQuery } from 'react-query';
import { AxiosError } from 'axios';
import UserList from './UserList';
import LoadingFallback from './LoadingFallback';

const UserListParents = () => {
  return (
    <div>
       <Suspense fallback={<LoadingFallback />}>
       		<UserList />
       </Suspense>
    </div>
  );
};
export default UserListParents;

useQuery에 suspense 옵션을 true로 설정한 후 useQuery를 사용하는 컴포넌트의 상위 컴포넌트에 Suspense 컴포넌트로 감싸주었다.

2. Streaming과 Hydrate

나는 NextJs를 사용하고 있기 때문에 React.lazy가 아닌 next/dynamic을 사용하기로 했다.

마이 페이지의 Sidebar 컴포넌트 내부에 Suspense를 적용해 보았다.

import React, { Suspense } from 'react';
import dynamic from 'next/dynamic';

import SideBarTabMenu from '@/components/molecules/SideBarTabMenu';
import { SideBarWrapper } from './style';

const ProfileInfo = dynamic(() => import('./ProfileInfo'), {
  suspense: true,
  ssr: false,
});

const SideBar = () => {
  return (
    <>
      <SideBarWrapper>
        <Suspense
          fallback={
            <div>
              {console.log('profile info loading...')}
              profile info loading...
            </div>
          }
        >
          <ProfileInfo />
        </Suspense>
        <Suspense
          fallback={
            <div>
              {console.log('tab menu loading...')}
              tab menu loading...
            </div>
          }
        >
          <SideBarTabMenu />
        </Suspense>
      </SideBarWrapper>
    </>
  );
};

export default SideBar;

처음 초기 스트리밍된 HTML을 확인해보자

초기 HTML에 fallback UI로 설정한 내용이 들어가 있는 걸 확인할 수 있다.

에러 발생

fallback UI도 확인하고 렌더링 된 내용도 확인했지만 에러가 발생했다.
에러의 내용은 아래와 같다.

Unhandled Runtime Error
Error: Text content does not match server-rendered HTML.

에러 원인

서버에서 보내준 트리와 클라이언트에서 생성된 트리가 다르기 때문이다.
나의 경우에는 Sidebar 컴포넌트를 마이페이지와 다른 사용자의 프로필 페이지에서 사용하고 있는데 마이페이지일 경우 버튼에 다른 텍스트를 표시하는 것이 에러 발생의 원인이었다.

에러 해결

사실 이렇게 발생한 에러는 크리티컬하지는 않다.
hydrate에 실패해도 클라이언트 렌더링으로 전환하기 때문에 페이지는 정상적으로 렌더링 된다.

console에 표시된 내용
There was an error while hydrating this Suspense boundary. Switched to client rendering.

해결하는 것이 마음이 편하기 때문에 해결하자면 useState와 useEffect를 사용하면 된다.

// 기존 (실제 코드를 요약하고 변경한 내용이다.)
...
<Button>{loginUser === profile.id ? '프로필 변경' : '매칭 신청' }</Button>
...
// 수정 (실제 코드를 요약하고 변경한 내용이다.)
...
const [btnText, setBtnText] = useState('');

useEffect(() => {
	if (loginUser === profile.id) {
    	setBtnText('프로필 변경');
        return;
    }
    setBtnText('매칭 신청);
}, [loginUser]);
<Button>{btnText}</Button>
...

이렇게 변경하여 서버에서 전달된 트리와 클라이언트 트리를 일치시킬 수 있다.

글을 작성한 이후, 다른 해결방법을 적용했는데 이는 추후 추가할 예정이다.

🗃️ 참고했던 글

New Suspense SSR Architecture in React 18

nextjs-suspense

How Suspense Works in React 18

How to use React Suspense for Code-Splitting?

Dynamic Imports

Practical data fetching with React Suspense that you can use today

Understanding Suspense-ful coding in React

1개의 댓글

comment-user-thumbnail
2022년 9월 14일

좋은 글 감사합니다 ㅎㅎ
마지막에 server와 client 트리 매치시켜준 것 어떤 방식으로 개선하셨나요?!

답글 달기