🔥 뮤테이션 후 자동 쿼리 무효화

1337자
17분

쿼리와 뮤테이션은 동전의 양면과 같습니다. 쿼리는 주로 데이터 가져오기를 통해 비동기 리소스를 읽는 방법을 정의합니다. 반면 뮤테이션은 이러한 리소스를 업데이트하는 작업입니다.

뮤테이션이 완료되면 대부분 쿼리에 영향을 미칩니다. 예를 들어 issue를 업데이트하면 issues 목록에 영향을 줄 가능성이 높습니다. 그래서 React Query가 뮤테이션을 쿼리와 전혀 연결하지 않는다는 점이 다소 놀라울 수 있습니다.

이렇게 설계한 이유는 간단합니다. React Query는 리소스 관리 방식에 대해 전혀 의견을 제시하지 않으며, 모든 사용자가 뮤테이션 후 다시 가져오기를 선호하지는 않기 때문입니다. 뮤테이션이 업데이트된 데이터를 반환하는 경우도 있어서, 추가 네트워크 요청을 피하기 위해 수동으로 캐시에 넣고 싶을 수 있습니다.

무효화를 수행하는 방법에도 여러 가지가 있습니다:

  • onSuccess에서 무효화할지 아니면 onSettled에서 무효화할지?
    전자는 뮤테이션이 성공했을 때만 실행되고, 후자는 오류가 발생한 경우에도 실행됩니다.
  • 무효화를 await할지?무효화를 await하면 다시 가져오기가 완료될 때까지 뮤테이션이 pending 상태를 유지합니다. 예를 들어 그동안 폼을 비활성화하고 싶다면 좋은 방법이 될 수 있습니다. 하지만 상세 화면에서 개요 페이지로 최대한 빨리 이동하고 싶은 경우에는 적합하지 않을 수 있습니다.

모든 상황에 맞는 단일 솔루션이 없기 때문에 React Query는 기본 기능을 제공하지 않습니다. 하지만 전역 캐시 콜백을 사용하면 원하는 방식으로 자동 무효화를 쉽게 구현할 수 있습니다.

전역 캐시 콜백

뮤테이션에는 각각의 useMutation에서 정의해야 하는 onSuccess, onError, onSettled 콜백이 있습니다. 또한 MutationCache에도 동일한 콜백이 존재합니다. 애플리케이션에는 하나의 MutationCache만 있으므로 이 콜백들은 "전역"입니다. 즉, 모든 뮤테이션에 대해 실행됩니다.

콜백이 있는 MutationCache를 만드는 방법은 명확하지 않을 수 있습니다. 대부분의 예제에서 QueryClient를 만들 때 MutationCache가 암시적으로 생성되기 때문입니다. 하지만 캐시를 수동으로 생성하고 콜백을 제공할 수 있습니다:

import { QueryClient, MutationCache } from '@tanstack/react-query'
 
const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess,
    onError,
    onSettled,
  }),
})
 
javascript

이 콜백들은 useMutation의 콜백과 동일한 인자를 받지만, 마지막 매개변수로 뮤테이션 인스턴스도 받습니다. 일반 콜백과 마찬가지로 반환된 Promise도 await됩니다.

그렇다면 전역 콜백이 자동 무효화에 어떻게 도움이 될까요? 전역 콜백 안에서 queryClient.invalidateQueries를 호출하면 됩니다:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries()
    },
  }),
})
 
javascript

이 다섯 줄의 코드로 Remix(죄송합니다, React-Router)와 같은 프레임워크가 수행하는 것과 유사한 동작을 얻을 수 있습니다. 모든 제출 후 모든 것을 무효화합니다. Alex가 이 방법을 보여준 것에 대해 감사를 표합니다:

@alexdotjs

저는 모든 뮤테이션에서 그냥 모든 것을 무효화합니다.
https://trpc.io/docs/client/react/useUtils#invalidate-full-cache-on-every-mutation

그렇지만 이렇게 하면 과도하지 않나요?

그럴 수도 있고 아닐 수도 있습니다. 상황에 따라 다릅니다. 다시 말하지만, 이것이 내장되어 있지 않은 이유는 접근 방식이 너무 다양하기 때문입니다. 여기서 명확히 해야 할 점은 무효화가 항상 다시 가져오기를 의미하지는 않는다는 것입니다.

무효화는 단순히 일치하는 모든 활성 쿼리를 다시 가져오고 나머지를 stale로 표시하여 다음에 사용될 때 다시 가져오도록 합니다.

