FrontEnd/React.js

useQuery 의 변경점 이해하기

SambaLim 2023. 9. 27. 16:57

Dominik의 트위터에 useQuery의 변경점에 대한 내용이 올라왔습니다.

이미 우리는 콜백(onSuccess, onError, onSettled)을 많이 사용하고 있습니다. 따라서 콜백을 제거하는 것에 대해 부정적으로 생각할 수 있지만, 기존의 콜백은 예상대로 동작하지 않을 수 있기에 변경이 필요했습니다.

API에서 중요한 요소중 하나는 일관성입니다. 사람들은 추상화되어있는 API에서 일관된 응답을 받기를 원합니다. 하지만 useQuery의 콜백들은 그렇지 않습니다.

Callback 들여다보기

useQuery에는 성공했을 때, 실패했을 때, 그리고 둘과 관계없이 실행되는 onSuccess, onError, onSettled 콜백이 있습니다.

onError를 사용하는 예시를 보면, onError 콜백을 사용하여 오류가났을 경우 에러 알림을 보여주는 부수효과(side effect)를 실행합니다.

export function useTodos() {
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onError: (error) => {
      toast.error(error.message)
    },
  })
}

사용자들은 이런 직관적인 API를 좋아합니다. 이러한 콜백을 사용하지 않는다면, 부수효과를 처리하기위해 무시무시한 useEffect 훅을 사용해야하는 것에 두려움을 느낍니다.

export function useTodos() {
  const query = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  React.useEffect(() => {
    if (query.error) {
      toast.error(query.error.message)
    }
  }, [query.error])

  return query
}

예시에 useTodos() 사용하는 함수 컴포넌트의 리렌더링으로 인해 hook을 두 번 호출한다면, 우리는 두 개의 에러 메시지(toast)를 보게됩니다. 사실 이는 onError 콜백을 사용했을 경우도 마찬가지입니다.

중복으로 호출했을 경우, 콜백은 각각의 컴포넌트에서의 값(클로져)을 가지고 실행됩니다.

이 문제를 해결하는 가장 좋은 방법은 QueryClient를 설정할 때, 전역 cache-level을 사용하는 것 입니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  }),
})

이렇게 설정한 콜백은 Query당 한 번만 호출합니다. 또한 useQuery를 호출한 컴포넌트 밖에서 호출되기 때문에 클로져문제가 발생하지 않습니다.

On-demand message 정의하기

Query마다 콜백의 error내부에 있는 값이 아닌 다른 메시지를 보여주고 싶을 경우, Query의 meta 필드를 사용할 수 있습니다.

meta 객체는 어떠한 정보든 넣을 수 있습니다.

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',
    },
  })
}

상태 동기화 (State syncing)

콜백 API를 삭제하는 또다른 이유는 많은 사람들이 콜백을 상태 동기화를 하는데 사용하기 때문입니다.

export function useTodos() {
  const [todoCount, setTodoCount] = React.useState(0)
  const { data: todos } = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    //😭 please don't
    onSuccess: (data) => {
      setTodoCount(data.length)
    },
  })

  return { todos, todoCount }
}

콜백 API가 사용자들에게 위의 예제와 같은 상태 동기화를 하도록 유도한다는 단점이 있습니다. 이러한 상태 동기화도 마찬가지로 앱이 예상하는대로 실행되지 않게합니다.

추가적인 렌더 사이클

setTodoCount는 또다른 렌더 사이클을 추가합니다. 이는 앱이 필요없는 렌더링을 하게할 뿐만 아니라(문제가 되거나 안될 수도 있음) 잘못된 값을 기자고 렌더링을 할 수 도 있게 합니다.

