CSR 환경에서 Suspense로 발생한 문제 해결하고 성능 개선하기

CSR 환경에서 Suspense로 발생한 문제 해결하고 성능 개선하기

시작하며

이 글에서 사용되는 라이브러리의 최소 버전은 아래와 같습니다.

  • react-router-dom: v6.4
  • @tanstack/react-query: v4
  • react: v18

안녕하세요. 저는 카카오페이 PFM-L 파티에서 자산 관리 서비스의 프론트엔드 개발자로 근무하고 있는 아더입니다. 최근 프론트엔드 생태계는 Next.js 기반의 SSR (Server Side Rendering)을 기반으로 구성되는 추세로 가고 있지만, 당연하게도 CSR (Client Side Rendering) 기반으로 구성된 프로젝트들도 많을 것입니다. 카카오페이에도 역시 CSR 환경으로 구성된 프로젝트가 존재합니다. CSR 환경에서 Suspense를 통한 선언적 로딩 처리를 할 때에 순차적 API 호출이 발생하고 있었는데, 이를 해결하면서 약 30% 정도의 성능 개선 효과를 얻을 수 있었습니다. 이 글을 통해 어떻게 문제를 해결했는지와 문제를 해결하면서 얻은 경험을 공유하며 비슷한 환경에서 운영 중인 프로젝트의 성능 개선에 도움이 되었으면 좋겠습니다.

react-router-dom 기반의 CSR 라우팅

prefetch를 통한 성능 개선에 앞서, react-router-dom 기반의 CSR 환경에서의 라우팅 방법에 대해 가볍게 소개하겠습니다. react-router-dom v6부터는 RouterProvidercreateBrowserRouter를 이용하여 객체 형태로 라우팅을 설정할 수 있습니다.

const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
  },
]);

<RouterProvider router={router} />;

또한 lazy 함수를 이용하여 코드 분할(Code Splitting)을 설정할 수 있습니다. 코드 분할을 진행하면 각 페이지 번들을 지연 로딩하므로 해당 페이지가 로딩될 때 표시될 fallback 컴포넌트를 Suspense를 통해 설정할 수 있습니다. 복잡한 코드 없이 코드 분할을 통해서도 번들 크기를 줄이고 초기 로딩 속도를 줄일 수 있는 효과를 얻을 수 있습니다.

lazy를 통한 코드 분할에 대한 자세한 설명은 React 공식 문서를 참고해 주세요.

import { lazy, Suspense } from 'react';

const LazyHome = lazy(() => import('./Home'));

const router = createBrowserRouter([
  {
    path: '/',
    element: (
      <Suspense fallback={<Loading />}>
        <LazyHome />
      </Suspense>
    ),
  },
]);

하지만 실제 서비스에서는 더 복잡합니다. 정적인 콘텐츠를 보여줄 때도 있지만, 서버와의 통신을 통해 동적인 데이터를 보여줄 때도 있기 때문에 이 과정에서 서비스의 성능이 저하되는 경우도 존재합니다.

@tanstack/react-query와 함께 Suspense를 사용했을 때의 문제점

카카오페이에서는 선언적으로 로딩 처리를 하기 위해 Suspense를 사용하고 있습니다.

카카오페이가 Suspense를 도입한 이유와 자세한 설명이 궁금하시다면 React Query와 함께 Concurrent UI Pattern을 도입하는 방법을 참고해 주세요.

다만, Suspense를 사용하면서 하나의 컴포넌트에서 여러 개의 데이터 호출이 이루어질 경우 순차적으로 API 호출이 이루어질 수 있습니다. 아래의 Home 컴포넌트에서는 두 개의 useQuery를 사용하는데, suspense 옵션을 켜게 되면 첫 번째 쿼리가 내부적으로 Promise를 발생시키고 다른 쿼리가 실행되기 전에 컴포넌트를 일시 중단하기 때문에 순차적으로 API 호출이 발생하게 됩니다.

suspense 옵션을 사용하는 컴포넌트

function Home() {
  const { data: userData } = useQuery({
    queryKey: ['USER'],
    queryFn: getUser,
    suspense: true,
  });
  const { data: bannerData } = useQuery({
    queryKey: ['BANNER'],
    queryFn: getBanner,
    suspense: true,
  });
  const { isCardUser } = userData ?? {};
  const { src } = bannerData ?? {};
  return (
    <div>
      {/** 다른 컴포넌트 ... */}
      {isCardUser && <Banner src={src} />}
      {/** 다른 컴포넌트 ... */}
    </div>
  );
}

네트워크 탭을 확인해 보면 아래의 이미지와 같이 API 요청이 순차적으로 발생하고 있습니다.

