invalidateQueries에 await을 쓰지 마세요
TL;DR
invalidateQueries는 await 없이 호출해도 캐시를 즉시 stale로 표시합니다await은 캐시 무효화가 아닌 refetch 완료를 기다리는 것입니다- 구독자가 없으면 refetch 자체가 일어나지 않아
await이 무의미합니다 - 대부분의 경우 await은 불필요합니다
들어가며
TanStack Query를 사용하다 보면 이런 코드를 자주 보게 됩니다:
// 어떤 개발자의 코드
await queryClient.invalidateQueries(queryOptions.getTodos)
// 다른 개발자의 코드
queryClient.invalidateQueries(queryOptions.getTodos)
같은 invalidateQueries인데 왜 어떤 사람은 await을 붙이고, 어떤 사람은 붙이지 않을까요?
더 혼란스러운 건, 둘 다 제대로 동작한다는 점입니다.
이 글에서는 많은 개발자들이 모르고 있는 invalidateQueries의 동작 방식을 알아보겠습니다.
invalidateQueries가 하는 두 가지 일
invalidateQueries는 비동기 함수지만, 사실 두 가지 작업을 수행합니다:
- 즉시 실행: 캐시를 stale(오래된 상태)로 표시
- 비동기 실행: 캐시를 구독하고 있는 곳이 있으면 refetch
이를 간단히 표현하면:
// invalidateQueries의 동작 방식 (개념적 설명)
async function invalidateQueries(queryKey) {
// 1단계: 즉시 실행 - 캐시를 "오래됨"으로 표시
markAsStale(queryKey) // 이건 동기적으로 바로 실행됨!
// 2단계: 비동기 실행 - 필요한 경우에만 새로 fetch
if (hasActiveObserver()) {
await refetch(queryKey) // 이것만 비동기
}
}
이 동작 방식을 통해, 다음에 서술할 흔한 오해들을 해소할 수 있습니다.
흔한 오해들
오해 1: "await을 안 붙이면 무효화가 안 된다"
아닙니다! await 여부와 관계없이 캐시는 즉시 stale로 표시됩니다.
// await 없는 경우
queryClient.invalidateQueries(queryOptions.getTodos)
// 👆 이 줄이 실행되는 순간 캐시는 이미 stale!
// await 있는 경우
await queryClient.invalidateQueries(queryOptions.getTodos)
// 👆 캐시가 stale로 표시되는 타이밍은 위와 동일!
// await은 단지 refetch가 완료될 때까지 기다릴 뿐
오해 2: "await을 붙이면 항상 refetch 요청을 기다린다"
아닙니다! await 여부와 관계없이, refetch는 일어날 수도 일어나지 않을 수도 있습니다. refetch가 일어나는 경우에만 await이 refetch 요청을 기다립니다.
// 아무도 이 데이터를 구독하고 있지 않다면?
await queryClient.invalidateQueries({ queryKey: ['unused-query'] })
// 👆 refetch가 일어나지 않아서 즉시 완료됨
위에서 설명한 invalidateQueries가 하는 두 가지 일에 있는 코드를 보면, hasActiveObserver() 조건문을 통해 선별적으로 refetch를 진행함을 알 수 있습니다.
즉, useQuery 등으로 해당 데이터를 구독하는 컴포넌트가 없다면, refetch 자체가 일어나지 않습니다. await을 붙여도 기다릴 게 없어서 바로 다음 줄의 코드가 실행됩니다.
오해 3: "mutation 후의 invalidation에는 항상 await을 붙여야 한다"
아래처럼, mutation 후에 연관된 쿼리를 onSuccess 콜백을 통해 무효화하는 패턴은 흔히 사용됩니다.
이때의 invalidateQueries에는 await을 꼭 붙여야 할까요?
const { mutate } = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries(queryOptions.getTodos)
}
})
정반대입니다. 대부분의 경우 await이 필요 없습니다. 위 예시에서, useQuery를 사용하여 getTodo 쿼리를 구독하는 컴포넌트가 있다면, 그것들이 알아서 새 데이터를 가져와서 화면을 업데이트하기 때문입니다.
하지만 refetch 완료를 기다려야 할 때가 있습니다. (이런 use case가 await을 꼭 붙여야 한다는 오해에 일조한 것 같습니다.)
예를 들어 프로필 업데이트 후 대시보드로 이동하는 상황을 보겠습니다: (await을 안 붙인 경우)
// 커스텀 훅 - 프로필 업데이트
function useUpdateProfile({ onSuccess }) {
const navigate = useNavigate()
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProfile,
onSuccess: () => {
// await 없이 호출, 콜백이(여기서는 navigate) 바로 실행됨
queryClient.invalidateQueries(queryOptions.getProfile)
onSuccess()
}
})
}
// 커스텀 훅 사용
const { mutate } = useUpdateProfile({
onSuccess: () => { navigate('/dashboard') }
})
// 대시보드 컴포넌트
function Dashboard() {
// 대시보드에서 user 데이터를 사용
const { data: user } = useQuery(queryOptions.getProfile)
// 문제: 아직 refetch가 진행 중이라 이전 데이터가 보일 수 있음
return <div>Welcome, {user.name}!</div>
}
이런 문제들이 발생합니다:
- 대시보드로 이동했는데 아직 이전 프로필 정보가 표시됨
- 잠시 후 refetch가 완료되면 갑자기 새 프로필로 변경됨
이러한 문제를 해결하기 위해, 다음 페이지로 넘어가기 전에 새로운 데이터를 받아와야 할 것입니다. 이를 위해 invalidateQueries 앞에 await을 붙이곤 합니다.
이는 여러 문제가 있습니다.
첫 번째 문제
'오해 2' 에서 언급했듯, refetch 자체가 일어나지 않을 가능성이 있기 때문에 await을 붙여도 아무 효과가 없을 가능성이 있습니다.
두 번째 문제 (커스텀 훅 사용할 때 한정)
refetch가 일어나더라도 문제입니다.
mutation을 호출한 컴포넌트에서 어떤 식으로든 getProfile 쿼리를 구독하고 있었던 경우, refetch가 일어날 것이므로 원하는 대로 동작하긴 할 것입니다.
function useUpdateProfile() {
const navigate = useNavigate()
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProfile,
onSuccess: async () => {
await queryClient.invalidateQueries(queryOptions.getProfile)
onSuccess() // refetch 완료까지 대기한 후 실행됩니다
}
})
}
// 커스텀 훅 사용
const { mutate } = useUpdateProfile({
onSuccess: () => {
// refetch를 기다리지 않는 선택지가 없음
navigate('/items')
}
})
하지만 useUpdateProfile 커스텀 훅은, 기다리고 싶지 않을 때도 무조건 기다려야 하는 유연하지 못한 구조가 됩니다.
만약 onSuccess 콜백에 '/dashboard'(내 정보 대시보드)로 이동하는 대신 '/items'(상품 페이지)와 같은, getProfile 쿼리를 호출하지 않는 페이지로 이동하는 경우에는 쓰이지도 않을 데이터를 가져오는 데 시간을 허비함으로써, 다음 페이지가 유저에게 더 늦게 보이게 될 것입니다.
이러한 상황에서는 커스텀 훅 내부의 invalidateQueries에 await을 붙이는 대신, 커스텀 훅을 사용하는 곳에서 prefetchQuery를 사용하는 것이 더 명확한 대안입니다.
왜 prefetchQuery가 더 나은가?
-
invalidateQueries+await의 문제점:- 커스텀 훅을 사용하는 곳의 구독자 유무에 의존적입니다 (구독자가 없으면 await이 무의미)
- 의도가 불명확합니다("캐시를 무효화하고 싶은 건지, 새 데이터를 기다리고 싶은 건지?". 개인적으로, 때때로 개발자를 헷갈리게 하는 API라고 생각합니다).
- 코드를 읽는 사람이 "왜 await을 붙였지?"라고 고민하게 만듭니다
-
prefetchQuery의 장점:- "데이터를 미리 가져온다"는 의도가 명확합니다
- 구독자 유무와 관계없이, stale하다면 항상 데이터를 fetch합니다
- 코드의 의도를 정확히 전달합니다
function useUpdateProfile() {
const navigate = useNavigate()
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateProfile,
onSuccess: async () => {
// 1. 캐시만 무효화 (await 없음) - "기존 데이터는 오래됐어"
queryClient.invalidateQueries(queryOptions.getProfile)
onSuccess()
}
})
}
// 커스텀 훅 사용
const { mutate } = useUpdateProfile({
onSuccess: () => {
// 2. 최신 데이터를 명시적으로 가져오기 - "새 데이터가 필요해"
await queryClient.prefetchQuery(queryOptions.getProfile)
// 3. 캐시가 최신 데이터로 채워져 있으므로 안전하게 페이지 이동
// Suspense fallback이나 화면 깜빡임 없음
navigate('/dashboard')
}
})
정리
invalidateQueries의 핵심 동작 원리:
- 캐시 무효화는 항상 즉시 일어난다 - await 붙이든 안 붙이든 상관없음
- await은 refetch를 기다리는 것 - 캐시 무효화를 기다리는 게 아님
- 구독자가 없으면 refetch 자체가 발생하지 않음 - await이 무의미해짐
최종 권장사항:
- await 없이
invalidateQueries사용을 추천- await을 붙일 경우 불필요하게 refetch를 기다리게 되거나, refetch가 일어나지 않는데 일어난다는 오해를 불러일으킬 수 있음
- mutation 커스텀 훅 내부인 경우 await을 붙이면 refetch를 기다릴 것인지 여부를 커스텀 훅 사용하는 쪽에서 유연하게 선택할 수 없음
- 페이지 이동 시, 다음 페이지에서 최신 데이터가 필요하다면 이동 전에
prefetchQuery로 명시적으로 가져오기 - 낙관적 업데이트(Optimistic update)를 활용하면 await 필요성을 더 줄일 수 있음