CSR 환경에서 prefetch하여 페이지 로딩 속도 개선하기

정수현·2024년 1월 10일
5

React

목록 보기
8/8
post-thumbnail

들어가기전) CSR과 SSR

CSRSSR
장점첫 페이지 로드 이후에는 인터랙션이 매끄럽다.이미 서버에서 렌더링된 페이지를 보여주기 때문에 첫 페이지 로딩 속도가 비교적 빠르다.
SEO 최적화에 용이하다.
단점빈 html 파일에 자바스크립트로 페이지를 그리기 때문에 첫 페이지 로딩 속도가 느리다.
SEO 최적화가 비교적 어렵다.
서버 비용이 많이 든다.
초기 로딩 이후 페이지 이동 시 CSR보다 속도가 느리다.

CSR, SSR의 장점을 모두 적용할 수 있다는 점에서 Next.js가 인기를 끌고 있다. 기존 진행하던 프로젝트는 순수 react를 사용하고 있어 프로젝트 전체가 클라이언트 사이드 렌더링으로 이루어져있는데 react-router-dom v6의 loader가 CSR 페이지 로딩 속도 개선에 있어 엄청난 역할을 할 수 있을것이라 기대했고, 이를 적용하는 과정과 결과를 소개해보려고 한다. 😎

react-router-dom v6

react-router-dom v6에서는 shocking change들이 있었는데 그 중 가장 기대되었던 기능은 loader, action, fetcher이다.

API설명
loader컴포넌트가 렌더링되기 전에 해당 경로를 방문할 때 api 호출
actionurl에 form과 같은 리퀘스트를 보낼 때 데이터를 처리하는 부분
fetcherurl을 변경하지 않고 요청한 url에 데이터를 요청

나는 ^6.15.0 버전의 react-router-dom을 사용하여 다음 코드를 작성했다.

📍 과정 1) 라우팅 마이그레이션

loader api를 사용하기 위해 우선적으로 라우팅 로직을 마이그레이션 해야했다. BrowserRouter와 Routes, Router 컴포넌트를 사용해서 라우팅하는 기존 로직에서 createBrowserRouter와 RouterProvider 컴포넌트를 사용하여 라우팅을 하는 방식으로 변경해보았다.

기존 익숙했던 라우팅 방식은 다음과 같았다.

import { BrowserRouter, Route, Routes } from 'react-router-dom';

const Home = lazy(() => import('pages/home'));
const Login = lazy(() => import('pages/user/login'));
const Error404 = lazy(() => import('pages/404'));
const DashBoard = lazy(() => import('pages/dashboard'));

const routeElements = [
  {
    path: '/',
    element: <Home />,
    lazyFallback: <Loading.Home />,
    isAuthRequired: true,
  },
  {
    path: '/dashboard',
    element: <DashBoard />,
    lazyFallback: <Loading.Dashboard />,
    isAuthRequired: true,
  },
  {
    path: '/login',
    element: <Login />,
    lazyFallback: <Loading.Login />,
    isAuthRequired: false,
  }
];

function Router() {
  return (
    <BrowserRouter>
      <QueryErrorResetBoundary>
        {({ reset }) => (
          <ErrorBoundary
            onReset={reset}
            FallbackComponent={Error}
          >
              <Layout>
                <Routes>
                  {routeElements.map((item) => (
                    <Route
                      key={item.path}
                      element={
                        <AuthRouter isAuthRequired={item.isAuthRequired} />
                      }
                    >
                      <Route
                        path={item.path}
                        element={
                          <Suspense fallback={item.lazyFallback}>
                            {item.element}
                          </Suspense>
                        }
                      />
                    </Route>
                  ))}
                  <Route
                    path='*'
                    element={
                      <Suspense fallback={<Loading.Loading404 />}>
                        <Error404 />
                      </Suspense>
                    }
                  />
                </Routes>
              </Layout>
          </ErrorBoundary>
        )}
      </QueryErrorResetBoundary>
    </BrowserRouter>
  );
}

function App() {
  return <Router />;
}
import { Suspense, lazy } from 'react';
import { Outlet, createBrowserRouter, RouterProvider } from 'react-router-dom';

const Home = lazy(() => import('pages/home'));
const Login = lazy(() => import('pages/user/login'));
const Error404 = lazy(() => import('pages/404'));
const DashBoard = lazy(() => import('pages/dashboard'));

