티스토리 뷰

반응형


안녕하세요. 오늘은 React Query에서 유용하게 활용할 수 있는 util 함수와 hook을 소개해드리려고 합니다. 특히, 여러 API 요청을 동시에 처리할 때 쿼리의 로딩 상태를 관리하는 데 도움을 줄 수 있는 방법을 다루겠습니다.

 

리액트 애플리케이션을 개발하다 보면 여러 API 요청을 동시에 처리해야 하는 상황이 자주 발생합니다. 이런 경우 각 요청의 로딩 상태를 효과적으로 관리하고 UI에 반영하는 것이 중요합니다. 오늘은 React Query를 활용하여 다중 쿼리의 로딩 상태를 깔끔하게 관리하는 방법을 공유해보려고 합니다.

 

왜 다중 쿼리의 로딩 상태 관리가 필요할까요?

예를 들어, 블로그 애플리케이션을 생각해보세요. 이 애플리케이션에는 두 개의 탭이 있어, 각각 "최신 글"과 "인기 글"을 표시합니다. 각 탭은 별도의 API 엔드포인트에서 데이터를 가져오기 때문에 두 개의 로딩 상태와 리스트를 관리해야 합니다. 이러한 상황에서 로딩 상태를 효율적으로 관리하는 것은 필수적입니다.

 

기본적인 로딩 상태 관리

먼저, 각 탭별로 개별적인 로딩 상태를 관리하는 예제를 살펴보겠습니다.

import { useQuery } from '@tanstack/react-query';

function Posts() {
  const latestPostsQuery = useQuery(['posts', 'latest'], fetchLatestPosts);
  const popularPostsQuery = useQuery(['posts', 'popular'], fetchPopularPosts);
 
  // 보통 router 경로로 관리하지만 편의상 state로 사용하겠습니다.
  const [activeTab, setActiveTab] = useState('latest');
  
  const isLoading = useMemo(() => {
    switch (activeTab) {
      case 'latest':
        return latestPostsQuery.isLoading;
      case 'popular':
        return popularPostsQuery.isLoading;
      default:
        throw new Error('invalid tab');
    }
  }, [latestPostsQuery.isLoading, popularPostsQuery.isLoading, activeTab]);


  const postList = useMemo(() => {
    switch (activeTab) {
      case 'latest':
        return latestPostsQuery.data;
      case 'popular':
        return popularPostsQuery.data;
      default:
        throw new Error('invalid tab');
    }
  }, [latestPostsQuery.data, popularPostsQuery.data,activeTab]);

  return (
    <div>
      <DataTab
       tabs={['latest', 'popuplar']}
       ...
      />
      <LoadingView isLoading={isLoading} fallback={<div>로딩중..</div>}>
        <PostList posts={postList} />
      </LoadingView>
    </div>
  );
}

 

 

이 방법은 직관적이지만, 쿼리가 많아질수록 코드가 복잡해질 수 있습니다. 특히 저는 제가 담당했던 구현부에서 탭을 많이 다루었고, 로딩 상태 뿐 아니라 현재 쿼리 데이터의 총 개수도 다루게 되면서 매번 같은 패턴으로 반복적으로 loading, list, count를 가져오는 것에 피로도를 느꼈습니다.  

 

그래서 조금 더 편하게 가져올 수 있는 방법은 없을까? 라는 고민을 하며 문서를 보고 나니, 생각보다 간단하게 좋은 방법을 찾을 수 있었습니다. 총 두가지가 있는데요. 이제 정말 소개해드릴게요.

 

방법 1 getActiveQueryLoading 활용하기

먼저 함수를 보여드리면 다음과 같습니다. 이 함수는 여러 쿼리를 전달받아 그중 활성화된 쿼리의 로딩 상태를 반환하는 함수입니다:

import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { isEmpty } from 'lodash-es';

export const getActiveQueryLoading = (
  queries: (UseInfiniteQueryResult | UseQueryResult)[],
) => {
  const isAllIdle = queries.every(
    (query) => query.fetchStatus === 'idle' && query.status === 'loading',
  );

  if (isAllIdle) return true;

  const activeQueries = queries.filter(
    (query) => query.isLoading && query.isFetching,
  );

  if (isEmpty(activeQueries)) return false;

  if (activeQueries.length > 1) {
    throw new Error('Multiple active queries detected. Expected only one.');
  }

  return activeQueries[0]?.isLoading || false;
};

 

React Query는 쿼리의 상태를 관리하기 위해 status와 fetchStatus 두 가지 상태를 제공합니다. 이 둘을 잘 이해하면 더 세밀한 로딩 상태 관리를 할 수 있습니다. 

export interface QueryObserverBaseResult<TData = unknown, TError = unknown> {
  ...
  status: QueryStatus
  fetchStatus: FetchStatus
}

 

  • Status 와 FetchStatus는 어떤 차이가 있나요?
    • status: 데이터에 대한 정보 제공 (어떤 데이터를 가지고 있는지 아닌지) 
      • 쿼리에 아직 데이터가 없는 경우: status === "loading" || isLoading
      • 쿼리에 오류가 발생한 경우 status === "error" || isError
      • 쿼리가 성공하고 데이터가 있는 경우 status === "success" || isSuccess
    • FetchStatus queryFn에 대한 정보 제공 (현재 실행 중인지 아닌지)
      • 현재 쿼리를 가져오는 중 fetchStatus === "fetching" 
      • 쿼리를 가져오려고 했으나 일시중지 됨 fetchStatus === "paused"
      • 쿼리가 현재 아무 작업도 수행하지 않음 fetchStatus === "idle"

