Suspense에 대해

seul_velog·2024년 3월 15일
post-thumbnail

✍️ React v18.0의 주요 기능 중 하나인 <Suspense> 에 대해서 알아보자


React 18과 Suspense

React 18은 스트리밍 서버 사이드 렌더링을 통해 Suspense를 지원한다. 이를 통해 데이터를 기다리는 동안 다른 컴포넌트의 로딩을 방해하지 않고 사용자 경험을 개선할 수 있다.
예를 들어, 서버에서 데이터를 불러오는 동안 대체 UI를 보여주는 것이 가능하다. 😀

  • Suspense는 어떤 컴포넌트가 읽어야 하는 데이터가 아직 준비가 되지 않았다고 리액트에게 알려주는 새로운 매커니즘이다.




스트리밍 서버 사이드 렌더링을 통한 Suspense 지원

스트리밍 서버 사이드 렌더링을 통한 Suspense 지원이란, 서버에서 웹 페이지의 초기 HTML을 생성하고, 필요한 데이터가 완전히 로드되기 전에 사용자에게 페이지의 일부를 먼저 보여줄 수 있도록 한다는 것을 의미한다. 이 과정에서 React는 필요한 데이터를 기다리는 동안 다른 부분의 UI를 먼저 렌더링하고, 모든 데이터가 준비되면 나머지 부분을 화면에 표시한다.

예를 들어, 온라인 쇼핑몰의 상품 페이지를 방문했을 때, 상품 목록을 불러오는 동안 빈 화면을 보여주는 대신 로고, 메뉴, 푸터 등 이미 준비된 부분을 먼저 보여줄 수 있다. 그리고 상품 데이터가 서버에서 처리되어 준비되면, 그 때 상품 목록을 화면에 추가로 렌더링한다.

이러한 방식은 사용자가 데이터를 모두 받아오기를 기다리는 동안 빈 화면을 바라보는 시간을 줄여주어, 사용자 경험을 개선하는 효과가 있다.

일반적으로 첫 로딩이 3초이상 소요되면 페이지로부터 사용자의 이탈률이 급속히 증가한다는 통계가 있다는 것을 들었던 적이있는데...😓
데이터를 기다리는 동안 사용자에게 어느 정도 인터랙티브한 페이지를 제공함으로써, 페이지 로딩이 느릴 때의 답답함을 덜어줄 수 있고 이탈률을 방지할 수도 있을 것이다. 🧐


스트리밍 서버 사이드 렌더링 (Streaming Server-Side Rendering) 이란 ?

서버에서 HTML을 생성하고 이를 클라이언트로 점진적으로 전송하는 기술.
이 방식은 웹 페이지의 초기 로딩 시간을 단축하고, 사용자가 콘텐츠를 더 빠르게 볼 수 있도록 한다.

조각별 전송: 서버는 페이지의 HTML을 조각으로 나누어 생성하며, 각 조각이 준비되는 대로 클라이언트로 전송한다. 이는 HTML 문서의 상단부터 차례로 진행될 수 있다.

  • 일반적인 SSR : 서버는 HTML 문서 전체를 렌더링한 후에야 사용자의 브라우저로 전체 문서를 한 번에 전송한다.

점진적 사용자 경험: 사용자는 페이지의 일부분이 서버에서 아직 처리 중이더라도, 이미 전송된 부분의 콘텐츠를 볼 수 있다.

리소스의 효율적 사용: 초기에 중요한 콘텐츠만 먼저 로드하고, 나머지는 나중에 로드함으로써 서버와 클라이언트 리소스를 보다 효율적으로 사용할 수 있다.

적용 예)
뉴스 웹사이트에서 스트리밍 SSR을 사용한다면, 사용자는 첫 번째 기사가 서버에서 처리되는 동안 머리글과 메뉴를 볼 수 있다. 나머지 기사나 이미지 등은 사용자가 페이지를 탐색하는 동안 점차적으로 로드된다.




Suspense의 등장시기 & 목적

React 18은 2022년 3월에 정식으로 릴리스되었다. 이 버전에서 Suspense는 서버 사이드 렌더링에 대한 지원을 확장하며, 개발자들이 비동기 데이터를 효율적으로 관리하면서도 사용자 경험을 개선할 수 있도록 도와준다.

