본문 바로가기

main/React

TanStack Query의 난제, 코드로 직접 뜯어보기: isPending과 isFetching은 어떻게 달라요?

과거 TanStack Query v4를 사용하면서

버튼의 disable 상태를 처리할 때 isLoading과 isFetching를 사용한 적이 있었다.

 

+) 참고: v4의 isLoading이 v5에서 isPending이 되었다.

v5의 isLoading은 v4의 isLoading과 다르다. 이 글에서는 v5의 name을 기준으로 설명하겠다.

 

isPending과 isFetching의 역할은 거의 비슷했지만 캐시 사용 여부에 따라 동작이 조금 달랐다.

당시 사용했던 상황에서는 해당 쿼리에서 캐시를 사용하지 않았었고 그렇다면 둘 중 어떤 것을 사용해도 차이가 없어 보였다.

둘 중 하나만 사용해도 특이점이 없는 것을 간단한 테스트로 확인했고

(isPending === true)일 때 버튼을 disable 시키도록 구현했다.

 

특별한 문제 없이 운영되었는데, 어느 날 한 유저에게 버그가 발생했다.

재현해 보니 버튼 첫 클릭 후 disable이 되더라도 해당 버튼을 무한 클릭하게 되면

로딩이 다 되었을 때 클릭이 가능한 것이 문제의 원인이었다.

즉, isPending === false가 되었지만 isFetching !== false였던 찰나의 순간이 존재하는데

disable된 버튼의 UI가 바뀌지 않았고, isFetching이 true로 페칭 중이니까 화면이 넘어가지 않는,

그 순간 버튼을 클릭하면 API의 중복 호출이 일어나게 되는 것이었다.

 

왜 isPending과 isFetching이 false가 되는 시점이 다른 것일까?

공식문서 소스코드(v5.62.8)에서 찾아보았다.

 

React Query의 동작 방식

먼저 TanStack Query의 react-query는 어떤 흐름으로 돌아가는지 보았다.

QueryClient

Query의 데이터 및 상태를 전역적으로 관리하는 중심 객체이다. 내부적으로 ContextAPI를 사용한다.

 

`packages/react-query/src/QueryClientProvider.tsx`

QueryObserver

특정 쿼리(useQuery(['any']))의 상태를 추적하고 UI에 변화 상태를 알린다.

옵저버가 useQuery의 상태를 추적한다면, useQuery의 동작을 먼저 알아보자.

 

`packages/react-query/src/useQuery.ts`

리턴 값이 useBaseQuery함수이며 QueryObserver가 인자에 포함되는 것이 보인다.

 

useBaseQuery 함수에서는 useSyncExternalStore()를 통해 observer 상태를 관리하고, 전달 및 처리한다.

observer는 단일 state지만 setObserver가 아니라 observer.subscribe()라는 내부 메서드를 통해 상태를 관리한다.

이는 Query의 상태가 업데이트되어도 컴포넌트에서 렌더링이 일어나지 않도록 하기 위함이다.

따라서 useSyncExternalStore()를 통해 외부 스토어를 구독하고, 내부 로직에 의해 컴포넌트의 리렌더링이 일어난다.

 

isPending  vs  isFetching

`packages/query-core/src/queryObserver.ts`

그래서 이 queryObserver를 살펴보면, observer의 상태를 업데이트하는 각종 조건의 메서드를 확인할 수 있다.

여기서 isPending과 isFetching을 찾을 수 있었다.

이 createResult의 리턴값이 nextResult인데,

이때 nextResult에 isFetching, isPending, 등등 직접 코드에서 query 사용 시 꺼내 쓰던 애들이 리턴된다.

 

isPending은 status 상태를 조회하고 있고, isFetching은 fetchStatus 상태를 조회하고 있다.

이 값들은 query.state.status에서, query.state.fetchStatus에서 가져오는 상태이다.

 

query의 type을 보자.

query.state.status는 QueryStatus 값인 'pending' | 'error' | 'success'를 가질 수 있고

query.state.fetchStatus는 FetchStatus인 'fetching' | 'paused' | 'idle'를 가질 수 있다.

두 값이 따로 정의된 것을 보면서 어쩌면 완전 다른 동작을 수행할지도 모른다는 느낌을 받았다.

 

status가 어떻게 업데이트되는지 찾아보러 다시 query.ts로 돌아간다.

default 리턴 값에서 status는 hasData 분기에 따라 success/pending이 반환된다.

이때 아래의 fetchStatus가 idle인 것을 볼 수 있는데,

status: 'success' -> isPending: true / fetchStatus: 'idle' -> isFetching: false

이를 통해 isPending과 isFetching의 상태가 함께 true가 되지 않는 케이스를 생각해 볼 수 있다.

 

흔히 말하는 isPending과 isFetching의 차이점이 이 케이스인데 캐싱 데이터 유무에 따라 다른 값을 가지게 된다.

