React-Query

Kyoorim LEE·2023년 5월 3일
2

스스로TIL

목록 보기
19/34

TanStack Query

React-Query를 왜 사용해?

이에 대한 질문은 state(상태) 관리에서 찾을 수 있다. 원래 React에서 상태 관리를 하려면 Redux를 활용하며 서버 데이터를 활용할 때는 반드시 Redux-saga, Redux-Thunk, RTK-Query 같은 또 다른 미들웨어를 사용해야 했다. 이로써 boiler-plate가 비대해지는 상황이 생긴다.

기존의 많이 쓰이던 상태관리 라이브러리는 클라이언트 상태를 작업하기에는 좋으나 async나 서버 상태 작업에 최적화되어있지는 않다. 그 이유는 서버 state는 완전히 그 개념이 다르기 때문이다

server state

  • 사용자가 제어하거나 소유하지 않는 위치에서 원격으로 유지됨(데이터베이스)
  • fetching하고 updating하는데 asynchronous API를 필요로 함 (데이터베이스에 접근하는데 api가 필요하다는 뜻)
  • 공용 소유를 의미하며 사용자 모르게 다른 사람이 변경할 수 있음
  • 주의하지 않으면 어플리케이션 내에서 잠재적으로 "오래된(out of date)"가 될 수 있음

1. 캐싱

캐싱은 서버에 새로 요청하지 않고도 빠르게 검색할 수 있도록 자주 액세스하는 데이터를 메모리에 저장하는 프로세스를 뜻함. 그러나 캐싱은 특히 자주 변경되는 데이터를 처리할 때 올바르게 구현하기 어려울 수 있음.

기본적으로 데이터를 fetching 해오면 react-query는 캐싱한다. 캐싱된 데이터를 불러오기 때문에 API 콜 횟수가 줄어들고 서버에 대한 호출 횟수가 줄어들게 된다.

만일 해당 데이터가 stale이라고 판단되면 refetching 해온다.

만일 서버 데이터를 fetching한 후 캐싱한 데이터를 사용자에게 보여주었는데 그 사이에 서버에서 데이터 상태가 변경된다면 사용자는 stale한 데이터를 볼 위험이 있다. 이것이 바로 캐싱의 단점이라고 할 수 있다. 하지만 이러한 특별한 경우를 제외하고서는 굳이 사용자에게 따끈따끈한 data를 매번 호출하여 보여줄 필요는 없다.

React-Query에서 기본적으로 제공하는 옵션은 다음과 같다.

refetchOnWindowFocus, // default: true
refetchOnMount, // default: true
refetchOnReconnect, // default: true
staleTime, // default: 0
cacheTime, // default: 5 minutes (60 * 5 * 1000 = 30000)

위의 경우를 살펴보면 React-Query가 refetching을 하는 경우는 다음과 같으며 이 경우를 제외하고서는 사용자에게 캐싱된 데이터를 보여준다.

  1. 윈도우에 focus가 들어온 경우
  2. 컴포넌트가 새로 마운트된 경우
  3. 네트워크 연결이 끊어졌다가 다시 연결된 경우

2. staleTime vs cacheTime

1/ staleTime

캐시된 데이터가 fresh한 데이터로 간주되는 기간을 나타낸다. 이 시간이 지나면 React-Query는 데이터가 오래되었다고 판단하고 서버에서 새로운 데이터를 가져온다. 기본적으로 staleTime은 0으로 설정되어 있다. staleTime을 높게 설정하면 캐시를 더 오래 사용할 수 있으며, 이로 인해 애플리케이션의 성능이 개선될 수 있다.

useQuery('todos', fetchTodos, { staleTime: 1000 * 60 * 5 }); // 5분 동안 데이터를 실제로 간주함.

2/ cacheTime

캐시된 데이터가 메모리에 남아있는 기간을 나타낸다. cacheTime이 지나면 캐시가 메모리에서 삭제되어 자원을 확보할 수 있다. 기본값은 5분(300,000ms). 캐시된 데이터는 cacheTime 동안 메모리에 유지된다.

useQuery('todos', fetchTodos, { cacheTime: 1000 * 60 * 30 }); // 30분 동안 데이터를 캐시에 저장함

