FrontEnd/React

[React] Tanstack Query(react-query) 훑어보기

Satisfaction 2024. 2. 12. 23:59

웹 어플리케이션에서 데이터 패칭, 캐싱, 서버 상태와 동기화 및 업데이트(fetching, caching, synchronizing and updating server state)를 쉽게 만들어주는 library

 

이 포스트에서는 react-query의 기초적인 개념 몇 가지에 대해 다룬다.

Installation

yarn add @tanstack/react-query 

 

eslint와 함께 사용하는 것을 추천, 세팅은 아래 링크 참조

https://tanstack.com/query/latest/docs/eslint/eslint-plugin-query

 

ESLint Plugin Query | TanStack Query Docs

 

tanstack.com

 

Quick Start: queryClient

https://tanstack.com/query/latest/docs/framework/react/quick-start

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

// Create a client
const queryClient = new QueryClient()

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
	  <div/>
    </QueryClientProvider>
  )
}

queryClient를 선언하고, QueryClientProvider를 통해 queryClient를 하위 컴포넌트에서 사용할 수 있도록 세팅한다.

 

queryClient는 react-query의 대부분의 기능을 담고 있는 핵심 객체기 때문에, react-query를 사용하기 위해 최상위 컴포넌트에서 Provider로 하위 컴포넌트들에게 제공되어야만 한다.

 

이제부터 react-query의 다음 3가지 기본 개념을 소개할 것이다.

 

  • queries
  • mutations
  • query invalidation

Quick Start: Queries

https://tanstack.com/query/latest/docs/framework/react/guides/queries

쿼리는 고유 키에 연결된 비동기 데이터 소스에 대한 선언적 종속성입니다.
A query is a declarative dependency on an asynchronous source of data that is tied to a unique key.

 

어렵게 보이지만, 쿼리는 어떤 ‘키’에 대한 ‘값’을 가져오는 것을 말한다.

간단히 예를 들어, ‘지금 날짜’라는 키를 요청하여 ‘20240211’라는 값을 가져오는 것을 ‘쿼리’라고 할 수 있다. (지금 날짜를 쿼리하여 ‘20240211’ 데이터를 얻는다)

 

그런데 react-query에서 주로 다루고자 하는 것은 서버가 반환하는 데이터와 같이 외부로부터 받아온 데이터다.

예를 들어 ‘유저 A의 회원정보’를 알기 위해서는 서버로부터 데이터를 받아와야만(외부로부터 데이터 필요) 알 수 있다.

위처럼 외부로부터 받아오는 데이터를 다루기 위해서는 어떤 것들이 필요할지 생각해 보자

 

  1. 쿼리 키 (어떤 유저의 어떤 정보인지 구분하는 값; 데이터 id)
  2. 쿼리 방법 (api fetch 함수; 어떻게 데이터를 가져올 것인지)
  3. 쿼리 값 (api fetch 결과 또는 이전에 캐싱된 값; 쿼리 키와 쿼리 방법으로부터 얻어낸 결과물)
  4. ...더 많은 속성들이 있을 수 있지만 나중에 생각하자

react-query 에서는 위에서 설명한 쿼리 과정과 요소를 아래와 같이 표현할 수 있다.

const { data } = useQuery({
  queryKey: '/info/a',
  queryFn: ()=>{
    //api fetch and return response data...
    return api.user.info.load('a');
  },
});

queryKey에 대한 data가 존재하는지 확인하고, 없으면(정확히는 데이터 갱신이 필요하면) queryFn을 호출하여 그 결과값으로 data를 갱신한다.

 

쿼리에 대한 더 많은 정보도 얻을 수 있을까?

const { isPending, isError, data, error } = useQuery({
  queryKey: '/info/a',
  queryFn: ()=>{
    //api fetch and return response data...
    return api.user.info.load('a');
  },
});

