Tanstack Query로 중복 API 제거 및 UX 지연 1.2s → 0s 개선한 사례

junjeong·2025년 5월 26일

Linkbrary

목록 보기
5/6
post-thumbnail

⚙️ Tanstack-Query란?

Tanstack-Query(구 React Query)는 서버상태 전용 라이브러리이다.

서버상태란 예를 들면 데이터가 로드 중인지 여부를 나타내는 isLoading, 요청이 실패했는지 성공했는지 isError, 서버에서 가져온 실제 데이터 data, 요청 실패 시 반환된 오류 메시지나 오류 객체 error와 같은 애들이다.

서버 상태관리 라이브러리가 굳이 따로 존재하는 이유는 무엇일까?

아무래도 서버 상태는 비동기적 요소이다 보니 앞서 나열한 것처럼 고려해야 하는 점들이 다소 많고 까다롭기 때문이지 않을까 싶다.(뇌피셜)

🤔 Linkbrary에서 Tanstack-Query를 채택하고자 하는 이유

비동기 요청이 많아지면 그에 따른 대체 UI가 많아진다. 대체 UI가 3초 이상 지연될 시 사용자 이탈율은 무려 90퍼로 치솟는다는 통계 자료가 있다..

linkbrary도 이런 위험에 노출되어 있다. 모든 비동기 요청에 캐싱 기능이 없기 때문에 사용자는 모든 비동기 요청에 대체 ui를 경험한다.

대체 ui가 무조건적으로 나쁘다는 말은 아니다. '모든 비동기 요청을 대체 ui로 보완하면 되겠지' 하는 판단이 매우 안일한 선택지라는 이야기이다.

하여 이번 시간에 Tanstack-Query 라이브러리를 활용해 캐싱 기능을 적용해 보고자 한다. 불필요하게 동일한 데이터를 받아오는 요청이 있다면 없애주어 프로젝트 의 전반적인 FCP 속도를 개선하고자 한다.

🏃‍♂️ Linkbrary is Tanstack-Quering...

1. 설치

우선 tanstack-query를 설치해주자. 관련한 eslint도 설치해주었다.

2. queryClient 인스턴스 주입

tanstack-query가 제공해주는 useQuery, useMutation와 같은 메소드를 사용하기 위해서는 new QueryClient()가 생성한 객체, 곧 인스턴스가 필요하다.

또 이것을 React의 Context api를 통해 하위 컴포넌트로 전달하기 때문에 반드시 아래와 같이 QueryClientProvider를 사용하여 주입을 해주어야 한다.

// App.tsx
const queryClient = new QueryClient();

export default function App({ Component, pageProps }: AppProps) {  return (
    <>
     //  ...(생략)

      <QueryClientProvider client={queryClient}>
            <Component {...pageProps} />
      </QueryClientProvider>
    </>
  );
}

특이한 점이 있었다면, new QueryClient() 생성자 함수 호출을 App 컴포넌트 바깥에서 해주어야 했다. 공식문서를 찾아보니 매번 렌더링할 때마다 새로운 인스턴스를 만들면 캐시가 제대로 유지되지 않거나 성능에 문제가 생길 수 있다고 한다.https://tanstack.com/query/latest/docs/eslint/stable-query-client

3. useQuery()

이번 시간에 가장 핵심적인 메소드이자, 가장 기본적인 쿼리 훅인 useQuery이다.
데이터를 가져오는 기능을 하며 기존의 fetch를 대체한다.

3.1 기존의 데이터 요청은 어떻게 하고 있었을까? 🧐

우선 기존에 데이터 흐름을 보자. 아래는 "/link" 페이지의 index.tsx이다.

// ..import문 생략 

interface LinkPageProps {
  linkList: LinkData[];
  folderList: FolderData[];
  totalCount: number;
}

// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  // accessToken이 없으면 클라이언트에서 실행될 때 /login 페이지로 이동시킴.
  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  const fetchData = async (endpoint: string) => {
    const res = await axiosInstance.get(endpoint, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return res.data;
  };

  const [links, folders] = await Promise.all([
    fetchData("/links"),
    fetchData("/folders"),
  ]);

 (...중략)
  };
};

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const router = useRouter();
  const { search, folder } = router.query;
  const { isOpen } = useModalStore();
  const { isMobile } = useViewport();
  const { totalCount, linkCardList, setLinkCardList } =
    useLinkCardStore.getState();
  const [isLoading, setIsLoading] = useState(false);
  const [folderName] = useFolderName(folder);
  const [folderList, setFolderList] = useState(initialFolderList);

  useEffect(() => {
    setLinkCardList(initialLinkList, initialTotalCount);
  }, [initialLinkList, initialTotalCount, setLinkCardList]);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkCardList, setIsLoading);

  return (
     (...중략)
  );
};