이는 보통 좋은 절충안입니다. 필터가 있는 이슈 목록을 생각해 보세요. 각 필터가 QueryKey의 일부여야 하므로 캐시에 여러 쿼리가 생깁니다. 하지만 한 번에 하나의 쿼리만 볼 수 있습니다. 모두 다시 가져오면 불필요한 요청이 많이 발생하고, 해당 필터로 목록으로 돌아갈 것이라는 보장도 없습니다.

따라서 무효화는 현재 화면에서 볼 수 있는 것(활성 쿼리)만 다시 가져와 최신 뷰를 얻고, 나머지는 다시 필요할 때 다시 가져옵니다.

특정 쿼리에 무효화 연결하기

잠깐만요. 세밀한 재검증은 어떻게 하나요? issue를 목록에 추가할 때 profile 데이터를 무효화하는 것은 거의 의미가 없습니다...

다시 말하지만, 이는 절충안입니다. 코드는 최대한 간단하고, 엄격하게 필요한 것보다 데이터를 더 자주 가져오는 것이 재가져오기를 놓치는 것보다 낫습니다. 정확히 무엇을 다시 가져와야 하는지 알고 있고, 그 일치 항목을 절대 확장할 필요가 없다면 세밀한 재검증이 좋습니다.

과거에는 세밀한 재검증을 자주 했지만, 나중에 사용된 무효화 패턴에 맞지 않는 다른 리소스를 추가해야 한다는 것을 알게 되었습니다. 그 시점에서 해당 리소스도 다시 가져와야 하는지 확인하기 위해 모든 뮤테이션 콜백을 검토해야 했습니다. 이는 번거롭고 오류가 발생하기 쉽습니다.

게다가 대부분의 쿼리에 대해 보통 2분 정도의 중간 크기 staleTime을 사용합니다. 따라서 관련 없는 사용자 상호 작용 후 무효화의 영향은 무시할 만합니다.

물론 재검증을 더 스마트하게 만들기 위해 로직을 더 복잡하게 만들 수 있습니다. 다음은 제가 과거에 사용한 몇 가지 기술입니다:

mutationKey에 연결하기

MutationKey와 QueryKey는 공통점이 없으며, 뮤테이션의 키는 선택 사항입니다. 원한다면 MutationKey를 사용하여 무효화할 쿼리를 지정할 수 있습니다:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
      })
    },
  }),
})
 
javascript

그런 다음 뮤테이션에 mutationKey: ['issues']를 지정하여 issue 관련 항목만 무효화할 수 있습니다. 키가 없는 뮤테이션은 여전히 모든 것을 무효화합니다. 좋습니다.

staleTime에 따라 쿼리 제외하기

