[🎁 React-Query 소스코드 분석] "Refetch" vs "invalidateQueries"

devAnderson·2024년 1월 24일
9

TIL

목록 보기
101/106

🥳 1. 발단

최근 개발을 진행하면서 React-Query를 사용하던 중, Refetch와 InvalidateQueries의 비교에 대한 레퍼런스를 동료 개발자분께 전달받아보게 되었다.


작성자는 심플하게 invalidateQueries 를 더 선호한다는 이야기를 해주고 있었다.
사실 그냥 거기서 고개를 끄덕이고 사용하여도 괜찮겠지만, 작성자가 언급한 내용에 대해서 상당부분을 이해하지 못하였기 때문에, 이를 연구하는 내용을 진행하려고 한다. 어디까지나 연구에 가깝기 때문에 이를 감안하고 봐주셨으면 한다.

TL. TD

만약 컴포넌트의 마운트가 해제되지 않는다면, 컴포넌트 내에서 invalidateQuries와 refetch 둘 중 어느것을 호출하더라도 큰 차이가 존재하지 않는다.


🥳 2. 내부 구조가 어떻게 생겼니?

React-Query가 어떻게 생겨먹었는지에 대한 내용은 이 블로그를 참조하면 좋다.

이미 여기에 정리가 다 잘 되어있지만, 최종 정리본을 띄워두고 실제 깃헙코드와 번갈아가며 내가 이해한 바를 요약 정리하자면 아래와 같다.

1. QueryClient

QueryCache의 데이터가 주입되는 그릇(Vessel)이다.
QueryClient를 초기화하여 만들어지는 인스턴스에는 query cache, mutation cache의 상태뿐만 아니라, 이 캐시데이터를 위한 요청, 조회, 설정 등이 가능한 메서드들을 제공한다.

📎 QueryClient

export class QueryClient {
  // 참고로, #은 Javascript에서 private을 구현하기 위한 prefix이다
  #queryCache: QueryCache // 쿼리 결과의 캐시
  #mutationCache: MutationCache // 뮤테이션 결과의 캐시
  .
  .
  
  #mountCount: number
  #unsubscribeFocus?: () => void
  #unsubscribeOnline?: () => void

  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    this.#mutationCache = config.mutationCache || new MutationCache()
    this.#defaultOptions = config.defaultOptions || {}
    this.#queryDefaults = new Map()
    this.#mutationDefaults = new Map()
    this.#mountCount = 0
  }
.
.
// 예를 들어, fetchQuery는 주입된 캐시 인스턴스를 통해 build 메서드로 쿼리 인스턴스를 초기화한 후, 
// 해당 쿼리의 stale을 확인해보고 stale하면 fetch하며, 
// 아니라면 해당 query의 state data를 resolve하고 있다.
 fetchQuery<
    TQueryFnData,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
    TPageParam = never,
  >(
    options: FetchQueryOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryKey,
      TPageParam
    >,
  ): Promise<TData> {
   	  .
      .
      .

    const query = this.#queryCache.build(this, defaultedOptions)

    return query.isStaleByTime(defaultedOptions.staleTime)
      ? query.fetch(defaultedOptions)
      : Promise.resolve(query.state.data as TData)
  }

2. QueryCache

시리얼라이즈된 쿼리 키를 기반으로 쿼리들을 저장할 수 있게 설계된 클래스이다. Map으로 이루어져있으며, add와 같은 메서드를 통해 쿼리를 저장하고, 이를 구독하는 리스너들에게 알리는(notify)등의 구조를 가지고 있다. 이벤트 타입을 보면 Redux에서 익숙하게 보아왔던 Flux 패턴 을 통해서 이벤트 기반 단방향 notifying을 진행하는 것을 확인할 수 있었다.

📎 QueryCache


export class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }
.
.

  add(query: Query<any, any, any, any>): void {
    if (!this.#queries.has(query.queryHash)) {
      this.#queries.set(query.queryHash, query)

      this.notify({
        type: 'added',
        query,
      })
    }
  }
  
  	
  .
  .
notify(event: QueryCacheNotifyEvent) {
    notifyManager.batch(() => {
      this.listeners.forEach((listener) => {
        listener(event)
      })
    })
  }
.
.

// notifyManager.ts
const batch = <T>(callback: () => T): T => {
    let result
    transactions++
    try {
      result = callback()
    } finally {
      transactions--
      if (!transactions) {
        flush()
      }
    }
    return result
  }