순차적 API 호출
순차적 API 호출

suspense 옵션을 사용하지 않는 컴포넌트

아래와 같이 Home 컴포넌트의 useQuery를 사용하는 부분의 suspense 옵션을 제거하면 두 개의 쿼리가 병렬로 실행됩니다.

function Home() {
  const { data: userData, isLoading: isUserLoading } = useQuery({
    queryKey: ['USER'],
    queryFn: getUser,
  });
  const { data: bannerData, isLoading: isBannerLoading } = useQuery({
    queryKey: ['BANNER'],
    queryFn: getBanner,
  });

  if (isUserLoading || isBannerLoading) {
    return <Loading />;
  }
  // ...
}

suspense 옵션을 비활성화하면서 순차적인 API 호출을 제거할 수 있었지만 기존의 선언적 컴포넌트 구성에 대한 장점을 이용할 수 없게 되었습니다.

Suspense를 사용하지 않았을 경우
Suspense를 사용하지 않았을 경우

순차적 API 호출의 문제점

예시처럼 적은 수의 API를 호출할 땐 큰 문제가 없더라도, 호출하는 API의 수가 늘어나면 늘어날수록 그에 비례하여 순차 호출로 인한 비효율이 증가할 것입니다.

더 복잡한 순차적 API 호출
더 복잡한 순차적 API 호출

실제 제가 개발을 진행했던 서비스는 계층 구조가 복잡하고 조건부로 렌더링 되는 컴포넌트가 많아 위와 같이 폭포가 심한 형태의 API 호출이 발생하기도 했습니다. Suspense를 사용하고 있으므로 화면 전체 렌더링이 끝나는 시점은 순차적으로 호출된 API 중 마지막 호출된 API의 응답이 오는 시점 이후가 될 것입니다. 이런 순차적 API 호출을 줄일 수 있다면 화면이 렌더링 되는 시점을 빠르게 당길 수 있을 것입니다. 그렇다면 이러한 순차적인 API 호출을 어떻게 제거할 수 있는지 살펴보겠습니다.

순차적 API 호출을 제거할 수 있는 방법

순차적 API 호출을 제거하는 방법은 2가지가 존재합니다. 첫 번째로 @tanstack/react-query에서 제공하는 useQueries 훅을 이용하는 방법이 있습니다.

1. @tanstack/react-query의 useQueries

v5부터는 useSuspenseQueries로 변경되었으며 TanStack 공식 문서를 참고해 주세요.

예시 컴포넌트에서는 아래와 같이 적용할 수 있습니다.

function Home() {
  const [{ data: userData }, { data: bannerData }] = useQueries({
    queries: [
      { queryKey: ['USER'], queryFn: getUser, suspense: true },
      { queryKey: ['BANNER'], queryFn: getBanner, suspense: true },
    ],
  });
  // ...
}

useQuery 훅에 전달하던 매개변수들을 queries 인자에 배열 형태로 전달하면 useQueries 훅을 이용할 수 있습니다. 아래의 이미지와 같이 개발자 도구의 네트워크 탭을 살펴보면 기존에 순차적이던 API 요청이 병렬로 바뀐 것을 확인할 수 있습니다.

useQueries를 사용한 결과
useQueries를 사용한 결과

이와 같이 하나의 컴포넌트에서 여러 개의 API를 호출해야 할 경우 useQueries 훅을 이용해 데이터를 병렬로 가져올 수 있습니다. 모든 데이터 요청이 앞으로 당겨져 호출이 되므로 전체적인 페이지의 렌더링이 기존의 순차적 API 호출이 발생했을 때 보다 빨라지게 됩니다.

2. react-router-dom의 loader

두 번째로 react-router-dom에서 제공하는 loader를 활용하면 API를 병렬로 호출할 수 있습니다.

loader는 데이터 라우터를 사용할 경우에만 사용할 수 있습니다. 자세한 내용은 React Router 공식 문서를 참고해 주세요.

import type { LoaderFunctionArgs } from 'react-router-dom';

// EXAMPLE: /card/:cardId
async function loader({ params }: LoaderFunctionArgs) {
  const { cardId = '' } = params;
  return getCardData(cardId);
}

loader 함수는 params를 포함하는 객체를 인자로 받으며 동적 라우팅을 사용할 경우 params를 통해 Path 파라미터 값을 전달받을 수 있습니다.

params에 대한 자세한 내용은 React Router 공식 문서를 참고해 주세요.

또한 Path 파라미터가 아닌 Search 파라미터를 이용해 데이터를 가져와야 할 경우 아래와 같이 request 인자를 사용할 수 있습니다.