3/ 리액트 쿼리 라이프싸이클

  • Query Initialization
    리액트 쿼리의 라이프사이클은 useQuery 훅을 사용하여 초기화된다. 이때 쿼리 키, 데이터를 가져오는 함수(fetch function), 그리고 선택적으로 쿼리 옵션 객체를 인자로 전달.
import { useQuery } from 'react-query';
import { fetchTodos } from './api';

function TodoList() {
  const { data, isLoading, error } = useQuery('todos', fetchTodos, { staleTime: 1000 * 60 * 5, cacheTime: 1000 * 60 * 30 }); // 쿼리키, 데이터 가져오는 함수, 쿼리옵션객체

  // ...
}
  • Fetching
    쿼리가 초기화되면, 리액트 쿼리는 데이터를 가져오는 함수를 실행하여 서버에서 데이터를 불러온다. 이때 캐시에 동일한 쿼리 키가 있는지 확인하여 캐시에 데이터가 없거나 캐시된 데이터가 오래된(stale) 경우에만 서버에서 데이터를 가져온다.
async function fetchTodos() {
  const response = await fetch('https://api.example.com/todos');
  if (!response.ok) {
    throw new Error('Failed to fetch data');
  }
  return await response.json();
}
  • Caching
    데이터를 성공적으로 가져오면 리액트 쿼리는 해당 데이터를 캐시에 저장한다.

  • Synchronization
    staleTime이 지나게 되면, 캐시된 데이터는 더 이상 fresh하지 않다고 판단한다. 이 경우, 리액트 쿼리는 서버에서 새로운 데이터를 가져와 캐시를 업데이트 ==> 데이터가 최신 상태로 유지됨.

  • Garbage Collection
    cacheTime이 지나면, 해당 캐시 항목은 메모리에서 삭제된다.


3. 클라이언트 state와 서버 state의 분리

React Query는 서버 상태와 클라이언트 상태를 분리하여 관리할 수 있다.

React Query에서는 서버 데이터는 useQuery 훅을 사용하여 가져올 수 있으며, 클라이언트 데이터는 useMutation 훅을 사용하여 관리할 수 있다.

useQuery 훅은 서버 데이터를 가져와 클라이언트 상태에 저장한다. 이 상태는 서버 데이터를 최신 상태로 유지하기 위해 자동으로 업데이트된다. 또한 이 과정에서 React Query는 캐시를 사용하여 동일한 쿼리가 여러 번 호출될 때 서버로부터 데이터를 다시 가져오지 않도록 한다.

반면 useMutation 훅은 클라이언트 데이터를 서버에 전송하여 저장한다. 이 경우 서버 데이터가 최신 상태인지 확인하려면 새로고침을 수행해야 한다.

useQuery

서버 데이터를 가져와 클라이언트 상태에 저장하는 예시 코드

import { useQuery } from 'react-query';

function App() {
  const { isLoading, error, data } = useQuery('todos', () =>
    fetch('https://jsonplaceholder.typicode.com/todos').then((res) => res.json())
  );

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

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

useMutation

클라이언트 데이터를 서버에 전송하여 저장하는 예시 코드

useMutation 훅은 addTodo라는 이름으로 쿼리를 정의하고 새로운 Todo를 서버에 전송하는 함수. onSuccess 옵션을 사용하여 Todo가 성공적으로 추가되면 로그를 출력하도록 설정합니다. mutate 함수는 새로운 Todo를 전달하고, 이를 서버에 저장합니다.

import { useMutation } from 'react-query';

function AddTodo() {
  const [title, setTitle] = useState('');
  const addTodo = useMutation(
    (newTodo) => fetch('https://jsonplaceholder.typicode.com/todos', {
      method: 'POST',
      body: JSON.stringify(newTodo),
      headers: {
        'Content-type': 'application/json; charset=UTF-8',
      },
    }).then((res) => res.json()),
    {
      onSuccess: () => {
        console.log('Todo added successfully!');
      },
    }
  );

  const handleSubmit = (e) => {
    e.preventDefault();
    addTodo.mutate({ title });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit">Add Todo</button>
    </form>
  );
}

4. Success와 Error를 컴포넌트 최상단에서 핸들링하기

success, error를 공통으로 핸들링하고 싶다면 최상위에서 QueryClient의 defaultOptions의 queries를 이용하여 핸들링할 수 있다.

QueryClient의 defaultOptions 프로퍼티에서 queries를 설정하여 공통으로 처리할 onSuccess와 onError 함수를 정의한다. 이렇게 하면 모든 쿼리에서 onSuccess와 onError 함수가 자동으로 호출되어 처리된다.

==> 이렇게 하면 모든 쿼리에서 발생하는 성공과 실패를 하나의 곳에서 처리할 수 있으므로, 코드의 중복을 줄이고 유지보수성을 높일 수 있다.

import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import App from './App';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 공통으로 처리할 success 함수
      onSuccess: (data, variables, context) => {
        console.log('Query succeeded:', { data, variables, context });
      },
      // 공통으로 처리할 error 함수
      onError: (error, variables, context) => {
        console.error('Query failed:', { error, variables, context });
      },
    },
  },
});