저는 종종 staleTime:Infinity를 지정하여 쿼리를 "정적"으로 표시합니다. 이러한 쿼리를 무효화하지 않으려면 쿼리의 staleTime 설정을 확인하고 predicate 필터를 사용하여 제외할 수 있습니다:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, ```javascript
 _context, mutation) => {
      const nonStaticQueries = (query) => {
        const defaultStaleTime =
          queryClient.getQueryDefaults(query.queryKey).staleTime ?? 0
        const staleTimes = query.observers
          .map((observer) => observer.options.staleTime)
          .filter((staleTime) => staleTime !== undefined)
 
        const staleTime =
          query.getObserversCount() > 0
            ? Math.min(...staleTimes)
            : defaultStaleTime
 
        return staleTime !== Number.POSITIVE_INFINITY
      }
 
      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
        predicate: nonStaticQueries,
      })
    },
  }),
})
 
javascript

쿼리의 실제 staleTime을 찾는 것은 그리 간단하지 않습니다. staleTime은 옵저버 수준의 속성이기 때문입니다. 하지만 가능하며, predicate 필터를 queryKey와 같은 다른 필터와 결합할 수도 있습니다. 멋집니다.

meta 옵션 사용하기

meta를 사용하여 뮤테이션에 대한 임의의 정적 정보를 저장할 수 있습니다. 예를 들어 invalidates 필드를 추가하여 뮤테이션에 "태그"를 지정할 수 있습니다. 이 태그를 사용하여 무효화하려는 쿼리를 대략적으로 일치시킬 수 있습니다:

import { matchQuery } from '@tanstack/react-query'
 
const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        predicate: (query) =>
          // 일치하는 모든 태그를 한 번에 무효화하거나
          // meta가 제공되지 않은 경우 모든 것을 무효화
          mutation.meta?.invalidates?.some((queryKey) =>
            matchQuery({ queryKey }, query)
          ) ?? true,
      })
    },
  }),
})
 
// 사용 예:
useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: [['issues'], ['labels']],
  },
})
 
javascript

여기서도 predicate 함수를 사용하여 queryClient.invalidateQueries를 한 번만 호출합니다. 하지만 그 안에서 React Query에서 가져올 수 있는 matchQuery 함수를 사용하여 퍼지 매칭을 수행합니다. 이 함수는 단일 queryKey를 필터로 전달할 때 내부적으로 사용되는 것과 동일한 함수입니다. 이제 여러 키로 이 작업을 수행할 수 있습니다.

이 패턴은 useMutation 자체에 onSuccess 콜백을 두는 것보다 약간 나을 뿐입니다. 하지만 적어도 매번 useQueryClient로 QueryClient를 가져올 필요가 없습니다. 또한 이를 기본적으로 모든 것을 무효화하는 것과 결합하면, 해당 동작을 선택 해제할 수 있는 좋은 방법이 될 것입니다.

TypeScript에서의 meta 옵션

일반적으로 metaRecord<string, unknown>으로 타입이 지정되지만, 모듈 확장을 통해 이를 조정할 수 있습니다:

declare module '@tanstack/react-query' {
  interface Register {
    mutationMeta: {
      invalidates?: Array<QueryKey>
    }
  }
}
 
typescript

meta 타이핑에 대해 더 자세히 알아보려면 문서를 참조하세요.

기다릴 것인가 말 것인가

위의 모든 예제에서 우리는 무효화를 await하지 않았습니다. 뮤테이션을 최대한 빨리 완료하고 싶다면 이는 괜찮습니다. 제가 자주 마주친 특정 상황은 모든 것을 무효화하되 중요한 하나의 다시 가져오기가 완료될 때까지 뮤테이션을 대기 상태로 유지하고 싶은 경우입니다. 예를 들어, 레이블을 업데이트한 후 레이블 관련 쿼리가 다시 가져와질 때까지 기다리고 싶지만 모든 것이 다시 가져와질 때까지 기다리고 싶지는 않을 수 있습니다.

이를 meta 솔루션에 포함시켜 구조를 다음과 같이 확장할 수 있습니다:

useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: 'all',
    awaits: ['labels'],
  },
})
 
javascript

또는 MutationCache의 콜백이 useMutation의 콜백보다 먼저 실행된다는 사실을 활용할 수 있습니다. 전역 콜백을 설정하여 모든 것을 무효화하더라도 원하는 것을 await하는 로컬 콜백을 추가할 수 있습니다:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries()
    },
  }),
})
 
useMutation({
  mutationFn: updateLabel,
  onSuccess: () => {
    // Promise를 반환하여 await
    return queryClient.invalidateQueries(
      { queryKey: ['labels'] },
      { cancelRefetch: false }
    )
  },
})
 
javascript

여기서 일어나는 일은 다음과 같습니다:

  • 먼저 전역 콜백이 실행되어 모든 쿼리를 무효화하지만, await하거나 반환하지 않으므로 이는 "발사 후 잊기" 무효화입니다.
  • 그 직후 로컬 콜백이 실행되어 ['labels']만 무효화하는 Promise를 생성합니다. 이 Promise를 반환하므로 ['labels']가 다시 가져와질 때까지 뮤테이션이 대기 상태를 유지합니다.

cancelRefetch
주의: 수동 invalidateQueries 호출에 cancelRefetch: false를 전달하고 있습니다. 이 플래그는 기본적으로 true입니다. 보통은 명령형 refetch 호출이 우선권을 갖고 현재 실행 중인 것을 취소하여 이후에 최신 데이터를 보장하기를 원하기 때문입니다.
하지만 여기서는 반대를 원합니다: 전역 콜백이 이미 모든 것을 무효화했기 때문에, invalidateQueries를 사용하여 이미 진행 중인 Promise를 "가져와(await 하여)" 반환하려고 합니다.
이렇게 하지 않으면 ['labels'] 쿼리에 대한 또 다른 요청이 발생할 것입니다.


이를 통해 자동 무효화를 위해 편안하게 사용할 수 있는 추상화를 추가하는 데 많은 코드가 필요하지 않다는 것을 알 수 있습니다. 다만 모든 추상화에는 비용이 있다는 점을 명심하세요: 새로운 API를 배우고, 이해하고, 적절하게 적용해야 합니다.

이 모든 가능성을 보여줌으로써 React Query에 내장된 기능이 없는 이유가 조금 더 명확해졌기를 바랍니다. 모든 경우를 다루면서도 복잡하지 않은 API를 찾는 것은 쉽지 않습니다. 이를 위해 _여러분_에게 사용자 영역에서 이를 구축할 수 있는 도구를 제공하는 것을 선호합니다.