React 18의 Suspense에 대해서 알아보자

 

ReactJS 커뮤니티의 "Suspense-in-react-18"와 "Behavioral changes to Suspense in React 18"을 읽고 정리한 글입니다.

 

Request For Comments(RFC)는 `<Suspense>` 컴포넌트의 동작에 대한 몇 가지 변경사항을 설명합니다.

  1. 동작 변경: 커밋된 트리는 항상 일관성을 유지
    커밋: React 컴포넌트 트리의 변경사항이 브라우저에 확정적으로 반영되었음을 의미.
  2. 새로운 기능: 스트리밍을 통한 서버 사이드 렌더링 지원
  3. 새로운 기능: 기존 컨텐츠를 숨기지 않기 위해 트랜지션 사용
  4. 동작 변경: 컨텐츠가 다시 나타날 때 레이아웃 효과 재실행

단, Suspense API 자체는 변경되지 않았습니다.

 

16.6에서 Suspense가 최초로 릴리즈 되엇을 때, 한 가지 use case밖에 없었습니다.

  • code splitting on the client with React.lazy

컴포넌트 내에 <Suspense> 경계를 설정할 수 있었음에도 React가 서버 렌더링과 같은 다른 목적으로 사용하지 않았기 때문에 <Suspense>의 활용하는데 한계가 있었습니다.

전체적인 목표는 선언적인 Suspense fallback을 통해 모든 비동기적인 연산을 다룰 수 있도록 하는 것입니다.

 

 

Suspense를 사용하면 React에게 트리의 일부가 아직 렌더링 준비가 되지 않았을 때 보여줄 내용을 선언적으로 지정할 수 있습니다.

<Suspense fallback={<PageGlimmer />}>
  <RightColumn>
    <ProfileHeader />
  </RightColumn>
  <LeftColumn>
    <Suspense fallback={<LeftColumnGlimmer />}>
      <Comments />
      <Photos />
    </Suspense>
  </LeftColumn>
</Suspense>

컨셉적으로는 Suspense는 catch block과 유사하게 작동하지만 error를 catching을 하기보다는 suspend 중인 컴포넌트를 catch 한다는 점이 다릅니다. 렌더링 트리 내에 있는 어떠한 컴포넌트도 suspend 상태가 될 수 있습니다.

JS에서 throw를 던질 때, 가장 가까운 catch 블록에서 이를 처리하는 것처럼 Suspense도 가장 가까운 Suspnse 컴포넌트에서 이를 catch합니다. 위의 예시를 들면 <ProfileHeader>가 suspend된다면 전체 page의 fallback UI는 <PageGilmmer />가 될 것이고, <Comments>나 <Photos>가 suspend되면 이 둘은 <LeftColumnGlimmer> 컴포넌트로 대체되겠죠.

이를 통해 컴포넌트가 어떤 비동기 코드나 데이터에 의존하는지 걱정할 필요 없이 시각적 UI 디자인의 세밀함에 따라 안전하게 Suspense 경계를 추가하거나 제거할 수 있습니다. 즉, 중요한점은 Suspense가 코드나 데이터의 로드 방식과 분리되어 있다(decouple)는 사실입니다. Suspense는 리액트에 선언적 로딩 상태를 인식시키는 메커니즘일 뿐, 데이터나 코드를 가져오는 방식에 대해 특정한 선택을 지시하지 않습니다.

 

위에서 말했던 변경점을 하나씩 살펴보도록 합시다.

1. 동작 변경: 커밋된 트리는 항상 일관성을 유지

// Main.tsx
import React, { Suspense } from "react";
import Spinner from "./Spinner";
import Panel from "./Panel";
const Comments = React.lazy(() => import("./Comments"));

export default function Main() {
  return (
    <>
      <Suspense fallback={<Spinner />}>
        <Panel>
          <Comments />
        </Panel>
      </Suspense>
    </>
  );
}

// Panel.tsx
import { PropsWithChildren } from "react";

export default function Panel({ children }: PropsWithChildren) {
  return (
    <div>
      <h1>패널입니다.</h1>
      {children}
    </div>
  );
}


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

