*TanStack Query v4 기준으로 작성된 글입니다.
리액트 쿼리에서 제공하는 옵션과 적절한 메서드를 활용하여 불필요한 API 호출을 없애 네트워크 비용을 줄이고 사용자 경험을 개선시켜 봅시다.
[딜리버블] ← 해당 프로젝트에 적용한 내용입니다.
정적 데이터에 staleTime 적용하기
리액트 쿼리의 useQuery의 옵션에는 staleTime
이 존재합니다. 이전 게시글에서도 언급한 내용이지만 staleTime
은 얼마의 시간이 흐른 뒤에 데이터를 stale(:신선하지 않은)하다고 취급할 것인지를 지정하는 옵션입니다. 기본값이 0이기 때문에 별도로 값을 지정하지 않으면 데이터는 받아오자마자 fresh
상태에서 stale
상태가 됩니다. 캐싱된 데이터가 stale
한 상태가 되면 리액트 쿼리는 다음 조건에 부합할 때 refetch
를 실행하게 됩니다.
[refetch 조건]
- 쿼리가 마운트 될 때 (옵션:
refetchOnMount
, 기본값: true) - 브라우저 화면을 이탈했다가 다시 포커스할 때 (옵션:
refetchOnWindowFocus
, 기본값: true) - 네트워크가 다시 연결될 때 (옵션:
refetchOnReconnect
, 기본값: true)
🤔 refetch해서 받아온 데이터가 기존의 캐싱 데이터와 차이점이 없다면 어떻게 동작할까?
기존의 캐싱 데이터를 그대로 유지할까? → nope! 캐시가 새로운 데이터로 채워집니다.
https://tanstack.com/query/v4/docs/react/guides/caching#basic-example
딜리버블은 항상 동일한 상태를 유지하는 정적 데이터를 꽤 많이 가지고 있습니다. 사용자에게 학습 방법을 알려주는 가이드 데이터, 추천 뉴스 목록 등등. 이러한 데이터들은 실시간성이 중요하지 않아서 수시로 refetch
를 해줄 필요가 없습니다. 따라서 해당 데이터들을 불러오는 useQuery의 옵션으로 staleTime
을 Infinity
로 지정하였습니다. 그리고 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,
});
};
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
가 이루어집니다.
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
입니다.) oldData
와 data
에 모두 스프레드 연산자를 사용하였기 때문에 중복되는 필드는 뒤에 위치한 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가 업데이트 되는 것을 확인할 수 있습니다.
setQueryData
를 통해 데이터를 캐시에 직접 넣으면 이 데이터가 백엔드에서 반환된 것처럼 동작하므로 해당 쿼리를 사용하는 모든 컴포넌트가 그에 따라 다시 렌더링됩니다.
저는 개인적으로 대부분의 경우 invalidation이 선호되어야 한다고 생각합니다. 물론 사용 사례에 따라 다르겠지만, 직접 업데이트가 안정적으로 작동하려면 프론트엔드에 더 많은 코드가 필요하고 백엔드에서 어느 정도 중복되는 로직이 필요합니다.
- Mastering Mutations in React Query#Direct updates
참고로 TkDodo의 블로그에서는 invalidation이 더 안전한 접근 방식이라고 말하고 있습니다. invalidateQueries
과 setQueryData
메서드 중 어떤 메서드를 사용하는 것이 본인의 프로젝트에 더 적합한지 고민하는 것이 중요할 것 같습니다.
저는 딜리버블의 학습과 관련된 대부분의 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
를 적용하였습니다.
데이터 로딩 중일 때 스켈레톤 UI를 보이게 해놨기 때문에 필터 조건을 걸 때마다 isLoading
이 true가 되어 뉴스 목록이 깜빡이는 현상이 있었습니다. 사용자 경험상 이런 깜빡임이 좋지 않다고 생각했기 때문에 keepPreviousData
를 적용하여 깜빡임을 제거하였습니다.
🤔 쿼리키가 달라지는데도 keepPreviousData가 동작하네?
필터링 조건을 모두 쿼리키로 넣고 있으므로 조건을 바꿀 때마다 키가 달라지고 있습니다. 쿼리키가 다른데도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
'프로그래밍 > React' 카테고리의 다른 글
[React Query] 리액트 쿼리를 왜 사용할까? - 서버 상태 분리 (0) | 2023.04.27 |
---|---|
[React] Github API를 이용하여 자동 팔로우 기능 만들기 (1) | 2022.10.22 |
[Recoil] 리코일 이해하기 (0) | 2022.09.15 |
[React Query] 리액트 쿼리 이해하기 (3) | 2022.08.22 |
[React] Link와 useNavigate (0) | 2022.06.23 |