*TanStack Query v4 기준으로 작성된 글입니다. 
리액트 쿼리에서 제공하는 옵션과 적절한 메서드를 활용하여 불필요한 API 호출을 없애 네트워크 비용을 줄이고 사용자 경험을 개선시켜 봅시다.
[딜리버블] ← 해당 프로젝트에 적용한 내용입니다. 

 

정적 데이터에 staleTime 적용하기

리액트 쿼리의 useQuery의 옵션에는 staleTime이 존재합니다. 이전 게시글에서도 언급한 내용이지만 staleTime얼마의 시간이 흐른 뒤에 데이터를 stale(:신선하지 않은)하다고 취급할 것인지를 지정하는 옵션입니다. 기본값이 0이기 때문에 별도로 값을 지정하지 않으면 데이터는 받아오자마자 fresh 상태에서 stale 상태가 됩니다. 캐싱된 데이터가 stale한 상태가 되면 리액트 쿼리는 다음 조건에 부합할 때 refetch를 실행하게 됩니다. 

 

[refetch 조건]

  1. 쿼리가 마운트 될 때 (옵션: refetchOnMount, 기본값: true)
  2. 브라우저 화면을 이탈했다가 다시 포커스할 때 (옵션: refetchOnWindowFocus, 기본값: true)
  3. 네트워크가 다시 연결될 때 (옵션: refetchOnReconnect, 기본값: true)
🤔 refetch해서 받아온 데이터가 기존의 캐싱 데이터와 차이점이 없다면 어떻게 동작할까?
기존의 캐싱 데이터를 그대로 유지할까? → nope! 캐시가 새로운 데이터로 채워집니다. 
https://tanstack.com/query/v4/docs/react/guides/caching#basic-example

 

딜리버블은 항상 동일한 상태를 유지하는 정적 데이터를 꽤 많이 가지고 있습니다. 사용자에게 학습 방법을 알려주는 가이드 데이터, 추천 뉴스 목록 등등. 이러한 데이터들은 실시간성이 중요하지 않아서 수시로 refetch를 해줄 필요가 없습니다. 따라서 해당 데이터들을 불러오는 useQuery의 옵션으로 staleTimeInfinity로 지정하였습니다. 그리고 cacheTime 또한 Infinity로 지정하였습니다.

📖 CacheTime?
cacheTime: inactive 상태로 메모리에 남아있는 시간을 의미합니다. 기본값은 5분이며, inactive 상태로 cacheTime이 지나면가비지 콜렉터에 의해 제거됩니다.따라서 staleTime을 무한으로 지정한다고 해도 cacheTime을 별도로 설정하지 않으면 기본인 5분이 지났을 때 메모리에서 제거되어 쿼리 인스턴스가 마운트될 때 다시 데이터를 fetch하게 됩니다. 이러면 staleTime을 무한으로 지정한 의미가 없어지기 때문에 cacheTime도 무한으로 설정합니다. 

 

export const useGetSpeechGuideList = () => {
  return useQuery(['getSpeechGuideList'], () => api.homeService.getSpeechGuideData(), {
    cacheTime: Infinity,
    staleTime: Infinity,
  });
};

데이터의 상태가 항상 fresh입니다.

 

 

invalidateQueries 메서드

POST, PUT, DELETE와 같이 데이터를 변화 시키는 요청은 useMutation을 주로 사용합니다. 보통 이렇게 데이터 업데이트 요청을 보낸 뒤에는 갱신된 데이터를 다시 서버로부터 받아오는 경우가 많습니다. 이럴 때 자주 사용되는 메서드로 invalidateQueries()가 있습니다.

 

invalidateQueries 메서드는 캐싱된 쿼리를 무효화하는 메서드입니다. 기본적으로 쿼리키와 일치하는 모든 쿼리를 즉시 유효하지 않은 것으로 표시하고 active한 상태의 쿼리는 백그라운드에서 refetch 합니다. 만일 active한 상태 말고도 inactive한 상태의 쿼리나 두 경우 모두 혹은 아예 refetch하고 싶지 않을 때 설정할 수 있는 옵션으로 refetchType이 있습니다. active, inactive, all, none라는 4가지 상태를 가지며 기본값은 active입니다. 

 

