티스토리 뷰

 
저번 프로젝트에서 TanStack-Query(React-Query)를 사용해보자는 논의가 나왔지만 라이브러리를 사용하지 않고도 구현할 수 있을 것이라는 결론이 내려져 사용하지 않았었다.
 
그래서 데이터 페칭을 위한 hook을 만들었었는데, 이후에 리팩토링하는 과정에서 이 hook을 useQuery와 useMutation으로 분리시켰다. (왜 분리했는지에 대한 자세한 내용은 링크에서 볼 수 있다.)

이렇게 훅을 분리하는 것에 대한 아이디어는 프로젝트 시작 전 TanStack Query에 대해 공부하면서 얻게되었는데, 프로젝트 이후 TanStack Query 공식문서를 읽다보니 api 예외처리를 간편하게 해주는 것 이외에도 다양한 기능이 많아서 공부해보려고 한다.
 
 

 

1. TanStack-Query(React-Query)는 무엇인가?


TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.

 
공식문서에 따르면 TanStack Query는 웹 애플리케이션을 위한 데이터 페칭 라이브러리이다. 또한 서버 상태를 페칭, 캐싱, 동기화 및 업데이트하는 작업을 쉽고 간편하게 해준다.
 
TanStack Query가 api 요청을 간편하게 해준다는 것은 익히 들어서 알고 있었다. 그런데 서버 상태를 캐싱해준다는 것은 처음 알게되었다.

단순히 복잡하고 장황한 코드를 줄여주기 위한 라이브러리인 줄 알았는데 캐싱..?
 
예전에 팀원과 캐시에 대해 얘기한 적이 있어서 따로 글로 정리했었는데, 그 내용을 다시 읽어보니 왜 TanStack Query가 캐싱을 지원하는 이유를 알게 되었다. (자세한 내용은 링크에서 확인할 수 있다.)

위 링크에 첨부된 글을 요약하자면 캐시는 아래와 같은 상황에서 사용하면 효과적이다.

1. 동일한 API 데이터를 빠르게 제공해야 할 때
2. 반복적으로 동일한 결과를 보여주어야 하는 경우 (이미지나 썸네일)

 
API의 경우, 동일한 요청을 매번 보내면 서버에 부담이 될 수 있다. 따라서 캐시를 사용해서 값을 미리 저장해두면 서버에 요청하지 않고 캐시에 저장된 값을 사용하여 서버의 부하를 줄이고 응답 시간을 단축할 수 있다.
 
그런데 만약 캐시에 부정확한 값이나 노후된 값이 들어있는 경우에는 어떻게 처리해야할까? TanStack Query는 이러한 값들을 어떻게 갱신하고 관리하는지 궁금해서 찾아보았다.
 
 

1. staleTime

공식 문서에 따르면 useQuery나 useInfiniteQuery의 경우 캐시된 데이터는 기본적으로 stale 상태이다.
또한 데이터가 stale 상태인 경우, refetch를 하는 특징이 있으므로 적절한 staleTime(기본값 0)을 설정해야 한다.
 
TanStack Query가 캐시된 데이터의 기본 상태를 stale로 설정한 이유를 생각해봤는데, 아마 지속적으로 최신의 데이터 상태를 유지하기 위함이 아닐까 싶다.

참고로, TanStack Query는 최신 데이터를 fresh한 상태, 기존 데이터를 stale한 상태라고 표현한다.

 
 

2. 자동적인 refetch

staleTime을 따로 지정하지 않고 자동적으로 데이터가 refetch되는 경우는 다음과 같다.

1. 새로운 컴포넌트가 마운트 될 때
2. 창이 다시 포커스 될 때
3. 네트워크가 다시 연결될 때
4. 쿼리가 선택적으로 재조회 간격으로 설정되었을 때

 
만약 이러한 기능들을 변경하려면 refetchOnMount, refetchOnWindowFocus, refetchOnReconnect, refetchInterval과 같은 옵션을 사용할 수 있다.
 
 

3. garbage collection

TanStack Query는 쿼리가 사용되지 않을 때 쿼리 결과를 inactive로 표시한다. 이 경우 데이터는 아직 캐시되어 있지만, 일정 시간(기본 5분)이 지나면 메모리에서 제거된다. 기본 시간은 gcTime을 변경하여 조절할 수 있다.

