안녕하세요. 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.
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 등은 생략했습니다.
※ 직전 포스팅을 확인하면 글 내용을 이해하는 데 도움이 될 수 있습니다.
QueryClient
는 Query
와 Mutation
의 상태를 관리하고 캐싱하는 역할을 수행합니다. Query
와 Mutation
은 다른 역할을 수행하고, 이에 따라 Cache
를 나누어 저장하고 있습니다.
export class QueryClient {
#queryCache: QueryCache
#mutationCache: MutationCache
// ...
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
this.#mutationCache = config.mutationCache || new MutationCache()
// ...
}
// ...
}
Query
와 Mutation
은 서로 다른 목적과 특성이 있기에 각각 별도의 Cache
를 사용합니다.
Query
Mutation
mutation
의 결과는 캐시에 저장되지 않거나 저장되더라도 재사용되지 않습니다.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을 생성할 때 사용할 IDQueryCache
는 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 메서드도 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 })
}
QueryCache
의 build
메서드에서는 queryKey
값을 기반으로 queryHash
로 변환하여 cache
에 접근한 뒤 캐싱된 Query
가 있다면 그대로 사용하고 없으면 Query
를 새로 생성하여 반환했었는데요.
MutationCache
build
메서드에서는 항상 Mutation
을 생성하고, 이를 #mutations
멤버 변수에 추가합니다.
Mutation
은 데이터의 변경을 관리하는 역할을 수행합니다.
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을 호출하면 MutationObserver
→ MutationCache
→ Mutation
→ Mutation.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
등의 옵션을 지정하여 사용한 일이 많았는데요. 해당 옵션들이 Mutation
의 execute
메서드 내부에서 async, await 키워드와 함께 순차적으로 이루어집니다.
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 }
}
useQuery
의 useBaseQuery
를 살펴볼 때와 거의 유사합니다.
useClient
Hook을 통해 QueryClient
를 가져온다.MutationObserver
인스턴스를 생성하고 변경(mutation)의 실행과 결과를 관리한다.useSyncExternalStore
를 통해 MutationObserver
의 변경을 구독하고 결과를 저장한다.observer.mutate
메서드를 활용해 mutate를 실행하는 mutate
콜백 함수와 observer.mutate
메서드를 그대로 사용하는 mutateAsync
를 반환한다.useQuery
의 경우 useQuery
를 호출하게 되면 QueryObserver
가 생성되면서 constructor를 통해 executeFetch
를 수행합니다. 조건에 따라 비동기 데이터를 바로 반환하면서 queryCache
에 저장하여 사용하는 것이죠.
useMutation
의 경우 mutate
를 시도하는 mutate
함수를 정의하여 반환합니다. useQuery
와 다른 점은 호출 시점에 바로 비동기 메서드를 호출하는 것이 아니라 비동기 메서드를 호출하는 함수를 반환하기에 사용자가 원하는 시점에 mutate
할 수 있게 됩니다.
useMutation
은 mutate
와 mutateAsync
메서드를 같이 반환합니다.
mutate
const mutate = React.useCallback<
UseMutateFunction<TData, TError, TVariables, TContext>
>(
(variables, mutateOptions) => {
observer.mutate(variables, mutateOptions).catch(noop)
},
[observer],
)
mutate
는 MutationObserver
의 mutate
메서드를 호출하는 함수인데요. 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
도 마찬가지로 MutationObserver
의 mutate
메서드를 반환하는 것인데, 위에서 설명한 mutate
는 observer.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과 같은 일부 최적화에 대해서 알고는 있지만 어떻게 동작하는지는 모릅니다. 기회가 된다면 이런 내용들도 한 번씩 확인해 보고 정리하는 시간을 가지려고 합니다.