쿼리를 갱신하는데 실패하거나(queryFn 실행 중 error가 발생한 경우 등), 갱신 진행 중이거나, 어떤 에러가 발생했는지와 같은 다양한 정보들도 알아낼 수 있다.

 

이 값에 따라 UI를 표시할 때 일반적인 패턴은

const { isPending, isError, data, error } = useQuery({
  queryKey: '/info/a',
  queryFn: ()=>{
    //api fetch and return response data...
    return api.user.info.load('a');
  },
});

if(isPending){
  return <div>로딩 중</div>;
}

if(isError){
  console.error(error);
  return <div>오류 발생</div>;
}

return <div>정보: {data}</div>
  1. 로딩 체크
  2. 에러 체크
  3. 데이터 표시

의 순서대로 체크하여 쿼리 결과에 따라 UI를 분기할 수 있다.

(물론 목적에 따라 순서가 바뀌거나 빠질 수 있다)

 

query key

https://tanstack.com/query/latest/docs/framework/react/guides/query-keys

쿼리 데이터는 쿼리 키(queryKey)로 분류되어 관리된다.

const { isPending, isError, data, error } = useQuery({
  queryKey: '/info/a',
  queryFn: ()=>{
    //api fetch and return response data...
    return api.user.info.load('a');
  },
});

예시에서는 query key를 단순 string으로 설정했지만, 실제로는 더 복잡한 형태의 쿼리 키를 설정할 수 있다.

useQuery({ queryKey: ['todos'], ... })

useQuery({ queryKey: ['something', 'special'], ... })

useQuery({ queryKey: ['todo', {done: true}], ... })

쿼리 키는 기본적으로 배열이어야 하고, 배열의 원소들은 직렬화(Serializable) 가능한 모든 데이터 타입을 사용할 수 있으며, 내부적으로 해쉬되어 사용된다.

같은 키는 같은 결과를 반환하고, 다른 키는 다른 결과를 반환한다.

 

주의할 점은 쿼리 키는 결정적(deterministic)으로 해쉬되어 사용된다는 것이다.

자바스크립트에서

const obj1= {a: 1};
const obj2= {a: 1};

obj1 === obj2 //false

는 자연스럽지만, react-query에서 query key에서는 다음과 같다.

const hash= (queryKey)=>{
  //react-query에서 queryKey를 해시하는 함수
  //...
}

const queryKey1= hash(['todos', {done: true}]);
const queryKey2= hash(['todos', {done: true}]);

queryKey1 === queryKey2 //true; 같은 키로 취급

const queryKey3= hash(['todos', {done: true, important: true}]);
const queryKey4= hash(['todos', {important: true, done: true}]);

queryKey3 === queryKey4 //true; 같은 키로 취급 (object가 담고 있는 내용은 동일하다)

const queryKey5= hash(['todos', '1', '2']);
const queryKey6= hash(['todos', '2', '1']);

queryKey5 === queryKey6 //false
//object에는 순서 개념이 없어 키 순서가 달라도 같은 데이터가 될 수 있지만, 
//array는 원소의 순서가 다르면 서로 다른 데이터이다

그런데 이전 예시들에선 배열이 아닌 string만을 사용했는데 어떻게 된 것일까?

//이것은
useQuery({queryKey: 'todos', ...})
//이것과 동일하다
useQuery({queryKey: ['todos'], ...})

useQuery가 자동으로 string 배열로 바꾸어주기 떄문에 키로 string을 사용해도 문제가 없었던 것이다.

 

Quick Start: mutation

https://tanstack.com/query/latest/docs/framework/react/guides/mutations

query와는 달리, mutation은 일반적으로 데이터의 생성/변경/삭제나 서버의 사이드이펙트(부수효과)를 일으키는데 사용됩니다.
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects.

 

mutation은 데이터의 생성/변경/삭제 (변형)을 일으키거나, 서버의 사이드이펙트를 일으키는 데 사용된다.

