들어가며

Tanstack Query의 `useQuery`는 HTTP GET 요청을 보낼 때 사용하는 함수입니다.

이 함수에는 GET 요청에 사용될 비동기 함수를 지정하는 queryFn과, 받아온 데이터를 캐싱할 queryKey를 지정할 수 있습니다.

 

공식문서 보기

 

이때, queryFn의 인자로 아래 코드와 같이 `QueryFunctionContext`가 제공되는데, 이를 활용하는 방법에 대해 알아보겠습니다.

 

// useQuery에서 제공되는 QueryFunctionContext
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: (QueryFunctionContext) => user_async_function() // QueryFunctionContext의 위치
    enabled: postId !== undefined
  })

QueryFunctionContext의 구성요소

`queryFn`은 `QueryFunctionContext`를 객체 형식의 인자로 제공합니다.

 

공식문서에서는 다음과 같이 설명해줍니다.

[공식문서]
The QueryFunctionContext is the object passed to each query function. It consists of:
1. queryKey: QueryKey - Query Keys  
2. signal?: AbortSignal
- AbortSignal instance provided by TanStack Query
- Can be used for Query Cancellation
3. meta: Record<string, unknown> | undefined
- an optional field you can fill with additional information about your query

 


queryKey

`queryKey`는 해당 `useQuery`에서 사용된 `queryKey`를 반환합니다.

 

이는 Query Key에 변수 값을 사용할 경우, 해당 값을 `queryFn`의 인자로 적용해야 할 때 유용합니다.

 

예를 들어, 글의 ID값을 기반으로 API를 호출한다고 가정해보겠습니다.

 

[대략적인 프로세스]

  1. 글의 id 값을 async fetch. (다른 API를 통해)
  2. 해당 id를 가진 글을 사용자가 클릭하면, 글의 정보를 async fetch.
  3. 이때, queryKey를 ['post', postId] 형태로 관리.

해당 로직은 기본적으로 다음과 같이 useQuery를 활용하여 구현할 수 있습니다.

// #0. Fetch Info using planId (useQuery)
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => getPlan({postId: postId})
    enabled: postId !== undefined
  })

 

이때, 제공되는 `queryKey` 값을 사용하면 컴포넌트에서 관리하는 state대신 useQuery에서 사용되는 queryKey를 사용할 수 있습니다.

  • `queryKey`는 위에서 제공해준 값과 동일하게 주어진다. (예시의 경우 ['post', postId])
const { data, isPending, isError, error } = useQuery({
  queryKey: ['post', postId],
  queryFn: ({ queryKey }) => getPlan({ postId: queryKey[1] }),
  enabled: postId !== undefined,
});

 

이처럼 queryKey를 통해 postId 값을 쉽게 활용할 수 있습니다.

그래서 뭐..? 같은 값에 접근하여 사용하는거 아닌가..?🤔

네 맞습니다.

 

위 예시는 `postId`에 해당하는 하나의 변수만은 `queryKey`에 사용하지만, 복잡한 구조를 가진 코드에서 변수 값을 찾으러 다니기 보다, 가장 가까운 `queryKey`에서 찾는 것이 미래의 내가 코드를 다시 읽을때 편리하지 않을까요?

아래 그림의 2번 루트가 1번 루트보다 가깝다고 할 수 있으니, 디버깅 하기에 원활하지 않을까 생각합니다.

queryKey 부연설명


signal: AbortSignal

(AbortSignal은 Typescript에서 사용하는 type입니다. 기본적으로 제공되는 타입이므로 import하지 않아도 됩니다.)

 

다음은 `signal`입니다. 개인적으로 이해하는데 상당히 애먹었던 개념입니다.

먼저, MDN에서는 Signal에 대해 이렇게 설명합니다.

[MDN, AbortSignal]
AbortSignal 인터페이스는 DOM 요청(Fetch와 같은)과 통신하고 필요한 경우 AbortController 객체를 통해 취소할 수 있게 해주는 신호 객체를 나타냅니다.

 

직역하면, “통신을 취소”할 때 발생하는 “신호”로 이해할 수 있습니다.

 

 

이때, `queryFn`에서 제공하는 signal은 useQuery로 인해 발생하는 signal을 자동으로 관리하는 역할을 수행합니다.

