들어가며
'주특기 주차 프로젝트'(09/14 ~ 09/27) 와 저번주 목요일(10/12)부터 진행되었던
실전 프로젝트를 진행하면서 시간여유와 심적으로 여유가 없었기 때문에
TIL, WIL 을 작성하지 못했습니다.😵
실제로 프로젝트를 진행하면서 만나는 수많은 오류들을 키워드와
참고했던 자료들을 모아두었지만, 블로그를 작성하는 것까진 매우 힘든 일이라는 걸 알았습니다.
동시에 매일 작성하시는 분들이 정말 대단한 것도 느꼈죠.
각설하고, 오늘은 실전 프로젝트에서 사용중인
서버 통신 라이브러리, 'React-Query' 에 알아보려고 합니다.
리액트 쿼리는 실제로 그 사용법이 쉬워 접근하기 좋아
처음에 사용할 때에는 큰 문제가 없지만,
그만큼 기본 default 로 제공되는 기능들이 많이 내장되어 있기 때문에
온전하게 '리액트 쿼리에 대해 알고 기능을 100% 사용하는가' 에 대한 의문❔이 들어
이 글을 작성하게 되었습니다.
React Query 란
React Query 는 데이터 Fetching, Caching, 동기화, 서버 데이터 업데이트 등을 쉽게 만들어 주는 라이브러리입니다.
React 에서 상태를 관리하기 위해 MobX, Redux 등 여러 라이브러리가 존재하지만, 현재 진행중인 프로젝트에 접하게 된 React Query 에 대해 알아보고자 합니다.
카카오페이 프론트엔드 개발자들이 React Query 를 선택한 이유❗
[if(kakao)2021 컨퍼런스] 에서 언급된 내용에 대해 가져왔습니다.
1. React Query 는 React Application 에서 서버 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트하는 작업을 도와주는 라이브러리입니다.
2. 복잡하고 장황한 코드가 필요한 다른 데이터 불러오기 방식과 달리 React Component 내부에서 간단하고 직관적으로 API 를 사용할 수 있습니다.
3. 더 나아가 React Query 에서 제공하는 캐싱, Window Focus Refetching 등 다양한 기능을 활용해 API 요청과 관련된 번잡한 작업 없이 "핵심 로직" 에 집중할 수 있습니다.
즉, React-Query 는 프론트엔드에서 비동기 데이터를 불러오는 과정 중 발생하는 문제들을 해결해주죠.
캐싱(Caching)
React-Query 의 장점 중 하나는 데이터를 캐싱한다는 점입니다.
캐싱이란, 특정 데이터의 복사본을 저장해 이후 동일한 데이터의 재접근 속도를 높이는 것을 말합니다.
React-Query 는 캐싱을 통해 동일한 데이터에 대한 반복적인 비동기 데이터 호출을 방지하고, 이는 불필요한 API 콜을 줄여 서버에 대한 부하를 줄이는 좋은 결과를 가져올 수 있습니다.
❔그렇다면, 최신의 데이터인지 어떻게 판별할 수 있을까요?
여기서 궁금한 건 데이터가 최신의 것인지 아닌지에 대한 것입니다.
만일 서버 데이터를 불러 캐싱한 후, 실제 서버 데이터를 확인했을 때 서버 상에서 데이터의 상태가 변경되어있다면, 사용자는 실제 데이터가 아닌 변경 전의 데이터를 바라볼 수 밖에 없게 됩니다. 이는 사용자에게 잘못된 정보를 보여주는ㅇ ㅔ러를 낳습니다.
참고로, React-Query 에서는 최신의 데이터를 fresh 한 데이터, 기존의 데이터를 stale 한 데이터라고 말합니다.
언제 데이터를 갱신해야하는지?
위와 같은 에러를 발생시키지 않는 좋은 캐싱 기능을 제공한다는 것은 결국 필요한 상황에 적절하게 데이터를 갱신해줄 수 있다는 말과 같을 겁니다. 그럼 그런 상황은 언제일까요?
- 화면을 보고 있을 때
- 페이지의 전환이 일어났을 때
- 페이지 전환 없이 이벤트가 발생해 데이터를 요청했을 때
크게 보면 위의 3가지 경우로 나눌 수 있습니다. 이를 위해 React-Query 에서는 기본적인 아래의 옵션들을 제공합니다.
❗리액트 쿼리 default 옵션 다섯가지
refetchOnWindowFocus, //default: true
refetchOnMount, //default: true
refetchOnReconnect, //default: true
staleTime, //default: 0
cacheTime, //default: 5분 (60 * 5 * 1000)
위 옵션들을 통해 React-Query 가 어떤 시점에 데이터를 Refetching 하는지 알 수 있습니다.
- 브라우저에 포커스가 들어온 경우(refetchOnWindowFocus)
- 새로운 컴포넌트 마운트가 발생한 경우(refetchOnMount)
- 네트워크 재연결이 발생한 경우(refetchOnReconnect)
staleTime & cachTime
staleTime
- staleTime 은 데이터가 fresh → stale 상태로 변경되는 데 걸리는 시간입니다.
- fresh 상태일 때는 Refetch 트리거(위 3가지 경우)가 발생해도 Refetch 가 일어나지 않습니다!
- 기본값이 0이므로 따로 설정해주지 않는다면, Refetch 트리거가 발생했을 때 무조건 Refetch 가 발생합니다.
cacheTime
- cacheTime 은 데이터가 inactive 한 상태일 때 캐싱된 상태로 남아있는 시간입니다.
- 특정 컴포넌트가 unmount(페이지 전환 등으로 화면에서 사라질 때) 되면 사용된 데이터는 inactive 상태로 바뀌고, 이때 데이터는 cacheTime 만큼 유지됩니다.
- cacheTime 이후 데이터는 가비지 콜렉터로 수집되어 메모리에서 해제됩니다.
- 만일 cacheTime 이 지나지 않았는데 해당 데이터를 사용하는 컴포넌트가 다시 mount 되면, 새로운 데이터를 fetch 해오는 동안 캐싱된 데이터를 보여줍니다.
- 즉, 캐싱된 데이터를 계속 보여주는게 아니라 fetch 하는 동안 임시로 보여주는 겁니다.
이외에도 사용자가 특정 이벤트가 발생했을 때 Refetching 을 하도록 설정해줄 수 있습니다. React-Query 의 이러한 기능들을 통해 사용자는 언제나 최선의 데이터를 제공받게 됩니다.
Client 데이터와 Server 데이터 간의 분리
프로젝트 규모가 커지고 관리해야할 데이터가 넘치다 보면, Client 에서 관리하는 데이터와 Server 에서 관리하는 데이터가 분리될 필요성을 느낍니다.
실제 Client 데이터의 경우 Redux, Recoil, mobX 와 같은 전역 상태 관리 라이브러리들을 통해 잘 관리되어오고 있으나, 문제는 이러한 라이브러리들이 Server 데이터까지도 관리를 해야하는 상황이 발생한다는 것입니다.
위 상태 관리 라이브러리에도 비동기 함수를 처리하는 로직이 존재하거나, 서드 파티 라이브러리를 지원하는 것이 많습니다. 그러나 이들이 Client 데이터와 Server 데이터를 완벽히 분리해 관리에 용이하도록 충분한 기능이 지원된다고 보기 어렵습니다.
즉 위 라이브러리들은 Client 데이터를 관리하는 데 로직이 집중되어있기 때문에, Server 데이터까지 효율적으로 관리하기에는 한계가 분명하다고 생각합니다.🧐
🤓React-Query 는 이러한 문제에 대한 해결책을 제시해줍니다.
const { data, isLoading } = useQueries(
['unique-key'],
() => {
return api({
url: URL,
method: 'GET',
});
},
{
onSuccess: (data) => {
// data로 이것저것 하는 로직
}
},
{
onError: (error) => {
// error로 이것저것 하는 로직
}
}
)
예시에서는 컴포넌트 내부에서 위와 같은 로직을 통해 Server 데이터를 가져오고 있는데, 이때 onSuccess 와 onError 함수를 통해 fetch 성공과 실패에 대한 분기를 아주 간단한게 구현할 수 있습니다. 이는 Server 데이터를 불러오는 과정에서 구현해야할 추가적인 설정들을 진행할 필요가 없다는 것과 같습니다.
👉 즉, Client 데이터는 상태 관리 라이브러리가 관리하고, Server 데이터는 React-Query 가 관리하는 구조죠. 이를 통해 Client 데이터와 Server 데이터를 온전하게 분리할 수 있습니다.
물론 여기서 React-Query 가 가져온 Server 데이터를 상태 관리 라이브러리를 통해 전역 상태로 가져올 수도 있는 건 사실입니다. 그러나 refetch 가 여러 번 일어나는 상황에 매번 Server 데이터를 전역 상태로 가져오는 것이 옳은지 판단하는 것은 각자의 몫입니다. 개발하는 서비스에 상황에 따라서 달라지겠죠.
사용하기
라이브러리 설치
본격적으로 리액트 쿼리 사용에 대해 알아보겠습니다. 먼저 react 프로젝트를 만들고, react-query 라이브러리를 설치합니다.
npx create-react-app my-app
cd my-app
yarn install react-query
yarn install && yarn start
기본 세팅
react 의 가장 기본이 되는 곳에 react-query 를 사용하도록 세팅합니다.
// src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
const queryClient = new QueryClient();
ReactDOM.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
{/* devtools */}
<ReactQueryDevtools initialIsOpen={true} />
<App />
</QueryClientProvider>
</React.StrictMode>,
document.getElementById("root")
);
리액트 쿼리에서 data fetching 을 위해 제공하는 대표적인 기능들입니다.
기본적으로 GET 에는 useQuery, PUT, POST, UPDATE, DELETE 에는 useMutation 이 사용됩니다.
useQuery
- 첫 번째 파라미터로 unique key 가 들어가고, 두 번째 파라미터로 비동기 함수(api호출 함수) 가 들어갑니다.(당연한 이야기이지만, 두 번째 파라미터는 promise 가 들어가야 합니다.)
- 첫 번째 파라미터로 설정한 unique Key 는 다른 컴포넌트에서도 해당 키를 사용하면 호출 가능합니다. unique Key 는 string 과 배열을 받습니다. 배열로 넘기면 0번 값은 string 값으로 다른 컴포넌트에서 부를 값이 들어가고, 두 번째 값을 넣으면 query 함수 내부에 파라미터로 해당 값이 전달됩니다.
- return 값은 api 의 성공, 실패여부, api return 값을 포함한 객체입니다.
- useQuery 는 비동기로 작동합니다. 즉, 한 컴포넌트에 여러 개의 useQuery 가 있다면 하나가 끝나고 다음 useQuery 가 실행되는 것이 아닌 두개의 useQuery 가 동시에 실행됩니다. 여러 개의 비동기 query 가 있다면, useQuery 보다는 useQueries 를 사용해야 합니다.
- enabled 를 사용하면, useQuery 를 동기적으로 사용 가능합니다. 아래 예시로 추가 설명하겠습니다.
예시
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);
}
});
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>
);
};
- isLoading, isError 말고, status 로 한 번에 처리 가능합니다.
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>
);
}
useQuery 동기적으로 실행(enabled)
- 위 설명대로 enabled 옵션을 사용하면 useQuery 를 동기적으로 사용 가능합니다.
- useQuery 의 3번째 인자로 옵션값이 들어가는데, 그 옵션의 enabled 에 값을 넣으면 그 값이 true 일 때, useQuery 를 실행합니다. 이것을 이용하면, 동기적으로 함수를 실행할 수 있습니다.
const { data: todoList, error, isFetching } = useQuery("todos", fetchTodoList);
const { data: nextTodo, error, isFetching } = useQuery(
"nextTodos",
fetchNextTodoList,
{
enabled: !!todoList // true가 되면 fetchNextTodoList를 실행한다
}
);
useQueries
- useQueries 를 비동기로 여러 개 실행할 경우 여러 귀찮은 경우가 생깁니다.
const usersQuery = useQuery("users", fetchUsers);
const teamsQuery = useQuery("teams", fetchTeams);
const projectsQuery = useQuery("projects", fetchProjects);
어짜피 세 함수 모두 비동기로 실행하는데, 세 변수를 개발자는 다 기억해야하고 세 변수에 대한 로딩, 성공, 실패처리를 모두 해야한다.
- 이때 promise.all 처럼 useQuery 를 하나로 묶을 수 있는데, 그것이 useQueries 입니다. promise.all 과 마찬가지로 하나의 배열에 각 쿼리에 대한 상태 값이 객체로 들어옵니다.
// 아래 예시는 롤 룬과, 스펠을 받아오는 예시입니다.
const result = useQueries([
{
queryKey: ["getRune", riot.version],
queryFn: () => api.getRunInfo(riot.version)
},
{
queryKey: ["getSpell", riot.version],
queryFn: () => api.getSpellInfo(riot.version)
}
]);
useEffect(() => {
console.log(result); // [{rune 정보, data: [], isSucces: true ...}, {spell 정보, data: [], isSucces: true ...}]
const loadingFinishAll = result.some(result => result.isLoading);
console.log(loadingFinishAll); // loadingFinishAll이 false이면 최종 완료
}, [result]);
unique key 활용
- 위에서 unique key 를 배열로 넣으면 query 함수 내부에서 변수로 사용 가능하다고 했는데, 그것을 활용하면 아래와 같습니다. params 를 주목해주세요.
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)
}
]);
QueryCache
- 쿼리에 대해 성공, 실패 전처리를 할 수 있습니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.log(error, query);
if (query.state.data !== undefined) {
toast.error(`에러가 났어요!!: ${error.message}`);
},
},
onSuccess: data => {
console.log(data)
}
})
});
useMutation
- 값을 바꿀 때 사용하는 api 입니다. return 값은 useQuery 와 동일하고, 간단한 예시코드로도 충분히 설명할 수 있습니다.
예시
import { useState, useContext, useEffect } from "react";
import loginApi from "api";
import { useMutation } from "react-query";
const Index = () => {
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation(loginApi, {
onMutate: variable => {
console.log("onMutate", variable);
// variable : {loginId: 'xxx', password; 'xxx'}
},
onError: (error, variable, context) => {
// error
},
onSuccess: (data, variables, context) => {
console.log("success", data, variables, context);
},
onSettled: () => {
console.log("end");
}
});
const handleSubmit = () => {
loginMutation.mutate({ loginId: id, password });
};
return (
<div>
{loginMutation.isSuccess ? "success" : "pending"}
{loginMutation.isError ? "error" : "pending"}
<input type="text" value={id} onChange={e => setId(e.target.value)} />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button onClick={handleSubmit}>로그인</button>
</div>
);
};
export default Index;
update 후에 get 다시 실행
- react-query 장점으로 update 후에 get 함수를 간단히 재실행할 수 있습니다.
- mutation 함수가 성공할 때, unique key 로 맵핑된 get 함수를 invalidateQueries 에 넣어주면 됩니다.
const mutation = useMutation(postTodo, {
onSuccess: () => {
// postTodo가 성공하면 todos로 맵핑된 useQuery api 함수를 실행합니다.
queryClient.invalidateQueries("todos");
}
});
- 만약 mutation 에서 return 된 값을 이용해서 get 함수의 파라미터를 변경해야할 경우 setQueryData 를 사용합니다.
const queryClient = useQueryClient();
const mutation = useMutation(editTodo, {
onSuccess: data => {
// data가 fetchTodoById로 들어간다
queryClient.setQueryData(["todo", { id: 5 }], data);
}
});
const { status, data, error } = useQuery(["todo", { id: 5 }], fetchTodoById);
mutation.mutate({
id: 5,
name: "nkh"
});
react Suspense 와 react-query 사용하기
- react-query 를 사용하는 또 하나의 이유입니다. 비동기를 좀 더 선언적으로 사용할 수 있어서 많이 사용하는 것 같습니다.
- Suspense 를 사용하며 loading 을, Error buundary 를 사용해 에러 핸들링을 더욱 직관적으로 할 수 있습니다.
- suspense 를 사용하기 위해 QueryClient 에 옵션을 하나 추가합니다. 아래 방법은 global 하게 suspense 를 사용한다고 정의할 때 예시입니다.
// src/index.js
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
suspense: true
}
}
});
ReactDOM.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
document.getElementById("root")
);
- 아래는 함수마다 suspense 를 사용하는 예시입니다.
const { data } = useQurey("test", testApi, { suspense: true });
- 위처럼 suspense 세팅을 완료했다면, react 에서 제공하는 Suspense 를 사용하면 됩니다.
const { data } = useQurey("test", testApi, { suspense: true });
return (
// isLoading이 true이면 Suspense의 fallback 내부 컴포넌트가 보여집니다.
// isError가 true이면 ErrorBoundary의 fallback 내부 컴포넌트가 보여집니다.
<Suspense fallback={<div>loading</div>}>
<ErrorBoundary fallback={<div>에러 발생</div>}>
<div>{data}</div>
</ErrorBoundary>
</Supense>
);
'항해 16기 > Week I Learned' 카테고리의 다른 글
[항해 77일차] WIL_실전 프로젝트 MVP 중간보고 피드백 (0) | 2023.10.29 |
---|---|
[항해 70일차] WIL_translate 를 이용한 Carousel(캐러셀) 구현 (0) | 2023.10.25 |
[항해 28일차] WIL_React 심화주차: 모달, 버튼을 포함한 웹 페이지 (0) | 2023.09.07 |
[항해 21일차] WIL_Lv.2 redux_todolist (0) | 2023.09.03 |
[항해 7일차] WIL_숫자야구게임 (0) | 2023.08.21 |