2024-06-15 TanStack Query로 서버 상태 관리하기
기존의 내 프로젝트는 axios를 통해 json server에서 데이터를 불러오고 있었다. 그리고 이를 통해 CRUD를 구현하고 있었는데, 이것을 TanStack으로 관리해보고자 한다.
참고로 CRUD에서 R은 useQuery를, CUD는 useMutation을 이용한다.
TanStack과 Axios로 json-server에 GET 요청 보내기 (useQuery)
기존 코드는 아래와 같다.
function App() {
const [expenses, setExpenses] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchExpenses = async () => {
setLoading(true);
try {
const { data } = await jsonApi.get('/expenses');
setExpenses(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchExpenses();
}, []);
이것을 TanStack으로 관리 해보고자 한다.
먼저 TanStack 패키지를 설치한다.
npm install @tanstack/react-query
다음 TanStack Devtools를 설치한다. 데브툴즈는 상태 관리를 시각화하여 관리하기 편하게 도와준다.
yarn add @tanstack/react-query-devtools
그리고 main.jsx에서 App 컴포넌트를 감싸준다. queryClient를 생성해주는 것도 잊지 않는다.
자동완성이 안 될 수 있으니 import에도 신경쓰자.
// main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initiallsOpen={false}/>
<App />
</QueryClientProvider>
)
그러면 아래와 같이 개발 서버를 연 브라우저 상에 아이콘이 나오며 사용할 수 있다. 데브툴즈는 중간 중간에 체크해보겠다.
TanStack Query를 사용하면 서버에서 응답받은 data를 토대로 상태 관리를 자동으로 해준다.
기존의 코드를 보면 expenses 상태 하나, loading 상태 하나, error 상태 하나를 만들어 관리하고 있다. TanStack Query에서는 저것들을 모두 상태로 관리할 수 있도록 반환해준다.
자, 그럼 TanStack Query와 Axios를 사용해서 GET 요청을 최상단 컴포넌트에서 실행해보겠다.
먼저 import부터 한다.
import { jsonApi } from "../api";
import { useQuery } from '@tanstack/react-query';
본인은 json server를 통해 Axios Instance를 활용하고 있기 때문에 해당 인스턴스도 함께 import 해주었다.
기존에는 3개의 상태를 만들었고, fetch를 useEffect 훅으로 마운트 시 한 번 보내고 있다. 그런데 TanStack Query는 이런 과정이 필요없다. 리액트 쿼리가 자체적으로 데이터 패칭 로직을 관리하기 때문이다.
리액트 쿼리에는 다음과 같은 특장점이 있다.
- 자동 캐싱 : 동일한 쿼리를 여러 번 요청하더라도 한 번만 데이터를 가져오고 캐싱된 데이터를 사용한다.
- 상태 관리 : 로딩 상태, 에러 상태, 성공 상태 등을 자동으로 관리한다.
- retry 로직 : 실패한 요청에 대해서도 자동으로 재시도 할 수 있다.
- 백그라운드 패칭 : 백그라운드에서 데이터를 새로고침 할 수 있다.
본인은 원본 data 쿼리 데이터, 로딩 상태, 에러 상태만 필요하기 때문에 저 정도만 사용하겠지만 TanStack에서 제공해주는 주요 속성은 아래와 같은 것들이 있다.
- data : 쿼리 데이터
- isLoading : 쿼리가 로딩 중인지 여부를 나타냄. (불리언 값)
- isError : 쿼리에서 에러가 발생했는지 여부를 나타냄. (불리언 값)
- error : 어떤 에러가 발생했는지 에러 객체를 반환함.
- isFetching : 백그라운드에서 데이터를 가져오는 중인지 여부를 나타냄. (불리언 값)
- refetch : 데이터를 다시 가져오는 함수.
- 사용 예제 : <button onClick={refetch}>다시 불러오기</button>
- status : 쿼리의 현재 상태 (idle, loading, error, success 중 하나)
- isSuccess : 쿼리가 성공적으로 완료되었는지 여부를 나타냄. (불리언 값)
- isIdle : 쿼리가 아직 시작되지 않았는지 여부를 나타냄. (불리언 값)
다음으로, useQuery를 사용하여 GET 요청 함수를 작성한다.
const { data: expenses, error, isLoading } = useQuery({
queryKey: ['expenses'],
queryFn: async () => {
const { data } = await jsonApi.get('/expenses');
return data;
},
// staleTime 기본값 0 -> 서버에서 방금 받아온 정보도 수정이 일어났을 수 있으니
// fresh하다고 보장할 수 없다. (stale하다)
// infinity하다고 하는 것은 fresh를 보장하는 것.
// 나 말고 어차피 아무도 건드릴 일이 없는 정보는 fresh하다고 보고 트래픽 낭비를 줄일 수 있음.
staleTime: Infinity
});
if (isLoading) return <div>로딩중입니다</div>;
if (error) return <div>에러 발생 : {error.massage}</div>;
console.log('json 서버에서 불러온 데이터입니다요 =>', expenses);
이게 전부다. 기존 상태는 전부 삭제한다. 이것 말고 다른 곳에서 useState와 useEffect 훅을 사용하는 곳이 없다면 import도 지우면 된다. React를 사용하면서 상태를 계속 다뤄왔는지 없애도 되는 것인지 의구심이 들 정도로 코드가 간결해졌다.
한 줄 씩 설명해보겠다.
const { data: expenses, error, isLoading } = useQuery({})
useQuery를 사용하겠다는 내용이다.
data: expenses
useQuery로 데이터 응답이 반환되면 data라는 이름으로 반환되는데, 이것을 내가 사용하던 상태인 expenses와 이름을 일치시키기 위해 data: expenses로 이름을 바꿔준다는 내용으로 시작한다.
error, isLoading
useQuery로 응답받은 내용 중 위 내용도 사용하겠다는 의미이다. error는 서버와 통신 실패 시 발생한 에러의 내용을 반환해준다.
isLoading은 서버에서 응답을 처리 중인지 아닌지 불리언 값으로 반환해준다.
queryKey: ['expenses']
쿼리의 키를 지정한다. expenses라는 이름으로 사용하겠다고 자유 작명한 것이고, 쿼리 키는 리액트 쿼리가 데이터를 저장하고 관리하기 위해 사용하는 이름표(label) 개념이다.
리액트 쿼리는 같은 데이터를 여러 번 요청하지 않는다는 데이터 캐싱(Caching)을 해준다는 장점이 있다. 그런데 지금 요청한 이 데이터가 같은 데이터인지 아닌지 판단할 때 바로 이 쿼리키라는 이름표가 필요한 것이다.
그리고 이따가 Update할 때 사용하겠지만 무효화(Invalidation)를 할 때도 이 쿼리 키가 필요하다. 무효화란 간단히 설명하자면 쿼리 키는 같은 데이터는 서버에 다시 요청하지 않는다고 하였는데, 만약 사용자가 서버에 저장될 데이터를 수정하게 되면 서버의 정보와 유저가 가지고 있는 정보를 일치화 해야 하기 때문에 이 로직을 무효화해야 한다. 그 때 어떤 데이터를 무효화 할 것인지 정확히 찾아야 하기 때문에 이 쿼리키 부여는 필요하다.
queryFn: async() => {
const { data } = await jsonApi.get('/expenses');
return data;
}
실제로 axios GET 요청을 보내는 함수를 비동기(async)로 작성한 것이다.
Axios instance에 작성한 정보에 get 요청을 보내며 엔드포인트는 /expenses이다.
그리고 응답이 완료되면 data를 반환한다. 이것은 위 axios에서 반환되는 이름과 맞춰서 작성해야 한다.
staleTime: Infinity
이 키워드는 생략 가능하다. 학습용으로만 사용한 것인데, 의미를 살펴보겠다.
TanStack Query에서는 데이터가 fresh하다 / stale하다라는 개념으로 분류한다.
이 사진은 TanStack DevTools의 화면인데, 데이터가 Fresh하다고 되어 있다.
Fetching은 데이터를 요청 중인지에 대한 내용이고, Paused는 흐름이 끊겼다는 의미이다. 그리고 Stale하다라는 개념도 있다.
여기서 fresh하다와 stale하다가 대비되는 개념인데,
fresh하다라는 의미는 말 그대로 신선하다, 서버에서 갓 받아 온 정보이다라는 의미이고 더 나아가서는 그렇기 때문에 서버의 정보와 사용자가 보고 있는 정보가 완전히 일치한다는 의미이다.
stale하다라는 개념의 의미는 반대로 사용자가 서버에서 정보를 받아오긴 했으나 아무리 방금 받아 온 정보이더라도 서버 관리자가 백엔드에서 정보를 수정했든, 서비스를 이용하는 다른 사용자가 정보를 변경했든 1초 사이에도 데이터의 변화가 있을 수 있기 때문에 사용자가 갖고 있는 정보가 서버의 정보와 완전히 일치한다라고 보장할 수 없다는 의미이다.
useQuery의 기본 staleTime 시간은 0초이다. 즉 서버를 받아온 지 0.1초밖에 지나지 않았더라도 그 정보는 stale하다고 간주하는 것이다. fetch를 무한대로 보내지 않는 이상 realtime으로 데이터의 변경 여부를 알 수는 없을 것이다.
stale한 정보는 당연히 새로 fetch를 보내어 서버의 정보를 새롭게 받아서 fresh한 상태로 만들어주어야 할 것이다. 따라서 useQuery를 기본 상태로 사용하면 컴포넌트가 리렌더링 될 때 계속 fetch가 요청된다.
하지만 서버와 사용자가 꼭 일치해야 하지 않는 별로 중요하지 않은 정보이거나, 나 말고는 변경할 경우가 아예 없는 유저 정보와 같은 데이터는 stale할 이유가 없기 때문에 다시 fetch를 불필요하게 보내는 일을 막기 위해 staleTime을 infinity로 해주면 불필요한 트래픽 발생을 줄일 수 있다.
이렇게 하면 useQuery를 사용해서 GET 요청을 완성하고 상태로까지 만들었다.
그런데 기존에 useState 훅을 사용해서 데이터를 props로 하위 컴포넌트에 내려주고 있었는데, 이 과정을 생략해도 되는 것은 아니다.
<Route
path="/"
element={<PrivateRoute element={<Home expenses={expenses}/>} />}
/>
페이지 라우팅을 할 때 expenses라는 상태를 없앴기 때문에 setExpenses라는 상태 변경 함수를 내려줄 필요가 없어 삭제했지만, expenses는 그대로 남겨두었는데, 이것은 useState로 만든 상태를 내려주는 것이 아니라 useQuery에서 받아온 data를 내려주는 것이다. data의 이름을 내가 expenses로 바꿔줬기 때문에 이렇게 내리는 것이다. 이름을 바꾸지 않았다면 data로 내려주면 되는데, data라는 키워드는 워낙 여러 군데에서 사용되기 때문에 시멘틱하지 않으니 바꿔주는 것이 권장된다.
헷갈릴 수 있는데, 상태처럼 사용할 수 있으나 상태는 아니다.
TanStack과 Axios로 json-server에 POST 요청 보내기 (useMutation)
바로 수정 함수를 작성해보겠다. 이번엔 useMutation을 사용한다.
먼저 기존 게시글 등록 함수를 살펴보겠다.
const handleAddExpense = async () => {
try {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(newDate)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
const parsedAmount = parseInt(newAmount, 10);
if (!newItem || parsedAmount <= 0) {
alert('유효한 항목과 금액을 입력해주세요.');
return;
}
const newExpense = {
id: uuidv4(),
month: parseInt(newDate.split("-")[1], 10),
date: newDate,
const handleAddExpense = async () => {
try {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(newDate)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
const parsedAmount = parseInt(newAmount, 10);
if (!newItem || parsedAmount <= 0) {
alert('유효한 항목과 금액을 입력해주세요.');
return;
}
const newExpense = {
id: uuidv4(),
month: parseInt(newDate.split("-")[1], 10),
date: newDate,
item: newItem,
amount: parsedAmount,
description: newDescription,
};
await jsonApi.post(`/expenses/`, newExpense);
setExpenses([...expenses, newExpense]);
alert(`${newItem} 항목을 등록하였습니다.`);
setNewDate(`2024-${String(month).padStart(2, "0")}-01`);
setNewItem("");
setNewAmount("");
setNewDescription("");
} catch (error) {
alert('등록 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
주요 로직을 살펴 보면 try...catch문을 사용하여 에러 처리를 해주고 있다.
try문에서는 필요한 유효성 검사를 해주고 있다.
이후 유효성 검사가 통과하면 입력필드와 연결되어 있는 (newDate, newItem...) 새로운 객체를 newExpense를 만든 후 axios POST 요청의 두 번째 매개 변수에 실어 보내고 있다.
그리고 상태 변경을 원본 배열의 불변성을 유지하면서 해주고 있는데, 이것은 서버 응답 여부와 별도로 화면에 즉각적인 피드백을 주기 위함이다. 이를 옵티미스트한 렌더링이라고 말한다.
마지막으로 입력 필드를 비워주는 것으로 마무리한다.
이를 TanStack Query로 바꿔 보겠다.
const mutation = useMutation( {
mutationFn: newExpense => jsonApi.post('/expenses', newExpense),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
}
});
const handleAddExpense = () => {
// 유효성 검사
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(newDate)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
const parsedAmount = parseInt(newAmount, 10);
if (!newItem || parsedAmount <= 0) {
alert('유효한 항목과 금액을 입력해주세요.');
return;
}
const newExpense = {
id: uuidv4(),
month: parseInt(newDate.split("-")[1], 10),
date: newDate,
item: newItem,
amount: parsedAmount,
description: newDescription,
};
try {mutation.mutate(newExpense);} catch(err) {
alert('게시글 등록 중 에러가 발생했습니다' + err.message);
};
}
코드 로직은 그대로 유지하는데, 바뀐 점에 대해서 설명해보겠다.
그 전에 먼저 이 컴포넌트에서도 main.jsx에서 생성한 Query Client 인스턴스에 접근해야 한다.
이 작업을 해 주어야 리액트 쿼리의 인스턴스에 접근해서 invalidate, refetch 등을 수행할 수 있다.
App 컴포넌트에서는 useQueryClient를 사용하지 않고 있는데, 이유는 App 컴포넌트에서는 단순히 데이터를 가져 오는 GET 요청만 하고 있기에 Query Client에서 제공하는 invalidate, refetch 기능을 사용하지 않기 때문이다.
const queryClient = useQueryClient();
게시글을 작성하는 이 로직에서는 데이터를 무효화(invalidate) 할 것이기 때문에 선언해준 것이다.
const mutation = useMutation( {
mutationFn: newExpense => jsonApi.post('/expenses', newExpense),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
}
});
이것은 패턴이다.
mutation이라는 함수를 선언해주는데, useMutation 훅을 사용한다. useMutation의 인자로는 객체가 들어가는데, mutationFn과 onCuccess 콜백 함수가 포함되어야 한다.
mutitonFn은 실제 데이터 변경을 처리하는 함수를 적는다. Axios 인스턴스에 의해 엔드인트가 /expenses로 post 요청을 보낸다.
그리고 onSuccess의 콜백 함수는 데이터 변경이 성공적으로 완료된 후 실행될 함수이다.
queryClient.invalidateQueries( { queryKey: ['expenses'] } ); 의 의미는, queryClient에서 무효화를 하는 invalidateQueries 메서드를 호출하여 ['expenses']라는 쿼리 키에 대한 캐시를 무효화한다는 의미이다.
이 말은 App 컴포넌트에서 expenses라는 쿼리 키를 만들어 데이터를 받아와 캐싱하였기 때문에 데이터를 다시 fetch하지 않게 되는데, 데이터를 사용자가 추가하는 행동을 했기 때문에 이것을 무효화하겠다는 의미이다. 그러면 브라우저는 다시 이 쿼리 키가 무효화되었기 때문에 다시 fetch를 요청한다.
그 다음 중간에 있는 로직은 유효성 검사를 하고 추가할 객체를 newExpense라는 이름으로 만드는 것을 그대로 사용하고 있고,
마지막에 try catch문을 사용하여 mutaiton.mutate 와 같이, 위에서 정의한 mutation 함수를 mutate한다. 그리고 catch문으로 에러를 처리한다.
mutate한다는 것은 아래와 같은 의미를 갖는다. useMutation 훅이 실행되면 다음과 같은 속성을 반환하는데, 그 중 데이터 변경을 트리거하는 함수가 mutate이다.
- mutate : 변이를 트리거하는 함수
- mutateAsync : 변이를 비동기적으로 트리거하는 함수
- isLoading : 변이가 진행 중인지 나타내는 불리언 값
- isError : 변이 중 에러가 발생했는지 나타내는 불리언 값
- isSuccess : 변이가 성공적으로 완료되었는지 나타내는 불리언 값
- error : 변이 중 발생한 에러 객체
- data : 변이의 결과 데이터
mutation.mutate(newExpense)
위 처럼 매개 변수로 데이터를 추가할 객체를 실어서 보내면 mutationFn 함수에 의해서 두 번째 매개 변수인 newExpense의 자리로 위에서 만든 newExpense 객체가 전달되어 데이터 변경이 이루어진다.
TanStack과 Axios로 json-server에 PUT 요청 보내기 (useMutation)
데이터 변경을 위한 PUT 요청을 보내는 함수를 TanStack과 Axios Instance를 활용해서 보내보겠다.
const queryClient = useQueryClient();
const editExpense = async (id) => {
try {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
if (!datePattern.test(date)) {
alert('날짜를 YYYY-MM-DD 형식으로 입력해주세요.');
return;
}
if (!item || amount <= 0) {
alert("유효한 항목과 금액을 입력해주세요.");
return;
}
const updatedExpense = {
...selectedExpense,
date: date,
item: item,
amount: amount,
description: description,
};
await jsonApi.put(`/expenses/${id}`, updatedExpense);
// 위 PUT 요청 이후 새롭게 GET 요청을 보내서 아래의 상태 변경으로 옵티미스트한 렌더링을 안 해줘도 됨.
queryClient.invalidateQueries({queryKey:['expenses']});
// setExpenses(expenses.map(expense => (expense.id === id ? updatedExpense : expense)));
alert(`${item} 항목을 수정하였습니다.`);
} catch (error) {
alert('수정 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
쿼리 키 무효화를 위해 역시 useQueryClient();를 선언해준다.
try...catch문을 통해 비동기로 데이터 변경을 진행한다.
const editExpense = async (id) => {
RESTful한 API 요청에서 데이터를 변경하는 PUT 요청의 엔드포인트는 /expenses/${id} 이다.
따라서 특정 아이템의 id를 매개 변수로 받아야 한다. 이 매개 변수는 수정 함수를 연결해둔 수정 버튼에서 매개 변수로 전달해준다.
PUT 요청은 변경하려는 모든 내용을 보내야 한다. PATCH 요청은 변경된 부분만 보내면 되는 것과 차이가 있다. 따라서 불변성을 유지하며 객체를 원형 그대로 유지하는 것이다.
// 원본 배열
{
"id": "b82b213e-8164-4c89-94b0-7246cdcfc4f9",
"month": 1,
"date": "2024-01-21",
"item": "식비",
"amount": 412000,
"description": "BBQ",
"createdBy": "Me"
}
// PUT 요청
{
"id": "b82b213e-8164-4c89-94b0-7246cdcfc4f9",
"month": 1,
"date": "2024-01-21",
"item": "식비",
"amount": 412000,
"description": "BBQ",
"createdBy": "Wonyoung" // 수정된 부분
}
// PATCH 요청
// 원본 배열
{
"createdBy": "Wonyoung" // 수정된 부분
}
PUT 요청은 바뀐 부분을 포함해서 기존의 데이터 형태를 그대로 포함시켜서 보내야 한다. 그러면 서버에서 바뀐 부분만 탐지하여 데이터를 변경해준다. 따라서 수정할 객체를 만들 때 수정하지 않을 부분은 불변성을 유지시킨 후 두 번째 매개 변수로 바뀐 정보를 담고 있는 것이다.
그리고 queryClient 에서 무효화 메서드를 호출하여 expenses라는 쿼리 키(GET요청)을 무효화하여 변경된 데이터를 다시 fetch 하도록 하고 있다.
TanStack과 Axios로 json-server에 DELETE 요청 보내기 (useMutation)
기존 함수를 살펴보겠다.
const deleteExpense = async (id) => {
try {
await jsonApi.delete(`/expenses/${id}`);
setExpenses(prevExpenses => prevExpenses.filter(expense => expense.id !== id));
alert(`${description} 항목을 삭제하였습니다.`);
navigate('/');
} catch (error) {
alert('삭제 과정에서 에러가 발생했습니다 : ' + error.message);
}
};
이것을 TanStack Query로 바꾸어 보겠다.
const deleteMutation = useMutation({
mutationFn: (id) => jsonApi.delete(`/expenses/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
alert(`${description} 항목을 삭제했습니다.`);
navigate('/');
},
onError: (error) => {
alert('삭제 과정에서 에러가 발생했습니다: ' + error.message);
},
});
useMutation 훅을 사용하는 것은 수정과 동일하다. 이유는 데이터의 변화가 발생했고, 기존의 쿼리 키(GET)를 무효화하는 invalidateQueries 메서드를 호출해서 캐싱된 데이터를 지우고 새로 받아와야 하기 때문이다.
mutationFn에 함수의 내용을 작성한다. 특정 아이템을 지우는 요청이기 때문에 id를 삭제 버튼에서 이벤트 핸들러로부터 매개 변수로 받아와야 한다.
Axios 인스턴스에 delete 요청을 보낸다. expenses/${id}가 RESTful한 엔드포인트이다.
그리고 onSuccess에 데이터 변경 성공 시 실행할 함수를 적는다. 먼저 GET 요청에 대한 쿼리 키를 무효화하고, alert창을 띄워 사용자 경험을 높여준다. 그리고 홈으로 이동시킨다.
마지막으로 onError에 에러 처리 로직을 작성해준다.
<Button danger="true" onClick={() => deleteMutation.mutate(id)}>
그리고 삭제를 실행할 버튼에 이벤트 핸들러로 deleteMutation 함수를 연결해주는데, id를 매개 변수로 전달해주어야 하고, mutate라는 트리거도 함수 뒤에 붙여줘야 실행이 된다.
'Programing > TIL' 카테고리의 다른 글
2024-06-18 유저 관리 기능에 대한 회고 (0) | 2024.06.18 |
---|---|
2024-06-16 (나의 생각) AI 시대와 개발자 (0) | 2024.06.16 |
2024-06-14 프로젝트 관리 중인 데이터를 json-server로 RESTful하게 리팩토링 하기 (1) | 2024.06.14 |
2024-06-13 axios, json-server, TanStack Query - RESTful (1) | 2024.06.13 |
2024-06-12 프론트 엔드 학습 가이드(로드맵) (1) | 2024.06.12 |
댓글