[아티클] React Query V5, 중단된 API에 대하여

5/9/2024
 
React Query 라이브러리는 V5에서 Breaking Changes를 감행했습니다. useQuery에서 onSuccess, onError, onSettled와 같은 콜백들이 실패에 가까운 API라 판단하였기 때문입니다.
 

왜 나쁜가요?

: 예상대로 동작하지 않을 가능성이 높습니다. 즉 나쁜 API인 것이죠

1) 잠재적으로 여러번 호출될 수 있습니다.

export function useTodos() { return useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, onError: (error) => { toast.error(error.message) }, }) }
: useQuery를 사용하여, fetch를 진행하고 성공/에러에 따라 사이드 이펙트가 발생하는 코드를 toast.error(error.message) 와 같은 코드를 실행시킬 수 있습니다.
사람들은 useEffect를 사용하지 않아도 되는, 이 직관적인 콜백을 좋아합니다. 하지만 위 예제처럼 사용하면, useTodos를 두번 호출하면 ,두 개의 에러 알림을 받아야 합니다
두 호출의 중복이 제거되지 않고, 콜백이 지역상태 값을 가둬두므로(클로저) 각각의 컴포넌트 안에서 실행됩니다.
그래서 제거되는 이 API를 피해서 useEffect를 사용할 거라고요? 그것도 추천하지 않습니다. Query당 한 번만 호출되는 콜백을 사용해야 하니까요.
아래처럼 QueryClient를 세팅할 때 전역 캐시 단계에서 콜백을 사용하세요!
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => toast.error(`Something went wrong: ${error.message}`), }), })
 
만약 Query마다 다른 메시지를 보고주고 싶다면요? 가장 간단하게는 Query의 meta 필드를 사용하세요
💡
meta - 원하는 정보로 채울 수 있는 임의의 객체 - 전역 콜백 등 Query에 접근할 수 있는 모든 곳에서 사용 가능
 
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { if (query.meta.errorMessage) { toast.error(query.meta.errorMessage) } }, }), }) export function useTodos() { return useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, meta: { errorMessage: 'Failed to fetch todos', }, }) }
 

2) 잘못된 상태 동기화를 유도

여러분 콜백을 사용해서 상태를 동기화하지 마세요. API가 이렇게 유도한다는 걸 알고, 이게 삭제하는 이유이기도 합니다.
export function useTodos() { const [todoCount, setTodoCount] = React.useState(0) const { data: todos } = useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, //😭 제발 이러지 마세요 onSuccess: (data) => { setTodoCount(data.length) }, }) return { todos, todoCount } }
 
setTodoCount는 또 다른 렌더링 사이클을 끼워넣거든요! 필요 이상으로 자주 렌더링하게 하고, 중간에 낀 렌더링 사이클에 잘못된 값이 포함될 수도 있다는 뜻입니다.
 fetchTodos는 length가 5인 목록을 반환한다고 가정해 보겠습니다. 위 코드에서 렌더링 사이클은 세 번입니다.
💡
렌더링 사이클
  1. todos는 undefined이고 length는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.
  1. todos는 length가 5인 배열이 되고 todoCount는 0이 됩니다. useQuery와 onSuccess는 이미 실행을 마쳤고 setTodoCount는 예약된, 중간에 낀 렌더링 사이클입니다. 값들이 동기화되지 않았기 때문에 잘못된 상태입니다.
  1. todos는 길이가 5인 배열이 되고 todoCount는 5가 됩니다. 이게 최종 상태이며 다시 올바른 상태가 되었습니다.
간단한 해결책은, 상태를 파생시키는 거죠!
export function useTodos() { const { data: todos } = useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos, }) const todoCount = todos?.length ?? 0 return { todos, todoCount } }
 

3) 콜백이 실행되지 않을 가능성

점진적으로 React Query를 사용하고 있어, 리덕스 등에 상태를 동기화해야 하는 상황을 가정해봅시다.
export function useTodos(filters) { const { dispatch } = useDispatch() return useQuery({ queryKey: ['todos', 'list', { filters }], queryFn: () => fetchTodos(filters), staleTime: 2 * 60 * 1000, onSuccess: (data) => { dispatch(setTodos(data)) }, }) }
그러나 이렇게 하면 앱이 곧 예상대로 흘러가지 않을 것입니다.
💡
실행되지 않는 onSuccess
  1. todos를 done: true로 필터링하고, React Query가 해당 데이터를 캐시에 저장하고, onSuccess는 redux에 넣습니다.
  1. todos를 done: false로 필터링하고, 동일한 과정이 진행됩니다.
  1. todos를 다시 done: true로 필터링하면 앱이 고장납니다.
왜냐고요? onSuccess가 다시 호출되지 않기 때문입니다.useTodos의 데이터를 바로 사용하는 곳은 올바르게 필터링된 값을 볼 수 있지만, redux의 데이터를 사용하는 곳은 그렇지 않습니다.
staleTime을 정의하면 React-Query는 최신 데이터를 가져오려 queryFn을 항상 호출하지 않습니다. staleTime: 2 * 60 * 1000 이 시간동안 신선하므로, 2분 동안 데이터는 캐시에서만 읽어옵니다. (이는 과도한 re-fatch를 피할 수 있죠)
하지만 onSuccess는 fetch가 발생해야만 실행됩니다.
동기화가 어긋나는 순간은 곧 발생하고 말것입니다.
 

요약

API는 간단하고 직관적이며 일관되어야 합니다. useQuery의 콜백은 이 기준에 부합하는 것처럼 보이도록 위장한 버그 생산기입니다. 
©JIYOUNG CHOI, All rights reserved