3. Query

실제 쿼리 처리에 대한 결과를 생성하는 클래스이다.
해당 클래스로 초기화되는 인스턴스에는 쿼리의 상태값(데이터 등) 뿐만 아니라 쿼리요청에 대한 상태, 쿼리 요청이 중복될 때에 대한 abort 처리, 쿼리취소, 재시도 등과 같은 메서드들을 포함한다.
또한 가장 중요한 부분은 해당 쿼리를 관찰하는 observers 를 갖고 있고, 이 쿼리의 내용이 변경될 때 해당 관찰자들에게 변경사항을 전파하는 관심사를 갖고 있다.

📎 Query

// 쿼리 내부에 존재하는 상태의 타입은 아래와 같다.
export interface QueryState<TData = unknown, TError = DefaultError> {
  data: TData | undefined
  error: TError | null
  .
  .
  status: QueryStatus
}

export class Query<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Removable {
  queryKey: TQueryKey
  .
  .
  state: QueryState<TData, TError>
  .
  .
  #cache: QueryCache
  #observers: Array<QueryObserver<any, any, any, any, any>>
  #abortSignalConsumed: boolean
  
 constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
    super()

    this.#abortSignalConsumed = false
    this.#defaultOptions = config.defaultOptions
    this.#setOptions(config.options)
    this.#observers = []
    this.#cache = config.cache
    this.queryKey = config.queryKey
    this.queryHash = config.queryHash
    this.#initialState = config.state || getDefaultState(this.options)
    this.state = this.#initialState
    this.scheduleGc()
  }
   .
   .
   .
   addObserver(observer: QueryObserver<any, any, any, any, any>): void {
    if (!this.#observers.includes(observer)) {
      this.#observers.push(observer)

      this.clearGcTimeout()

      this.#cache.notify({ type: 'observerAdded', query: this, observer })
    }
  }
  
  // 해당 함수는 추후 후술할 QueryClient의 invalidateQueries에서 사용된다.
  // 보이는 것처럼, flux 패턴으로 invalidate event를 전달하게 되어 상태가 invalid로 변경되면
  // isStaleByTime 가 리턴하는 값은 true가 되게 된다.
   invalidate(): void {
    if (!this.state.isInvalidated) {
      this.#dispatch({ type: 'invalidate' })
    }
  }
  
  .
  .
  // 쿼리 인스턴스는 state로 update날짜를 기록하고 있다.
  // 쿼리가 stale한지 아닌지에 대해 메서드를 구현해두고,
  // 직접적으로 stale 유무의 판별에 대한 책임은 Observer에게 전달하고 있는 점이 눈여겨볼만하다.
  // (무슨 의미이냐면,) 추후 observer 파트에서 확인할 수 있는 "isStale" 메서드 내부에는
  // 주입받은 Query 인스턴스 내 "isStaleByTime"을 호출하여 해당결과를 활용한다.
  isStaleByTime(staleTime = 0): boolean {
    return (
      this.state.isInvalidated ||
      !this.state.dataUpdatedAt ||
      !timeUntilStale(this.state.dataUpdatedAt, staleTime)
    )
  }

4. QueryObserver은

실제 컴포넌트와 쿼리 캐시데이터간의 바인더 역할을 한다.
컴포넌트가 호출될 때, 내부에 존재하는 useQuery 훅이 호출되는 순간 Observer가 생성되고 해당 쿼리 결과에 대해서 관찰(Listen)하는 상태가 된다.
Observer은 컴포넌트가 데이터 필드의 어떤 Property를 관찰하는지를 알고 있으며, 변동사항이 발생할 경우 이를 알려서 리랜더링을 발생시킨다.

📎 QueriesObserver

📎 QueryObserver

// queriesObserver.ts
// Query들을 관찰하고 있는 Observer들을 전부 가지고 한번에 처리하는 클래스가 존재하며,
export class QueriesObserver<
  TCombinedResult = Array<QueryObserverResult>,