찾아보니 v5에서 cacheTime의 이름이 gcTime으로 변경되었다.
링크를 들어가보면 gcTime으로 변경되기 전에 gcMaxAge, inactiveCacheTime 등 여러 후보군이 있었는데 결국 gcTime으로 정해진 것을 볼 수 있다ㅋㅋㅋ

왜 변경 되었는지에 대해서도 문서에 적혀있는데, 이유는 많은 사람들이 cacheTime 이라는 이름 때문이 이 옵션을 잘못 이해했기 때문이다.

cacheTime(=gcTime) 은 데이터가 inactive 상태일 때 캐싱된 상태로 남아있는 시간을 의미했지만, 많은 사람들이 이를 '데이터가 캐시되는 시간'으로 오해했다. 나 또한 이름이 cacheTime이었다면 어떤 옵션인지 헷갈렸을 것 같다. gcTime으로 잘 바뀐 것 같다!

 
이외에도 TanStack Query는 다양한 캐싱 전략을 사용해서 데이터를 최신화하고 불필요한 데이터를 삭제시켜준다.
 
 
 

2. TanStack Query의 대표적인 기능들


기본적으로 GET 요청은 useQuery가, PUT, UPDATE, DELETE 요청은 useMutation이 사용된다.
 

1. useQuery

const {
  data,
  dataUpdatedAt,
  error,
  errorUpdatedAt,
  failureCount,
  failureReason,
  fetchStatus,
  isError,
  isFetched,
  isFetchedAfterMount,
  isFetching,
  isInitialLoading,
  isLoading,
  isLoadingError,
  isPaused,
  isPending,
  isPlaceholderData,
  isRefetchError,
  isRefetching,
  isStale,
  isSuccess,
  refetch,
  status,
} = useQuery(
  {
    queryKey,
    queryFn,
    gcTime,
    enabled,
    networkMode,
    initialData,
    initialDataUpdatedAt,
    meta,
    notifyOnChangeProps,
    placeholderData,
    queryKeyHashFn,
    refetchInterval,
    refetchIntervalInBackground,
    refetchOnMount,
    refetchOnReconnect,
    refetchOnWindowFocus,
    retry,
    retryOnMount,
    retryDelay,
    select,
    staleTime,
    structuralSharing,
    throwOnError,
  },
  queryClient,
)

 
몇 가지 중요한 options와 returns를 다뤄보자.
 
Options
옵션 중 queryKeyqueryFn은 필수 옵션이다.
 
1. staleTime: number | ((query: Query) => number)
기본값은 0이다. 함수로 설정된 경우 쿼리와 함께 실행되어 staleTime을 계산한다.
2. gcTime: number | Infinity
기본값은 5 * 60 * 1000(5분) 또는 무한대이다. 무한대로 설정하면 garbage collection이 비활성화된다.
 
Returns
returns에는 status 값인 Query StatusFetch Status가 있다.

1. status: QueryStatus
query status는 데이터를 받아왔는지 여부를 나타내며 pending, success, error 세 가지 상태를 가진다.

  • pending: 아직 데이터를 받아오지 못한 상태
  • success: 데이터를 성공적으로 받아 온 상태
  • error: 데이터를 받아오는 중에 에러가 발생한 상태

2. fetchStatus: FetchStatus
fetch status는 쿼리 함수(queryFn)가 현재 실행 중인지 여부를 나타내며 fetching, paused, idle 세 가지 상태를 가진다.

  • fetching: 쿼리 함수가 실행 중인 상태
  • paused: 쿼리 함수가 실행을 시작했지만 네트워크가 오프라인이어서 중단된 상태
  • idle: 쿼리 함수가 실행 중이지 않은 상태
idle이라는 단어는 처음 본다.
사전에 검색해보니 게으른이라는 뜻인데, 앞으로 게을러서 함수를 실행하지 않은 상태(?)..이런식으로 기억해야겠다.

 
 
 
query status와 fetch status는 독립적인 상태 값이기 때문에 아래와 같이 다양한 조합의 형태로 나타날 수 있다. 이상적인 상황에서는 pending & fetching 상태에서 success & idle 상태로 전환되지만 에러가 발생하는 경우에는 error & idle 상태가 될 수도 있다.

1. 초기 컴포넌트 마운트 시
query status: pending
fetch status: fetching

2. 네트워크 오프라인 시
fetch status: paused