useMutation(api.commonService.postLikeData, {
  onSuccess: (data) => queryClient.invalidateQueries(['getVideoData', data.newsId]);
}

위와 같이 사용합니다. 뉴스의 좋아요 버튼을 누르는 요청이 성공적으로 완료되었다면 onSuccess 옵션에서 기존 뉴스 데이터를 무효화 시킵니다. refetchType 옵션의 기본값이 active이므로 해당 쿼리가 active 상태라면 refetch가 이루어집니다. 

좋아요를 누르니 fetching이 일어나는 것을 볼 수 있습니다.

 

invalidateQueries()말고 setQueryData() 사용하기

invaidateQuries 메서드를 이용하여 쿼리를 무효화시킨 후 재요청을 통해 데이터를 다시 가져오는 것도 UI를 업데이트하는 방법입니다. 하지만 서버에서 내려주는 응답 데이터가 갱신된 새로운 데이터라면 추가적인 GET 요청없이(네트워크 낭비없이)도 업데이트가 가능하지 않을까요?

 

setQueryData()쿼리의 캐시된 데이터를 즉시 업데이트하는 데 사용할 수 있는 동기식 함수입니다. 첫 번째 인자로는 변경시키고자 하는 쿼리의 키를 입력하고, 두 번째 인자로는 업데이트 함수를 입력합니다. 업데이트 함수의 인자로 oldData가 들어가는데 이 값은 입력한 쿼리키와 일치하는 쿼리가 가지고 있던 데이터를 의미합니다. 이 oldData와 서버에서 응답받은 데이터를 통해 쿼리의 캐시된 데이터를 업데이트할 수 있습니다. 참고로 oldData는 불변성을 지켜야 합니다.

 

// 여기저기서 사용되어 따로 함수로 분리하였음
const updateVideoData = (data: VideoData, clickedTitleIndex: number) => {
  queryClient.setQueryData<VideoData>(['getVideoData', data.id, clickedTitleIndex, false], (oldData) => {
    return { ...oldData, ...data };
  });
};

export const usePostMemoData = () => {
  return useMutation(api.learnDetailService.postMemoData, {
    onSuccess: (data, { clickedTitleIndex }) => updateVideoData(data, clickedTitleIndex),
  });
};

서버로부터 응답받은 데이터는 onSuccess의 첫 번째 인자인 data를 통해서 들어오게 됩니다. (참고로 onSuccess의 두 번째 인자는 mutate를 호출할 때 넘겨준 variables입니다.) oldDatadata에 모두 스프레드 연산자를 사용하였기 때문에 중복되는 필드는 뒤에 위치한 data가 가지고 있는 값으로 덮어 씌워집니다.  

 

데이터 구조에 따라서 newData를 더 복잡하게 가공하는 경우도 있습니다. 

export const useUpdateScriptNameData = () => {
  return useMutation(api.learnDetailService.updateScriptNameData, {
    onSuccess: (data, { clickedTitleIndex }) => {
      queryClient.setQueryData<VideoData>(['getVideoData', data.id, clickedTitleIndex, false], (oldData) => {
        if (oldData?.names && data.name) {
          const names = [...oldData.names];
          names[clickedTitleIndex].name = data.name;
          return { ...oldData, names };
        }
        return oldData;
      });
    },
  });
};

이제 네트워크 탭을 확인해보면 invalidateQueries()를 사용했을 때와는 달리 GET 요청을 보내지 않고, UI가 업데이트 되는 것을 확인할 수 있습니다. 

첫 번째 GET은 refetchOnWindowFocus 옵션으로 발생한 요청이므로 무시해주세요.

 

setQueryData를 통해 데이터를 캐시에 직접 넣으면 이 데이터가 백엔드에서 반환된 것처럼 동작하므로 해당 쿼리를 사용하는 모든 컴포넌트가 그에 따라 다시 렌더링됩니다. 

 

저는 개인적으로 대부분의 경우 invalidation이 선호되어야 한다고 생각합니다. 물론 사용 사례에 따라 다르겠지만, 직접 업데이트가 안정적으로 작동하려면 프론트엔드에 더 많은 코드가 필요하고 백엔드에서 어느 정도 중복되는 로직이 필요합니다.
- Mastering Mutations in React Query#Direct updates

참고로 TkDodo의 블로그에서는 invalidation이 더 안전한 접근 방식이라고 말하고 있습니다. invalidateQueriessetQueryData 메서드 중 어떤 메서드를 사용하는 것이 본인의 프로젝트에 더 적합한지 고민하는 것이 중요할 것 같습니다. 

저는 딜리버블의 학습과 관련된 대부분의 API가 갱신된 뉴스 데이터를 응답하고 있고, 직접 업데이트 하는 코드가 엄청 복잡하고 어려운 수준은 아니라고 생각했기 때문에 setQueryData 메서드를 활용하였습니다. 

 

KeepPreviousData 옵션으로 이전 데이터 유지하기

리액트 쿼리가 stale-while-revalidate의 아이디어를 차용하여 캐시 기능을 제공하는 이유는 뭘까요? 

오래된 데이터가 데이터가 아예 없는 것보다 낫기 때문입니다. 왜냐하면 데이터가 없다는 것은 대개 로딩 스피너의 표시를 의미하는데, 이는 사용자들에게 "느린" 것으로 인식될 것이기 때문입니다. - React Query as a State Manager#stale-while-revalidate

리액트 쿼리는 오래된 데이터를 보여줌과 동시에 백그라운드에서 refetch를 수행하여 해당 데이터를 다시 검증합니다. 

 

오래된 데이터를 유지하기 위해 사용할 수 있는 useQuery 옵션으로 keepPreviousData가 있습니다. 옵션값으로 true 혹은 false를 지정할 수 있으며 기본값은 false입니다. 이 옵션을 true로 설정하면 새로운 데이터를 가져올 때 이전 데이터가 유지됩니다.

 

export const usePostSearchCondition = (data: PostSearchConditionRequestBody) => {
  return useQuery(['postSearchCondition', data], () => api.learnService.postSearchCondition(data), {
    keepPreviousData: true,
    cacheTime: Infinity,
    staleTime: Infinity,
  });
};

뉴스 목록을 방송사, 분야, 아나운서 성별에 따라 필터링을 하는 기능이 있는데 이 부분에 keepPreviousData를 적용하였습니다. 

좌 keepPrevious: false / 우 keepPrevious: true

데이터 로딩 중일 때 스켈레톤 UI를 보이게 해놨기 때문에 필터 조건을 걸 때마다 isLoading이 true가 되어 뉴스 목록이 깜빡이는 현상이 있었습니다. 사용자 경험상 이런 깜빡임이 좋지 않다고 생각했기 때문에 keepPreviousData를 적용하여 깜빡임을 제거하였습니다. 

 

🤔 쿼리키가 달라지는데도 keepPreviousData가 동작하네?
방송사를 'SBS'로 설정했습니다.
필터링 조건을 모두 쿼리키로 넣고 있으므로 조건을 바꿀 때마다 키가 달라지고 있습니다. 쿼리키가 다른데도 keepPreviousData가 작동하는 이유가 뭘까요? invalidateQueries 쿼리 매칭시 쿼리 무효화의 범위가 쿼리키 범위의 상위에서 하위로 전파되는 것과 비슷한 이유일까 싶었는데 공식문서에 관련 내용이 나와 있었습니다.

"The data from the last successful fetch is available while new data is being requested, even though the query key has changed."
쿼리 키가 변경되었더라도 새로운 데이터가 요청되는 동안에는 마지막으로 가져온 데이터를 사용할 수 있다고 합니다.

 

enabled 옵션으로 쿼리 자동 실행 방지하기

쿼리가 자동으로 실행되게 하고 싶지 않을 때enabled 옵션을 false로 설정하면 됩니다. 

export const usePostReviewVideoList = (currentPage: number, tab: ReviewTab) => {
  const isLoggedIn = useRecoilValue(loginState);
  return useQuery(
    ['postReviewList', currentPage, tab],
    () => api.reviewService.postReviewVideoList({ currentPage, listSize: LIST_SIZE }, tab),
    {
      enabled: isLoggedIn,
    },
  );
};

사용자가 로그인을 했을 때만 해당 쿼리가 실행되는 코드입니다. 

 


리액트 쿼리를 잘못 사용하면 서버에 과부화를 줄 수 있다는 것 때문에 사실 사용하는 것에 대한 두려움이 있었는데, 딜리버블에 리액트 쿼리를 적용하기 위해 공부를 하면서 어느정도 두려움이 사라진 것 같습니다. 물론 아직도 모르는 게 많지만 그래도 기본적인 건 어느정도 알게 된 것 같습니다 :D

 

 

복사했습니다!