Suspense 는 데이터 로딩 동안 사용자 인터페이스(UI)를 차단하지 않고, 부드러운 사용자 경험을 제공하기 위해 설계되었다.




기본 문법

콘텐츠가 로드되는 동안 대체 UI 보여주기

📌 Suspense 컴포넌트를 사용하면 React에서 비동기 작업을 처리하는 방식을 선언적으로 관리할 수 있다.

사용법
로딩 중에 보여줄 Fallback UI를 fallback prop으로 정의하고, 데이터를 요구하는 컴포넌트를 Suspense 태그 안에 위치시킨다.

예를 들어, 데이터를 불러오는 PostList 컴포넌트를 <Suspense> 태그로 감싸고, 이 컴포넌트가 데이터를 모두 불러올 때까지 사용자에게 보여줄 대체 UI를 fallback 속성으로 지정할 수 있다. 😮

<Suspense fallback={<Loading />}> // 혹은 <Spinner /> 등
  <PostList />
</Suspense>

여기서 fallback={<Loading />} 은 PostList 컴포넌트가 준비되기 전까지 Loading 컴포넌트를 보여준다.




사용예시

📌 React 공식 Suspense 예시

import { Suspense } from 'react';
import Albums from './Albums.js';

export default function ArtistPage({ artist }) {
  return (
    <>
      <h1>{artist.name}</h1>
      <Suspense fallback={<Loading />}>
        <Albums artistId={artist.id} />
      </Suspense>
    </>
  );
}

function Loading() {
  return <h2>🌀 Loading...</h2>;
}

앨범 목록을 가져오는 동안 앨범 컴포넌트 Albums 가 지연된다. 렌더링할 준비가 될 때까지 가장 가까운 Suspensefallback , 즉 Loading 컴포넌트를 표시한다. 데이터가 모두 로드되면 React는 Loading fallback을 숨기고 로드된 데이터로 앨범 컴포넌트를 렌더링한다.




Suspense 사용 전

// Main.jsx
import User from './User';

export default function Main() {
  return (
    <main>
      <h2>Suspense 사용 전</h2>
      <User userId='1' />
    </main>
  );
}

// User.jsx
import { useEffect, useState } from 'react';
import Posts from './Posts';

export default function User({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => res.json())
      .then((userData) => {
        setTimeout(() => {
          setUser(userData);
          setIsLoading(false);
        }, 2000);
      });
  }, [userId]);

  if (isLoading) return <div> 사용자 정보 로딩중 ... 🌀 </div>;

  return (
    <div>
      <p>
        {user.name}({user.email}) 님이 작성한 글
      </p>
      <Posts userId={userId} />
    </div>
  );
}
//Posts.jsx
import { useEffect, useState } from 'react';

export default function Posts({ userId }) {
  const [isLoading, setIsLoading] = useState(true);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
      .then((res) => res.json())
      .then((postsData) => {
        setTimeout(() => {
          setPosts(postsData);
          setIsLoading(false);
        }, 2000);
      });
  }, []);

  if (isLoading) return <div> 포스트 목록 로딩중 ... 🌀 </div>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}




Suspense 사용 후

// fetchData.js
export function fetchUser(userId) {
  let user = null;
  const pendingPromise = fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  )
    .then((response) => response.json())
    .then((data) => {
      setTimeout(() => {
        user = data;
      }, 2000);
    });

  return {
    getUserData() {
      if (user === null) throw pendingPromise;
      return user;
    },
  };
}

