[React] React-Query 동작 원리 (3)

배준형·2024년 2월 26일
1

서문

안녕하세요. 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.

React-Query 동작 원리 알아보기 3번째 포스팅입니다. 이전 포스팅에서 (1) Query-Core의 QueryClient, QueryCache, Query에 대해 알아보았고, (2) QueryObserver, NotifyManager, useQuery, useBaseQuery와 Data Fetching 흐름을 알아보았습니다.

이번 포스팅에서는 Mutation, MutationCache, MutationObserver, useMutation 등에 대해 알아보고 중간마다 Query와 다른 점은 무엇인지 확인해 보겠습니다.


※ Tanstack Query v5.18.0 버전으로 살펴봅니다. 버전별로 내용이 다를 수 있으니 참고 부탁드립니다.🙏

https://github.com/TanStack/query
이해를 돕기 위해 가져온 코드 중 일부 내용, Generic Type 등은 생략했습니다.

※ 직전 포스팅을 확인하면 글 내용을 이해하는 데 도움이 될 수 있습니다.

[React] React-Query 동작 원리 (1)
[React] React-Query 동작 원리 (2)


QueryClient

QueryClientQueryMutation의 상태를 관리하고 캐싱하는 역할을 수행합니다. QueryMutation은 다른 역할을 수행하고, 이에 따라 Cache를 나누어 저장하고 있습니다.

query-core/src/queryClient.ts

export class QueryClient {
  #queryCache: QueryCache
  #mutationCache: MutationCache
  // ...

  constructor(config: QueryClientConfig = {}) {
    this.#queryCache = config.queryCache || new QueryCache()
    this.#mutationCache = config.mutationCache || new MutationCache()
    // ...
  }

  // ...
}

QueryMutation은 서로 다른 목적과 특성이 있기에 각각 별도의 Cache를 사용합니다.

Query

  • 데이터를 읽어오며 여러 컴포넌트에서 재사용될 수 있습니다.
  • 동일 query가 다시 요청될 때 이전 결과를 재사용할 수 있도록 결과를 캐시에 저장됩니다.

Mutation

  • 데이터를 변경하며 일반적으로 특정 컴포넌트에서만 사용됩니다.
  • mutation의 결과는 캐시에 저장되지 않거나 저장되더라도 재사용되지 않습니다.

MutationCache

MutationCache는 Mutation의 상태를 관리하고 캐싱하는 역할을 수행합니다.

query-core/src/mutationCache.ts

export class MutationCache extends Subscribable<MutationCacheListener> {
  #mutations: Array<Mutation<...>>
  #mutationId: number
  // ...

  constructor(public config: MutationCacheConfig = {}) {
    super()
    this.#mutations = []
    this.#mutationId = 0
  }

  // ...
}
  • #mutations: 캐시에 저장된 Mutation 배열
  • #mutationId: 새로운 Mutation을 생성할 때 사용할 ID

QueryCache는 Map 객체를 통해 QueryHash값을 Key로 결과를 캐싱했었죠. MutationCache는 배열을 사용하여 최근 실행된 뮤테이션들이 순서대로 저장합니다.


QueryCache가 Cache에 Map 객체를 사용하는 이유

Query로 데이터를 조회할 때 동일한 쿼리 키에 대해서는 하나의 캐시 데이터만 유지해야 합니다. 데이터가 갱신될 때는 키를 기준으로 업데이트되어야 하고요.

Map 객체를 사용하면 Key 값으로 데이터를 조회하게 되면서 빠르게 캐싱 결과 값에 접근할 수 있습니다. 데이터 갱신에도 동일하게 Key 값으로 접근이 가능하니 용이하게 갱신할 수 있고, 캐시 데이터의 크기가 커지더라도 조회 성능은 유지할 수 있습니다.


MutationCache가 Cache에 Array를 사용하는 이유

MutationCache에는 최근 실행된 Mutation들이 순서대로 저장되고, 각 Mutation은 고유한 ID를 가지고 있어서 이 ID를 통해 Mutation을 식별합니다.

일반적으로 Mutate는 순차적으로 발생하고, 특정 Mutate를 빠르게 검색할 필요가 없기 때문에 Array를 사용합니다.


MutationCache - Build

MutationCache Build 메서드도 QueryCache Build 메서드와 조금 다릅니다.

build<...>(
  client: QueryClient,
  options: MutationOptions<...>,
  state?: MutationState<...>,
): Mutation<...> {
  const mutation = new Mutation({
    mutationCache: this,
    mutationId: ++this.#mutationId,
    options: client.defaultMutationOptions(options),
    state,
  })

  this.add(mutation)

  return mutation
}

add(mutation: Mutation<...>): void {
  this.#mutations.push(mutation)
  this.notify({ type: 'added', mutation })
}

QueryCachebuild 메서드에서는 queryKey 값을 기반으로 queryHash로 변환하여 cache에 접근한 뒤 캐싱된 Query가 있다면 그대로 사용하고 없으면 Query를 새로 생성하여 반환했었는데요.

