본문 바로가기

프로젝트/You-Together

Hydration API를 사용한 데이터 프리패칭을 통한 초기 로딩 속도 개선

문제 정의

- YouTogether는 누구나 손쉽게 친구와 Youtube 영상을 함께 보는 서비스로, 별도의 로그인 없이 사용이 가능하다.

- 따라서, YouTogether 프로젝트의 메인 페이지는 전체 방 목록을 확인할 수 있는 사용자 진입점 역할을 한다.

- 하지만 기존 메인 페이지에서 방 목록을 불러오는 API 호출과 응답에 대한 로딩이 길어 아래 영상과 같이 로딩 스피너가 꽤 오랜시간 표시되고 이를 수정하고 싶었다.

새로고침을 눌렀을 때 모습

해결 방안

- 나는 초기 로딩 속도 개선을 위해 서버에서 데이터 프리패칭(prefetching) 하기로 했다.

 

- 내가 고려했던 사항은 다음과 같다.

1. 방 목록은 사용자에게 무한 스크롤을 이용해 콘텐츠를 보여주고 있기 때문에, 서버에서 방 목록의 첫 페이지의 데이터를 프리패칭(prefetching) 하고 클라이언트에서는 첫 페이지 데이터를 GET 하는 API를 호출하지 않는다.

2. 첫 페이지의 데이터가 서버에서 로드되고 스크롤이 정해진 위치에 도달했을 때, 추가 데이터의 로드는 클라이언트에서 API 호출을 한다.

3. 프로젝트에서 API 호출 및 서버 상태 관리는 React Query를 사용하고 있기 때문에 규칙성을 지키기 위해 React Query 활용한다.

 

- React Query 공식문서에서는 서버에서 데이터 프리패칭(prefetching) 및 클라이언트에서 해당 데이터 사용을 위한 2가지 방식을 제공하고 있다.

1. initialData를 사용하여 서버에서 프리패칭(prefetching)한 데이터 사용하기

2. Hydration API 사용하여 서버에서 프리패칭(prefetching)한 데이터 사용하기

 

- 나는 두 가지 방법 중에 Hydration API를 사용하는 방식을 선택했는데, initialData를 사용하는 방법과의 차이점에 대해서는 다음 글에 자세히 설명하기로 하고, 이번 글에서는 Hydration API를 어떻게 적용했는지 설명하도록 하겠다.

 

* 먼저 프로젝트는 Next.js 버전 14 및 app 라우팅 방식을 사용하고 있습니다.

* Next.js 버전 13 이상 및 app 라우팅 방식에서는 getServerSideProps를 사용할 수 없습니다.

수정 전 코드

// app/page.tsx

import NavBar from '@/components/navbar';
import RoomTable from '@/components/room-table';
import { Suspense } from 'react';

const HomePage = () => {
  return (
    <>
      <NavBar isHomePage={true} />
      <div className="flex justify-center items-center px-40">
        <Suspense>
          <RoomTable />
        </Suspense>
      </div>
    </>
  );
};

export default HomePage;
// RoomTable.tsx
'use client';

import { useForm, Controller } from 'react-hook-form';
import { useDebounce } from '@/hooks/use-debounce';
import { zodResolver } from '@hookform/resolvers/zod';
import { useGetRooms } from '@/hooks/use-get-rooms';
import { useIntersectionObserver } from '@/hooks/use-intersection-observer';
import { TRoomSearchPayload, roomSearchSchema } from '@/schemas/rooms';