react query 공식문서에서 알려주는 의미인데요. 문서에도 써있지만 결국 두 상태를 사용해야 비로소 모든 조합이 가능해진다고 하는 문구에서 힌트를 얻었습니다. v3에서는 status만 있었는데 v4에서 fetchStatus가 추가된 이유가 여기에 있다고 생각합니다 .(지금은 v5지만요)

 

아무튼 다시 유틸함수를 볼까요?

제가 정의한 isIdle은 쿼리에 아직 데이터가 없으며 && 아무 작업도 수행중이지 않은 상태입니다. 해당 상태이면 활성화된 쿼리의 상태는 아니기 때문에 early return 시킵니다.

const getActiveQueryLoading = (
  queries: (UseInfiniteQueryResult | UseQueryResult)[],
) => {
  const isAllIdle = queries.every(
    (query) => query.fetchStatus === 'idle' && query.status === 'loading',
  );

  if (isAllIdle) return true;
...
};

 

isIdle이 아니라면 데이터가 없는 상태에서 마운트 되고 있는 쿼리를 찾아주고 해당 쿼리의 로딩상태를 반환합니다. 물론 해당 함수는 단일 쿼리만 반환하기 때문에 복수개라면 에러를 던져줍니다.

const getActiveQueryLoading = (
  queries: UseQueryResult[],
) => {
  ...
  if (isEmpty(activeQueries)) return false;
  if (activeQueries.length > 1) {
    throw new Error('Multiple active queries detected. Expected only one.');
  }
  return activeQueries[0]?.isLoading || false;
};

 

응용 : 활성화 된 쿼리

이런식으로 query에서 반환하는 상태드를 응용하면, loading상태 뿐만아니라 현재 활성화 된 쿼리를 찾을 수도 있습니다. 

import { UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';

type Query = UseInfiniteQueryResult | UseQueryResult;

export const getActiveQuery = <T extends Query>(
  queries: T[],
): T | undefined => {
  const activeQuery = queries.find((query) => !query.isStale);

  if (!activeQuery) {
    console.log('No active query found.');
    return;
  }

  return activeQuery;
};

 

 

방법 2 쿼리키만 넣기

react query 의 useFetching, useIsMutating이라는 훅을 아시나요? 해당 훅에서 아이디어를 받아 만든 isLoading 훅입니다. 쿼리 키를 사용하여 캐싱된 쿼리 중 활성화된 쿼리의 로딩 상태를 관리합니다.

 

 

 

 

쿼리키만을 받고, 그 쿼리키에 맞는 복수개의 쿼리들 중 활성 상태 쿼리를 수집합니다. 이후 React useSyncExternalStore를 사용해 쿼리 상태 변경을 감지하여 상태를 업데이트 해주고 getQueryLoading 함수를 사용하여 쿼리의 로딩 상태를 계산하여 반환해줍니다.

notifyManager는 callback들을 모아 일괄처리 (batching)할수 있는 도구입니다. batchCalls 메서드를 사용해 여러 상태 변경 알림을 묶어서 한 번에 처리하게 해줍니다. 해당 역할은 결국 쿼리캐시에서 상태가 변경될 때 그 변경을 감지하여 onStoreChange
함수를 호출하고, 여러 상태 변경을 묶어서 효율적으로 처리하도록 해줍니다. 
자세한 내용은 공식문서를 참고해주세요
import React from 'react';

import { QueryKey, notifyManager } from '@tanstack/query-core';
import { useQueryClient } from '@tanstack/react-query';

import { getQueryLoading } from '@/utils/query/get-query-loading'; 

export function useIsLoading(queryKey: QueryKey): boolean {
  const client = useQueryClient();
  const queryCache = client.getQueryCache();
  const queries = client
    .getQueryCache()
    .findAll({ queryKey, type: 'active' })
    .flatMap((k) => k.state);

  return React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) =>
        queryCache.subscribe(notifyManager.batchCalls(onStoreChange)),
      [queryCache],
    ),
    () => getQueryLoading(queries),
    () => getQueryLoading(queries),
  );
}

 

 

 

적용

구현한 hook과 util함수를 적용시켜 보았습니다. 어떤가요? 이제 n개의 탭을 구현한다고 해도 두렵지 않을 것 같아요...!

function Posts() {
  const latestPostsQuery = useQuery(['posts', 'latest'], fetchLatestPosts);
  const popularPostsQuery = useQuery(['posts', 'popular'], fetchPopularPosts);
 
  const [activeTab, setActiveTab] = useState('latest');
  
  // 방법 1 쿼리로 찾기
  // const isLoading = getActiveQueryLoading([
  //   latestPostsQuery,
  //   popularPostsQuery,
  // ]);
  
  // 방법 2 쿼리키로 찾기 
  const isLoading = useIsLoading(['posts']);
  
  const postList = getActiveQuery([latestPostsQuery, popularPostsQuery]);
  
  return (
    <div>
      <DataTab
       tabs={['latest', 'popuplar']}
       ...
      />
      <LoadingView isLoading={isLoading} fallback={<div>로딩중..</div>}>
        <PostList posts={postList} />
      </LoadingView>
    </div>
  );
}

 

주저리 결론

앞서 보여드린 switch문 예제도 직관적이어서 좋다고 생각합니다. 제 방법은 다수의 동일한 반환 데이터 구조를 가지는 경우에 유용하게 사용하실 수 있을 것 같습니다. 문서를 보며 생각보다 놓치고 있던 부분들이 많음을 느꼈습니다. isLoading, isFetching과 같은 원시값만 써서 그런지 status/fetchStatus가 이런 용도로 분류되고 있는지 잘 몰랐었는데, 직접 사용해보니 용도와 목적에 대해 다양하게 활용할 수 있어 좋았던 것 같습니다. 

반응형