SWR을 학습한 경험이 있다면 혼동이 올 수 있다.

 

SWR에서는 client-side에서 데이터를 변경하는 것을 mutation라고 하지만, react-query에서는 서버의 상태를 변경하는 것 을 mutation이라고 한다는 것을 염두에 두자.

function App() {
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })

  return (
    <div>
      {mutation.isPending ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}

          {mutation.isSuccess ? <div>Todo added!</div> : null}

          <button
            onClick={() => {
              mutation.mutate({ id: new Date(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}

위의 예제 코드는 mutation의 기본 개념을 소개한다.

  • 버튼을 누르면 mutation.mutate 함수가 호출된다.
  • mutate가 처리 중(isPending)이면 ‘Adding todo…’를 표시한다.
  • mutate가 처리 실패(isError)상태라면 에러 메시지를 표시한다.
  • mutate가 처리 성공(isSuccess)되었다면 ‘Todo added!’ 를 표시한다.

mutation이라는 조금 거창한 이름을 가지고 있지만, request를 추상화한것에 불과하다.

mutation이라는 낯선 단어 대신 http request로 생각해 보자.

  • 버튼을 누르면 http request 함수가 호출된다.
  • http request 가 처리 중(isPending)이면 ‘Adding todo…’를 표시한다.
  • http request 가 실패(isError)상태라면 에러 메시지를 표시한다.
  • http request 가 성공(isSuccess)되었다면 ‘Todo added!’ 를 표시한다.

별로 특별할 것 없는 기능이 되었다.

 

우리가 request를 구현할 때, [로딩, 성공, 실패] 등의 케이스를 고려할 때 코드가 복잡해지는 경험을 한번쯤은 해본 적이 있을 것이다.

mutation은 그것을 하나의 규칙으로 묶어낸 것 뿐이다.

 

다시 mutation 얘기로 돌아와서, mutation의 라이프사이클은 다음 순서로 진행된다.

  1. idle (아직 실행되지 않음)
  2. pending (실행 중)
  3. success 또는 error (실행 결과에 따라 성공실패)

이는 코드 상에서도 확인할 수 있다.

const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
  })
const {isIdle, isPending, isError, isSuccess} = mutation;

3단계로 와서 실행이 끝나면, mutation은 그 상태로 유지된다.

 

물론 다시 mutate를 호출하면 실행은 가능하지만 idle 또는 pending 상태로 다시 돌아가는것이 아니라 다음 결과에 따라 success 또는 error상태로만 변경된다.

  • 첫 번째 mutate가 성공했다면 상태는 다음과 같이 변화한다.
    • idle→ pending→ success
  • 그런 다음 mutate가 실패한다면 상태는 다음과 같이 변화한다.
    • success → error

만약 다시 idle상태로 만들어서 다음 번 mutate가 idle부터 시작하도록 변경하려면, mutation.reset() 를 호출하여 mutation 상태를 초기화할 수 있다.

 

mutation callback

mutation의 각 페이즈마다 콜백을 지정하거나, 추가적으로 side effect를 발생시킬 수 있다.

useMutation({
  mutationFn: addTodo,
  onMutate: (variables) => {
		// mutation이 발생한 직후
		// 선택적으로 롤백이 발생할 때 사용할 데이터를 포함한 conext를 포함할 수 있다
    return { id: 1 }
  },
  onError: (error, variables, context) => {
    // 에러 발생
    console.log(`rolling back optimistic update with id ${context.id}`)
  },
  onSuccess: (data, variables, context) => {
    // 성공
  },
  onSettled: (data, error, variables, context) => {
    // 에러 발생 또는 성공 둘중 하나가 발생
  },
})

콜백 실행 순서는 onMutate → onErrror / onSuccess → onSettled 이다.

 

만약 콜백에서 promise를 반환한다면, 이전 라이프사이클 콜백이 처리되기 전까지 다음 라이프사이클 콜백은 시작되지 않는다

useMutation({
  mutationFn: addTodo,
  onSuccess: async () => {
		console.log('onSuccess');
		// sleep 5s
		await sleep(5000);
  },
  onSettled: async () => {
		console.log('onSettled');
  },
})

useMutation에서 지정한 콜백 외 추가적인 콜백이 필요한 경우에는 다음과 같이 mutation을 호출할 때 지정할 수있다.

useMutation({
  mutationFn: addTodo,
  onSuccess: (data, variables, context) => {
    console.log('1');
  },
  onError: (error, variables, context) => {
    console.log('1');
  },
  onSettled: (data, error, variables, context) => {
    console.log('3');
  },
})

mutate(todo, {
  onSuccess: (data, variables, context) => {
    console.log('2');
  },
  onError: (error, variables, context) => {
    console.log('2');
  },
  onSettled: (data, error, variables, context) => {
    console.log('3');
  },
})

호출 순서는 mutate에서 지정한 콜백보다 useMutation에서 지정한 콜백이 우선하며, 이 경우에도 콜백에서 promise를 반환하면 처리가 끝날떄까지 대기한다.

 

한 번의 mutate에 대해 출력 순서는 1→ 2→ 3으로 보장된다.

 

여러 번의 mutate가 발생한다면, useMutate에 지정한 콜백과 mutate에 지정한 콜백은 조금 다르게 동작한다.

useMutation({
  mutationFn: addTodo,
  onSuccess: (data, error, variables, context) => {
    // 3번 호출됨
  },
})

const todos = ['Todo 1', 'Todo 2', 'Todo 3']
todos.forEach((todo) => {
  mutate(todo, {
    onSuccess: (data, error, variables, context) => {
      // 어떤 것이 먼저 완료되든지, 마지막 mutate(Todo3)에 대해 한번 실행된다
    },
  })
})

mutation observer가 mutate가 실행될 때 마다 제거되었다가 다시 추가되기 때문에, mutate가 완료되지 않은 상태에서 다시 mutate가 실행된다면 콜백을 받지 못할 수도 있다.

 

반대로 useMutation의 경우 그렇지 않기 때문에 3번 모두 호출된다.

 

promise

mutate() 의 반환값은 void 타입이다. (오류가 발생해도 error가 throw 되지 않고, await를 사용하더라도 mutation에 지정한 mutationFn 완료를 대기하지 않음)

만약 mutate를 promise 처럼 다루고 싶다면 mutateAsync() 를 사용한다.

const mutation = useMutation({ mutationFn: addTodo })

try {
  const todo = await mutation.mutateAsync(todo)
  console.log(todo)
} catch (error) {
  console.error(error)
} finally {
  console.log('done')
}

 

retry (재시도)

에러가 발생해도 기본적으로 재시도하진 않는다. 만약 에러가 발생했을 때 일정 횟수만큼 재시도하도록 하고 싶다면 retry 옵션을 사용한다.

// error가 발생하면 3번 재시도
const mutation = useMutation({
  mutationFn: addTodo,
  retry: 3,
})

Quick Start: Query Invalidation

어떤 데이터가 지정한 시간이 되기 전에도 stale 상태(오래된 상태; 데이터 갱신이 필요한 상태)가 될 수 있다.

예를 들어 todo list에서 가져온 리스트를 60초에 한 번 갱신한다는 정책이 있을 때, 사용자가 데이터를 삭제하거나, 강제 리프레시를 일으켰을 때 60초가 지나지 않았더라도 즉시 갱신이 필요하다.

// 캐시에 있는 모든 쿼리 무효화
queryClient.invalidateQueries()
// todos로 시작하는 키를 가진 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] })

invalidateQueries를 이용해, 지정한 staleTime과 관계 없이 쿼리를 stale상태로 만들고, 자동으로 갱신하도록(queryFn 재호출) 할 수 있다.

 

invlidateQueries나 removeQueries같은 API를 사용할 때는 여러 개의 쿼리나, 정확한 단 하나의 쿼리를 대상으로 할 수 있다.

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

// Get QueryClient from the context
const queryClient = useQueryClient()

// todos 쿼리 키를 가진 쿼리를 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] })

// 두 쿼리 모두 무효화됨
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: fetchTodoList,
})

쿼리는 ‘계층적’으로 동작한다는 사실을 명심하자. 무효화할 쿼리 키를 [‘todos’]로 지정하면, ‘todos’를 포함해 하위에 있는 쿼리들까지 전부 무효화된다.

예를 들어 다음과 같은 쿼리가 있다고 생각해보자

  • queryKey: [‘a’]
  • queryKey: [’a’,, ’b’]
  • queryKey: [’a’, ’b’, ’c’]

여기서 invalidateQueries({queryKey: ['a']}) 하면 세 쿼리가 모두 invalidate된다.

만약 invalidateQueries({queryKey: ['a', 'b']}) 하면 [’a’, ‘b’]와 [’a’,’b’,’c’] 쿼리가 invaldate된다.

즉 지정한 쿼리와, 하위에 있는 쿼리가 대상이 되는 것이다.

 

이것을 이해하고 아래 코드를 보면 아주 쉽게 이해할 수 있다.

queryClient.invalidateQueries({
  queryKey: ['todos', { type: 'done' }],
})

// 이 쿼리는 무효화된다
const todoListQuery = useQuery({
  queryKey: ['todos', { type: 'done' }],
  queryFn: fetchTodoList,
})

// 하지만 이 쿼리는 무효화되지 않는다
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})

