시작하며
이 글에서 사용되는 라이브러리의 최소 버전은 아래와 같습니다.
- 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부터는 RouterProvider
와 createBrowserRouter
를 이용하여 객체 형태로 라우팅을 설정할 수 있습니다.
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 요청이 순차적으로 발생하고 있습니다.
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 호출을 제거할 수 있었지만 기존의 선언적 컴포넌트 구성에 대한 장점을 이용할 수 없게 되었습니다.
순차적 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 요청이 병렬로 바뀐 것을 확인할 수 있습니다.
이와 같이 하나의 컴포넌트에서 여러 개의 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
가 반환하는 Promise
가 resolve
될 경우 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
훅을 추가적으로 이용해야 하는 다소 번거로운 방법이지만, 기존의 코드 구조를 유지한 상태로 데이터를 빠르게 가져올 수 있게 되었습니다. 그렇다면 해당 기능을 적용한 서비스는 어느 정도의 성능 개선 효과를 얻을 수 있었을까요?
loader
적용 전의 코드는 전체 페이지 로딩이 끝날 때까지 평균 1.3초 정도의 시간이 소요되는 반면 적용 후의 코드는 평균 0.9초 정도의 시간이 소요되는 것을 확인할 수 있습니다. API 요청을 병렬로 처리하면서 약 30% 정도의 성능 개선 효과를 얻을 수 있었습니다.
useQueries
가 아닌 loader
를 선택한 이유
위에서 순차적 API 호출을 제거하는 방법에는 useQueries
훅을 이용하거나 loader
를 이용하는 두 가지 방법이 있다고 언급했습니다. 결과적으로는 loader
를 선택하게 되었고, loader
를 선택하게 된 이유와 사용하면서 느낀 주의점을 공유하려 합니다.
loader
를 선택하게 된 이유는 아래와 같습니다.
useQueries
를 이용하게 될 경우 기존의 코드 구조를 대폭적으로 수정해야 했습니다.
기존 코드는 각 컴포넌트에서 의존하는 데이터를 반환하는 쿼리를 최대한 가까이 두고 호출하고 있었습니다. useQueries
를 이용할 경우 쿼리를 끌어올려 props로 넘겨주는 방식으로 코드를 변경해야 했습니다. 따라서 기존 코드 구조를 해치지 않고 사용할 수 있는 loader
를 선택했습니다.
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
를 이용하면서 느낀 주의해야 할 점은 아래와 같습니다.
-
컴포넌트 내부에서 호출하는 API의 추가, 변경, 삭제가 발생하는 경우
loader
에서도 동일하게 변경해야 합니다. -
페이지에서 호출하는 API 중 어떤 것을 prefetch 할지 고민해야 합니다.
조건부로 호출이 발생하지 않을 수 있는 API가 존재할 수 있고, 다른 API의 응답 값에 따라 호출 값이 결정되는 API도 있으므로 라우트에서 조건 없이 호출되는 API들만 prefetch 할 수 있도록 신경 써야 합니다.
- HTTP 1.1에서는 병렬로 요청이 이루어져도 한 번에 유지되는 커넥션의 개수의 제한이 있습니다.
크롬 브라우저 기준으로 6개의 요청만 동시에 처리되며 다른 요청들은 큐에 들어가 순차적으로 처리되게 됩니다.
위의 이미지에서 확인할 수 있는 것처럼 6개 이후의 요청은 앞의 요청이 끝나면서 순차적으로 처리됩니다. 요청 시점은 앞으로 당겨졌지만, 대기해야 하는 시간이 생길 수 있다는 것을 유의해야 합니다.
마치며
지금까지 간단하게 순차적 API 호출이 생기는 구조와 프리패칭을 통해 이 문제를 어떻게 해결하고 성능을 개선할 수 있는지 알아보았습니다. 0.4초라는 수치의 개선은 사실 큰 차이를 느끼지 어려울 수 있지만, 서비스 품질에 작게라도 영향을 줄 수 있는 의미있는 수치라고 생각합니다. 카카오페이에서는 이런 작은 성능 개선이 누적되어 큰 차이를 만들어 좋은 사용자 경험으로 이어질 수 있도록 개발을 하고 있습니다. 지금까지 긴 글을 읽어주셔서 감사합니다.