TanStack Query v5에서 콜백이 사라졌다: onSuccess, onError 대처 방법

최소희·2024년 11월 10일
1

프론트엔드 학습

목록 보기
20/23
post-thumbnail

2023년 10월, TanStack Query v5가 출시되면서 API의 내부 아키텍처가 크게 개선되었다.

그 중 주목할 만한 변화가 있었다.

useQuery에서 onSuccess, onError, onSettled와 같은 콜백 함수들이 제거되었다.

이 변화는 처음에는 많은 개발자에게 혼란을 줄 수 있지만,
TanStack Query 팀의 의도는 기존 콜백의 문제점 해결더 단순하고 일관된 API 설계를 추구하는 데 있다.

이번 글에서는 이러한 변화와 그에 따른 대처 방법에 대해 알아본다.


1. v4에서의 useQuery 특징과 onSuccessonError 역할

1.1 v4의 useQuery: 오버로딩을 통한 다양한 호출 방식

TanStack Query v4에서는 useQuery가 다양한 형태로 호출될 수 있었다.

react-query

One of the "cute and dynamic" constructs we had in React Query from when it started out (where it had no types), was was actually useQuery, because you could call it 3 different ways:

with different positional arguments. There's no good way to make this work in TypeScript except with overloads, which is what we did. Overloads are problematic because they are a lot of overhead and error messages aren't good.

“React Query 초기에는 타입이 없었던 시절부터 useQuery는 '귀엽고 동적인' 구조 중 하나였습니다. useQuery는 세 가지 다른 방식으로 호출할 수 있었습니다. 각기 다른 위치에 인자를 전달할 수 있었죠. TypeScript에서 이를 제대로 작동하게 만드는 방법은 오버로딩 외에는 없습니다. 그래서 우리는 오버로딩을 사용했습니다. 그러나 오버로딩은 많은 오버헤드를 유발하며 오류 메시지가 그리 좋지 않다는 문제가 있습니다.”

TkDodo, "React Query API Design Lessons Learned"

useQuery는 다음과 같은 다양한 형태의 호출을 지원했다:

  1. 옵션만 전달할 때
  2. queryKey와 옵션을 함께 전달할 때
  3. queryKey, queryFn, 옵션을 모두 전달할 때

이러한 다양한 호출 방식을 처리하기 위해 TanStack Query v4useQuery는 여러 오버로딩 시그니처를 정의했다.

  • TanStack Query v4 useQuery 내부
    // query/packages/react-query/src/useQuery.ts
    
    'use client'
    import { QueryObserver, parseQueryArgs } from '@tanstack/query-core'
    import { useBaseQuery } from './useBaseQuery'
    import type { QueryFunction, QueryKey } from '@tanstack/query-core'
    import type {
      DefinedUseQueryResult,
      UseQueryOptions,
      UseQueryResult,
    } from './types'
    
    // HOOK
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      options: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'initialData'
      > & { initialData?: () => undefined },
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      options: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'initialData'
      > & { initialData: TQueryFnData | (() => TQueryFnData) },
    ): DefinedUseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey' | 'initialData'
      > & { initialData?: () => undefined },
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey' | 'initialData'
      > & { initialData: TQueryFnData | (() => TQueryFnData) },
    ): DefinedUseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey'
      >,
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      queryFn: QueryFunction<TQueryFnData, TQueryKey>,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey' | 'queryFn' | 'initialData'
      > & { initialData?: () => undefined },
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      queryFn: QueryFunction<TQueryFnData, TQueryKey>,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey' | 'queryFn' | 'initialData'
      > & { initialData: TQueryFnData | (() => TQueryFnData) },
    ): DefinedUseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData = unknown,
      TError = unknown,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      queryKey: TQueryKey,
      queryFn: QueryFunction<TQueryFnData, TQueryKey>,
      options?: Omit<
        UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
        'queryKey' | 'queryFn'
      >,
    ): UseQueryResult<TData, TError>
    
    export function useQuery<
      TQueryFnData,
      TError,
      TData = TQueryFnData,
      TQueryKey extends QueryKey = QueryKey,
    >(
      arg1: TQueryKey | UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
      arg2?:
        | QueryFunction<TQueryFnData, TQueryKey>
        | UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
      arg3?: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    ): UseQueryResult<TData, TError> {
      const parsedOptions = parseQueryArgs(arg1, arg2, arg3)
      return useBaseQuery(parsedOptions, QueryObserver)
    }
    

그리고 useQuery 훅의 옵션 중 onSuccessonError 콜백을 사용하여,
쿼리가 성공하거나 실패한 후에 부수 효과를 처리할 수 있었다.

import { useQuery } from '@tanstack/react-query';