MutationCache build 메서드에서는 항상 Mutation을 생성하고, 이를 #mutations 멤버 변수에 추가합니다.


Mutation

Mutation은 데이터의 변경을 관리하는 역할을 수행합니다.

query-core/src/mutation.ts

export class Mutation<...> extends Removable {
  state: MutationState<...>
  options!: MutationOptions<...>
  readonly mutationId: number

  #observers: Array<MutationObserver<...>>
  #defaultOptions?: MutationOptions<...>
  #mutationCache: MutationCache
  #retryer?: Retryer<...>

  constructor(config: MutationConfig<...>) {
    super()

    this.mutationId = config.mutationId
    this.#defaultOptions = config.defaultOptions
    this.#mutationCache = config.mutationCache
    this.#observers = []
    this.state = config.state || getDefaultState()

    // ...
  }

  // ...
}

build 메서드를 통해 생성할 때 mutationId, mutationCache, state 등을 넘겨주기 때문에 그 값을 그대로 멤버 변수에 저장하여 활용합니다. 후술하겠지만, useMutation Hook을 호출하면 MutationObserverMutationCacheMutationMutation.execute() 순서로 메서드를 호출하는데요. 실질적으로 mutation을 수행하는 메서드는 Mutation 내부의 execute 메서드입니다.


execute()

※ 코드가 많아(약 200줄) 많은 부분 생략되었습니다.

async execute(variables: TVariables): Promise<TData> {
  const executeMutation = () => {
    this.#retryer = createRetryer({
      fn: () => this.options.mutationFn(variables),
      onFail: (failureCount, error) => {
        this.#dispatch({ type: 'failed', failureCount, error })
      },
      // ...
    })

    return this.#retryer.promise
  }

  // ...

  try {
    // ...
    const data = await executeMutation()

    // ...

    await this.options.onSuccess?.(data, variables, this.state.context!)

    // ...

    await this.options.onSettled?.(data, null, variables, this.state.context)

    this.#dispatch({ type: 'success', data })
    return data
  } catch (error) {
    try {
      // ...
      await this.options.onError?.(error as TError, variables, this.state.context)

      // ...
      await this.options.onSettled?.(undefined, error as TError, variables, this.state.context)
      throw error
    } finally {
      this.#dispatch({ type: 'error', error: error as TError })
    }
  }
}

이 메서드는 Mutation Option으로 받은 mutationFn을 실행하는 비동기 함수로 createRetryer를 호출하여 mutationFn을 실행합니다. mutationFn이 성공했다면 Option으로 받은 onSuccess, onSettled 함수를, 에러가 발생했다면 onError, onSettled 함수를 순차적으로 실행합니다. 성공했을 경우 mutation의 결과를 반환합니다.

useMutation Hook을 사용할 때 onSuccess, onError 등의 옵션을 지정하여 사용한 일이 많았는데요. 해당 옵션들이 Mutationexecute 메서드 내부에서 async, await 키워드와 함께 순차적으로 이루어집니다.


useMutation

useMutation Hook은 데이터를 변경(mutation)하는 작업을 수행하고 그 결과를 반환하는 역할을 수행합니다.

react-query/src/useMutation.ts

export function useMutation<...>(
  options: UseMutationOptions<...>,
  queryClient?: QueryClient,
): UseMutationResult<...> {
  const client = useQueryClient(queryClient)

  const [observer] = React.useState(
    () =>
      new MutationObserver<TData, TError, TVariables, TContext>(
        client,
        options,
      ),
  )

  // ...

  const result = React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) =>
        observer.subscribe(notifyManager.batchCalls(onStoreChange)),
      [observer],
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult(),
  )

  const mutate = React.useCallback<
    UseMutateFunction<TData, TError, TVariables, TContext>
  >(
    (variables, mutateOptions) => {
      observer.mutate(variables, mutateOptions).catch(noop)
    },
    [observer],
  )

  // ...

  return { ...result, mutate, mutateAsync: result.mutate }
}

useQueryuseBaseQuery를 살펴볼 때와 거의 유사합니다.

  1. useClient Hook을 통해 QueryClient를 가져온다.
  2. MutationObserver 인스턴스를 생성하고 변경(mutation)의 실행과 결과를 관리한다.
  3. useSyncExternalStore를 통해 MutationObserver의 변경을 구독하고 결과를 저장한다.
  4. observer.mutate 메서드를 활용해 mutate를 실행하는 mutate 콜백 함수와 observer.mutate 메서드를 그대로 사용하는 mutateAsync 를 반환한다.

useQuery의 경우 useQuery를 호출하게 되면 QueryObserver가 생성되면서 constructor를 통해 executeFetch를 수행합니다. 조건에 따라 비동기 데이터를 바로 반환하면서 queryCache에 저장하여 사용하는 것이죠.