> extends Subscribable<QueriesObserverListener> {
  #client: QueryClient
  #result!: Array<QueryObserverResult>
  #queries: Array<QueryObserverOptions>
  #observers: Array<QueryObserver>
  #options?: QueriesObserverOptions<TCombinedResult>
  #combinedResult!: TCombinedResult

  constructor(
    client: QueryClient,
    queries: Array<QueryObserverOptions>,
    options?: QueriesObserverOptions<TCombinedResult>,
  ) {
    super()

    this.#client = client
    this.#queries = []
    this.#observers = []

    this.#setResult([])
    this.setQueries(queries, options)
  }

.
.
.

 protected onSubscribe(): void {
    if (this.listeners.size === 1) {
      this.#observers.forEach((observer) => {
        observer.subscribe((result) => {
          this.#onUpdate(observer, result)
        })
      })
    }
  }

  
// queryObserver.ts
// 개별 Query Observer 클래스는 주입되는 Query 인스턴스 내부에 존재하는, 
// Observer을 등록하는 메서드를 통해서 해당 Observer 인스턴스를 등록시킬 수 있다.
  
 export class QueryObserver<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
  #client: QueryClient
  #currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined!
  .
  .
  .
  #selectFn?: (data: TQueryData) => TData
  #selectResult?: TData

  //subscribe 함수를 통해, 특정 쿼리의 옵저버로 등록될 수 있다.
  //shouldFetchOnMount는 내부에서 stale유무를 판별하여 boolean을 리턴하고,
  //fetch가 mount 당시에 필요하다고 여겨지면 #executeFetch를 호출하여 쿼리요청을 한다.
   protected onSubscribe(): void {
    if (this.listeners.size === 1) {
      this.#currentQuery.addObserver(this)

      if (shouldFetchOnMount(this.#currentQuery, this.options)) {
        this.#executeFetch()
      } else {
        this.updateResult()
      }

      this.#updateTimers()
    }
  }

  .
  .
  // 방금 전 상단 Query 파트에서 언급했던 것처럼, 
  //Observer은 Query의 isStaleByTime을 활용해서 해당 쿼리가 stale한지 판별한다.
  function isStale(
  query: Query<any, any, any, any>,
  options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
  return query.isStaleByTime(options.staleTime)
}  

🥳 전체 시나리오

코드 전체를 다 살펴보진 않았지만, 전반적인 컨셉은 코드를 뜯어보며 확인해볼 수 있었다.

이를 기반으로 실제 개발시 이루어질 플로우는 아래와 같다.

  1. 컴포넌트가 마운트된다
  2. 컴포넌트 내에 있는 useQuery가 실행된다
  3. QueryObserver가 만들어지고, 쿼리 요청에 대한 결과를 구독한다
  4. 만약 컴포넌트 마운트 당시 Observer가 확인한 쿼리결과가 stale할 경우, 백그라운드에서 refetch가 이루어지며, 옵저버는 fetching 변화에 대해서 이를 구독하고 있는 컴포넌트들에게 notify한다(re-rendering)
  5. fetch가 완료될 경우, 쿼리는 이를 observer들에게 알리고, obserever은 다시 이 변화에 대해서 컴포넌트에게 notify한다.

이제 Observer에 대해서 조금은 알게 된 것 같다.

그럼 이를 기반으로 근본 주제였던 refetch와 invalidateQueries의 코드를 살펴보자.

1. 🥳 invalidateQueries

📎 invalidateQueries

// queryClient.ts

  invalidateQueries(
    filters: InvalidateQueryFilters = {},
    options: InvalidateOptions = {},
  ): Promise<void> {
    return notifyManager.batch(() => {
     	// 쿼리 캐시의 맵객체에 있는 쿼리들 중, 무엇을 대상으로 작업을 진행할지 filter 옵션으로 필터링하고
        // 순회하면서 해당하는 쿼리의 invalidate 메서드를 이용해 쿼리상태를 전부 invalidate시킨다.
       // query가 invalidated => isStale호출결과가 true, observer가 확인하는 enabled 가 false가 된다.
      // => 결과적으로, query observer 의 shouldeFetchOn도 true를 리턴한다.
      this.#queryCache.findAll(filters).forEach((query) => {
        query.invalidate()
      })

      // 이후, 옵션으로 refetchType을 none으로 주었다면, 
      // fetch를 하지 않고 마무리되며
      if (filters.refetchType === 'none') {
        return Promise.resolve()
      }

	  // 아니라면 filteres를 제공해주어 refetch를 진행한다. 
      const refetchFilters: RefetchQueryFilters = {
        ...filters,
        type: filters.refetchType ?? filters.type ?? 'active',
      }
      return this.refetchQueries(refetchFilters, options)
    })
  }
   
  // refetchQueries는 쿼리캐시에 존재하는 모든 쿼리들 중,
  // 필터링으로 제공한 쿼리들을 추린 후, 여기에서 옵저버가 존재하고 상태가 enabled한 대상(!isDisabled())인 쿼리들에 대해서
  // fetch를 호출시켜 요청을 날린다.
  // 이후 Promise 배열을 Promise.all()로 전달하는데, await을 주지 않는 것을 주목하자
  // 해당 처리는 백그라운드에서 비동기적으로 작동할 것이다.
  refetchQueries(
    filters: RefetchQueryFilters = {},
    options?: RefetchOptions,
  ): Promise<void> {
    const fetchOptions = {
      ...options,
      cancelRefetch: options?.cancelRefetch ?? true,
    }
    const promises = notifyManager.batch(() =>
      this.#queryCache
        .findAll(filters)
        .filter((query) => !query.isDisabled())
        .map((query) => {
          let promise = query.fetch(undefined, fetchOptions)
          if (!fetchOptions.throwOnError) {
            promise = promise.catch(noop)
          }
          return query.state.fetchStatus === 'paused'
            ? Promise.resolve()
            : promise
        }),
    )

    return Promise.all(promises).then(noop)
  }

