request waterfall 해결
로그인하고 홈페이지로 이동하면 하면 로딩 스피너가 보입니다. 잘 몰랐지만 상당히 기초가 없는 짓이라고 합니다.

이 아티클 위에서 로그인 후 또 로딩하는 현상을 해결하는 방법을 다룹니다.
왜 해결해야 하는가?
UX적으로도 해결해야 하는 문제는 맞습니다. 일반적으로 시스템에서 유저는 A를 달라고 하고 처리하를 기다린 후에 A를 받아야 합니다. 하지만 2번의 다른 스피너를 보여준 다는 것은 2번의 request-response 라이프 사이클을 보여주는 것입니다. A를 달라고 하고 잠시 기다렸다가 B를 보여주고 B를 기다리다가 다시 A를 주는 방식입니다. 그냥 1번 기다리게 하는 것이 UX적으로 더 좋습니다. 이것은 action cost 관점이 아니라 시스템 피드백 관점에서 필요한 것입니다. 유저에게 불필요한 2번째 요청을 한다는 정보를 숨기는 해결책입니다.
누군가 먼저 해결했는가?
사실 이 글은 아래 아티클을 해결(적용)을 먼저하고 여기서 일부를 풀어 쓰는 것입니다. 또 저보다 더 잘했다고 생각하는 원티드 프리온보딩 레포의 일부를 보고 배낀 것입니다. 죄의식을 갖을 필요는 있지만 사실 별로 없습니다.
React Query meets React Router - tkdodo
놀랍게도 번역도 올라왔습니다.
어떻게 해결하는가?
SSR의 getServerSideProps를 클라이언트 사이드에서 흉내낼 수있습니다. CSR의 프리패칭 전략입니다.
import { redirect } from 'react-router-dom';
import { checkLogin } from '..';
import { ROUTE_PATHS } from '@/constant/config';
type Direction = 'signin' | 'cards';
const DirectionMap: { [key in Direction]: () => null } = {
cards: () => {
if (checkLogin()) throw redirect(ROUTE_PATHS.CARDS);
return null;
},
signin: () => {
if (!checkLogin()) throw redirect(ROUTE_PATHS.SIGN_IN);
return null;
},
} as const;
export function protectRoutes(direction: Direction) {
return DirectionMap[direction];
}
const routes = createBrowserRouter([
{
path: ROUTE_PATHS.WELCOME,
element: <Layout />,
children: [
{
index: true,
element: <Landing />,
loader: protectRoutes('cards'),
},
{
path: ROUTE_PATHS.SIGN_IN,
element: <SignIn />,
loader: protectRoutes('cards'),
},
{
path: ROUTE_PATHS.SIGN_UP,
element: <SignUp />,
loader: protectRoutes('cards'),
},
{
path: ROUTE_PATHS.CARDS,
element: <Cards />,
loader: protectRoutes('signin'),
},
{
path: ROUTE_PATHS.DECK,
element: <Deck />,
loader: protectRoutes('signin'),
},
{
path: ROUTE_PATHS.SETTING,
element: <Setting />,
loader: protectRoutes('signin'),
},
],
errorElement: <NotFound />,
},
]);
function Router() {
return <RouterProvider router={routes} />;
}
export default Router;
예전에 loader 리다이렉팅을 처리하기 위햇 작성했던 코드입니다. 기존에 갖고 있던 로직은 2개입니다. 로그인 상태로 token이 있으면 접근하지 말아야 할 페이지를 마운트 시키기 전에 리다이렉팅을 loader에서 호출하는 것으로 깜박임을 방지합니다. 만약 해당 페이지에 컴포넌트 속에 위치하면 깜박임이 있을 것입니다.
하지만 좋은 컨벤션이 아닌 점이 있습니다. 관심사의 문제입니다. loader는 그 특정 페이지가 먼저 처리해주기 바라는 로직을 처리해달라는 것입니다. 그런 측면에서 loader 함수는 page에 존재해야 합니다.
export function loader() {
return null;
}
function Page() {
return <div></div>;
}
export default Page;
위와 같은 예시로 작성하는 것이 일반적입니다. loader는 getServerSideProps처럼 컴포넌트를 호출하기 전에 먼저 실행하는 함수입니다. 그냥 loader 혹은 loaderPageName으로 명명하는 것도 컨벤션입니다.
컨벤션상 같은 모듈에 위치시키는 것을 보고 coLocation이라고 부릅니다. deno fresh에서도 볼 수 있습니다. 아마 다른 메타프레임워크에서도 볼 수 있을 것입니다. 하지만 저의 경우 coLocation을 깼습니다. 페이지마다 고유한 loader 로직이 많지 않아서 util에서 정의하고 Route 컴포넌트에서 호출했습니다.
import { cardsQuery } from '@/utils';
import queryClient from '@/libs/queryClient';
export const cardLoader = () => async () => {
const query = () => ({
queryKey: ['cards'],
queryFn: getCardsAPI,
staleTime: 5000,
});
return (
queryClient.getQueryData<Card[]>(query.queryKey) ??
(await queryClient.fetchQuery(query))
);
};
loader는 반드시 어떤 값을 반환해야 합니다. 즉 void 타입함수일 수 없습니다. 여기서 봐야 할 것은 queryClient에서 먼저 데이터(query-cache)를 접근하고 없으면 요청을 보내는 방식으로 동작합니다. queryClient를 사용하는 이유는 loader는 컴포넌트도 hook도 아니기 때문입니다. 여기서 주의할 것이 있는데 queryClient는 싱글튼처럼 모두 동일한 인스턴스로 접근할 수 있게 대입해야 합니다. App 혹은 index에서 Provider로 대입한 queryClient랑 동일한 인스턴스로 접근해야 합니다.
loader에서 어떤 로직을 처리하고 반환값도 접근이 가능합니다.
반환값은 Page 컴포넌트에서 useLoaderData로 접근할 수 있습니다.
import { useQuery } from '@tanstack/react-query';
import { cardLoader, cardsQuery } from '@/utils';
import { useLoaderData } from 'react-router-dom';
export function useCards() {
const loaderCards = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof cardLoader>>
>;
const query = cardsQuery();
const {
data: cards,
isLoading,
error,
} = useQuery({ ...query, initialData: loaderCards });
return { cards, isLoading, error };
}
반환값을 page 컴포넌트에서 바로 접근하지 않고 custom hook에서 접근했습니다. 동일로직이 2번 사용되었기 때문에 추출을 결정했습니다.
위 예시의 경우 loader로 실행한 반환값을 먼저 접근합니다. 또 loader를 타입지정에 활용하면 typesafe하게 assertion할 수 있습니다. 지금 상황에서는 일단 먼저 믿음(좋좋소?)으로 가야 합니다.
먼저 서버에 요청하고 실행한 결과를 loaderCards를 초깃값으로 지정하면 react-query의 경우 일단 cache가 있기 때문에 onMount 라이프 사이클에 isLoading flag는 활성화되지 않습니다.
function Cards() {
const { cards, isLoading, error } = useCards();
return <>{/* ... 생략 */}</>;
}
로직이 중복해서 useCard custom hook으로 담습니다. 또 custom hook이지만 캐시를 공유하기 때문에 페이지 전환에 발생하는 loading 스피너도 없습니다.

- 로그인 시점에 1번만 기다리고 페이지를 방문할 수 있게 됩니다.
- 다른 페이지를 접근해도 불필요한 로딩 스피너가 보이지 않습니다.