const TodoList = () => {
  const { data, error } = useQuery(['todos'], fetchTodos, {
    onSuccess: (data) => {
      console.log('할 일 목록을 가져왔습니다:', data);
    },
    onError: (error) => {
      console.error('할 일 목록을 가져오는 중 오류 발생:', error);
    },
  });

  // 렌더링 로직...
}

2. v5에서 콜백 함수가 제거된 이유

TanStack Query v5에서는 useQuery의 오버로드가 단일 객체 시그니처로 변경되고, onSuccess, onError, onSettled와 같은 콜백 함수들이 제거됐다. 이유는 다음과 같다.

  1. 동일한 queryKey를 사용하는 여러 컴포넌트에서의 중복 호출 문제:
    TanStack Query v4에서 동일한 queryKey를 사용하는 여러 컴포넌트가 존재할 때, onSuccess 콜백이 각 컴포넌트마다 호출되는 문제가 발생할 수 있었다.
    이러한 중복 호출은 의도치 않게 UI에 중복된 토스트 메시지나 로그를 표시하는 등의 문제를 일으킬 수 있다.
// useTodos.ts

export const useTodos = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  onSuccess: () => {
    console.log('onSuccess from useTodos');
  }
});

// ComponentOne.tsx

const ComponentOne = () => {
  const { data } = useTodos();
}

// ComponentTwo.tsx

const ComponentTwo = () => {
  const { data } = useTodos();
}

TkDodo가 든 위 예시에서는
useTodos 훅의 queryKey가 동일하기 때문에, onSuccess 콜백이 ComponentOneComponentTwo에서 각각 호출된다.
따라서 console.log 문이 두 번 실행되며, 만약 토스트 알림 코드가 있다면 두 번 표시될 것이다.

  1. API의 단순성과 일관성
    API는 단순하고 직관적이어야 하며 일관성을 가져야 한다.
    useQuery의 콜백은 처음에는 이러한 기준을 충족하는 것처럼 보였지만, 실제로는 복잡성과 버그를 초래할 수 있다.

APIs need to be simple, intuitive and consistent. The callbacks on useQuery

look like they fit these criteria, but they are bug-producers in disguise. It's pretty bad because they will likely do what you want when you first implement them, but they have a toll when you refactor or extend your App as it grows. They also invite antipatterns because you can introduce error-prone state-syncing without feeling bad while doing so.

"API는 단순하고 직관적이며 일관성 있어야 합니다. useQuery의 콜백은 이러한 기준을 충족하는 것처럼 보이지만, 사실 버그를 유발하는 요인입니다. 처음 구현할 때는 원하는 대로 동작할 가능성이 높지만, 앱이 커지고 리팩터링하거나 확장할 때 그 대가를 치르게 됩니다. 또한 오류가 발생하기 쉬운 상태 동기화 패턴을 무의식적으로 도입할 수 있어, 문제를 유발할 수 있습니다."

TkDodo, "Breaking React Query's API on purpose"

이러한 이유로 TanStack Query v5에서는 useQuery 콜백들이 제거되며, 대신 React의 권장 패턴인 useEffect 훅을 사용하여 부수 효과를 처리하도록 유도하고 있다.

3. v5에서 권장하는 방법: useEffectqueryClient 활용

TkDodo는 부수 효과를 처리하기 위해 useEffect와 전역의 queryClient를 활용할 것을 권장한다.

3.1 useEffect를 사용한 부수 효과 처리

첫 번째 방법은 useEffect 를 사용하여 부수 효과를 처리하는 것이다.

import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';

function TodoList() {
  const { data, error, isSuccess, isError } = useQuery(['todos'], fetchTodos);

  useEffect(() => {
    if (isSuccess) {
      console.log('할 일 목록을 가져왔습니다:', data);
    }
  }, [isSuccess, data]);

  useEffect(() => {
    if (isError) {
      console.error('할 일 목록을 가져오는 중 오류 발생:', error);
    }
  }, [isError, error]);

  // 렌더링 로직...
}

..물론,

TanStack Query v4의 주요 장점 중 하나인 useEffect 없이 API 호출 및 상태 관리를 할 수 있다는 점을 좋아했던 개발자분들로부터 미움을 받는 거 같다. 😂

discussion
(TkDodo의 useEffect 방식 제안에 대해 굿바이 인사를 하는 개발자.. 🥲)

3.2 queryClient의 meta 정보를 활용한 전역 처리

두 번째 방법은 Querymeta 필드를 사용하는 것이다.

meta는 원하는 정보를 자유롭게 담을 수 있는 객체로, 글로벌 콜백 등 쿼리에 접근할 수 있는 모든 곳에서 활용할 수 있다.

기존에는 동일한 queryKey를 사용하는 여러 컴포넌트에서 개별 useQuery 콜백이 중복 호출되는 문제가 있었다.