import type { LoaderFunctionArgs } from 'react-router-dom';

// EXAMPLE: /card/:cardId/list?month=2023-11
async function loader({ request, params }: LoaderFunctionArgs) {
  const { cardId = '' } = params;
  const searchParams = new URL(request.url).searchParams;
  const month = searchParams.get('month');
  return getCardSpentListByMonth(month);
}

request에 대한 자세한 내용은 React Router 공식 문서를 참고해 주세요.

loader 함수에서 반환된 데이터는 컴포넌트에서 useLoaderData 훅을 이용해 사용할 수 있습니다.

useLoaderData 훅의 반환 타입은 unknown이므로 TypeScript를 사용하는 경우 as를 이용한 타입 단언을 함께 사용하면 편리합니다.

function Home() {
  const data = useLoaderData();
}

loader를 통해 순차적 API 호출을 제거하기

이제 loader를 통해 순차적 API 호출을 제거하는 방법을 살펴보도록 하겠습니다. @tanstack/react-query에서는 쿼리의 타입에 따라 prefetchQuery 또는 prefetchInfiniteQuery를 이용해 데이터를 prefetch 할 수 있습니다. 지금은 하나의 Suspense를 이용해 전체 로딩을 처리하고 있으므로 여러 쿼리들을 하나의 Promise로 묶어주기 위해 추가적으로 Promise.all을 사용합니다.

import { queryClient } from '~/somewhere';

async function loader() {
  return await Promise.all([
    queryClient.prefetchQuery({ queryKey: ['USER'], queryFn: getUser }),
    queryClient.prefetchQuery({ queryKey: ['BANNER'], queryFn: getBanner }),
  ]);
}

const router = createBrowserRouter([
  {
    path: '/',
    loader,
    element: (
      <Suspense fallback={<Loading />}>
        <LazyHome />
      </Suspense>
    ),
  },
]);

이대로 코드를 사용하면 기존 코드의 구조를 유지하며 데이터를 병렬 호출을 할 수 있습니다. 하지만 지금의 코드로는 기존에 이용하던 Suspense의 fallback 요소가 정상적으로 렌더링 되지 않습니다. 정상적으로 fallback 요소를 렌더링 하기 위해서는 react-router-dom에서 제공하는 defer 함수를 이용해 loader의 반환값이 지연된 값임을 알려야 합니다.

import { defer } from 'react-router-dom';

function loader() {
  return defer({
    data: Promise.all([
      queryClient.prefetchQuery({ queryKey: ['USER'], queryFn: getUser }),
      queryClient.prefetchQuery({ queryKey: ['BANNER'], queryFn: getBanner }),
    ]),
  });
}

또한 react-router-dom에서 제공하는 Await 컴포넌트를 이용해야 합니다. Await 컴포넌트는 useLoaderData가 반환하는 Promiseresolve될 경우 fallback 요소를 언마운트 하고 하위 요소를 렌더링 합니다.

defer 함수와 Await 컴포넌트에 대한 자세한 내용은 React Router 공식 문서를 참고해 주세요.

import { Await, useLoaderData } from 'react-router-dom';

function Home() {
  const { data } = useLoaderData() as { data: Promise<void[]> };
  const { data: userData } = useQuery({
    queryKey: ['USER'],
    queryFn: getUser,
    suspense: true,
  });
  const { data: bannerData } = useQuery({
    queryKey: ['BANNER'],
    queryFn: getBanner,
    suspense: true,
  });
  const { isCardUser } = userData ?? {};
  const { src } = bannerData ?? {};
  return (
    <Await resolve={data}>
      <div>
        {/** 다른 컴포넌트 ... */}
        {isCardUser && <Banner src={src} />}
        {/** 다른 컴포넌트 ... */}
      </div>
    <Await>
  );
}

Await 컴포넌트와 useLoaderData 훅을 추가적으로 이용해야 하는 다소 번거로운 방법이지만, 기존의 코드 구조를 유지한 상태로 데이터를 빠르게 가져올 수 있게 되었습니다. 그렇다면 해당 기능을 적용한 서비스는 어느 정도의 성능 개선 효과를 얻을 수 있었을까요?

Before
Before

After
After

loader 적용 전의 코드는 전체 페이지 로딩이 끝날 때까지 평균 1.3초 정도의 시간이 소요되는 반면 적용 후의 코드는 평균 0.9초 정도의 시간이 소요되는 것을 확인할 수 있습니다. API 요청을 병렬로 처리하면서 약 30% 정도의 성능 개선 효과를 얻을 수 있었습니다.