ReactDOM.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  document.getElementById('root')
);

<다음 시간>

2. 동일한 데이터에 대한 여러 요청을 단일 요청으로 바꿔서 중복 제거하기

어플리케이션 내 다양한 부분에서 동일한 정보를 요청하게 될 경우 요청횟수가 여러번이 될 수 밖에 없는데 이 경우 중복 요청을 제거하기가 쉽지 않음

3. 백그라운드에서 "오래된" 데이터 업데이트

서버에서 데이터가 바뀐 경우 클라이언트 상에서 해당 데이터는 "오래된" 데이터가 되어버린다. 이 경우 polling을 통해 주기적으로 서버에 요청을 보내서 업데이트를 체크해야한다.

polling

서버에서 업데이트가 있을 경우 서버에서 클라이언트로 업데이트를 실시간으로 push해주는 방식이 아니라 서버에 주기적 요청을 보내서 업데이트를 체크하는 방식을 말한다.

4. "오래된" 데이터가 된 시점 알기

서버에 저장된 state는 다른 사람이나 프로세스에 의해 상시 바뀔 수 있으므로 클라이언트 상에서 언제든지 오래된 데이터가 되어버릴 수 있다.

5. 데이터 업데이트를 최대한 신속하게 반영하기

서버에서 데이터가 업데이트되면 클라이언트 상에 빠르게 반영되어야하는데 만약 대상이 방대한 dataset이거나 네트워크 연결이 느릴 경우 이것이 어려울 수 있음

6. 페이지네이션 및 lazy loading 데이터와 같은 성능 최적화

dataset이 방대할 경우 페이지네이션이나 lazy loading을 통해 성능최적화를 할 수 있으나 데이터 모델에 대해 신중하게 고려하여 진행해야할 필요가 있음

lazy loading

로딩 시 전체 데이터를 다 보여주는 것이 아니라 필요한 데이터만 보여주는 방식이다.
ex) 스크롤을 내림에 따라 해당 아이템만 순차적으로 보여주는 방식.

7. 서버 상태의 메모리 및 가비지 수집 관리

방대한 dataset을 다룰 때 메모리 관리가 매우 중요해진다. 어떤 데이터를 남겨두고 어떤 데이터를 제거할 지 그 기준이 매우 중요해진다.

8. 구조적 공유(structural sharing)로 쿼리 결과 메모하기

메모이제이션은 비용이 많이 드는 함수 호출의 결과를 저장하여 다시 계산할 필요 없이 빠르게 검색할 수 있도록 함. 서버 상태를 처리할 때 메모이제이션을 사용하여 서버에 요청해야 하는 요청 수를 줄임으로써 성능을 향상시킬 수 있음.


React-Query는 서버 state 관리에 관하여 위의 도전 과제들을 해결할 수 있는 가장 좋은 라이브러리라 할 수 있다.


2. React-Query 시작하기

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

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

function Example() {
  const { isLoading, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
        (res) => res.json(),
      ),
  })

  if (isLoading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>{data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}

3. Query

쿼리는 고유 키(unique key)에 연결된 데이터의 비동기 소스에 대한 선언적 종속성(declarative dependency)이다. Promise 기반 메서드(GET 및 POST 메서드 포함)와 함께 쿼리를 사용하여 서버에서 데이터를 가져올 수 있으며 메서드가 서버의 데이터를 수정하는 경우에는 대신 Mutations를 사용하는 것이 좋다.

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

function App() {
  const info = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })
}