...
/// query 내부 메서드
 invalidate(): void {
    if (!this.state.isInvalidated) {
      this.#dispatch({ type: 'invalidate' })
    }
  }

  isActive(): boolean {
    return this.#observers.some(
      (observer) => observer.options.enabled !== false,
    )
  }

  isDisabled(): boolean {
    return this.getObserversCount() > 0 && !this.isActive()
  }

  isStale(): boolean {
    return (
      this.state.isInvalidated ||
      !this.state.dataUpdatedAt ||
      this.#observers.some((observer) => observer.getCurrentResult().isStale)
    )
  }

살짝 타고가다보면 멍해지는 순간이 오는데, 일단 핵심적인 부분을 확인하면 아래와 같다.

  1. invalidateQueries는 쿼리 캐시 내 대상 쿼리들을 찾아 순회하면서 state의 ìsInvalidated 를 true로 변환시킨다.
  2. 그리고 refetchQueries를 호출하여 다시 쿼리캐시 내의 대상 쿼리들을 찾아 순회하면서 쿼리의 isDisabled를 호출해봤을 때 false가 아닌애들을 추려서 fetch를 호출한다.
  3. isDisabled를 호출해본다는 뜻은, query가 자신을 살펴보고 있는 Observer들의 상태를 확인했을 때, 단 하나라도 enabled한 존재가 있는 지를 찾는 것이다. (하나라도 옵저버가 살아있늬?)
  4. enabled한 옵저버가 하나라도 존재한다는 뜻은, 해당 쿼리에 대해서 useQuery훅을 호출한 컴포넌트가 현재 화면에 마운트 된 것이 하나라도 존재한다는 뜻이다.
// useQuery가 호출하는 useBaseQuery의 내부 상태값은
// 인자로 들어오는 Observer을 초기화하여 가지고 있다.
 const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        client,
        defaultedOptions,
      ),
  )

** 즉, 결론적으로 말해서
1. invalidateQueries를 호출하게 되었을 때, query는 invalidated되지만, 옵저버가 살아있다면 해당 쿼리를 다시 refetch하는 것을 알 수 있었다.
2. 즉, useQuery가 호출되어 있는 컴포넌트가 그대로 마운트된 상태라면 query는 최신화되어 보여지게 될 것이다.
3. 그리고 추가적으로 query 자체가 invalidated되었기 때문에 query의 isStaleByTime() 호출결과는 true가 된다.
4. isStaleByTime() 이 true라는 뜻은, 쿼리를 감지해서 이를 컴포넌트에게 연락하는 옵저버 입장에서 refetch를 해야한다고 평가되게 만든다.
5. 즉, 해당 쿼리 키로 요청을 날린 useQuery 훅을 호출하는 컴포넌트가 다시 마운트되는 순간이 온다면 refetch를 하게 된다.

위의 결론을 코드적으로 분석하면 아래와 같다.

📎 isStaleByTime
📎 shouldFetchOn
📎 shouldFetchOnMount
📎 onSubscribe

//  query.ts
// 쿼리 입장에선 현재 쿼리가 stale하다고 판별되며
 isStaleByTime(staleTime = 0): boolean {
    return (
      this.state.isInvalidated ||
      !this.state.dataUpdatedAt ||
      !timeUntilStale(this.state.dataUpdatedAt, staleTime)
    )
  }