const RoomTable = () => {
  const {
    control,
    watch,
    setValue,
    clearErrors,
    formState: { errors },
  } = useForm<TRoomSearchPayload>({
    resolver: zodResolver(roomSearchSchema),
    defaultValues: { searchKeyword: '' },
    mode: 'onChange',
  });

  const searchKeyword = watch('searchKeyword', '');
  const debouncedKeyword = useDebounce(
    errors.searchKeyword ? '' : searchKeyword ?? '',
    300
  );

  const {
    data: roomData,
    isPending,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
  } = useGetRooms(debouncedKeyword);

  const router = useRouter();

  useIntersectionObserver({
    targetId: 'load-more-trigger',
    onIntersect: fetchNextPage,
    enabled: hasNextPage && !isFetchingNextPage,
  });

  //...
// getRooms.ts
import axios from 'axios';

export const getRooms = async (
  page: number,
  keyword?: string
): Promise<TRoomsListData> => {
  try {
    const res = await axios.get(
      `${process.env.NEXT_PUBLIC_BASE_URL}/rooms?page=${page}${
        keyword ? `&keyword=${encodeURIComponent(keyword)}` : ''
      }`,
      {
        withCredentials: true,
      }
    );

    return res.data.data;
  } catch (error) {
    throw new Error('방 목록을 불러오지 못했습니다.');
  }
};

// useGetRooms.tsx
import { getRooms } from '@/api/get-rooms';
import { useInfiniteQuery } from '@tanstack/react-query';

export const useGetRooms = (keyword = '') => {
  const { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage } =
    useInfiniteQuery<TRoomsListData>({
      queryKey: ['rooms', keyword],
      queryFn: ({ pageParam }) => getRooms(pageParam as number, keyword),
      initialPageParam: 0,
      getNextPageParam: ({ hasNext, pageNumber }) =>
        hasNext ? pageNumber + 1 : null,
    });

  return { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage };
};

- 수정 전 코드에 대해서 간단하게 설명하면, 먼저 app/page.tsx 파일에는 HomePage가 있고, RoomTable에서는 useGetRooms 훅을 사용하여 서버에서 방 목록을 받아오고 useIntersectionObserver를 통해 스크롤이 일정 위치에 도달했을 때, 추가 방 목록 데이터를 받아오는 API를 호출하고 있다.

Hydration API

- Hydration API란 서버에서 미리 가져온 데이터와 클라이언트에서의 React Query 캐시 상태를 동기화하는 기능을 제공하여 서버에서 프리페치한 데이터를 클라이언트로 전달하고, 클라이언트가 이를 하이드레이션(hydration)하여 사용함으로써 초기 로딩 성능을 최적화할 수 있도록 해준다.

 

- 작동 방식은 다음과 같다.

1. 서버에서 React Query의 `prefetchQuery` 또는 `prefetchInfiniteQuery`를 사용하여 데이터를 미리 가져오고 데이터를 쿼리 캐시에 저장한다.

2. 서버에서 가져온 데이터를 `dehydrate` 함수를 사용하여 직렬화하여 클라이언트로 전달한다.

3. 클라이언트에서 페이지가 로드될 때, 서버에서 직렬화한 데이터를 `HydrationBoundary` 컴포넌트를 통해 하이드레이션한다.

 

즉, 서버에서 프리패칭(prefetching)한 데이터를 클라이언트에서 동일한 쿼리를 실행할 때, 서버에서 가져온 데이터를 그대로 사용할 수 있게되고 이를 통해 초기 로딩 성능을 최적화 할 수 있다.

 

* 여기서 직렬화란 객체나 데이터 구조를 특정 형식(예: JSON, XML, 바이너리 등)으로 변환하여 저장하거나 전송할 수 있게 하는 과정을 의미한다. 직렬화된 데이터는 파일에 저장하거나 네트워크를 통해 전송할 수 있으며, 전송된 데이터를 수신하는 쪽에서는 이를 다시 역직렬화(Deserialization)하여 원래의 객체나 데이터 구조로 복원할 수 있다.

수정 후 코드

- 다음은 Hydration API를 적용하여 수정한 코드이다.

// app/page.tsx

import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query';
import NavBar from '@/components/navbar';
import RoomTable from '@/components/room-table';
import { Suspense } from 'react';
import { getRooms } from '@/api/get-rooms';

export default async function HomePage() {
  const queryClient = new QueryClient();

  await queryClient.prefetchInfiniteQuery({
    queryKey: ['rooms', ''],
    queryFn: ({ pageParam }) => getRooms(pageParam as number),
    initialPageParam: 0,
  });

  const dehydratedState = dehydrate(queryClient);

  return (
    <>
      <NavBar isHomePage={true} />
      <div className="flex justify-center items-center px-40">
        <Suspense>
          <HydrationBoundary state={dehydratedState}>
            <RoomTable />
          </HydrationBoundary>
        </Suspense>
      </div>
    </>
  );
}
// RoomTable.tsx
// 이전 코드와 동일
'use client';

import { useForm, Controller } from 'react-hook-form';
import { useDebounce } from '@/hooks/use-debounce';
import { zodResolver } from '@hookform/resolvers/zod';
import { useGetRooms } from '@/hooks/use-get-rooms';
import { useIntersectionObserver } from '@/hooks/use-intersection-observer';
import { TRoomSearchPayload, roomSearchSchema } from '@/schemas/rooms';

const RoomTable = () => {
  const {
    control,
    watch,
    setValue,
    clearErrors,
    formState: { errors },
  } = useForm<TRoomSearchPayload>({
    resolver: zodResolver(roomSearchSchema),
    defaultValues: { searchKeyword: '' },
    mode: 'onChange',
  });

  const searchKeyword = watch('searchKeyword', '');
  const debouncedKeyword = useDebounce(
    errors.searchKeyword ? '' : searchKeyword ?? '',
    300
  );

  const {
    data: roomData,
    isPending,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
  } = useGetRooms(debouncedKeyword);

  const router = useRouter();

  useIntersectionObserver({
    targetId: 'load-more-trigger',
    onIntersect: fetchNextPage,
    enabled: hasNextPage && !isFetchingNextPage,
  });

  //...


- 먼저 HomePage 컴포넌트를 보면, 서버에서 사용할 queryClient를 새롭게 생성하고 useGetRooms에서 사용하는 동일한 queryKey를 갖는 query를 프리패칭(prefetching)한다.

* useGetRooms는 useInfiniteQuery를 사용하기에, prefetchInfiniteQuery를 사용

- dehydrate 함수를 사용하여 서버에서 미리 가져온 데이터를 직렬화(serialize)하여 클라이언트 측에서 하이드레이션(hydration)할 수 있도록 준비하고 HydrationBoundry를 사용하여 서버에서 미리 받아온 데이터를 클라이언트에서 재사용할 수 있도록 해준다.

* 나는 전체 애플리케이션 레벨이 아닌, 프리패칭이 필요하다고 판단한 HomePage에 HydrationBoundry를 적용했지만, 전체 애플리케이션 레벨에도 적용할 수 있다.(아래 React Query 공식 문서 링크 참고)

 

- 여기서부터 내가 가장 고민했던 부분인데, 위와 같이 Hydration API를 적용했을 때, 방 목록 초기 데이터를 서버에서 프리패칭해오기 때문에 로딩 성능 최적화 및 로딩 스피너 없이 방 목록을 볼 수 있었지만, 클라이언트에서 동일한 첫 번째 방 목록을 받아오는 API를 호출하고 있었다.

- 내 계획으로는 첫 번째 방 목록을 정상적으로 서버에서 프리패칭 했을 때, 클라이언트에서 추가 API 호출을 없애고 싶었기 때문에 꽤 오랜 시간 고민했고, 해결 방법은 다음과 같았다.

// getRooms.ts 코드는 이전과 동일

// useGetRooms.tsx

import { getRooms } from '@/api/get-rooms';
import { useInfiniteQuery } from '@tanstack/react-query';

export const useGetRooms = (keyword = '') => {
  const { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage } =
    useInfiniteQuery<TRoomsListData>({
      queryKey: ['rooms', keyword],
      queryFn: ({ pageParam }) => getRooms(pageParam as number, keyword),
      initialPageParam: 0,
      getNextPageParam: ({ hasNext, pageNumber }) =>
        hasNext ? pageNumber + 1 : null,
      staleTime: 1000 * 30,
    });

  return { data, isPending, hasNextPage, fetchNextPage, isFetchingNextPage };
};

 

- 위 코드는 수정된 useGetRooms 훅인데, options 중  staleTime을 1분으로 수정했다.(이전에는 초기값으로 0 적용)

* staleTime에 대해서 간단히 설명하면, 데이터가 "신선한(fresh)" 상태로 간주되는 시간을 의미하며 데이터가 "신선한" 상태에서 React Query는 데이터를 재요청하지 않고 캐시에 저장된 데이터를 사용한다. 즉, 만약 staleTime이 모두 경과하여 데이터가 "신선하지 못한" 상태가 되면 React Query는 데이터를 재요청한다.

- staleTime을 수정한 의도는, 서버에서 프리패칭된 데이터를 hydrate 해서 초기 로딩 성능을 최적화했지만, React Query는 해당 쿼리 키의 데이터가 "신선하지 못한" 상태로 판단하여 클라이언트에서 다시 한번 해당 데이터를 재요청하기 때문이다.

- staleTime을 30초로 설정한 이유는 방 목록은 서버에서 프리패칭 하지만, 최신 데이터를 유지하고 싶었고 프리패칭을 했지만 로딩이 길어지는 경우에는 사용자에게 최신 방 목록을 보여주기 위해 클라이언트에서 API를 호출 하기 위해서다.



- 최종 코드가 적용된 화면을 보면, 새로고침 시에 로딩 스피너가 표시되는 대신 서버에서 프리패칭된 데이터를 이용해 방 목록이 바로 표시되는 것을 볼 수 있다.

- 또한, 개발자 도구의 network 탭을 보면 서버에서 프리패칭된 첫 번째 페이지(0페이지)의 데이터를 받아오는 rooms?page=0 API를 호출하지 않고 있으며, 사용자가 스크롤을 하단으로 이동했을 때, 다음 페이지 데이터를 받아오는 rooms?page=1 API를 클라이언트에서 호출하는 것을 확인할 수 있다.


학습 단계로 잘못된 정보가 있을 수 있습니다. 잘못된 부분에 대해 알려주시면 정정하도록 하겠습니다

참고 : https://velog.io/@shrewslampe/Next.js%EC%99%80-React-Query%EB%A1%9C-SSR-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

https://tanstack.com/query/latest/docs/framework/react/guides/ssr