useMutation의 경우 mutate를 시도하는 mutate함수를 정의하여 반환합니다. useQuery와 다른 점은 호출 시점에 바로 비동기 메서드를 호출하는 것이 아니라 비동기 메서드를 호출하는 함수를 반환하기에 사용자가 원하는 시점에 mutate할 수 있게 됩니다.


mutate vs mutateAsync

useMutationmutatemutateAsync 메서드를 같이 반환합니다.

mutate

const mutate = React.useCallback<
  UseMutateFunction<TData, TError, TVariables, TContext>
>(
  (variables, mutateOptions) => {
    observer.mutate(variables, mutateOptions).catch(noop)
  },
  [observer],
)

mutateMutationObservermutate 메서드를 호출하는 함수인데요. async, await 없이 mutate를 실행하고 결과를 기다리지 않습니다.


mutateAsync

// useMutation.ts
export function useMutation(...) {
  // ...

  return { ...result, mutate, mutateAsync: result.mutate }
}

// mutationObserver.ts
export class MutationObserver {
  // ...

  constructor(...) {
    // ...

    this.#updateResult()
  }

  #updateResult() {
    // ...

    this.#currentResult = {
      ...state,
      mutate: this.mutate,
    } as MutationObserverResult<...>
  }

  mutate(...) {
    // ...

    return this.#currentMutation.execute(variables)
  }

  // ...
}

// mutation.ts
export class Mutation {
  // ...

  constructor(...) { ... }
  
  async execute(variables: TVariables): Promise<TData> {
    const executeMutation = () => {
      this.#retryer = createRetryer({
        fn: () => {
          if (!this.options.mutationFn) {
            return Promise.reject(new Error('No mutationFn found'))
          }
          return this.options.mutationFn(variables)
        },
        // ...
      })

      return this.#retryer.promise

      const data = await executeMutation()

      // ...
  }

  // ...
}

mutateAsync도 마찬가지로 MutationObservermutate메서드를 반환하는 것인데, 위에서 설명한 mutateobserver.mutate를 직접 호출한 것이고, mutateAsync는 async, await 키워드와 함께 프로미스 형태로 사용되며 결과 값을 data에 담아 반환합니다.

결과적으로 변이의 결과를 기다리지 않고 다른 작업을 계속하려면 mutate를 사용하고, 변이의 결과를 기다려야 하거나 변이가 실패했을 때 에러를 처리하려면 mutateAsync를 사용하면 됩니다.


반환값

mutate는 반환 값이 없고, mutateAsync는 프로미스를 반환합니다. 따라서 mutate는 mutate options를 통해 응답 값을 제어하고, mutateAsync는 프로미스를 사용하여 응답 값을 제어할 수 있습니다.

mutate(variables, {
  onSuccess: (data) => {
    console.log('Mutation data:', data);
  },
  onError: (error) => {
    console.error('Mutation error:', error);
  },
});
mutateAsync(variables)
  .then((data) => {
    console.log('Mutation data:', data);
  })
  .catch((error) => {
    console.error('Mutation error:', error);
  });

정리

useMutation은 서버의 데이터를 변경(Mutation)하는데 사용되는 커스텀 훅으로 useQuery와 유사하지만, 역할과 동작이 다릅니다.

useQuery의 경우 데이터를 가져오는 API를 호출할 때 사용되며, Query, QueryObserver, QueryCache가 사용됩니다. useQuery를 호출하면 QueryObserver 인스턴스가 생성되며 그 과정에서 비동기 메서드가 1회 호출됩니다. 응답 데이터는 QueryCache의 Map 객체에 캐싱 되며 statleTime 등에 의해 캐싱 된 값을 사용할지, API 응답을 사용할지 결정하게 됩니다.

useMutation의 경우 데이터를 변경하는 API를 호출할 때 사용되며, Mutation, MutationObserver, MutationCache가 사용됩니다. useMutation에 변경 상태를 추적하고 성공, 실패에 대한 콜백 함수를 Option으로 전달하여 사용할 수 있습니다.


이번 포스팅에서는 Mutation에 대해 알아보았습니다. Query와 다른 부분도 있었지만, 그 흐름은 유사해서 Query를 처음 봤던 것에 비해 상대적으로 이해하기에 수월했습니다. 동작 원리를 이해해 가면서 직접 사용할 때도 도움이 되는 것 같아요. 그래서 단순히 React-Query에 대해서만 확인해보고 있는데, 점점 다른 오픈 소스 라이브러리의 동작 원리들이 궁금해지기도 합니다.

개인적으로 Zustand를 유용하게 잘 쓰고 있는데, 이 Zustand가 어떻게 동작하는지는 잘 모르고, 마찬가지로 React를 유용하게 쓰고 있고 가상돔, Batching과 같은 일부 최적화에 대해서 알고는 있지만 어떻게 동작하는지는 모릅니다. 기회가 된다면 이런 내용들도 한 번씩 확인해 보고 정리하는 시간을 가지려고 합니다.


참조

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글