useQueries가 아닌 loader를 선택한 이유

위에서 순차적 API 호출을 제거하는 방법에는 useQueries 훅을 이용하거나 loader를 이용하는 두 가지 방법이 있다고 언급했습니다. 결과적으로는 loader를 선택하게 되었고, loader를 선택하게 된 이유와 사용하면서 느낀 주의점을 공유하려 합니다.

loader를 선택하게 된 이유는 아래와 같습니다.

  1. useQueries를 이용하게 될 경우 기존의 코드 구조를 대폭적으로 수정해야 했습니다.

기존 코드는 각 컴포넌트에서 의존하는 데이터를 반환하는 쿼리를 최대한 가까이 두고 호출하고 있었습니다. useQueries를 이용할 경우 쿼리를 끌어올려 props로 넘겨주는 방식으로 코드를 변경해야 했습니다. 따라서 기존 코드 구조를 해치지 않고 사용할 수 있는 loader를 선택했습니다.

  1. Suspense를 분리해 사용할 여지가 있었습니다.

기존 코드는 페이지 컴포넌트 최상단의 하나의 Suspense를 이용해 로딩 처리를 진행하고 있었습니다. 로딩을 분리해 사용하게 될 경우 확장하기 더 쉬울 것이라 판단했습니다.

  • Promise.all 을 사용하지 않고 각 쿼리를 분리한 경우
function loader() {
  return defer({
    userData: queryClient.prefetchQuery({
      queryKey: ['USER'],
      queryFn: getUser,
    }),
    bannerData: queryClient.prefetchQuery({
      queryKey: ['BANNER'],
      queryFn: getBanner,
    }),
  });
}
  • 위의 loader를 이용해 로딩을 분리한 경우
function Home() {
  const { userData, bannerData } = useLoaderData() as {
    userData: Promise<void>;
    bannerData: Promise<void>;
  };
  return (
    <Fragment>
      <Suspense fallback={<ProfileLoading />}>
        <Await resolve={userData}>
          <Profile />
        </Await>
      </Suspense>
      <Suspense fallback={<BannerLoading />}>
        <Await resolve={bannerData}>
          <Banner />
        </Await>
      </Suspense>
    </Fragment>
  );
}

loader를 이용하면서 느낀 주의해야 할 점은 아래와 같습니다.

  1. 컴포넌트 내부에서 호출하는 API의 추가, 변경, 삭제가 발생하는 경우 loader에서도 동일하게 변경해야 합니다.

  2. 페이지에서 호출하는 API 중 어떤 것을 prefetch 할지 고민해야 합니다.

조건부로 호출이 발생하지 않을 수 있는 API가 존재할 수 있고, 다른 API의 응답 값에 따라 호출 값이 결정되는 API도 있으므로 라우트에서 조건 없이 호출되는 API들만 prefetch 할 수 있도록 신경 써야 합니다.

  1. HTTP 1.1에서는 병렬로 요청이 이루어져도 한 번에 유지되는 커넥션의 개수의 제한이 있습니다.

크롬 브라우저 기준으로 6개의 요청만 동시에 처리되며 다른 요청들은 큐에 들어가 순차적으로 처리되게 됩니다.

HTTP 1.1에서의 네트워크 연결 제한
HTTP 1.1에서의 네트워크 연결 제한

위의 이미지에서 확인할 수 있는 것처럼 6개 이후의 요청은 앞의 요청이 끝나면서 순차적으로 처리됩니다. 요청 시점은 앞으로 당겨졌지만, 대기해야 하는 시간이 생길 수 있다는 것을 유의해야 합니다.

마치며

지금까지 간단하게 순차적 API 호출이 생기는 구조와 프리패칭을 통해 이 문제를 어떻게 해결하고 성능을 개선할 수 있는지 알아보았습니다. 0.4초라는 수치의 개선은 사실 큰 차이를 느끼지 어려울 수 있지만, 서비스 품질에 작게라도 영향을 줄 수 있는 의미있는 수치라고 생각합니다. 카카오페이에서는 이런 작은 성능 개선이 누적되어 큰 차이를 만들어 좋은 사용자 경험으로 이어질 수 있도록 개발을 하고 있습니다. 지금까지 긴 글을 읽어주셔서 감사합니다.

출처

  1. https://reactrouter.com/en/main

  2. https://tanstack.com/query/v4

ader.error
ader.error

카카오페이 PFM-L 파티에서 프론트엔드 개발자로 근무하고 있는 아더입니다. 다양한 개발 분야에 관심이 많으며 깊게 몰입하는 것과 지식 공유를 좋아합니다.