3. 데이터를 성공적으로 받아온 경우
query status: success
fetch status: idle

4. 에러 발생 시
query status: error
fetch status: idle

5. 데이터 리패치 시
fetch status: fetching

 
 
 

2. useMutation

const {
  data,
  error,
  isError,
  isIdle,
  isPending,
  isPaused,
  isSuccess,
  failureCount,
  failureReason,
  mutate,
  mutateAsync,
  reset,
  status,
  submittedAt,
  variables,
} = useMutation({
  mutationFn,
  gcTime,
  meta,
  mutationKey,
  networkMode,
  onError,
  onMutate,
  onSettled,
  onSuccess,
  retry,
  retryDelay,
  scope,
  throwOnError,
})

mutate(variables, {
  onError,
  onSettled,
  onSuccess,
})

 

Options
옵션 중 queryKeyqueryFn은 필수 옵션이다.
 
1. mutationFn: (variables: TVariables) => Promise<TData>
default mutation 함수가 정의되지 않은 경우에만 필수이다. variables는 mutate가 mutationFn에 전달 할 객체이다.
 
Returns
1. mutate: (variables: TVariables, { onSuccess, onSettled, onError }) => void
mutate는 아래와 같이 세 가지의 옵션을 받을 수 있다. 

  • onSuccess: 뮤테이션 함수가 성공적으로 완료된 후 호출되는 콜백
  • onError: 뮤테이션 함수 실행 중 오류가 발생했을 때 호출되는 콜백
  • onSettled: 뮤테이션 함수가 완료된 후(성공 또는 실패 여부와 관계없이) 호출되는 콜백

useMutation()은 useQuery()와 다르게 실제로 뮤테이션 하는 함수(=mutation())를 직접 실행해 주어야 한다.
mutate()로 mutationFn으로 등록했던 함수를 실행해야 실제로 백엔드 데이터를 수정할 수 있다.
 
주의할 점은 mutate()를 하면 백엔드 데이터는 변경되지만 현재 캐시에 저장된 데이터는 refetch를 하지 않기 때문에 refetch를 해주어야 변경된 데이터를 화면에 제대로 반영할 수 있다.
 
useMutation을 사용했을 때, 데이터가 바로 반영되지 않아 헤맸었는데 ReactQueryDevtools을 보고 post 요청을 보내도 get 요청이 stale 상태인 것을 알게 되었고, 찾아보니 따로 refetch 로직을 작성해야 한다는 것을 알게 되었다.
 

 

invalidateQueris()를 사용하면 두번째 칸(fetching)의 0이 순간적으로 1이 되었다가 0으로 변하는데, 이걸 통해 fetching이 제대로 되었다는 것을 알 수 있다.

 
따라서 이 경우 QueryClient의 메소드 중 하나인 invalidateQueries()를 사용해서 자동으로 refetch 되도록 해야한다.
 
참고로 invalidate는 '무효화하다' 라는 뜻으로, 캐시에 저장된 쿼리를 무효화한다는 의미이다.
 
쿼리를 무효화하면 해당 쿼리를 통해 받아 온 데이터를 stale time이 지났는지 아닌지에 상관없이 무조건 stale 상태로 만들고, 해당 데이터를 백그라운드에서 refetch하게 된다.

import { useQueryClient } from '@tanstack/react-query'

const queryClient = useQueryClient();

// ...

queryClient.invalidateQueries();

 
 
 

따라서 뮤테이션이 성공한 시점에 쿼리를 invalidate 해 주는 함수를 콜백으로 등록해주면 refetch가 잘 되는 것을 확인할 수 있다.

const queryClient = useQueryClient();

// ...