export default LinkPage;

getServerSideProps 함수를 활용해 초기데이터를 받아오고 있다. 사용자가 페이지 최초 접속 시에 서버에서 온전한 HTML을 만들어 FCP를 빠르게 하는데 목적이 있었던 것으로 보인다.

최초에 데이터 요청시에 캐싱 기능을 활용하면 어떨까?
getServerSideProps로 SSR을 할 때에는 Next에서 지원해 주는 캐싱 기능이 없다. 방법이 없는 것은 아니지만 이 또한 다른 라이브러리 설치가 필요하기 때문에 당장의 해결책은 되지 못한다.

getServerSideProps에서 useQuery()를 써야 하나?
useQuery()는 클라이언트에서 요청하는 메소드이고 getServerSideProps는 서버에서 실행되는 함수이다. 또한 useQuery도 결국 hook이다. hook은 반드시 커스텀 훅 또는 리액트 컴포넌트에서 사용 가능하기 때문에 이 방법 또한 불가능하다.

최초 데이터만 SSR로 요청을 하고 이후에 요청은 useQuery()를 활용하자
최종 결론이다. 초기 페이지 로딩 시 서버에서 데이터를 미리 가져오고, 이후의 데이터 패칭은 클라이언트 측에서 useQuery()로 관리하면 FCP를 빠르게 챙길 수도 있고, 불필요하게 발생하는 api 요청까지 개선할 수 있을 것 같다.

3.2 불필요한 api 요청은 useQuery()로 최적화하자!

프로젝트에서 사용자와 상호작용 이후에 업데이트 되어야 하는 데이터가 있다면 무엇일까?

  1. 링크 목록
  2. 폴더 목록

위에 두가지 정도이다. 그렇다면 각각의 상태(links,folders)를 업데이트하는 함수들이 어디서 호출되고 있는지 추적하면 될 것 같다.

첫번째로 링크 목록을 업데이트 하는 함수는, 바로 보이는useFetchLinks 커스텀 훅에서 관리하고 있는 것으로 보인다. 추상화한 내부 코드를 살펴보자.

// useFetchLinks.tsx
const useFetchLinks = (
  setLinkCardList: (list: LinkData[], totalCount: number) => void,
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
) => {
  const router = useRouter();
  const { query, pathname } = router;
  const { isTablet } = useViewport();

  useEffect(() => {
    const fetchLinks = async () => {
      setIsLoading(true);
      // 경로에 따라 API 엔드포인트 분기
      let endpoint =
        pathname === "/favorite"
          ? "/api/favorites"
          : query?.folder
            ? `/api/folders/${query.folder}/links`
            : "/api/links";

      const res = await proxy.get(endpoint, {
        params: {
          page: query?.page,
          pageSize: isTablet ? 6 : 9,
          search: query?.search,
        },
      });
      setLinkCardList(res.data.list, res.data.totalCount);
      setIsLoading(false);
    };

    if (query) fetchLinks();
  }, [setLinkCardList, setIsLoading, pathname, query, isTablet]);
};

export default useFetchLinks;

useEffect 안에서 fetchLinks 함수를 통해 업데이트를 하고 있고, 디펜던시 목록을 보니 url 경로가 바뀌거나, 쿼리가 추가되거나, 태블릿 반응형으로 바뀔 때에 업데이트가 이루어지는 모습이다.

이렇게 설계한 이유는 사용자가 검색, 정렬 등의 상호작용을 하면 router.push로 url 데이터를 필터링하고, 페이지가 즉시 업데이트되도록 하고 싶었던 것 같다.

문제는 아래 사진처럼, 사용자는 화면을 줄였다 늘리는 것만으로도 똑같은 데이터 요청이 보내지고 이는 1.2초라는 딜레이를 요구한다.

useQuery()를 추가해 캐싱 기능을 추가해보자. 코드는 다음과 같이 변했다.