대표적인 이 두 메서드의 차이가 어떻게 코드로 구현되어 있는지 여기서 확인할 수 있었다.

 

+) 참고: 이외에도 Placeholder data에 따라서 status 값을 바꾸는 등 관련 동작의 플로우를 볼 수 있음

 

뭐가 어떻게 다른지 확인해 보기

다시 리마인드해보면 당시 문제의 상황은 캐싱 데이터가 없었고,

status: 'success'가 되었지만, 여전히 fetchStatus: 'fetching'이었던 상황이다.

 

이때 status: 'success'로 인하여 (isPending === true)가 되었지만

당장 UI의 변경이 없는 것은 useSyncExternalStore() 내부 동작 때문으로 추측된다.

 

isPending

isPending의 상태를 감지하고 그때의 값을 바꾸는 로직을 찾아봤다.

 

QueryObserver는 상태 변화를 감지했을 때 #currentResult에 QueryObserverResult type의 어떤 값을 받는다.

 

QueryObserverResult는 QueryObserverPendingResult를 갖고,

DefinedQueryObserverResult 내에는 또 QueryObserverSuccessResult가 있다.

 

이때 이 상태들에서 status 값과, isPending의 값을 변경하는 부분이 있지만

fetchStatus나, isFetching과 관련된 상태는 변경시키지 않는 것이 확인된다.

 

그렇다면 isLoading === false 이 되었을 때 isFetching === ??? 이 될 수 있겠다.

 

+) TanStack Query v4 브랜치의 코드에서도 동일하게 fetch 관련 상태를 건드리지 않는다..!

 

isFetching

그럼 isFetching은 언제 변하는감?

fetch를 할 때는 #dispatch를 부른다.

action.type이 'success'인 케이스가 되면, (fetch가 완료되면)

status: 'success'가 되고 -> isPending: false

fetchStatus: 'idle'가 되어 -> isFetching: false

두 값이 동시에 false가 되는 동작을 예상할 수 있다.

 

심봤다!

따라서 isPending이 false, isFetching이 true가 발생할 가능성을 찾았고,

fetch가 완료되어 isFetching이 false가 되었을 때는 isPending 역시 false가 된다는 것을 알 수 있다.

 

공식 문서에는 어떻게 되어있을까?

이것은 v4 버전에서 찾아본 내용이라서 단어가 loading으로 사용되고 있다.

공식 문서를 찾아보아도 loading 상태만으로는 충분하지 않을 수 있다는 설명 외에 특별히 이유에 대한 언급은 없다.

 

공식 문서에서 AI 검색 같은 서비스를 제공하고 있기에 검색을 해보았다.

'isPending'은 쿼리가 현재 데이터를 갖고 있지 않음을 의미하므로, 적합하지 않을 수 있다고 하며,

여러 이유로 'isLoading'을 사용하는 것이 상태 처리에 더 나은 옵션이 될 수 있다고 추천하고 있다.

 

v5의 'isLoading'은 (isPending && isFetching) 상태로 구현되어 있다.

이를 미루어 보았을 때 v4 버전에서 나와 비슷한 문제를 겪은 많은 개발자들이 있었을지도 모르겠다.

어쨌든 TanStack 측에서도 문제를 인지하고, v5에서 개선 방안을 내놓은 것으로 보인다.

 

+) isPending만이 아니라, isFetching만 쓰면?

세상에 공식문서의 AI가 한글로 물어봐도 답해준다..

어쨌든 위의 대답과 비슷하게 lazy 쿼리의 경우 문제가 생긴다고 하는데 이건 안 써봐서 아직 뭔지 잘 모르겠다.

 

마무리

이 문제는 내가 과거에 직접 겪었던 해프닝이지만,

당시에는 왜 isLoading과 isFetching에 차이가 있지..?라고 생각만 하고

다른 우선순위에 밀려 특별히 이유를 찾아보지 못했다.

 

앞단콘에서 이 사건을 발표하기로 하고 발표 준비를 하면서

여전히 공식 문서에 해당 부분에 대한 설명이 없는 것을 보고

어 ..? 이게 여기 없으면 코드를 봐야 알겠는데 ...? 라는 생각을 하게 되었는데

 

이런 흐름으로 이어질 수 있었다는 것 자체가

혹시 내가 조금 성장한 것은 아닐까 하는 기쁨으로 다가왔다.

 

완전히 모든 코드를 이해한 건 절대 아니지만

어떤 함수의 동작에 의해서 과거의 사태가 일어났었는지 내 눈으로 직접 확인할 수 있었던 뜻깊은 시간이었다.

 

 

작성에 참고한 글

 

tanstack-query 소스 뜯어보기

프론트엔드 개발에서 비동기 데이터 관리는 중요한 과제이다. 데이터 관리가 잘 되어 있으면, 서버 부하를 줄일 수 있고, 응답 속도가 향상이 되어 사용자 경험을 향상시킬 수 있으며 개발과정

slowlife1012.tistory.com