
React 그리고 FE 작업을 진행하다 보면 비동기 처리에 대한 까다로움, 그리고 데이터를 관리하는 것의 복잡함은 누구나 겪는 문제라고 생각합니다. 저는저의 이러한 고민을 해결하기 위해 React-Query를 이번 프로젝트에 도입해보았습니다.
React Query는 React Application에서 서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트 하는 작업을 도와주는 라이브러리
- 클라이언트 상태(state)와 서버 상태(state)를 분리하고 서버 상태를 관리하기 위한 목적으로 설계
- React 어플리케이션 내에서 데이터 fetching, caching, 동기화 그리고, 서버 상태의 업데이트를 용이하게 만들어 준다.
브라우저에 포커스가 들어왔을 경우(refetchOnWindowFocus)
새로 마운트가 되었을 경우(refetchOnMount)
네트워크가 끊어졌다가 다시 연결된 경우(refetchOnReconnect)
(기본 옵션 설정으로 인해) 캐싱에 저장된 파일은 stale로 판단되어, 새롭게 mount 될 때마다 데이터를 fetching
// React-Query의 default 옵션값
// 1. data가 stale한 상태일 때, 브라우저에 포커스가 들어왔을 경우 refetch
refetchOnWindowFocus, //default: true
// 2. data가 stale한 상태일 때, 새로 마운트 되었을 경우 refetch
refetchOnMount, //default: true
// 3. data가 stale한 상태일 때, 네트워크가 끊어졌다가 다시 연결된 경우 refetch
refetchOnReconnect, //default: true
// staleTime : 캐싱 데이터의 상태를 fresh에서 stale로 변경하는데 걸리는 시간
staleTime, //default: 0
// cacheTime : 데이터가 inactive 상태일 때 캐싱된 상태로 남아있는 시간
// inactive : 쿼리 instance가 unmount 된 상태를 의미하며, 만일 새롭게 렌더링 될 경우 사용자에게 캐시에 있는 데이터를 보여준다
// cahceTime이 지나게 되면 데이터는 가비지 콜렉터로 수집된다
cacheTime, //default: 5분 (60 * 5 * 1000)
staleTime이 지났지만, ****cacheTime이 아직 지나지 않은 경우
staleTime이 지났으므로 해당 데이터는 stale 상태 → 새롭게 data를 fetch(refetch)cacheTime이 지나지 않았기 때문에, refetch 하는 동안 캐시에 남아있는 데이터를 보여준다cacheTime은 staleTime과 무관하게 inactive가 된 시점을 기준으로 데이터 삭제를 결정
// 가장 기본적인 형태의 React Query useQuery Hook 사용 예시
const { data } = useQuery(
queryKey, // 이 Query 요청에 대한 응답 데이터를 캐시할 때 사용할 Unique Key (required)
fetchFn, // 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
options, // useQuery에서 사용되는 Option 객체 (optional)
);
HTTP METHOD GET 요청과 같이 서버에 저장된 “상태”를 불러와 사용할 때 Query 요청을 사용
Unique Key(query key)
unique key를 배열로 넣으면 query 함수 내부에서 변수로 사용 가능
const riot = {
version: "12.1.1"
};
const result = useQueries([
{
queryKey: ["getRune", riot.version],
queryFn: params => {
console.log(params); // {queryKey: ['getRune', '12.1.1'], pageParam: undefined, meta: undefined}
return api.getRunInfo(riot.version);
}
},
{
queryKey: ["getSpell", riot.version],
queryFn: () => api.getSpellInfo(riot.version)
}
]);
Query Function(fetch function)
useQuery가 반환하는 주요 states
isLoading or status === 'loading' : 현재 데이터를 요청 중이거나 아직 데이터가 없는 경우isError or status === 'error' : 쿼리에서 에러가 났을 경우error : 해당 property로 에러 메세지를 확인할 수 있다isSuccess or status === 'success : 쿼리 요청 성공data : 해당 property로 성공한 데이터를 확인할 수 있음isIdle or status === 'idle' : 이 쿼리는 현재 사용할 수 없을 때isFetching : 데이터 요청 중일 때 True를 반환 (내부적으로 리패칭하는 경우 포함)예시 코드1(state를 boolean으로 반환 받는 경우)
const Todos = () => {
const { isLoading, isError, data, error } = useQuery("todos", fetchTodoList, {
refetchOnWindowFocus: false, // react-query는 사용자가 사용하는 윈도우가 다른 곳을 갔다가 다시 화면으로 돌아오면 이 함수를 재실행합니다. 그 재실행 여부 옵션 입니다.
retry: 0, // 실패시 재호출 몇번 할지
onSuccess: data => {
// 성공시 호출
console.log(data);
},
onError: e => {
// 실패시 호출 (401, 404 같은 error가 아니라 정말 api 호출이 실패한 경우만 호출됩니다.)
// 강제로 에러 발생시키려면 api단에서 throw Error 날립니다. (참조: https://react-query.tanstack.com/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default)
console.log(e.message);
}
});
// isLoading이 True인 경우 Loading 표시
if (isLoading) {
return <span>Loading...</span>;
}
// 에러가 발생한 경우 에러 메세지를 표시
if (isError) {
return <span>Error: {error.message}</span>;
}
// 정상적으로 데이터를 수신한 경우 리스트로 데이터를 표시
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
};
function Todos() {
const { status, data, error } = useQuery("todos", fetchTodoList);
if (status === "loading") {
return <span>Loading...</span>;
}
if (status === "error") {
return <span>Error: {error.message}</span>;
}
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// 서버에 토론방 데이터를 요청하는 코드를 custom hook으로 만들어 사용
import { useQuery } from "react-query";
import customAxios from "utils/customAxios";
const axios = customAxios();
// useQuery의 Query Function 역할을 하는 함수
const fetchDebateList = ({ queryKey }) => {
const url = queryKey[1];
// axios get을 통해 받는 데이터를 반환
return axios.get(url);
}
export const useDebateList = (url) => {
// query key : ["debateList", url] (list 형태)
// query function : fetchDebateList
// 이하 options
return useQuery(["debateList", url], fetchDebateList, {
// select : query function이 반환한 데이터를 가공하여 대신 반환하는 데이터
select,
// onError : 에러 발생 시, 실행할 작업 내용
onError,
// retry: 에러 발생 시, 다시 시도하는 fetch 횟수 /default: 3
retry: 0,
// reftchOnwindowFocus : 데이터가 stale 상태일 경우, 사용자가 다른 작업을 하던 후(혹은 다른 웹사이트를 보던 후) 해당 웹으로 다시 돌아올 경우 refetch 하는 옵션 /default: true
refetchOnWindowFocus: false,
// refetchOnMount : 데이터가 stale 상태일 경우, 마운트될 때마다 refetch하는 코드 /default: true
refetchOnMount: true,
// refetchOnReconnect : 데이터가 stale 상태일 경우, 재연결될 때마다 refetch하는 코드 / default: true
refetchOnReconnect: true,
// staleTime : fresh -> stale로 가는데 소요되는 시간 / default : 0
staleTime: 60*1000,
// cacheTime : inactive된 캐싱 데이터를 삭제하는데 소요되는 시간 / default : 5분 (60 * 5 * 1000)
cacheTime: 60*5*1000,
});
}
// axios response로 받은 데이터를 재가공하여 반환하는 함수
const select = (response) => {
// 이하 데이터 가공 함수
// 해당 코드의 경우 response 내의 response.data.body.content에 있는 데이터로 정제하여 반환
console.log("inside useDebateList select. data fetch result >> ", response);
if (!response.data.body || response.data.body.hasOwnProperty("content")) {
console.log("there is content property")
return response.data.body.content;
}
console.log("there is no content property")
return response.data.body;
}
// error가 발생할 때 수행하는 함수
const onError = (err) => {
// err.alterData: 에러가 발생 시, 대체할 데이터를 설정하는 옵션
err.alterData = [
{
"roomId": 26,
"roomName": "라면에 하나만 넣는다면?",
"roomCreaterName": "라면조아",
"roomDebateType": "FORMAL",
"roomOpinionLeft": "김치",
"roomOpinionRight": "계란",
"roomHashtags": "#라면,#김치,#계란,#뭐든좋아",
"roomWatchCnt": 344,
"roomPhase": 2,
"roomPhaseCurrentTimeMinute": 0,
"roomPhaseCurrentTimeSecond": 5,
"roomStartTime": "2023-02-03T12:32:51.44181",
"roomThumbnailUrl": "https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOd05v%2FbtrXHT3JqkJ%2Fl0Qhf5QygUqOIskZwYEqWK%2Fimg.png",
"roomCategory": "음식",
"roomState": false,
"leftUserList": [
"이동진",
"로버트다우니주니어",
"라면조아"
],
"rightUserList": [
"영화좋아하는사람",
"김영한",
"안침착맨"
]
}
]
// DebateRoom을 rendering하는 component 내에 앞에서 만든 custom hook "useDebateList"를 호출
const { isLoading, data, isError, error } = useDebateList(url);
// isLoading이 true일 경우 <Spinner />(로딩창)을 import
if (isLoading) return <Spinner />;
// component내 데이터를 설정하는 코드
// 에러일경우(isError) 대체 데이터 / 정상일 경우 : data(select로 가공된 데이터)
const debateList = isError ? error.alterData : data;
import { useMutation } from "react-query";
// 더 많은 return 값들이 있다.
const { data, isLoading, mutate, mutateAsync } = useMutation(mutationFn, options);
mutate(variables, {
onError,
onSettled,
onSuccess,
});
mutationFnonMuatateonSuccessonErroronSettledmutationKey, retry, cacheTime 등의 옵션이 존재mutatemutateAsyncmutate의 결과를 Promise로 반환하는 함수catch() 로 에러를 직접 처리해야 한다.isIdle or status === 'idle' : mutation 내에 데이터가 비어있을 때isLoading or status === 'loading' : mutation 작업이 진행중일 때isError or status === 'error' : mutation에서 에러가 발생했을 때error : 해당 property로 수신된 에러 메세지를 확인할 수 있다isSuccess or status === 'success' : mutation이 성공적으로 마치고 해당 데이터를 사용할 수 있을 때data : 해당 property가 성공적으로 수신한 데이터를 확일할 수 있다.useAddSuperHeroMutation() Hook 만들기
// src/hooks/apis/useSuperHeroQuery.js
import { useMutation, useQuery, useQueryClient } from "react-query";
import axios from "axios";
// mutationFn에 들어갈 비동기 함수 정의
const fetchAddSuperHero = (hero) => {
return axios.post("http://localhost:4000/superheroes", hero)
}
// useMutation을 수행하는 custom hook 작성
export const useAddSuperHeroMutation = () => {
return useMutation(fetchAddSuperHero)
}
component에서 활용하기
import { useState } from "react";
import { Link } from "react-router-dom";
import { useSuperHeroesQuery, useAddSuperHeroMutation } from "../hooks/apis/useSuperHeroesQuery";
export const RQSuperHeroesPage = () => {
const [newHero, setNewHero] = useState({ name: "", alterEgo: "" });
const { isLoading, data, isError, error, refetch } = useSuperHeroesQuery();
// 1) useAddSuperHeroMutation() Hooks 가져오기
const { mutate: addHero, isLoading2, isError2, error2 } = useAddSuperHeroMutation(newHero)
// 2) mutate() 함수 실행부
const handleClickAddButton = () => {
addHero(newHero)
setNewHero({ name: "", alterEgo: "" })
}
if (isLoading) return <h2>Loading...!!</h2>;
if (isError) return <h2>{error.message}</h2>;
return (
<>
<h2>React Query Super Heroes Page</h2>
<div>
<input
value={newHero.name}
onChange={(e) =>
setNewHero((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="name"
/>
<input
value={newHero.alterEgo}
onChange={(e) =>
setNewHero((prev) => ({ ...prev, alterEgo: e.target.value }))
}
placeholder="alterEgo"
/>
<button onClick={handleClickAddButton}>Add Hero</button>
</div>
<button onClick={refetch}>refresh</button>
{data?.data.map((hero) => (
<div key={hero.name}>
<Link to={`/rq-super-hero/${hero.id}`}>{hero.name}</Link>
</div>
))}
</>
);
};
useAddSuperHeroMutation() hook을 호출
addHero 의 이름으로 mutate() 함수를 호출mutate() 함수 실행부
addHero 의 argument로 입력된 newHero 변수는 useAddSuperHeroMutation() 의 훅 내부의 useMutation()의 첫번째 인자인 mutationFn에 전달된다.button을 클릭했을 때, haddleClickAddButton 핸들러 함수가 실행 ⇒ mutate() 함수 실행
npm install react-query
const queryClient = new QueryClient();
queryClient가 가지고 있는 캐시와 모든 기본 옵션을 QueryClientProvider의 자녀 컴포넌트도 사용할 수 있음
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from 'react-query';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root')!)
const queryClient = new QueryClient();
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);
React-Query를 사용하고 나서 느낀 가장 큰 장점은 깔끔한 코드를 작성할 수 있었다는 점입니다. React-Query를 통해 커스텀훅을 만들어 보기에 더 간결한 코드를 작성할 수 있는 점 그리고 api 등의 요청을 통해 받은 데이터를 비동기로 쉽게 처리하기 때문에, 훨씬 깔끔한 코드를 작성할 수 있었습니다.
또 api 통신을 실패할 때의 재요청, 데이터 캐싱과 같은 강력한 기능들을 쉽게 사용할 수 있다는 점 또한 너무나도 매력적이었습니다. 앞으로의 많은 React 프로젝트에서 익히고 사용해나가며, 많이 배워보고 싶은 기술 이었습니다.
React-Query : https://react-query-v3.tanstack.com/
카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유 : https://tech.kakaopay.com/post/react-query-1/
React-Query 도입을 위한 고민 (feat. Recoil) : https://tech.osci.kr/2022/07/13/react-query/
react-query : https://kyounghwan01.github.io/blog/React/react-query/basic/#usequery
[React Query] 리액트 쿼리 useMutation 기본 편 : https://velog.io/@kimhyo_0218/React-Query-리액트-쿼리-useMutation-기본-편
React Query useMutation() : https://abangpa1ace.tistory.com/266