const useFetchLinks = (
  setLinkCardList: (list: LinkData[], totalCount: number) => void,
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
) => {
  const router = useRouter();
  const { query, pathname } = router;
  const { isTablet } = useViewport();

  const fetchLinks = async () => {
    // 경로에 따라 API 엔드포인트 분기
    let endpoint =
      pathname === "/favorite"
        ? "/api/favorites"
        : query?.folder
          ? `/api/folders/${query.folder}/links`
          : "/api/links";

    const res = await proxy.get(endpoint, {
      params: {
        page: query?.page,
        pageSize: isTablet ? 6 : 9,
        search: query?.search,
      },
    });
    return res.data;
  };

  const { isLoading } = useQuery({
    queryKey: ["links", query, pathname, isTablet], // query, pathname, isTablet이 바뀔 때 fresh -> stale
    queryFn: fetchLinks,
    enabled: !!query, // query가 존재할 때만 요청
    staleTime: 1000 * 60 * 5, // 5분 동안 fresh 상태 유지
    select: (data) => setLinkCardList(data.list, data.totalCount),
  });

  // 로딩 상태 설정
  useEffect(() => {
    setIsLoading(isLoading);
  }, [isLoading, setIsLoading]);
};

export default useFetchLinks;

기존에 useEffect가 사라지고 useQuery로 useFetchLinks 함수를 호출해주는 모습이다. staleTime을 30000 값을 넣어주어 5분동안 캐시에 데이터를 저장하게끔 한 모습이다. 결과는 어떻게 바뀌었을까??

의도한대로 1.2초나 걸리는 요청들이 모두 없어졌고 반응형에 따른 링크목록 ui 전환도 이전보다 빨라졌다. 이처럼 useQuery()는 정말 강력한 기능을 제공한다.

하지만 작업 도중 심각한 문제를 발견한다 ㅎㅎ...🥲

useQuery의 queryKey의 디펜던시로 useRouter가 반환하는 router.query를 넣어주었는데, 아차차.. useRouter가 반환하는 router 객체는 router.push가 일어날 때마다 다른 참조의 객체였다는 사실이다.(값이 같아도 다른 객체로 할당된다는 말이다.)

이 말은 useQuery가 인식하는 query는 매번 다른 녀석이기 때문에 애초에 캐싱이 불가능하다는 뜻이다.

고민이 수포로 돌아간 것일까? 아니다 문제는 router.push와 useRouter()이다.
이 두 녀석을 쓰지 않고 다시 아래의 숙제만 해결하면 되는 문제이다.

  1. 사용자와의 상호작용 이후에 클라이언트 상태를 어떻게 추적할 것인가? (ex. 현재 경로, 사용자가 현재 머무는 폴더id, 정렬 기준, 검색어...)
  2. 상태의 값이 동일하다는 것을 useQuery()도 알고 있는가?

머리를 식히고 차분히 다시 생각을 해보니 단순히 url로 데이터를 저장하던 것을 상태로 관리하면 해결 될 것 같았다. 이벤트가 발생했을 떄 router.push가 아닌 setState()함수로 상태를 업데이트 시키면 된다.

3.2.2 상태변경 알림을 router.push()가 아닌 setState()로 바꿔보자! useQuery()가 기억할 수 있도록!

useQuery()가 기억해야 하는 상태들이 router.push로 url에 추가되는 로직이 어디에서 실행되는지 추적하면 되는데, 이전에 페이지 컴포넌트에서 상태로 저장할 수 있도록 queryKey라는 객체를 useState()로 하나 만들어주자. 이 안에는 query.search, query.folder, pathname, isTablet과 같은 값들이 들어갈 것이다.

코드는 다음과 같이 변경되었다.

interface LinkPageProps {
  linkList: LinkData[];
  folderList: FolderData[];
  totalCount: number;
}