예를 들어 fetchTodos는 길이가 5인 목록을 반환한다고 가정해 보겠습니다. 위 코드에서 렌더링 사이클은 세 번입니다.

  1. todosundefined이고 길이는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.
  2. todos는 길이가 5인 배열이 되고 todoCount는 0이 됩니다. 이건 useQueryonSuccess는 이미 실행을 마쳤고 setTodoCount는 예약된 중간의 렌더링 사이클입니다. 값들이 동기화되지 않았기 때문에 잘못된 상태입니다.
  3. todos는 길이가 5인 배열이 되고 todoCount는 5가 됩니다. 이게 최종 상태이며 다시 올바른 상태가 되었습니다.

위의 예제는 크게 걱정될 정도는 아니지만, 2번에서 동기화되지 않은 상태로 렌더링 된다는 사실은 끔찍합니다. 이로인해 끔찍한 버그가 생성될 수 있습니다.

이러한 문제를 가장 쉽게 해결하는 방법은 상태를 파생시키는 것 입니다.

export function useTodos() {
  const { data: todos } = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  const todoCount = todos?.length ?? 0

  return { todos, todoCount }
}

이렇게 하면 동기화가 깨질 일이 없습니다.

실행되지 않을 수 있는 콜백

React Query를 점진적으로 적용해갈때 Redux와 같은 상태관리 라이브러리와의 동기화가 필요합니다. 아직 React Query를 모두 사용할 수 없는 부분에 대해 아래의 예시와 같이 사용하곤 합니다.

export function useTodos() {
  const { dispatch } = useDispatch()

  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onSuccess: (data) => {
      dispatch(setTodos(data))
    },
  })
}

React Query를 비동기 상태 관리자로 사용하는 경우, onSuccess 콜백에서 staleTime을 정의한 캐싱된 데이터를 읽어오는 것은 심각한 문제를 일으킬 수 있습니다.

filtersstaleTime을 추가한 예시를 봐보겠습니다.

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))
    },
  })
}
  1. todos를 done: true로 필터링하고, React Query가 해당 데이터를 캐시에 저장하고, onSuccess는 redux에 넣습니다.
  2. todos를 done: false로 필터링하고, 동일한 과정이 진행됩니다.
  3. todos를 다시 done: true로 필터링하면 앱이 고장납니다.

이와같은 과정을 통해 onSuccess가 다시 호출되지 않아 dispatch 가 실행되지 않습니다. 따라서 useTodos의 데이터는 잘 필터된 데이터이지만, Redux에서 불러오는 값은 필터링되지 않았을 것입니다.

staleTime 에 정의된 시간동안 최신의 데이터를 가져오기 위해 queryFn 을 호출하지 않습니다. 캐시된 데이터를 사용하는 것은 re-fetch를 피할 수 있다는 장점이 있지만, onSuccess가 호출되지 않아 싱크가 맞지않을 수 있습니다.

onDataChanged

data가 변경될때마다 호출되는 onDataChanged 를 구현하고자 해도 useEffect 없이 만들 수 없습니다. 하지만 이를 구현하기 위해 또 useEffect를 사용할 수 밖에 없습니다.

export function useTodos(filters) {
  const { dispatch } = useDispatch()

  const query = useQuery({
    queryKey: ['todos', 'list', { filters }],
    queryFn: () => fetchTodos(filters),
    staleTime: 2 * 60 * 1000,
  })

  React.useEffect(() => {
    if (query.data) {
      dispatch(setTodos(query.data))
    }
  }, [query.data])

  return query
}

이를 통해 구현은 가능하지만, 권장할만한 코드는 아닙니다.

마무리

기존 useQuery의 콜백(onSuccess, onError, onSettled)은 데이터의 일관성을 보장할 수 없는 문제가 있습니다. 이는 API가 일관성을 가져야한다는 것에 부합하지 않습니다.

또한 이를 useState를 사용하는 상태와 동기화하여 사용하는 경우가 있는데 절대 그렇게 사용해서는 안됩니다. 이는 개발자가 의도하지 않은 렌더링을 추가하여 잘못된 동작을 초래할 수 있습니다.

이러한 이유로 useQuery의 API가 변경될 예정입니다.

참고