그러나 meta 기반의 글로벌 콜백은 이러한 중복 호출을 방지하고, 필요한 경우 한 번만 실행되도록 하여 일관성을 유지할 수 있다.

또한, 개별 쿼리마다 onSuccessonError 콜백을 반복 작성할 필요가 없다.

meta 필드에 필요한 정보를 추가함으로써 동일한 로직을 한 곳에서 중앙 집중적으로 관리할 수 있다.
이는 코드의 중복을 줄이고 유지 보수성을 높이는 데 기여한다.

// queryClient.ts

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.meta.errorMessage) {
        toast.error(query.meta.errorMessage)
      }
    },
  }),
})

// useTodos.ts

export function useTodos() {
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    meta: {
      errorMessage: 'Failed to fetch todos',
    },
  })
}

4. meta 체계적으로 관리하기

앞서 설명한 것처럼, meta 필드를 사용하여 쿼리별로 추가 정보를 저장하고 이를 글로벌 콜백에서 활용할 수 있다.

meta 필드를 효과적으로 관리하는 것은 코드의 가독성과 확장성을 높이는 데 중요하다.

중앙에서 각 쿼리의 meta 변수를 활용하여 중복 관리 최소화

각 쿼리는 저마다 다른 meta 정보를 가질 수 있다.

이를 중앙의 QueryClient 설정에서 일관되게 관리하면 중복을 줄이고 코드의 일관성을 유지할 수 있다.

4.1 각 쿼리에서 meta 정보 정의

쿼리별로 meta 옵션을 사용해 필요한 부수 효과 함수나 데이터를 설정한다.

// metaHandlers.js

export const todosMeta = {
  onSuccess: (data) => {
    console.log('할 일 목록을 성공적으로 가져왔습니다:', data);
    // 추가적인 부수 효과 로직
  },
  onError: (error) => {
    console.error('할 일 목록을 가져오는 중 오류 발생:', error);
    // 추가적인 오류 처리 로직
  },
};

export const usersMeta = {
  onSuccess: (data) => {
    console.log('사용자 목록을 성공적으로 가져왔습니다:', data);
    // 사용자 쿼리의 부수 효과 로직
  },
  // onError를 정의하지 않으면 기본 처리 사용
};

이제 useQuery에서 meta 정보를 설정해 사용한다.

import { useQuery } from '@tanstack/react-query';
import { todosMeta, usersMeta } from './metaHandlers';

function TodoList() {
  const { data } = useQuery(['todos'], fetchTodos, {
    meta: todosMeta,
  });

  // 렌더링 로직...
}

function UserList() {
  const { data } = useQuery(['users'], fetchUsers, {
    meta: usersMeta,
  });

  // 렌더링 로직...
}

4.2 중앙의 QueryClient에서 meta 변수 활용하기

중앙의 QueryClient 설정에서 각 쿼리의 meta 정보를 활용하여 부수 효과를 처리한다.

이를 통해 코드 중복을 줄이고 모든 쿼리를 체계적으로 관리할 수 있다.

// queryClient.js
import { QueryClient, QueryCache } from '@tanstack/react-query';
import { notifySuccess, notifyError } from './notificationService';

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onSuccess: (data, query) => {
      const { meta } = query.options;
      if (meta?.onSuccess) {
        meta.onSuccess(data);
      } else {
        // 기본 성공 처리 로직
        notifySuccess('데이터를 성공적으로 가져왔습니다.');
      }
    },
    onError: (error, query) => {
      const { meta } = query.options;
      if (meta?.onError) {
        meta.onError(error);
      } else {
        // 기본 오류 처리 로직
        notifyError('데이터를 가져오는 중 오류 발생.');
      }
    },
  }),
});

export default queryClient;

마무리

TanStack Query v5에서 onSuccessonError 콜백이 제거된 것은 더 단순하고 일관된 API 설계를 추구하는 데 있다.

처음에는 익숙했던 콜백 방식이 사라진 점이 불편하게 느껴질 수도 있다. (discussion의 개발자들처럼..)

하지만 useEffect, queryClientmeta 필드를 통해 부수 효과를 관리하는 새로운 패턴은 코드의 유지 보수성과 확장성을 높이는 데 도움을 준다.

새로운 패턴으로 인해 코드베이스가 커지고 복잡해지더라도 중앙 집중적으로 부수 효과를 관리할 수 있어, 앱의 안정성과 예측 가능성을 높일 수 있다.

이렇게 변화를 적극적으로 수용하고 새로운 방법을 익히면, 더 견고하고 유지보수하기 쉬운 애플리케이션을 만들 수 있을 것이라고 생각한다.


참고 자료

profile
프론트엔드 개발자 👩🏻‍💻

2개의 댓글

comment-user-thumbnail
2024년 12월 13일

각 쿼리에서 meta 정보 정의해서 관리하고 사용하는 방법도 있었네요 👍🏻

1개의 답글