// /link 페이지 접속시에 초기렌더링 데이터(전체 폴더, 전체링크리스트)만 fetch해서 client로 전달.
export const getServerSideProps = async (
  context: GetServerSidePropsContext
) => {
  const { req } = context;
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  // accessToken이 없으면 클라이언트에서 실행될 때 /login 페이지로 이동시킴.
  if (!accessToken) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  const fetchData = async (endpoint: string) => {
    const res = await axiosInstance.get(endpoint, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return res.data;
  };

  const [links, folders] = await Promise.all([
    fetchData("/links"),
    fetchData("/folders"),
  ]);

  return {
    props: {
      linkList: links.list || [],
      folderList: folders || [],
      totalCount: links.totalCount || 0,
    },
  };
};

const LinkPage = ({
  linkList: initialLinkList,
  folderList: initialFolderList,
  totalCount: initialTotalCount,
}: LinkPageProps) => {
  const router = useRouter();
  const { query } = router;
  const { isMobile, isTablet } = useViewport();
  const { isOpen } = useModalStore();
  const [linkListData, setLinkListData] = useState({
    list: initialLinkList,
    totalCount: initialTotalCount,
  });
  const [folderList, setFolderList] = useState(initialFolderList);
  const [isLoading, setIsLoading] = useState(false);
  const [queryKeys, setQueryKeys] = useState({
    pathname: router.pathname,
    page: query.page,
    search: query.search,
    folder: query.folder,
    isTablet: isTablet,
  });
  const [folderName] = useFolderName(queryKeys.folder);

  useEffect(() => {
    setQueryKeys({
      pathname: router.pathname,
      page: query.page,
      search: query.search as string,
      folder: query.folder as string,
      isTablet: isTablet,
    });
  }, [
    query.search,
    isTablet,
    query.folder,
    router.pathname,
    setQueryKeys,
    query.page,
  ]);

  // 링크페이지의 query가 바뀌면 새로운 리스트로 업데이트 해주는 훅
  useFetchLinks(setLinkListData, setIsLoading, queryKeys);

  return (
     (...중략)
  );
};

export default LinkPage;

코드가 긴데 다 볼 필요없이 중요한 점이 있다면, 기존에 페이지 컴포넌트에서 queryKey들을 따로 상태로 저장하는 useState 함수가 추가되었다는 점이다.

다음으로 링크 목록을 업데이트하는 useFetchLinks() 함수는 어떻게 바뀌었는지 보자.

const useFetchLinks = (
  setLinkListData: (data: { list: LinkData[]; totalCount: number }) => void,
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
  queryKeys: QueryKeys
) => {
  const { pathname, folder, page, search, isTablet } = queryKeys;

  const fetchLinks = async () => {
    // 경로에 따라 API 엔드포인트 분기
    let endpoint =
      pathname === "/favorite"
        ? "/api/favorites"
        : folder
          ? `/api/folders/${folder}/links`
          : "/api/links";

    const res = await proxy.get(endpoint, {
      params: {
        page: page,
        pageSize: isTablet ? 6 : 9,
        search: search,
      },
    });
    return res.data;
  };

  const { data, isSuccess, isLoading } = useQuery({
    queryKey: ["links", folder, search, pathname, isTablet], // query, pathname, isTablet이 바뀔 때 fresh -> stale
    queryFn: fetchLinks,
    staleTime: 1000 * 60 * 5, // 5분 동안 fresh 상태 유지
  });

  useEffect(() => {
    if (isSuccess) {
      setLinkListData({ list: data.list, totalCount: data.totalCount });
    }
  }, [data, isSuccess, setLinkListData]);

  // 로딩 상태 설정
  useEffect(() => {
    setIsLoading(isLoading);
  }, [isLoading, setIsLoading]);
};

export default useFetchLinks;

기존에 useRouter로 query나 pathname을 받아오던 코드가 모두 사라졌고, 앞서 페이지 컴포넌트에서 관리하는 상태를 그대로 props로 받아 참조하고 있는 모습이다. 객체가 아닌 상태, 곧 변수를 참조하게 되므로 같은 값이면 같은 api요청이라고 인식해 캐싱된 데이터를 받아온다.

결과는 다음과 같다.

🎉🎉🎉 Amazing!!

useQuery가 드디어 search라는 queryKey를 새로운 데이터로 인식하지 않고 이전에 데이터로 기억하는 모습이다. 캐싱된 데이터를 바로 가져오기 때문에 화면 전환도 훨씬 빨라졌고 불필요한 api 요청도 하나도 발생하지 않는 모습이다!! 마치 Link 컴포넌트로 프리패치한 데이터를 받아오듯이, 마치 SSG로 정적 페이지를 로드하듯이 UX가 많이 개선된 것 같다.

⭐️ 요약

이번 시간에는 비동기 요청을 가능한 최대한으로 줄여 LCP 시간을 단축시키고, 궁극적으로 사용자가 Loading Indicator에 지쳐 페이지를 이탈하는 일이 없게끔 하기 위한 UX 개선 작업이었다.

서버에 데이터가 바뀌지 않았을 경우에 클라이언트에서는 굳이 새로운 api 요청을 할 필요가 없다는 것이 이번 시간에 교훈이다. useQuery()를 사용하면 기존에 캐싱된 데이터를 그대로 받아올 수 있기 때문이다.

그렇다면 서버에서 데이터 업데이트가 발생해 최신의 데이터로 가져와야 할 때에는 어떻게 해야할까?? 그 과정은 다음 시간 "tanstack-query로 데이터 최신화 하기" 포스팅에서 다루겠다.

profile
Whether you're doing well or not, just keep going👨🏻‍💻🔥

0개의 댓글