본문 바로가기

웹 프론트엔드

혹시 무분별하게 Suspense 를 사용하고 계신가요? (react-query)

서언

React v18 부터 Suspense가 API call에 따른 Loading 상태를 표현할 수 있게 되었습니다. 그에 따라, react-query, swr 같은 data fetching library가 Suspense를 지원하고 있습니다. suspense 옵션만 true로 설정해주면, API 요청 훅이 알아서 내부 처리를 통해 Suspense를 동작시킵니다. 이로써 로딩을 선언적으로 보여줄 수 있게 되었고, ErrorBoundary 조합과 함께라면 컴포넌트는 Success 케이스만 표현하면 되었습니다. 이런 혁신에 대해서는 잘 인지하고 있었지만, Suspense의 위험성에 대해서는 인지하지 못 했습니다. 특히 저희 서비스는 잘못된 Suspense 사용으로 어플리케이션 로딩 성능 저하를 겪었습니다. Suspense가 어떻게 로딩 성능을 저하시킬 수 있을까요? 이번 글은 무분별한 Suspense 사용이 초래할 수 있는 문제점과, 그에 대한 해결책을 알아보겠습니다.

 

이 글은 react-query를 도구로 Suspense를 설명합니다. 다른 data fetching library도 동일하게 동작할 것입니다.

Simple use case of Suspense

간단하게 Suspense 의 사용 방법을 알아보겠습니다.

function App() {
  return (
    <Suspense fallback={<div>...loading</div>}>
      <TodoList />
    </Suspense>
  );
}

function TodoList() {
  const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
    suspense: true,
  });

  return (
    <div>
      <section>
        <h2>Todo List</h2>
        {todoList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
    </div>
  );
}

상위에서 Suspense로 컴포넌트를 감싸주고, useQuery 옵션에서 suspense 를 켜주기만 하면, Loading 상태를 나타냅니다. TodoList 에서 API fetch가 발생하는 동안 Loading fallback을 보여주는 것이지요. 매우 간단합니다.

 

Suspense는 어떻게 동작하는 것일까요?

 

Suspense의 원리를 알아야 이후 발생할 문제점을 제대로 이해할 수 있습니다. 이번 아티클은 Suspense의 동작방식 보다는, Suspense가 발생시킬 수 있는 문제점에 집중할 것이므로 자세한 설명은 타 아티클에 맡기도록 하겠습니다.

https://www.daleseo.com/react-suspense/#suspense-사용-후

 

간단하게 설명드리자면, Suspense는 Promise를 catch 합니다. ErrorBoundary가 error을 catch 하는 것처럼 말이죠. 그렇다면 Suspense가 Loading을 보여주게 하기 위해서는 Promise를 throw 하면 될 것입니다. API call의 Promise를 던지면, Suspense가 이를 catch하고, Promise가 pending 상태이면 LoadingFallback을 return하고, settled(fulfilled, rejected)이면 children을 return 하는 형태일 것입니다.

Suspense 남용의 위험성

아래 컴포넌트를 보겠습니다. Before 컴포넌트를 Suspense가 감싸고 있습니다. 그리고 내부에서 "2개의 Query"를 요청하고 있습니다.

// App.jsx
function App() {
  return (
    <Suspense fallback={<div>...loading</div>}>
      <Before />
    </Suspense>
  );
}

// Before.jsx
const BASE_URL = "https://jsonplaceholder.typicode.com";
const client = axios.create({ baseURL: BASE_URL });

