React Suspense 알아보기

Lee·2023년 3월 8일
21
post-thumbnail

블로그 이전했습니다 ! 블로그 링크 !

Suspense는 React 16.6에서 추가된 기능이에요. 처음에는 코드 스플리팅(lazy)을 위해서만 사용됐으나 나중에는 데이터 Fetching을 기다릴수도 있고 SSR Streaming이라는 기능도 제공되게 기능이 확장되는 중이에요.

  • Suspense란 무엇일까 ?
  • Lazy
  • Data Fetching
    • waterfall
    • Suspense 안써도 똑같은데요 ? 안쓰면 안댐 ?
      • 관심사 분리와 선언형
      • 단순히 코드만 깔끔해지나 ?
      • Suspense를 써도 Waterfall이 발생하는데요 ?
        • useQueries
        • prefetch
  • SSR Streaming

Suspense란 무엇일까 ?

React Beta 문서에 나온 내용을 보면

Suspense lets you display a fallback until its children have finished loading.
Suspense는 자식의 로딩이 끝날때까지 fallback을 보여준다.

라고 써있네요.
여러분들은 로딩이라고 하면 뭐가 떠오르시나요 ? 저는 data fetching이 제일 먼저 떠올랐어요. 또 코드 스플리팅을 한 컴포넌트를 받아오면서도 로딩이 있을 수 있겠죠. 우선 Suspense를 처음 활용할 수 있었던 lazy부터 살펴볼게요.


Lazy

React Beta 문서에 나와있는 내용을 보면

lazy lets you defer loading component’s code until it is rendered for the first time.
lazy는 컴포넌트가 처음 렌더링될 때까지 컴포넌트의 코드 로딩을 지연시킬 수 있다.

lazy를 사용하면 코드 스플리팅을 할 수 있어요. 이 글은 Suspense에 관한 글이기 때문에 코드 스플리팅,lazy에 대해서는 따로 설명하지는 않을게요. 바로 코드랑 그 결과를 볼게요.

import { lazy, Suspense } from "react";

const LazyComponent = lazy(() => import("../Component/LazyComponent"));

export const LazyPage = () => (
 <Suspense fallback={<h1>Lazy 컴포넌트 로딩</h1>}>
   <LazyComponent />
 </Suspense>
);

이런식으로 작성해봤어요. 크롬 개발자도구 성능 측정텝에서 결과를 한번 볼게요 !

이렇게 Suspense의 fallback을 보여주고

안에 있는 LazyComponent를 보여주네요.


Data Fetching

여기서는 Waterfall 현상이 뭐고 언제 일어나고 useEffect를 사용한 data fetch방법과 Suspense를 사용한 방법을 비교하고 Suspense를 사용했을때도 Waterfall현상이 발생하고 그것을 해결하는 방법에 대해서 알아볼거에요.

Waterfall

DataFetchEffect.js

export const ArticleList = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const init = async () => {
      const result = await getArticleList();
      setData(result);
      setLoading(false);
    };
    init();
  }, []);

  if (loading) return <h1>Article Loading...</h1>;

  return (
    <>
      <ul>
        {data?.map((item) => (
          <li key={item.title}>
            <span>{item.title}</span>
          </li>
        ))}
      </ul>
      <UserListEffect />
    </>
  );
};

UserListEffect.js

export const UserList = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    const init = async () => {
      const result = await getUserList();
      setData(result);
      setLoading(false);
    };
    init();
  }, []);

  if (loading) return <h1>User List Loading</h1>;

  return (
    <ul>
      {data?.map((item) => (
        <li key={item.name}>
          <span>이름은 {item.name}</span>
          <span>나이는 {item.age}</span>
        </li>
      ))}
    </ul>
  );
};

이런식으로 코드가 작성되면



DataFetchEffect컴포넌트가 loading중에 UserListEffect 컴포넌트가 실행되지 않으니까 당연히 이런식으로 실행이 되겠죠 ??
이 Waterfall 현상을 해결해볼게요.
우선 Suspense를 써서 해결해볼게요.

export const DataFetchSuspensePage = () => (
  <>
  	<Suspense fallback={<h1>User List Loading...</h1>}>
      <UserList />
  	</Suspense>
  	<Suspense fallback={<h1>ArticleList Loading...</h1>}>
      <ArticleList />
  	</Suspense>
  </>
);

컴포넌트 안에는 react-query라이브러리를 사용해서 구현했어요. 이런식으로 코드를 짜면


한번에 불러와져서 waterfall현상이 사라졌네요 !
근데 이게 Suspense때문에 해결된게 맞을까요 ?
Suspense를 사용안하고 병렬적으로 fetch할 수 있게 코드를 짜면

export const DataFetchEffectPage = () => (
  <>
    <UserList />
    <ArticleList />
  </>
);



이렇게 Waterfall이 해결돼요 !

Suspense 안써도 똑같은데요 ? 안쓰면 안댐 ?

우선 방금 위에서 봤던거처럼 Suspense를 안쓰고 사용해도 waterfall을 막을 수 있었어요.
그러면 Suspense를 왜 사용할까요 ?