const routes = createBrowserRouter([
  {
    element: (
      <Layout>
        <Outlet />
      </Layout>
    ),
    children: [
      {
        path: '/',
        element: (
          <AuthRouter isAuthRequired>
            <Suspense fallback={<Loading.Home />}>
              <Home />
            </Suspense>
          </AuthRouter>
        ),
      },
      {
        path: '/dashboard',
        element: (
          <AuthRouter isAuthRequired>
            <Suspense fallback={<Loading.Dashboard />}>
              <DashBoard />
            </Suspense>
          </AuthRouter>
        ),
      },
      {
        path: '/login',
        element: (
          <AuthRouter isAuthRequired={false}>
            <Suspense fallback={<Loading.Login />}>
              <Login />
            </Suspense>
          </AuthRouter>
        ),
      },
      {
        path: '*',
        element: (
          <Suspense fallback={<Loading.Loading404 />}>
            <Error404 />
          </Suspense>
        ),
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={routes} />;
}

🚨 이슈 1) Layout을 전체적으로 감싸주고 싶다.

💡 해결방법
Next.js의 layout.js 처럼 전체적으로 레이아웃 컴포넌트를 적용하고 싶다면 이처럼 가장 상위 element를 <Outlet/> 컴포넌트를 <Layout>컴포넌트로 감싸준 후, children 속성에 레이아웃을 적용할 요소들을 넣어주면 된다.

const routes = createBrowserRouter([
  {
    element: (
      <Layout>
        <Outlet />
      </Layout>
    ),
	children: [
      // ..
    ]
  }
 ])

컴포넌트 lazy loading
그리고 컴포넌트의 lazy load를 적용할 수 있는 lazy 옵션도 추가되었는데,, createBrowserRouter의 lazy 속성을 사용해서 컴포넌트를 lazy load하는 방법은 다음과 같다.

그런데.. 나처럼 page 별로 lazy load를 적용한 후 fallback 컴포넌트를 적용하는 방법은 못찾아서 lazy 옵션은 사용하지 못하고 위의 코드처럼 그냥 Suspense를 사용하여 감싸주었다. 방법을 알고 계시다면 댓글 부탁드립니다.🙏

일단 라우팅은 마이그레이션이 완료되었다. 라우팅 로직을 변경하면서 UI 로직과 다른 비즈니스로직과의 관심사 분리까지 잘 된거같다.

📍 과정 2) loader 사용해보기

loader를 사용할 기반을 갖추었으니 이제 사용해봐야겠다. loader를 사용하지 않았을 때와 사용했을 때의 차이는 다음과 같으며, 그 결과로 빠른 페이지 로딩을 기대해볼 수 있겠다.

기존 로직loader
컴포넌트 마운트 시 데이터 페칭컴포넌트가 렌더링되기 전에 해당 경로를 방문할 때 api 호출

다음 코드는 react-router-dom 공식문서의 loader 사용 예시를 간소화해본 것이다.

createBrowserRouter([
  {
    element: <Teams />,
    path: "teams",
    loader: async ({ params }) => {
          return fetch(`/api/teams`);
	},
  }
]);

loader를 통해서 미리 받아온 데이터는 useLoaderData라는 훅을 사용하여 컴포넌트 내에서 받아올 수 있다.

import {
  createBrowserRouter,
  useLoaderData,
} from "react-router-dom";


export function Albums() {
  const albums = useLoaderData(); // ⭐️
  // ...
}

const router = createBrowserRouter([
  {
    path: "/",
    loader: fetchFakeAlbums,
    element: <Albums />,
  },
]);

🚨 이슈 2) loader로 fetch한 data를 기존 컴포넌트에서 useQuery로 받아온 데이터로 어떻게 갈아끼울 수 있을까?

위의 공식문서의 예시를 보고 그대로 fetch를 사용해서 따라해봤다. 페이지가 이동하면서 빠르게 loader로 작성한 api가 호출되는 것은 느껴졌으나, 여기서 이제 두번째 난관에 봉착해버렸다. 기존 페이지에서 react query를 사용해서 데이터를 페칭해오고 있는데. 서로 독립적으로 api를 호출하고 있기 때문에 같은 api를 두 번씩 호출하게 되는 것이다.

그래서 컴포넌트에서 별도의 data를 저장하는 state를 만들고 useEffect를 사용해서 바꿔주어야 하나 고민고민하다가 리액트 쿼리+react-router-dom v6 사용 예시들을 찾아보며 고민해보았다.

💡 해결방법
react-query의 queryKey와 캐시를 사용하여 중복 요청 생성 방지

기존 컴포넌트 내부에 존재하던 useQuery 코드이다.

export async function getNewList() {
  const { data } = await AxiosInstance.get<ResponseType<HomeType.ListResponse[]>>(`list/new`);
  return { data: data.response, page: data.page };
}

export async function getTodayList() {
  const { data } = await AxiosInstance.get<ResponseType<HomeType.ListResponse[]>>(`list/today`);
  return { data: data.response, page: data.page };
}

 const {
    data: createdList,
    isLoading: createdListLoading,
    error: createdListListError,
  } = useQuery({
    queryKey: ['list', 'new'],
    queryFn: getNewList,
    staleTime: 1000 * 60,
  });

  const {
    data: todayList,
    isLoading: todayListLoading,
    error: todayListError,
  } = useQuery({
    queryKey: ['list', 'toady'],
    queryFn: getTodayList,
    staleTime: 1000 * 60,
  });
loader: async () => {
	return await Promise.all([
		queryClient.fetchQuery({
			queryKey: ['list', 'new'],
			queryFn: DashBoardQueries.getNewList,
    }),
		queryClient.fetchQuery({
			queryKey: ['list', 'today'],
			queryFn: DashBoardQueries.getTodayList,
		}),
	]);
}

동일한 queryKey를 사용하고, staleTime을 설정하고 Promise.all을 사용하여 두 쿼리를 병렬처리하도록 했다. 이렇게 되면 컴포넌트가 렌더링되기 전에 해당 경로를 방문할 때 loader에서 ['list', 'new'], ['list', 'today'] 쿼리 키를 가진 쿼리가 실행되고, 그 결과가 1분 동안 캐싱된다. 컴포넌트가 마운트되고 컴포넌트 내부의 useQuery가 실행될 때 같은 쿼리키를 가진 캐싱된 데이터를 반환하므로 api를 중복하여 호출하지 않는 것이다. 와우~

BEFOREAFTER
Performance 95점Performance 99점 (헐!)
loader로 호출한 api들이 더 먼저 호출된다.

Queued at s : 개발자 도구를 켠 순간부터 큐에 적재되는데 까지 걸리는 시간
Started at s : 개발자 도구를 켠 순간부터 request를 보내는데 까지 걸리는 시간
Queueing : 구문을 분석한 시점에서 큐에 적재되어 있는 시간
Stalled : 큐에서 request를 보내는 동안 정지되어 있는 시간
Proxy negotiation : 브라우저가 프록시 서버로 요청을 보내는데까지 걸리는 시간
Request sent : request를 보내는데 걸리는 시간
Waiting (TTFB) : response의 첫번째 바이트가 도달하는데까지 걸리는 시간 (TTFB: Time To First Byte)
Content Download : content가 다운로드가 되는데 까지 기다린 시간
Explanation : 총 소요되는 시간

같은 페이지를 LightHouse로 검사해보았을 때 성능 점수가 99점까지 올랐다. 꽤나 페이지 속도에 효과가 있는 듯하다.

참고자료

1개의 댓글

comment-user-thumbnail
2024년 1월 20일

작성하신 글 잘 봤습니다! 실제로 프로젝트에 적용하면서 테스트하신 경험을 작성하셔서 이해하기가 쉬웠네요.
최근에 next.js에서 제공하는 next/link의 prefetch에 대해 학습하고 있었는데, 만약 면접에서 next.js를 사용하시는 이유가 뭔가요? 라는 질문이 왔을 때 답변으로 'next/link를 사용해서 prefetch 기능을 사용할 수 있어서요'라고 하면 안되겠네요
CSR 환경에서도 충분히 구현할 수 있어서 미리 데이터를 받아와서 페이지 이동할 때 사용자는 더 빠르게 화면을 볼 수 있겠네요.
next/link의 prefetch는 통신 뿐만이 아니라 번들 파일까지 받아올 수 있는것으로 알고있어서 각자의 특징을 잘 알고있어야 할 것 같네요!

답글 달기