고유 키는 내부적으로 refetching, caching 하거나 query를 공유하는데 쓰인다. useQuery로 리턴된 결과는 query에 대한 모든 정보를 담고 있다.

const result = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList })

result 객체는 아래와 같은 매우 중요한 상태들을 담고있다.

  • isLoading or status === 'loading' => The query has no data yet
  • isError or status === 'error' => The query encountered an error
  • isSuccess or status === 'success' => The query was successful and data is available

-error : 만일 쿼리가 isError 상태라면 error프로퍼티를 통해 에러를 사용할 수 있음

  • data : 만일 쿼리가 isSuccess 상태라면 data프로퍼티를 통해 데이터를 사용할 수 있음

데이터 렌더링 예시

function Todos() {
  const { isLoading, isError, data, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
  })

  if (isLoading) {
    return <span>Loading...</span>
  }

  if (isError) {
    return <span>Error: {error.message}</span>
  }

  // We can assume by this point that `isSuccess === true`
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

4. Mutations

Mutation은 query와는 다르게 데이터를 create/update/delete를 하기 위한 것 혹은 server side-effect 처리를 위한 것이다. 이 경우 useMutation 혹을 쓰면 된다.

1/ 사용 예시

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

  return (
    <div>
      {mutation.isLoading ? (
        '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>
  )
}

2/ mutate 사용 시 주의할 점 (feat. React event pooling)

// 1번 예시 => 동작 안함
const CreateTodo = () => {
  const mutation = useMutation({
    mutationFn: (event) => {
      event.preventDefault()
      return fetch('/api', new FormData(event.target))
    },
  })

  return <form onSubmit={mutation.mutate}>...</form>
}

// 2번 예시 => 동작 함
const CreateTodo = () => {
  const mutation = useMutation({
    mutationFn: (formData) => {
      return fetch('/api', formData)
    },
  })
  const onSubmit = (event) => {
    event.preventDefault()
    mutation.mutate(new FormData(event.target))
  }

  return <form onSubmit={onSubmit}>...</form>
}

1번의 경우 mutationFn가 event 객체를 받아서 event.preventDefault()를 호출하고 이후에 new FormData(event.target)을 사용하여 FormData 객체를 생성한다.

하지만 이벤트 객체는 비동기 함수가 호출될 때까지만 유효하고, 비동기 함수가 호출되는 시점에는 이미 재사용 가능한 이벤트 객체(event pool)로 돌아가버린 상태일 수 있다. 이렇게 되면 new FormData(event.target)에서는 잘못된 값을 가져올 수 있다.

벤트 객체는 비동기 함수가 호출될 때까지만 유효하고, 비동기 함수가 호출되는 시점에는 이미 재사용 가능한 이벤트 객체(event pool)로 돌아가버린 상태라는게 무슨 말?

React는 이벤트가 발생할 때마다, 새로운 이벤트 객체를 생성하지 않고, 미리 생성된 이벤트 풀(event pool)에서 사용 가능한 이벤트 객체를 가져와서 이벤트 정보를 업데이트한다. 이렇게 함으로써, 메모리 사용량을 줄이고 이벤트 핸들러가 호출될 때마다 새로운 객체를 생성하는 오버헤드를 줄일 수 있다.

그러나, 이벤트가 발생한 후, 이벤트 핸들러가 호출되기 전에 다른 비동기 함수가 실행될 경우, 이벤트 객체는 이미 이벤트 풀에 반환된 상태일 수 있다. 따라서, 비동기 함수에서 이벤트 객체를 사용하려면, 이벤트 객체의 정보를 유지해야 한다.

이를 해결하는 방법 중 하나는 event.persist() 함수를 호출하여 이벤트 객체를 유지(persist)하는 것이다. 하지만 이 방법은 React 16 이전 버전에서는 동작하지 않기 때문에, 두 번째 코드에서와 같이 이벤트 객체 대신 FormData 객체를 직접 생성하는 것이 좋습니다.

따라서, 첫 번째 코드에서는 이벤트 객체를 FormData 객체로 변환할 때 문제가 있을 수 있기 때문에 작동하지 않을 수 있으며, 두 번째 코드에서는 이벤트 객체 대신 FormData 객체를 사용하여 문제를 해결하고 작동할 수 있습니다.


profile
oneThing

0개의 댓글