function Before() {
  const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
    suspense: true,
  });

  const { data: postList } = useQuery("posts", () => client.get("/posts"), {
    suspense: true,
  });

  return (
    <div style={{ display: "flex" }}>
      <section>
        <h2>Todo List</h2>
        {todoList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
      <section>
        <h2>Post List</h2>
        {postList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
    </div>
  );
}

요청이 어떻게 가고 있는지 네트워크 탭을 살펴보겠습니다.

network waterfall을 만들고 있습니다. 저희 서비스는 이 문제점을 겪었고, 앱의 Loading이 길어졌습니다.

 

아래는 저희 서비스의 예제입니다.

4번의 API 요청을 하는데요. waterfall이 발생하여 무려 4번의 Loading을 보여주고 있는 것입니다. 그래서 실제로 모바일에서 앱에 접속하는데 3초 이상이 소요되는 현상이 발생했습니다. Lighthouse의 LCP(Largest Contentful Paint) 지표도 악화된 것을 확인했습니다.

 

왜 이런 상황이 발생할까요? 바로 Suspense의 잘못된 사용 때문입니다. 위에서 설명한대로 Suspense는 Promise를 catch하여 Promise 상태에 따라서 children 또는 LoadingFallback 컴포넌트를 반환합니다. 즉, pending 상태일 때에는 Loading을 반환하고 있고, children을 실행시키지 않는다는 것이죠. 그렇기 때문에, 하나의 API 요청이 발생하면, children 컴포넌트의 실행은 멈추고 Loading을 반환하게 됩니다. Promise가 settled 상태가 되면 다시 children을 반환하여 children 컴포넌트를 렌더링하겠지요.

이러한 이유 때문에 Suspense가 감싸고 있는 하나의 컴포넌트에서 2개 이상의 요청을 할 때 네트워크 병목이 발생하는 것입니다.

 

해결방안 1 - useQueries

react-query를 사용하는 경우, 여러 query를 동시에 요청하는 방법으로 useQueries를 사용할 수 있습니다.

function After_useQueries() {
  const [{ data: todoList }, { data: postList }] = useQueries([
    {
      queryKey: ["todo"],
      queryFn: () => client.get<Todo[]>("/todos"),
      suspense: true,
    },
    {
      queryKey: ["post"],
      queryFn: () => client.get<Post[]>("/posts"),
      suspense: true,
    },
  ]);

  return (
    <div style={{ display: "flex" }}>
      <section style={{ width: "50%" }}>
        <h2>Todo List</h2>
        {todoList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
      <section style={{ width: "50%" }}>
        <h2>Post List</h2>
        {postList?.data.map(({ id, title }) => (
          <div key={id}>{title}</div>
        ))}
      </section>
    </div>
  );
}

네트워크 탭에서 보시는 바와 같이 병렬 처리를 지원하고 있습니다.

실행 화면을 한번 보겠습니다.

loading이 찍히고 있지 않습니다. 무슨 일일까요? github issue를 열심히 찾아보았습니다. react-query 메인테이너는 다음과 같이 말하고 있습니다.

“v4(현재 버전)에서도 여전히 useQueries에 suspense를 지원하지 않습니다. 우리는 다양한 아이디어를 실현시켜줄 새로운 API를 가지고 있지만, 아직 아무 것도 해결되지는 않았습니다.
현재로서 useQuery는 waterfall을 만드는 한계를 가지고 있기 때문에, 저는 이 기능(useQueries의 suspense)를 결국 해내고 싶습니다.”

 

useQueries에 suspense 옵션을 구현하기 어려운 배경을 찾아보니, useQueries는 useQuery의 복수가 아니라, 아예 다른 메커니즘으로 동작하고 있기 때문입니다. 그래서 useQuery에서 suspense가 지원된다고 하여 useQueries에도 지원되는 것이 성립하지 않습니다. 

 

아래 이슈와 PR을 찾아보시면, 더욱 상세한 내용을 이해할 수 있을 것입니다.

 

https://github.com/TanStack/query/issues/1523

https://github.com/TanStack/query/pull/2109

https://github.com/TanStack/query/issues/2923

 

다시 돌아와서, useQueries는 suspense가 지원되지 않는다는 것을 알았습니다. 더불어 useQueries는 useErrorBoundary 옵션도 지원하지 않습니다. suspense가 되지 않는 것과 동일한 매커니즘입니다. 이런 점들로 미루어보았을 때, useQueries는 아직 안정된 기능이 아니라는 생각이 듭니다. 가령, v3에서 v4로 바뀌면서 명세 자체가 변경되었습니다. v3에서 하나의 인자(queries)만 받다가, v4에서 2개의 인자로 들어나면서 객체 구조의 인자로 변경되었습니다. 즉, 저희 프로젝트는 v3를 사용 중인데, 이후 버전 업데이트 시 호환되지 않을 것입니다.

https://react-query-v3.tanstack.com/reference/useQueries#_top https://tanstack.com/query/v4/docs/reference/useQueries

 

이런 단점을 가진 useQueries를 사용하기엔 다소 무리가 있어 보입니다. Suspense와 ErrorBoundary를 모두 사용하지 않겠다고 하면 일부 단점을 안고 useQueries를 사용해도 무방할 것 같습니다.

 

다른 방안들을 떠올려보겠습니다.

 


위 useQueries suspense 라이브러리 문제는 @tanstack/react-query v4.15.0 에서 해결되었습니다.

https://github.com/TanStack/query/releases/tag/v4.15.0

 

Release v4.15.0 · TanStack/query

Version 4.15.0 - 11/12/2022, 7:28 AM Changes Feat react-query: suspense for useQueries (#4498) (9d9aea5) by Dominik Dorfmeister Packages @tanstack/query-core@4.15.0 @tanstack/react-query@4.15.0 ...

github.com


 

해결방안 2 - Suspense 2개, Component 2개, API call 2개

Suspense 내의 컴포넌트에서 두 개 이상의 요청이 발생하면, 네트워크 워터폴 현상이 생깁니다. 그렇다면 한 컴포넌트에 하나의 요청을 유지하면 해결되겠지요. 아래 코드는 2개의 컴포넌트로 분리하고, 각각 Query 요청을 수행하고, 각각 Suspense로 감싸준 코드입니다. (Suspense 2개, Component 2개, API call 2개)

function AfterEachSuspense() {
  return (
    <div>
      <div style={{ display: "flex" }}>
        <section>
          <h2>Todo List</h2>
          <Suspense fallback={<div>...loading</div>}>
            <TodoList />
          </Suspense>
        </section>
        <section>
          <h2>Post List</h2>
          <Suspense fallback={<div>...loading</div>}>
            <PostList />
          </Suspense>
        </section>
      </div>
    </div>
  );
}

const TodoList = () => {
  const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
    suspense: true,
  });

  return (
    <div>
      {todoList?.data.map(({ id, title }) => (
        <div key={id}>{title}</div>
      ))}
    </div>
  );
};

const PostList = () => {
  const { data: postList } = useQuery("posts", () => client.get("/posts"), {
    suspense: true,
  });

  return (
    <div>
      {postList?.data.map(({ id, title }) => (
        <div key={id}>{title}</div>
      ))}
    </div>
  );
};

이 코드의 네트워크 요청 결과는 다음과 같습니다.

드디어 병목 현상을 해결하고, 정상적으로 병렬 처리되는 모습을 확인할 수 있습니다. Suspense가 분리되었고, 각각의 Suspense가 각각의 children을 관장하므로 병렬적으로 실행되는 것이 당연한 결과입니다.

 

하지만 이 방식은 상황에 따라서 문제가 될 수 있는데요.

 

바로, 로딩 상태가 제각각 이라는 것입니다. 위 네트워크 탭에서 todos 와 posts 요청을 보시면, 끝나는 시간이 다른데요. 각각의 Suspense는 각각의 네트워크 요청이 끝나는 것을 감지하여 children을 렌더링 시킵니다. 그렇다면, 요청이 먼저 끝난 컴포넌트가 먼저 보여지겠죠.

버벅이는 느낌이 들지 않나요?

TodoList가 먼저 렌더링되기 때문입니다. 유저에게 버벅거리는 느낌을 준다는 점에서 유저 경험을 악화시킬 가능성도 있습니다. 이는 상황에 따라서 좋은 경험일 수도 있고, 좋지 않은 경험일 수 있습니다.

 

정리하자면, 컴포넌트를 분리한 후, 각각 Suspense를 감싸주는 것은 유저 경험을 악화시킬 가능성이 있습니다. 그렇다면 두 개 모두가 로딩이 끝나는 시점에 한번에 렌더링 하려면 어떻게 해야할까요?

해결방안 3 - Suspense 1개, Component 2개, API call 2개

코드부터 살펴보겠습니다. 위 코드와 같이 컴포넌트와 Query는 분리되었지만, Suspense는 하나로 감싸고 있다는 점이 다릅니다.

function AfterSameSuspense() {
  return (
    <div>
      <Suspense fallback={<div>...loading</div>}>
        <div style={{ display: "flex" }}>
          <section>
            <h2>Todo List</h2>
            <TodoList />
          </section>
          <section>
            <h2>Post List</h2>
            <PostList />
          </section>
        </div>
      </Suspense>
    </div>
  );
}

네트워크 탭을 살펴보겠습니다. 동일하게 병렬 처리되고 있고, 임의의 API call이 먼저 끝나는 상황입니다.

gif를 보겠습니다.

우리가 의도한 대로, 두개의 요청이 모두 끝날 때를 기다리고 렌더링 해줍니다.

 

이는 Suspense 내부 코드를 살펴봐야 정확히 알겠지만, Suspense 내부에서 API 요청이 발생한 소재를 파악하여, 그곳은 LoadingFallback으로 대체하고, 나머지 부분은 정상적으로 실행시키는 것을 알 수 있습니다.

 

정리해보겠습니다.

  1. 1 Suspense, 1 Component, 2 API call
    • 네트워크 워터폴을 만듭니다.
  2. useQueries
    • 병렬 처리가 가능하지만, suspense를 지원하지 않습니다.
  3. 2 Suspense, 2 Component, 2 API call
    • Suspense가 제각각 실현되고, 먼저 끝난 요청이 먼저 렌더링됩니다.
  4. 1 Suspense, 2 Component, 2 API call
    • Suspense가 하나로 실현되고, 2개의 요청이 모두 끝나야 렌더링 합니다.

1번 상황은 지양해야합니다. Suspense를 사용하면서 한 컴포넌트에서 두 개 이상의 요청을 하면, 네트워크 워터폴을 만들어 로딩 시간을 길게 합니다. useQueries를 대안으로 떠올릴 수 있으나 이는 아직 suspense를 지원하기 않기 때문에 한계가 있습니다. 그러므로 Suspense를 사용하기 위해서는 한 컴포넌트 당 하나의 API call을 유지해야 합니다. 이를 위해 컴포넌트를 분리하는 작업이 수반될 수 있습니다. 3번과 4번의 경우는 미세한 차이를 보이면서, 어떤 유저 경험을 주고 싶냐에 따라서 다른 선택을 할 수 있습니다. 먼저 끝난 요청을 먼저 렌더링 하여 유저에게 빠르게 보여주길 원한다면 3번을 택하면 될 것이고, 한번에 렌더링하여 버벅거리는 유저 경험을 주고 싶지 않다면 4번을 택하면 좋을 것입니다.

 

또는 가장 근본적으로 suspense의 사용 자체를 고민해보는 것도 좋은 접근입니다.

 

마치며

이 아티클에서 살펴보았듯, 무분별한 Suspense는 심각한 로딩 성능 저하를 낳습니다. 어떤 기술이나 그렇듯, Suspense의 장점만 인지하고 Suspense를 사용해서는 안 되며, 동작 원리와 적절한 use case를 꾸준히 탐구해야 합니다. 저희 서비스에서는 이 문제를 해결하였고, 현재 Suspense 사용 자체를 고민 중인 단계입니다. Suspense 내의 한 컴포넌트에서 여러개의 API 요청을 병렬적으로 처리할 수 없는지 고민하고 있습니다. 특별한 조치를 취해주지 않는다면, Suspense를 사용하기 위해서는 1 Component, 1 API call을 유지해야 하며, Suspense를 감싸는 위치까지 고려해줘야 하는 것입니다. 이는 선언적인 프로그래밍이라는 확실한 장점이 있지만, 개발 비용을 높이고 Suspense와 isLoading 중 하나의 원칙을 가져갈 수 없다는 점에서 일관성 문제가 부각될 수 있습니다. Suspense의 장단점을 명확히 파악했길 바라며 글을 마칩니다.

 

혹시 Suspense를 더 잘 활용하는 방법에 대해 토론거리가 있다면 댓글 부탁드립니다.

 

작성자 : https://github.com/euijinkk

코드 확인하러 가기