관심사 분리와 선언형

export const UserList = () => {
  const { data, isLoading } = useQuery(["userList"], getUserList);

  if (isLoading) return <h1>User List Loading...</h1>;

  return (
    <>
      <ul>
        {data.map((item) => (
          <li key={item.name}>
            <span>이름은 {item.name}</span>
            <span>나이는 {item.age}</span>
          </li>
        ))}
      </ul>
    </>
  );
};

Suspense를 사용하지 않으면 보통 이런식으로 코드를 짤거 같아요. react query를 사용안하고 useEffect안에서 fetch하는 방법도 loading state, data state를 만들고 비슷한 방법으로 보여줄거에요.
우선 이 코드를 보면 관심사 분리가 잘안됐다고 느껴져요. 왜냐하면 UserList 컴포넌트의 역활은 User의 List를 보여주는 역활인데 여기서는 User List를 불러오는 loading도 같이 관리하고 있어요. 그리고 선언형인 React인데 명령형인 구조가 나오기도 하고요.

Suspense를 사용하면

export const UserList = () => {
 const { data } = useQuery(["userList"], getUserList, {
   suspense: true,
 });

 return (
   <ul>
     {data.map((item) => (
       <li key={item.name}>
         <span>이름은 {item.name}</span>
         <span>나이는 {item.age}</span>
       </li>
     ))}
   </ul>
 );
};


export const DataFetchSuspensePage = () => (
   <Suspense fallback={<h1>User List Loading...</h1>}>
     <UserList />
   </Suspense>
);

이런식으로 UserList컴포넌트는 데이터를 받아와서 보여주는 부분만 집중하고 loading관련 상태는 밖으로 빼니 코드가 더 간결해지고 목적이 분명하게 보이는 거 같아요 그리고 선언적이고요.

단순히 코드만 깔끔해지나 ?

그러면 Suspense는 단순히 코드만 깔끔해지는 Syntactic sugar일까요 ?
useEffect을 이용한 data fetch와 Suspense과 비교해볼게요
각각 user list와 article list를 fetch할때와 데이터를 받았을때 console.log을 찍어서 타이밍을 확인 해볼거에요.
useEffect를 사용한 fetch 방법

export const DataFetchEffectPage = () => (
 <>
   <UserListEffect />
   <ArticleListEffect />
 </>
);


export const UserListEffect = () => {
 const [data, setData] = useState([]);
 const [loading, setLoading] = useState(true);
 useEffect(() => {
   console.log("UserListEffect mount");
   const init = async () => {
     const result = await getUserList();
     setData(result);
     setLoading(false);
   };
   init();
 }, []);

 if (loading) return <h1>User List Loading</h1>;

 return (
   <ul>
     {data?.map((item) => (
       <li key={item.name}>
         <span>이름은 {item.name}</span>
         <span>나이는 {item.age}</span>
       </li>
     ))}
   </ul>
 );
};

export const ArticleListEffect = () => {
 const [data, setData] = useState([]);
 const [loading, setLoading] = useState(true);

 useEffect(() => {
   console.log("ArticleListEffect mount");
   const init = async () => {
     const result = await getArticleList();
     setData(result);
     setLoading(false);
   };
   init();
 }, []);

 if (loading) return <h1>Effect Loading...</h1>;

 return (
   <ul>
     {data?.map((item) => (
       <li key={item.title}>
         <span>{item.title}</span>
       </li>
     ))}
   </ul>
 );
};

이렇게 코드를 구성하고 실행을 시켜볼까요 ?

이러한 순서대로 콘솔이 찍히네요. 이렇게 useEffect를 사용한 fetch 방법을 Fetch-on-render 이라고 해요. 컴포넌트가 mount되고 useEffect가 실행되니까

mount => fetch 시작 => fetch 종료

이 순서로 진행되겠죠 ??
이번에는 Suspense를 사용해서 만들고 콘솔을 찍어볼게요.
Suspense를 사용한 fetch 방법

export const DataFetchSuspensePage = () => (
  <>
    <Suspense fallback={<h1>User List Loading...</h1>}>
      <UserListSuspense />
    </Suspense>
    <Suspense fallback={<h1>Article List Loading...</h1>}>
      <ArticleListSuspense />
    </Suspense>
  </>
);

export const UserListSuspense = () => {
  const { data } = useQuery(["userList"], getUserList, {
    suspense: true,
  });

  useEffect(() => {
    console.log("mount userList");
  }, []);

  return (
    <ul>
      {data.map((item) => (
        <li key={item.name}>
          <span>이름은 {item.name}</span>
          <span>나이는 {item.age}</span>
        </li>
      ))}
    </ul>
  );
};

export const ArticleListSuspense = () => {
  const { data } = useQuery(["articleList"], getArticleList, {
    suspense: true,
  });

  useEffect(() => {
    console.log("mount ArticleList");
  }, []);

  return (
    <ul>
      {data?.map((item) => (
        <li key={item.title}>{item.title}</li>
      ))}
    </ul>
  );
};

이제 콘솔을 확인해볼게요