// queryObserver.ts
// 쿼리를 관찰하고 있는 옵저버입장에선
// isStale() = true 이고
// shouldFetchOn() = true 이며
// shouldFetchOnMount() = true이기에
// onSubscribe() 으로 보았을 때 this.#executeFetch()를 해야되게 판별된다.
function isStale(
  query: Query<any, any, any, any>,
  options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
  return query.isStaleByTime(options.staleTime)
}

function shouldFetchOn(
  query: Query<any, any, any, any>,
  ...
) {
  if (options.enabled !== false) {
    const value = typeof field === 'function' ? field(query) : field

    return value === 'always' || (value !== false && isStale(query, options)) <---
  }
  return false
}
    
function shouldFetchOnMount(
  query: Query<any, any, any, any>,
  options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
  return (
    shouldLoadOnMount(query, options) || 
    (query.state.dataUpdatedAt > 0 &&
      shouldFetchOn(query, options, options.refetchOnMount)) <------
  )
}
    
protected onSubscribe(): void {
   .
   .
   .
  
      if (shouldFetchOnMount(this.#currentQuery, this.options)) { 
        this.#executeFetch() <-----
      } else {
        this.updateResult()
      }

      this.#updateTimers()
    }
  }

2. 🥳 refetch

우선, refetch 메서드를 제공하게 되는 부분은 observer가 담당하고 있음을 미리 인지하고 가야한다.

// useBaseQuery
.
.
 const result = observer.getOptimisticResult(defaultedOptions)


// queryObserver

  getOptimisticResult(
    options: DefaultedQueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    const query = this.#client.getQueryCache().build(this.#client, options)

    const result = this.createResult(query, options) <-- -
   }      

 protected createResult(
    query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
    options: QueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
   .
   .
   .
   
    const result: QueryObserverBaseResult<TData, TError> = {
      .
      .
      .
     
      refetch: this.refetch,
    }

    return result as QueryObserverResult<TData, TError>

그러면 observer에 존재하는 메서드 refetch 가 무엇을 하는지 살펴보자

  refetch({ ...options }: RefetchOptions = {}): Promise<
    QueryObserverResult<TData, TError>
  > {
    return this.fetch({
      ...options,
    })
  }
  
  protected fetch(
    fetchOptions: ObserverFetchOptions,
  ): Promise<QueryObserverResult<TData, TError>> {
    return this.#executeFetch({ <----- 어라? 아까 본건데?
      ...fetchOptions,
      cancelRefetch: fetchOptions.cancelRefetch ?? true,
    }).then(() => {
      this.updateResult()
      return this.#currentResult
    })
  }

중간에 보면 뭔가 익숙한 코드가 있지 않은가? this.#executeFetch

그렇다. 방금 전 위에서 보았던 invalidateQuries 후 query의 isInvalidated 가 true가 되게 되면서 최종적으로 호출되게 되는 함수다.

// queryObserver
function shouldFetchOnMount(
  query: Query<any, any, any, any>,
  options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
  return (
    shouldLoadOnMount(query, options) || 
    (query.state.dataUpdatedAt > 0 &&
      shouldFetchOn(query, options, options.refetchOnMount)) <------
  )
}
    
protected onSubscribe(): void {
   .
   .
   .
  
      if (shouldFetchOnMount(this.#currentQuery, this.options)) { 
        this.#executeFetch() <---- 요기
      } else {
        this.updateResult()
      }

      this.#updateTimers()
    }
  }

몹시 허망하지만 저게 끝이다.

즉, refetch를 호출하게 되면, 해당 쿼리의 상태가 invalidated인지 아닌지의 여부를 판단하지 않고 바로 excuteFetch를 하게 된다.

이제 다시 그 처음에 있었던 답변을 확인해보자.


🥳 맺음글

상당히 긴 시간동안 소스코드를 해석해보는 시간을 가져보면서 내 자신도 인내심(?) 과 좋은 코드의 구조가 어떻게 생긴것인지에 대한 편린을 볼 수 있었다.

덧붙여 이렇게 공부해보고 고민해볼 수 있는 시간을 가져볼 소스를 제공해주신 동료분께 감사를 드리며 이만 글을 줄인다.

profile
자라나라 프론트엔드 개발새싹!

0개의 댓글