export function fetchPosts(userId) {
  let posts = null;
  const pendingPromise = fetch(
    `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
  )
    .then((res) => res.json())
    .then((data) => {
      setTimeout(() => {
        posts = data;
      }, 2000);
    });

  return {
    getPostsData() {
      if (posts === null) throw pendingPromise;
      return posts;
    },
  };
}

export function fetchData(userId) {
  return {
    user: fetchUser(userId),
    posts: fetchPosts(userId),
  };
}
// Main.jsx
import { Suspense } from 'react';
import User from './User';
import { fetchData } from './fetchData';

export default function Main() {
  const apiResponse = fetchData('1');

  return (
    <main>
      <h2>Suspense 사용 후</h2>
      <Suspense fallback={<p> 사용자 정보 로딩중 ... 🌀 </p>}>
        <User apiResponse={apiResponse} />
      </Suspense>
    </main>
  );
}
// User.jsx
import { Suspense } from 'react';
import Posts from './Posts';

export default function User({ apiResponse }) {
  const user = apiResponse.user.getUserData();

  return (
    <div>
      <p>
        {user.name}({user.email}) 님이 작성한 글
      </p>
      <Suspense fallback={<p> 포스트 목록 로딩중 ... 🌀 </p>}>
        <Posts apiResponse={apiResponse} />
      </Suspense>
    </div>
  );
}
// Posts.jsx
export default function Posts({ apiResponse }) {
  const posts = apiResponse.posts.getPostsData();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

fetchData()
: fetchData 함수는 사용자 ID를 인자로 받고, fetchUser와 fetchPosts 함수를 호출하여 반환된 객체를 통해 user와 posts 데이터에 접근할 수 있는 메서드를 제공한다.

이 메서드들은 데이터가 로드될 때까지 내부적으로 pendingPromise를 throw 하므로, Suspense가 이를 캐치하고 대기하도록 한다.

fetchUser()와 fetchPosts()
: 이 함수들은 각각 사용자 데이터와 게시물 데이터를 비동기적으로 패치한다.

각 함수 내에서는 데이터가 로드되기 전까지 null 값을 갖는 변수(user, posts)를 정의하고, 외부에서 접근할 수 있도록 getUserData와 getPostsData 메서드를 제공한다.

이 메서드들은 내부적으로 데이터가 로드되지 않았을 경우 pendingPromise를 throw 하고, 데이터가 로드되면 해당 데이터를 반환한다.



Suspense와 Promise의 상호작용

Suspense의 작동 원리
React에서 Suspense는 컴포넌트 트리 내에서 발생하는 Promise를 "캐치"할 수 있도록 설계되었다고 한다.🧐 컴포넌트 실행 도중에 throw된 Promise를 Suspense가 캐치하면, 그 시점에서 컴포넌트의 렌더링을 중단하고 설정된 fallback 컴포넌트를 렌더링한다. Promise가 해결되고 (즉, 데이터 패칭이 완료되면) Suspense는 자식 컴포넌트를 다시 렌더링하게 되어 실제 데이터로 UI를 갱신하게 된다.

Promise를 throw하는 이유
컴포넌트 함수 내에서 비동기 데이터가 준비되지 않았을 경우, 데이터 패칭을 위해 생성된 Promise 객체를 throw하게 된다. 이는 JavaScript의 일반적인 예외 처리와는 다소 다른 패턴으로, Suspense는 이러한 특수한 사용 사례를 처리하기 위해 설계되었다고 한다.


+)
여기서 궁금한 것은...🤨
❓ promise는 (pending, fulfilled, rejected) 이 세가지 상태를 가질 수 있는 것인데, pendingPromise는 처음에 아마도 외부 비동기처리를 하면서 pending상태겠지? (나는setTimeout으로 시뮬레이션을함) 그럼 'suspense는 promise를 캐치' 할 수 있다고 했는데, 이 중에서 어떤 상태에서만 fallback컴포넌트를 보여주는건지?

👉 React의 Suspense는 Promise의 특정 상태에 반응한다. 컴포넌트가 데이터를 필요로 할 때, 해당 데이터가 준비되지 않았다면 (pending 상태의 Promise를 throw할 경우), Suspense는 이를 캐치하고 해당 컴포넌트의 렌더링을 중단하고 설정된 fallback 컴포넌트를 화면에 보여준다고 한다.

❓ 언제 fallback 컴포넌트가 보여지나?

  • pending 상태의 Promise를 throw 했을 때
    : Suspense는 이 throw된 Promise를 캐치하고, Promise가 해결될 때까지 (즉, fulfilled나 rejected 상태가 될 때까지) fallback 컴포넌트를 보여준다.
  • 만약 Promise가 fulfilled 상태가 되면, 데이터가 준비된 것이므로 Suspense는 자식 컴포넌트를 다시 렌더링하여 실제 데이터로 UI를 갱신한다.
  • rejected 상태의 Promise는 일반적으로 에러 처리를 사용하여 안전하게 관리한다.

❓ throw된 promise라 함은, promise를 캐치할 수 있도록 해야만 suspense가 작동한다는 건가?

return {
    getUserData() {
      if (user === null) throw pendingPromise;
      return user;
    },
  };

즉 여기에서 throw를 안해주면 안되는 걸까?

👉 만약 throw를 하지 않는다면, Suspense는 pending 상태의 Promise를 인지하지 못하고 대기하지 않는다. 그 결과, 데이터가 준비되지 않은 상태에서 컴포넌트가 렌더링을 시도할 수 있으며, 이는 불완전한 데이터나 오류를 발생시킬 수 있다고 한다. 🧐

실제로 if (user === null) throw pendingPromise; 코드를 지워봤더니 아직 데이터를 받아오지 않은 상태에서 {user.name}({user.email}) 로 접근하려 했으므로 에러가 발생했다.
{user?.name}({user?.email}) 과 같이 옵셔널 체이닝으로 처리를 했지만 역시나 데이터가 보이지 않는 상태로 렌더링이 되었다.

✍️ 결국 여기서 Suspense 방식을 사용하지 않고, 이전의 전통적인 React의 데이터 패칭 및 상태 관리 방식으로 해결하려면 useStateuseEffect 를 사용하여 데이터를 관리하고 컴포넌트의 상태를 업데이트 해야할 것이다. 🧐




Suspense 사용 전, 사용 후 비교 해보기

❓ 이때 서스펜스를 쓰기 전, 후를 비교해보니 waterfall 현상이 사라지는 것도 알 수 있었다 그 이유는 뭘까 😮

Waterfall 현상
여러 컴포넌트가 각각 데이터를 필요로 할 때 발생하는 현상으로, 각 컴포넌트가 순차적으로 데이터를 요청하고, 그 결과를 기다리면서 발생하는 지연을 의미한다.
각 컴포넌트의 데이터 요청이 이전 컴포넌트의 데이터 로딩 완료를 기다려야 하기 때문에 전체 페이지의 로드 시간이 길어질 수 있다.

📌 Suspense 사용 전
전통적인 데이터 패칭 방식에서는 각 컴포넌트가 독립적으로 데이터를 요청하고 처리한다.
예를 들어, 상위 컴포넌트가 API로부터 데이터를 받아오고, 그 데이터를 바탕으로 하위 컴포넌트들을 렌더링하는 경우, 하위 컴포넌트들은 상위 컴포넌트의 데이터가 완전히 로드될 때까지 기다려야 한다. 이로 인해 하위 컴포넌트의 로딩이 연쇄적으로 지연되며, 이것이 바로 waterfall 현상이다!

📌 Suspense 사용 후
React Suspense는 이러한 waterfall 현상을 해결하기 위해 설계되었다고 한다.
Suspense를 사용하면, 컴포넌트 트리 전체에서 여러 데이터 요청을 동시에 시작할 수 있으며, 각 요청이 완료되는 대로 컴포넌트를 렌더링할 수 있다.

또한 기능을 지원하는 특정 라이브러리가 있다고 한다. (Relay, SWR, React-Query, Recoil ...)
내 경우 fetch() 함수와 같은 비동기 작업을 suspense가 관리할 수 있도록 Suspense와 호환되는 방식으로 구성한 방법(데이터가 준비되지 않은 상태에서 'throw' 사용) 덕분에 사용할 수 있었고, 마찬가지로 waterfall 현상이 사라진 것이라고 예상했다.

✍️ 따라서 Suspense를 사용하면 비동기 로딩 로직을 선언적으로 관리할 수 있어, 개발자는 자식 컴포넌트의 데이터 로딩에 대해 신경 쓰지 않고 UI 로직에만 집중할 수 있다. 이것은 관심사의 분리를 통해 코드의 유지보수성을 높이고, 애플리케이션의 반응성을 개선하는 데 도움을 줄 수 있다. 또한, Suspense는 로딩 상태의 UI를 중앙에서 효과적으로 처리하므로, 복잡한 상태 관리 코드 없이도 사용자에게 일관된 경험을 제공할 수 있다. 😤





reference)

react18
react-suspense
suspense_blog

profile
기억보단 기록을 ✨

0개의 댓글