즉, Tanstack Query의 라이프사이클(쿼리 재시작, 컴포넌트 언마운트 등)에 따라 자동으로 쿼리를 취소하는 역할을 합니다. 따라서, 개발자는 fetch의 옵션으로 signal을 제공해주기만 하면 됩니다.

 

구현 예제

async function fetch_example({ queryKey, signal }) {

	// 다음과 같이 signal을 옵션으로 넘겨주면 됩니다
	const response = await fetch(`/api/todos?status=${status}&page=${page}`, { signal });
	if (!response.ok) {
		throw new Error('Failed to fetch data');
	}
	return response.json();

	if (err.name === 'AbortError') {
		console.log('Request aborted');
		return;
	}
	throw err;
}

 

Signal에 대해 이해하셨다면, 이를 직접 생성하여 `fetch` 요청을 자유자재로 다룰 수 있습니다.

 

예를 들어

유저는 너무 길어지는 GET 요청에 화난 나머지, “취소” 버튼을 눌러 요청을 중단하고 싶다고 하면, 해당 버튼을 클릭했을때 요청이 중단되어야 합니다. 그렇지 않으면, 화난 유저는 사이트를 떠나 버리거나 쌍욕을 하며 회원탈퇴를 해버릴 수도 있습니다.

 

또는 개발자 측에서 길어지는 요청을 방지하기 위해, 의도적으로 Abort 시키는 방법도 있습니다.

이후, 유저에게 “네트워크 상황을 확인해주세요” 토스트를 띄워, 유저의 네트워크 상황에 의한 시간지연을 웹사이트 성능의 문제점으로 인식시키지 않도록 할 수 있습니다.

 

 

이처럼, AbortSignal은 요청을 취소하는 신호 객체로, 긴 요청을 중단하거나 사용자가 중단 버튼을 클릭했을 때 유용합니다.

 

다음은 일정시간이 지난 이후, 의도적으로 개발자가 요청을 중단시키는 코드입니다.

구현 예제

async function fetchWithTimeout({ queryKey, signal: querySignal }) {
  const [_key, { status, page }] = queryKey;
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10000); // 10초 후 요청 중단

  const signal = querySignal || controller.signal;

  try {
    const response = await fetch(`/api/todos?status=${status}&page=${page}`, { signal });
    clearTimeout(timeoutId);

    if (!response.ok) {
      throw new Error('Failed to fetch data');
    }
    return response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('Request aborted');
      return;
    }
    throw err;
  }
}

 

 

useQuery는 기존처럼 동일하게 사용하면 됩니다.

function Todos({ status, page }) {
  const { data, error, isLoading } = useQuery({
    queryKey: ['todos', { status, page }],
    queryFn: fetchWithTimeout,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

🤔근데, AbortController을 통해 객체를 따로 생성하는 이유는 무엇이에요?

말씀드렸다 시피, queryFn에서 제공하는 signal은 Tanstack Query의 라이프사이클(쿼리 재시작, 컴포넌트 언마운트 등)에 따라 자동으로 쿼리를 취소하는 역할을 하므로, 특정한 상황에 대응하기 위해서는 객체를 직접 생성해야 합니다.

 

이를 활용하여 다양한 `fetch`에 대한 커스텀 훅을 만들어서 npm package로 관리해보는 것도 좋은 방법일 것 같습니다.


meta

meta는 개발자가 원하는 값을 객체 형태로 추가할 수 있는 필드입니다.

 

예를 들어, fetch마다 다른 에러 메시지를 관리하고 싶을 때 활용할 수 있습니다.

 

이때, v5부터 `onError`, `onSuccess`, `onSettled` 과 같은 콜백 함수들을 더이상 useQuery에서 사용할 수 없으므로, QueryClient 객체에서 다음과 같이 설정해주어야 합니다.

 

[콜백함수가 사라진 이유]

 

구현 예제

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

 

 


마치며

QueryFunctionContext는 queryKey, signal, meta와 같은 다양한 기능을 제공합니다.

창의적이고 새로운 기술을 만드는 것도 좋지만, 사용하고 있는 기술에 대해 깊이있게 이해하는 것도 중요하다고 생각되는 공부였습니다.

 

참고 문헌

TODO LIST

  • 길어지는 요청에 대한 fetch 함수를 커스텀 함수로 만들어서 관리하기
  • queryFn의 abort에 대한 역할을 직접 network tab에서 관찰해보기

+ Recent posts