const uploadPostMutation = useMutation({
  mutationFn: (newPost) => uploadPost(newPost),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

 
 
 
여기서 한가지 더 알아두어야 하는 것은 useMutation()에 등록된 콜백 함수들은 컴포넌트가 언마운트 되더라도 실행되지만 mutate()의 콜백 함수들은 뮤테이션이 끝나기 전에 컴포넌트가 언마운트 되면 실행되지 않는다는 것이다.
 
따라서 꼭 필요한 로직은 useMutation()을 통해 등록하고, 리다이렉트나 결과를 토스트로 띄워주는 것 같이 컴포넌트에 종속적인 로직은 mutate()를 통해 등록해주면 된다.

const uploadPostMutation = useMutation({
  mutationFn: (newPost) => uploadPost(newPost),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

const handleUploadPost = (newPost) => {
  uploadPostMutation.mutate(newPost, {
    onSuccess: () => {
      toast('포스트가 성공적으로 업로드 되었습니다!');
    },
  });
};

 
 
 

3. 사용해보기


먼저 useQuery를 사용해보았다.

function FleaMarketPage() {
    const {
        status: allProductsStatus,
        error: allProductsError,
        data: allProductsData,
      } = useQuery({
        queryKey: ['products', page, orderBy],
        queryFn: () => getAllProducts({ page, pageSize: PAGE_SIZE, orderBy }),
      });

      if (allProductsStatus === 'pending') return <h1>Loading...</h1>;
      if (allProductsStatus === 'error') return <h1>{allProductsError.message}</h1>;

      return (
        <>
            {allProductsStatus === 'success' && (
                <AllProducts products={allProductsData?.list || []} setOrderBy={setOrderBy} />
            )}
        </>
      );
 }

 
위 코드는 전체 상품 목록을 보여주는 코드이다.
useQuery의 returns 값으로 status, error, data를 반환했고 각각의 상태에 따라 다른 UI를 보이도록 했다.
 
 
 

function ProductDetailPage() {  
    const {
    status: productDetailStatus,
    error: productDetailError,
    data: productDetailData,
    } = useQuery({
    enabled: productId !== null,
    queryKey: ['productDetail', productId],
    queryFn: () => getProductDetail(productId),
    });

    if (productDetailStatus === 'pending') return <h1>Loading...</h1>;
    if (productDetailStatus === 'error') return <h1>{productDetailError.message}</h1>;
	
     return (
      <S.ProductDetailContainer>
        {productDetailStatus === 'success' && (
        ...
        )
      </S.ProductDetailContainer>
     )
}

 
이후 상세 상품 목록을 보여주는 코드를 위와 같이 작성했다.
 
컴포넌트에 진입하자마자 data fetching을 하지 않고 productId 값이 존재할 경우에 fetching되도록 enabled 옵션을 사용했다. 그리고 마찬가지로 status에 따라 다른 UI를 보이게 해주었다.
 
간단하게 사용해보았는데, API 요청과 관련 상태관리가 편해진 것은 두말할 것도 없고 아직 다양한 기능을 사용해보진 않았지만 enabled 같은 옵션도 제공해주어 요청을 세밀하게 조정할 수 있는 장점또한 있는 것 같다.
 
개인적으로 공부하면서 추가로 알게 된 options는 initialDataplaceholderData이다. 하나씩 어떤 기능인지 알아보자.
 

1. initialData

 
 

 
TanStack Query는 캐싱한 데이터를 ReactQueryDevtools에서 보여준다. 그래서 Data Explorer 섹션에서 데이터를 펼처보면 어떤 데이터가 들어왔는지 확인할 수 있다.
 
들어온 데이터들을 보면서 든 생각은 캐싱된 데이터를 재사용할 수 있지 않을까? 였다.
 
글 서두에서 언급했듯이 TanStack Query의 기능 중 하나는 값을 캐싱해준다는 것이다. 그런데 단순히 캐싱을 하는 것을 넘어서 어떤 방식으로 활용할 수 있을지에 대해 궁금했다.
 
특히 상품 상세 데이터의 경우, 이전의 상품 목록의 데이터와 완전히 동일한 데이터이기 때문에 똑같은 데이터를 서버에 두 번 요청하는 것 보다 상품 목록 api에 속해있는 상세 데이터를 가져와 사용하는 방식이 더 경제적일 것이라고 생각했다.
 
그래서 공식문서와 여러 아티클을 찾아보았는데 queryClient의 getQueryData 메소드를 이용하면 캐싱된 데이터를 재사용할 수 있다..!
 
따라서 아래와 같이 queryKey가 bestProducts인 쿼리를 찾아서 id 값과 productId 값이 동일 한 경우 캐싱된 데이터를 사용하도록 했다.

  const {
    status: productDetailStatus,
    error: productDetailError,
    data: productDetailData,
  } = useQuery({
    enabled: productId !== null,
    queryKey: ['productDetail', productId],
    queryFn: () => getProductDetail(productId),
    initialData: () => {
      const products = queryClient.getQueryData<Product>(['bestProducts']);
      const productDetail = products?.list?.find((p: { id: number | string }) => {
        return p.id === productId;
      });
      return productDetail;
    },
    staleTime: 60 * 1000,
  });

 
여기서 중요한 것은 staleTime을 지정하는 것이다.
staleTime을 사용하면 일정 시간 동안 stale 상태가 되지 않기 때문에, 쿼리가 다시 실행될 때 네트워크 요청을 보내지 않고 캐시된 데이터가 사용되도록 할 수 있다. (기본적인 staleTime은 0이다)
 
initialData와 staleTime을 적용하고 네트워크 탭을 보면 맨 처음 해당 상세 페이지에 접속 한 경우에는 요청이 가지만,

 
 
 
이후에는 staleTime 기간 동안은 api 요청이 가지 않고 캐시된 데이터를 사용한다.

나는 staleTime을 1분으로 설정했다. 따라서 1분이 지나면 캐싱 시간이 끝났기 때문에 TanStack Query는 불러온지 오래된 데이터라고 판단하고 다시 호출한다.
 
공식문서에 따르면 모든 곳에서 initialData를 실행하는게 아니라면 함수형으로 전달하여 쿼리가 초기화될때만 실행되게하여 메모리와 CPU자원을 절약할 수 있다고 한다.
 
또 다른 장점은 initialData를 사용하는 경우, 옵셔널 체이닝(?)을 사용하지 않아도 Cannot read properties of undefined... 와 같은 에러를 마주하지 않는다는 점이다.
 
Cannot read properties of undefined...는 대부분 값이 정의되어 있지 않거나 데이터가 들어오기 전에 화면을 그리기 때문에 발생하는 에러인데 초기 데이터를 넣어줌으로써 옵셔널 체이닝을 사용하지 않아도 된다.

<S.ProductImage src={productDetailData.images} alt={productDetailData.name} />
    <S.DescriptionContainer>
      <div>
        <div>
          <S.DescriptionBox>
            <div>
              <S.DescriptionTitle>{productDetailData.name}</S.DescriptionTitle>
              <S.DescriptionPrice>{productDetailData.price}</S.DescriptionPrice>
            </div>
...

 
따라서 기존에는 productDetailData뒤에 ?.를 일일히 붙여주었는데 이러한 수고를 덜 수 있다.
 
 

2. placeholderData

다음으로 placeholderData 옵션이다. 이 옵션은 쿼리가 실제 데이터를 가져오기 전에 마치 이미 데이터를 가지고 있는 것처럼 동작하도록 하는 옵션이다.
 
initialData 옵션과 유사하지만 placeholderData는 캐시에 저장되지 않는다. 단지 쿼리가 서버에서 실제 데이터를 가져오는 동안 임시로 사용할 가짜 데이터를 보여주는 역할을 한다.
 
이렇게 가짜 데이터를 재워줌으로써 CLS(Cumulative Layout Shift)을 막아주기 때문에 사용자에게 더 나은 경험을 제공하는 장점이있다.
 
이 옵션을 사용한 이유는 데이터의 상태가 pending일 때 Loading... 이라는 글자를 띄웠더니 화면 깜빡임 현상이 도드라져 보였기 때문이다. 따라서 데이터가 들어올 자리를 임시 데이터 채워서 사용자 경험을 개선했다.
또한 네트워크 요청이 지연되는 경우에도 Loading... 이라는 글자를 띄우는 것보다 임시 데이터를 채워서 보여주는 게 페이지 이탈률도 줄일 것 같다.
 
다음으로 useMutation이다.

  const { status, error, mutate } = useMutation({
    mutationFn: postComment,
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: ['comments', productId] });
      setContent('');
      console.log(data, '댓글 달기 성공');
    },
    onError: (data) => {
      console.log(data, '댓글 달기 실패');
    },
  });

  const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    mutate({ productId, content });
  };

 

useMutation의 경우 invalidateQueries를 사용해서 자동으로 refetch 시켜주었다. 사실 post 말고 다른 요청은 아직 해보지 않아서 앞으로 여러 요청을 해보면서 글을 추가할 예정이다.
 
+) 추가적으로 공부해보고 싶은 건 Next.js에서 TanStack Query를 어떻게 사용할 수 있을지에 대한 부분이다. 이부분도 차차 공부 할 예정이다.
 
 
 
참고
https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-function

https://dulumungsil.com/react-query-initialData