오....! 순서가 위에 useEffect를 다르네요. 이 방법을 Render-as-you-fetch 방법이라고 불러요. data fetch를 먼저 시작하고 data를 받고 컴포넌트를 렌더링하는 것처럼 보이네요.

Suspense를 써도 Waterfall이 발생하는데요 ?

코드를 한번 볼까요 ?

export const DataFetchSuspensePage = () => (
  <Suspense fallback={<h1>User List Loading...</h1>}>
    <UserListSuspense />
  </Suspense>
);

export const UserListSuspense = () => {
  useEffect(() => {
    console.log("mount userList");
  }, []);

  const { data } = useQuery(["userList"], getUserList, {
    suspense: true,
  });

  return (
    <>
      <ul>
        {data.map((item) => (
          <li key={item.name}>
            <span>이름은 {item.name}</span>
            <span>나이는 {item.age}</span>
          </li>
        ))}
      </ul>
      <Suspense fallback={<h1>Article List Loading...</h1>}>
        <ArticleListSuspense />
      </Suspense>
    </>
  );
};

export const ArticleListSuspense = () => {
  const { data } = useQuery(["articleList"], getArticleList, {
    suspense: true,
  });

  useEffect(() => {
    console.log("mount ArticleList");
  }, []);

  return (
    <ul>
      {data?.map((item) => (
        <li key={item.title}>{item.title}</li>
      ))}
    </ul>
  );
};

이런식으로 코드를 짜면




이런식으로 응답이 오네요. 물론 이런 경우에는 ArticleListSuspense 컴포넌트를 밖으로 빼서
UserListSuspense 컴포넌트와 병렬적으로 배치하면 해결될 문제입니다. 하지만 만약에 병렬적인 배치가 힘든 코드 구조가 있고 그런 구조에서는 어떻게 해결하는게 좋을까요 ?

저는 이렇게 해결해볼거 같아요

1. useQueries

export const UserListSuspense = () => {
  const results = useQueries({
    queries: [
      {
        queryKey: ["userList"],
        queryFn: getUserList,
        suspense: true,
      },
      {
        queryKey: ["articleList"],
        queryFn: getArticleList,
        suspense: true,
      },
    ],
  });

  useEffect(() => {
    console.log("mount userList");
  }, []);

  return (
    <>
      <ul>
        {results[0].data.map((item) => (
          <li key={item.name}>
            <span>이름은 {item.name}</span>
            <span>나이는 {item.age}</span>
          </li>
        ))}
      </ul>
      <Suspense fallback={<h1>Article List Loading...</h1>}>
        <ArticleListSuspense />
      </Suspense>
    </>
  );
};

이런식으로 react query 라이브러리의 useQueries를 사용해서 처리하면

이런식으로 다시 돌아와요 ! 하지만 이런 경우에는 Article api의 응답이 많이 느린 경우 User List를 받아왔음에도 불구하고 loading만 보여줘야하는 경우도 생겨요.

2. prefetch
prefetch를 이용해서 코드를 짜면

const prefetchTodos = async () => {
  console.log("prefetch");
  await queryClient.prefetchQuery(["articleList"], getArticleList);
};
prefetchTodos();

export const UserListSuspense = () => {
  const { data } = useQuery(["userList"], getUserList, {
    suspense: true,
  });

  useEffect(() => {
    console.log("mount userList");
  }, []);

  return (
    <>
      <ul>
        {data.map((item) => (
          <li key={item.name}>
            <span>이름은 {item.name}</span>
            <span>나이는 {item.age}</span>
          </li>
        ))}
      </ul>
      <Suspense fallback={<h1>Article List Loading...</h1>}>
        <ArticleListSuspense />
      </Suspense>
    </>
  );
};


이런식으로 결과가 나오네요. 근데 이 방법을 사용하면 제가 잘못짠거인지는 모르겠는데 다른 페이지에서도 prefetch를 시도하더라고요. 저는 다른 페이지말고 저 컴포넌트를 사용하는 페이지에서만 prefetch를 적용하고 싶어서 고민해본 결과

const LoadingComponent = () => {
  const prefetchTodos = async () => {
    console.log("prefetch");
    await queryClient.prefetchQuery(["articleList"], getArticleList);
  };
  prefetchTodos();

  return <h1>User List Loading...</h1>;
};

export const DataFetchSuspensePage = () => (
  <Suspense fallback={<LoadingComponent />}>
    <UserListSuspense />
  </Suspense>
);

이런식으로 코드를 짜서 확인해본결과 다른 페이지들에서 prefetch를 하지 않고 해당 로딩 컴포넌트를 사용한 페이지에서만 prefetch를 하는식으로 동작을 해요. 근데 이렇게 fallback에 들어가는 컴포넌트에 prefetch로직을 넣어도 괜찮나 궁금하네요 ㅎㅎ

여기까지 제가 Suspense에 대해서 알고 있는것들에 대해서 작성해봤어요. 혹시 잘못된 내용이 있으면 피드백 부탁드립니다 감사합니다 :)

profile
프론트엔드 개발자

0개의 댓글