export default function Comments() {
  const [users, setUsers] = useState<any>([]);

  useEffect(() => {
    getUsers().then((users) => {
      setUsers(users);
    });
  }, []);

  console.log(users);

  return (
    <div>
      <h1>유저 목록</h1>
      <ul>
        {users.map((user: any) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "force-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

다음과 같은 코드가 있다고 가정해봅시다. 

먼저 React 17에서는 다음과 같은 영상처럼 작동하게 됩니다.

 

동작의 순서는 다음과 같습니다.

1. <Comments> 컴포넌트의 내용을 비워둔 채로 <Panel> 컴포넌트를 DOM에 넣습니다.

2. <Panel> 컴포넌트에 display: none을 추사하여 보이지 않는 상태로 둡니다.

3. <Spinner>를 DOM에 추가합니다.

4. <Panel>은 보이지 않지만 기술적으로 마운트된 상태이므로, effect를 실행합니다.

5. <Comments> 컴포넌트가 준비될 때까지 기다린 후, 렌더링을 시도합니다.

6. <Spinner>를 지우고, DOM에 이미 존재했던 <Panel> 컴포넌트에 <Comments>를 배치합니다.

7. <Panel>의 display:none 속성을 제거하여 보여줍니다.

 

하지만 React 18에서는 화면과 같이 동작합니다.

56560

동작의 순서는 다음과 같습니다.

1. <Panel>을 DOM에 배치하지 않고,  fallback UI인 <Spinner>를 보여줍니다.

2. <Comments> 컴포넌트가 준비될 때까지 기다린 후, 렌더링을 시도합니다.

3. <Spinner>를 지우고, <Panel>과 <Comments>를 배치합니다.

4. <Panel>의 effect를 실행합니다.

 

컴포넌트가 완전히 준비되었을 때 커밋을 하기 때문에 effect에서 항상 완전한 트리를 observe할 수 있게 되었습니다. 

 

2. 새로운 기능: 스트리밍을 통한 서버 사이드 렌더링 지원

17 버전에서는 컴포넌트가 서버 렌더링 중 중단 되면, 리액트는 심각한 오류를 발생시켰습니다. 서버 렌더링을 사용하는 앱들이 코드 분할을 위해 Suspense를 지원할 수 없다는 것을 의미했습니다. 새로운 서버 렌더러는 HTML을 순서대로 스트리밍하는 것을 지원합니다. 
기존의 서버 렌더러는 동기적으로 문자열을 생성하는 반면에 새로운 렌더러는 Stream을 생성합니다. Stream은 조기에 전송될 수 있는 초기 HTML으로부터 시작합니다. 새로운 렌더러는 Suspense와 통합되어 있어, 컴포넌트 트리 요소 중 준비되지 않은 부분을 "기다릴 수 있고", 필요한 경우 fallback HTML을 보낼 수 있습니다. 컨텐츠가 준비되면 React는 올바른 위치에 포함된 `inline <script>` 태그에 동일한 스트림에 컨텐츠가 있는 HTML을 삽입합니다. 즉, 서버에서 페이지의 일부가 느리더라도 사용자는 점진적으로 로딩되는 페이지를 볼 수 있으며 클라이언트 JS가 로드되기 전의 중간 로딩 상태를 설계할 수 있게 됩니다. 
이 기능은 Data Fetching을 지원하는 Suspense에서 유용한 기능입니다. 데이터를 기다리는 동안의 HTML Streaming을 가능하게 합니다. 특히 <Suspens>는 hydration과 연관되어 있습니다. 예를 들어 코드가 아직 로드되지 않은 lazy Component가 <Suspense>로 감싸져 있으면, React는 코드 스플리팅된 chunk를 기다리지 않고, 앱의 나머지 부분을 hydrate할 수 있습니다. 리액트는 서버에서 받은 컨텐츠 HTML을 보존하고, 해당 클라이언트 코드가 로드된 후에 hyrdation 합니다. 이는 hydration을 위해 모든 코드 스플리팅 chunk의 로딩을 기다릴 필요가 없이 이미 렌더링된 부분을 hydrate 할 수 있기에 성능을 크게 향상시킬 수 있습니다. 메인 번들이 준비되는 대로 hydration을 시작할 수 있게됩니다. 
새로운 스트리밍 API에 대한 자세한 내용은 링크를 통해 확인할 수 있습니다. 또한, 이 링크에서 제안된 아키텍처의 작동방식과 가능성에 대해서 자세히 알아볼 수 있습니다. 또한, 이 유튜브 링크를 통해서 Suspense 아키텍처에 대한 고급 개요를 볼 수 있습니다.

 

3. 동작 변경: 컨텐츠가 나타날 때 Layout Effect가 Rerun 됩니다. (useLayoutEffect가 컨텐츠가 마운트되고 실행)

먼저 예제를 보도록 합시다.

// Main.tsx
import { Suspense, useState } from "react";
import Spinner from "./Spinner";
import Comments from "./Comments";
import AutoSize from "./AutoSize";
import Photos from "./Photos";

export default function Main() {
  const [tab, setTab] = useState("comments");
  return (
    <div>
      <button onClick={() => setTab("comments")}>댓글</button>
      <button onClick={() => setTab("photos")}>포토</button>
      <Suspense fallback={<Spinner />}>
        <AutoSize>{tab === "comments" ? <Comments /> : <Photos />}</AutoSize>
      </Suspense>
    </div>
  );
}

// AutoSize.tsx
import React, { PropsWithChildren, useLayoutEffect, useRef } from "react";

export default function AutoSize({ children }: PropsWithChildren) {
  const autoSizeRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    console.log(autoSizeRef.current?.clientHeight);
  }, [autoSizeRef.current?.clientHeight]);

  return <div ref={autoSizeRef}>{children}</div>;
}

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

export default function Photos() {
  const [photos, setPhotos] = useState<any>([]);

  useEffect(() => {
    getPhotos().then((photos) => setPhotos(photos));
  }, []);

  return (
    <div style={{ minHeight: "80px" }}>
      <h1>사진</h1>
      <ul>
        {photos.map((photo) => (
          <li key={photo.id}>
            <img src={photo.thumbnailUrl} alt={photo.title} />
            <span>{photo.title}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

async function getPhotos() {
  const response = await fetch("https://jsonplaceholder.typicode.com/photos");
  const photos = await response.json();
  return photos;
}

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

export default function Comments() {
  const [users, setUsers] = useState<any>([]);

  useEffect(() => {
    getUsers().then((users) => {
      setUsers(users);
    });
  }, []);

  return (
    <div>
      <h1>유저 목록</h1>
      <ul>
        {users.map((user: any) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "force-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

버튼을 클릭함에 따라 <Suspense>의 컴포넌트를 변경할 수 있습니다. 또한, <AutoSize> 컴포넌트에서는 useLayoutEffect()를 통해, 자식의 높이를 측정할 수 있도록 구현하였습니다.

 

React 17버전의 동작은 화면과 같습니다.

화면과 같이 첫번째로 Lazy Load된 경우에는 콘솔에 0으로 찍히게 됩니다. 왜냐하면 17버전의 동작에서 <Panel>의 경우 display: none 속성을 추가하여 DOM에 추가(마운트)되기 때문에 부모 컴포넌트 내부의 effect를 실행하게 됩니다.

 

 

React 18 버전의 동작은 화면과 같습니다.

560

 

Lazy Loading이 완료된 컴포넌트가 렌더링되어야 Suspense 내부의 자식 컴포넌트들이 렌더링 되기 때문에, Lazy Loading 컴포넌트가 렌더링이 완료된 후에 useLayoutEffect()를 실행합니다. 

만약 Lazy Loading 컴포넌트를 숨기고 fallback UI를 보여준다면 useLayoutEffect()를 clean up 합니다.

 

4. 새로운 기능: 기존 컨텐츠를 숨기지 않기 위해 트랜지션 사용 (Transition을 이용하여 fallback UI를 방지)

import { Suspense, lazy, useState } from "react";
import Spinner from "./Spinner";
const Photos = lazy(() => import("./Photos"));
const Comments = lazy(() => import("./Comments"));
import AutoSize from "./AutoSize";

export default function Main() {
  const [tab, setTab] = useState("comments");
  return (
    <div>
      <button onClick={() => setTab("comments")}>댓글</button>
      <button onClick={() => setTab("photos")}>포토</button>
      <Suspense fallback={<Spinner />}>
        <div>{tab === "comments" ? <Comments /> : <Photos />}</div>
      </Suspense>
    </div>
  );
}

 

 

위의 코드는 다음 화면과 같이 동작합니다. 버튼을 누르면 fallback UI가 나타나고, 시간이 지난 후 컨텐츠가 보여집니다. 

디자인과 UX 측면에서 fallback UI가 아닌 동작을 보여주는 것이 더 나을 때가 있는데, 이런 경우에 쓰는 훅이 바로 `useTransition` 입니다. 탭 변경 동작이 transition임을 리액트에게 알리면, 리액트는 버튼을 누를 때 fallback UI가 아닌 이전 UI를 유지하며 컴포넌트가 준비된 경우에 해당 컴포넌트로 transition됩니다. (이 때, 첫 UI 로딩의 경우는 fallback UI를 보여줍니다.) 

코드로 작성하면 다음과 같이 작성될 것입니다.

import { Suspense, lazy, useState, useTransition } from "react";
import Spinner from "./Spinner";
const Photos = lazy(() => import("./Photos"));
const Comments = lazy(() => import("./Comments"));

export default function Main() {
  const [tab, setTab] = useState("comments");
  const [isPending, startTransition] = useTransition();

  const handleChangeTab = (tabName: string) => {
    startTransition(() => setTab(tabName));
  };

  return (
    <div>
      <button onClick={() => handleChangeTab("comments")}>댓글</button>
      <button onClick={() => handleChangeTab("photos")}>포토</button>
      <Suspense fallback={<Spinner />}>
        <div style={{ opacity: isPending ? 0.2 : 1 }}>
          {tab === "comments" ? <Comments /> : <Photos />}
        </div>
      </Suspense>
    </div>
  );
}

 

isPending 속성을 통해서 사용자에게 인터랙션을 주어 작성하였고, 동작 방식은 다음 화면과 같습니다.


다양한 상황에서의 동작을 확인해보고자 경우를 나누어 테스트했었습니다.

1. useEffect를 사용했을 때

 

`useEffect`훅을 통해 데이터를 비동기로 가져오는 컴포넌트를 부모에서 `Suspense` 컴포넌트로 감싸게 되더라도 fallback UI를 보여주지 못하게 됩니다. 

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

export default function page() {
  return (
    <div>
      메인 페이지입니다.
      <Suspense fallback={<div>로딩중...</div>}>
        <User />
      </Suspense>
    </div>
  );
}


// User.tsx
"use client";
import { useEffect, useState } from "react";

export default function User() {
  const [users, setUsers] = useState<any>([]);

  useEffect(() => {
    getUsers().then((data) => setUsers(data));
  }, []);

  return (
    <div>
      <h1>유저 목록</h1>
      {users.map((user: any) => {
        return <div key={user.id}>{user.name}</div>;
      })}
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "no-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

 

클라이언트에서 서버에 요청을 보내고 나면, 서버에서 렌더링을 한 HTML을 보내고, JS 파일을 보내게 됩니다.

이 때, 받은 HTML 파일은 다음과 같으며, 클라이언트 컴포넌트에서 비동기로 받아와서 렌더링 해야하는 부분은 미리 렌더링 되지 못한 상태로 HTML이 만들어 집니다.

 

만약 fetch의 cache 옵션을 "force-cache"로 주어도 동일할까요? 

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "force-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

다음과 같이 코드를 작성해도 (1) fallback UI가 나오지 않으며, (2) 서버에서 비동기로 처리하는 부분을 프리렌더링한 HTML로 보내주지 못합니다.

 

2. use를 사용했을 때

이번에는 use 훅을 통해 데이터를 가져오는 경우를 보도록 해보겠습니다.

먼저 cache 옵션을 "no-cache" 로 해보도록 합시다.

export default function User() {
  const users: any = use(getUsers());

  return (
    <div>
      <h1>유저목록</h1>
      {users.map((user: any) => {
        return <div key={user.id}>{user.name}</div>;
      })}
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "no-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

`use` 훅을 통해 데이터를 가져오게 되면, fallback UI가 잘 보입니다. 또한, prerendering 한 HTML에도 fallback UI가 렌더링되어져 있는 것을 확인할 수 있습니다. 다만 비동기로 처리되는 부분 뿐만 아니라, 변동이 없는 `<h1>`태그가 있는 부분조차 prerendering이 되지 않는 것을 볼 수 있습니다.

다만, use 훅이 아직 실험적인 기능이라 그런지 fetch가 여러번 보내지는 이슈가 있어, 이 부분에 대해서는 고민을 해봐야겠습니다. 

 

이번에는 cache 옵션을 "force-cache"로 두고 데이터를 가져와보도록 하겠습니다.

export default function User() {
  const users: any = use(getUsers());

  return (
    <div>
      <h1>유저 목록</h1>
      {users.map((user: any) => {
        return <div key={user.id}>{user.name}</div>;
      })}
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "force-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

이 또한, "no-cache"로 설정했을 때와 크게 다르지 않았습니다. 

3. React Server Component를 사용했을 때

먼저 cache 옵션으로 "no-cache"로 하고, 실행을 해보도록 하겠습니다.

export default async function User() {
  const users: any = await getUsers();

  return (
    <div>
      {users.map((user: any) => {
        return <div key={user.id}>{user.name}</div>;
      })}
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "no-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

 

HTML은 동일해보이지만 client에서 `fetch` 요청을 보내지 않는다는 점에서 차이점이 있습니다. 만약 Suspense로 감싸지 않았다면, 서버에서 렌더링할 때까지 대기하지만 Suspense로 감싸게 된다면 먼저 rendering 가능한 부분을 HTML로 보내주게 되고, Promise로 나중에 데이터를 받아서 렌더링해줍니다. 

 

이번에는 cache 옵션을 "force-cache"로 두고 실행해보겠습니다.

export default async function User() {
  const users: any = await getUsers();

  return (
    <div>
      {users.map((user: any) => {
        return <div key={user.id}>{user.name}</div>;
      })}
    </div>
  );
}

async function getUsers() {
  const response = await fetch("http://jsonplaceholder.typicode.com/users", {
    cache: "force-cache",
  });
  const users = await response.json();

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(users);
    }, 3000);
  });
}

"force-cache"로 두었을 때, 이미 서버에서 데이터를 fetching하고 난 뒤에 프리 렌더링된 HTML을 보내주는 것을 확인할 수 있습니다.