하지만 만약 하위 쿼리는 그대로 두고, 지정한 쿼리만 대상으로 하고 싶다면 어떨까? 이 경우에는 exact 옵션을 사용할 수 있다.

queryClient.invalidateQueries({
  queryKey: ['todos'],
  exact: true,
})
// The query below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
// However, the following query below will NOT be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos', { type: 'done' }],
  queryFn: fetchTodoList,
})

exact 옵션을 사용하면 지정한 쿼리 키와 정확히 일치하는 쿼리 하나만 무효화한다.

  • queryKey: [‘a’]
  • queryKey: [’a’,, ’b’]
  • queryKey: [’a’, ’b’, ’c’]

여기서 invalidateQueries({queryKey: ['a'], exact: true}) 하면 [’a’] 쿼리만 모두 invalidate된다. (exact 옵션 없이는 세 쿼리가 모두 invalidate되었다)

만약 invalidateQueries({queryKey: ['a', 'b'], exact: true}) 하면 [’a’, ‘b’]쿼리가 invaldate된다. (exact 옵션 없이는 [’a’, ‘b’] 와 [’a’, ‘b’, ‘c’] 쿼리가 invalidate 되었다)

또는 predicate 옵션을 사용해, 쿼리 키를 직접 전달하지 않고 함수를 통해 대상을 지정할 수도 있다

queryClient.invalidateQueries({
	//지정한 predicate가 true를 반환하는 쿼리만 invalidate
	//첫 번째 쿼리 키가 todos이고, 두 번째 쿼리 키의 버전이 10 이상인 쿼리만 invalidate
  predicate: (query) =>
    query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})

// 쿼리 키가 predicate를 만족하므로 이 쿼리는 invalidate 된다
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 20 }],
  queryFn: fetchTodoList,
})

// 쿼리 키가 predicate를 만족하므로 이 쿼리는 invalidate 된다
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 10 }],
  queryFn: fetchTodoList,
})

// 쿼리 키가 predicate를 만족하지 않으므로 이 쿼리는 invalidate 되지 않는다
const todoListQuery = useQuery({
  queryKey: ['todos', { version: 5 }],
  queryFn: fetchTodoList,
})