많은 사람들이 TanStack Query를 선택하는 이유 중 하나로 클라이언트와 서버의 상태관리를 분리하기 위해 사용을 합니다.
예전에 Redux가 React의 전역상태관리 1위로 자리를 잡고 있을 무렵 서버 데이터를 Redux-saga를 통해 비동기 처리 이후 전역상태관리로 사용을 하였는데 이 부분에 있어서 클라이언트의 서버 상태관리와 사용하는 목적이 다르기에 많은 개발자들이 이에 대해 불편함을 가지게 되었습니다.
여기에 Redux와 Redux-saga의 보일러플레이트가 작은 불씨에 기름을 붙는 역할을 한 것 같습니다.
지금 회사에서는 전역 상태관리를 거의 쓰지 않는데 유일하게 사용하는 부분이 사용자가 즐겨찾기 메뉴와 메뉴를 수정 할 때입니다.
즐겨찾기 메뉴 혹은 메뉴의 수정을 하게 되면 전역상태관리의 토글을 변경되고 메뉴/즐겨찾기 메뉴를 호출하는 컴포넌트에서 토글을 감지하여 API를 재호출하는 형식으로 설계되어있습니다.
여기서 필자는 Jotai를 사용한 토글 감지로 API를 호출하는 방법이 과연 효율적인가 생각을 해보았고 TanStack을 사용하여 이를 서버 상태관리로 명확하게 정의를 하자고 마음을 먹었습니다.
토글의 전역관리로 API를 호출하는 방법이 틀린 방법을 아니지만 이 토글이 클라이언트 흐름을 위한 상태관리인지 API 호출을 위한 상태관리인지 알기 쉽지 않기에 좋은 방법이라곤 생각이 들지 않았습니다.
추후에 Jotai에 새로운 클라이언트 상태관리가 들어가게 된다면 이는 더욱 더 구별하기 어려워질 것입니다.
관리자가 사용자를 등록할 수 있는 기능이 있습니다. 일종의 회원가입을 관리자가 해주는 기능입니다.
당연하게도 Grid 형식의 사용자들을 조회 할 수 있습니다. 관리자가 사용자 등록을 위해 등록 모달에 등록을 마치고 모달이 꺼진 후 다시 사용자 리스트를 조회하는 로직이 있습니다.
const handleCreateUser = () => {
// 사용자를 신규 등록하기 위해 Post 요청
axios.post("/user", userCreateInput).then((res) => {
if(res.status === 201){
// 등록 후 새로운 유저 리스트를 담는 요청
axios.get("/user").then((res) => {
setUserList(res.data);
});
};
}).catch(err => console.error(err));
// 이후 모달을 종료
setUserCreateModalToggle(true);
};
위의 코드와 같이 등록이 정상적으로 되고나서 다시 유저 리스트를 재호출을 하게됩니다.
코드의 길이도 길어지며 post 요청 이후 then 문법을 사용하더라도 가독성이 좋진 않습니다.
useEffect(()=>{
axios.get("/user").then((res)=> setUserList(res.data))
},[renderToggle])
혹은 위와 같이 생성,수정,삭제 시 useEffect를 사용하여 새로운 유저 리스트를 받아오는 로직으로 재호출을 했습니다.
이러한 로직이 한 컴포넌트에 여러개 있다보니 코드는 600줄을 훌쩍 넘어가 유지보수를 하는데 스크롤을 자주 사용하게 되거나 분할 화면을 통해 개발을 했어야했습니다.
또한 앞서 말한 클라이언트의 상태관리에 renderToggle의 움직임까지 살펴봐야하는 불편함이 생깁니다.
TanStack-Query에서 대표적으로 useQuery와 useMutation 커스텀 훅을 사용합니다.
useQuery는 GET 요청을 보낼 때 사용하고 useMutation은 POST,PUT,PATCH,DELETE 요청을 보낼 때 사용합니다.
import {
useQuery,
useMutation,
useQueryClient
} from '@tanstack/react-query'
function UserList() {
const queryClient = useQueryClient();
const { isFetching: isUserFetching, data: userData } = useQuery({
queryKey: ['userList'],
queryFn: async() => {
const response = await axios.get("/user");
return response
},
})
const {mutate: userCreateMutate, isPending: isUserCreatePending} = useMutation({
mutationFn: async(reqBody) => {
const response = await axios.post("/user", reqBody)
return response
},
onSuccess: () => {
// 성공 시 userList의 queryKey를 가진 query를 새롭게 재호출
queryClient.invalidateQueries({ queryKey: ['userList'] })
},
})
if(isFetching) return <Circular />
//...후략
위의 코드는 간단한 useQuery와 useMutation의 예시입니다.
(useQuery와 useMutation 대한 자세한 설명은 생략하겠습니다.)
📁 User
ㄴ 📁customHooks
ㄴ 📁 queries
ㄴ 📄 useUserQuery.js
ㄴ 📁 mutations
ㄴ 📄 useUserMutation.js
앞서 설명드린 간단한 예시 코드의 useQuery는 queries 폴더에, useMutation은 mutations 폴더에 따로 관리를 합니다.
이후 각 query와 mutation을 import하여 사용을 합니다.
import useUserQuery from "./customHooks/queries/useUserQuery";
import useUserMutation from "./customHooks/mutaitions/useUserMutation";
function UserList(){
const { isFetching: isUserFetching, data: userData } = useUserQuery();
const {mutate: userCreateMutate, isPending: isUserCreatePending} = useMutation();
...후략
}
이전 User 컴포넌트 안에 axios 요청이 뒤범벅 되어있는 코드에서 한결 보기 좋아졌고 클라이언트의 상태와 분리되어 사용하는 모습을 볼 수 있습니다.
const [userList, setUserList] = useState([]);
const [filterNameState, setFilterNameState] = useState("");
const handleChangeFilterValue = (event) => {
setFilterNameState(event.target.value);
};
const handleClickSearchButton = () => {
axios.get("/user", { params: { name: filterNameState } }).then((res) => {
setUserList(res.data);
})
};
// ... 후략
기존 코드에선 handleClickSearchButton 함수에 axios GET 요청을 filterNameState와 함께 보냈습니다.
가장 기초적인 코드인 만큼 크게 문제가 없는 코드입니다만 TanStack-Query에서 더욱 간단하게 호출 할 수 있도록 지원합니다.
/** useUserQuery 커스텀 훅 */
import {
useQuery
} from '@tanstack/react-query'
function useUserQuery(paramsState){
const { isFetching, data } = useQuery({
// queryKey에 params를 넣어준다.
queryKey: ['userList', paramsState],
queryFn: async() =>{
const response = await axios.get("/user", { params: { name: paramsState }});
return response
},
})
return { data, isFetching }
}
export default useUserQuery;
import useUserQuery from "./customHooks/queries/useUserQuery";
const [filterNameState, setFilterNameState] = useState("");
const [paramsNameState, setParamsNameState] = useState("")
const { isFetching: isUserFetching, data: userData } = useUserQuery(paramsNameState);
const handleChangeFilterValue = (event) => {
setFilterNameState(event.target.value);
};
const handleClickSearchButton = () => {
setParamsNameState(filterNameState)
};
// ... 후략
queryKey가 ParamsState를 감지하여 State가 변할 때 마다 새로운 Params를 가진 API를 호출하게 됩니다.
기존 코드에서 axios를 새로 받아오는 로직이 빠지며 response를 setState에 넣는 부분 또한 사라지게 됩니다.
저희는 오로지 useQuery에서 제공하는 data(위 코드의 userData)만 사용하면 됩니다.
프론트엔드에서 백엔드의 소스코드를 가공 해야할 일이 생기기 마련입니다.
// 백엔드에서 오는 데이터 형태
const serverData = ["A", "B", "C"];
// 프론트엔드에서 사용해야하는 데이터 형태
const clientData = { factory: "A", line: "B", value: "C" }
간단한 예시로 배열로 주는 서버 데이터를 클라이언트에서 객체 형태로 변환한다고 가정해보겠습니다.
(극단적인 예시일 뿐. 저희 회사에서 실제로 저런 식의 데이터를 백엔드 개발자가 주지 않습니다. 😂)
import {
useQuery
} from '@tanstack/react-query'
import { changeFactoryData } from "./func"
function useUserQuery(){
const { isFetching, data } = useQuery({
queryKey: ['userList'],
queryFn: async() => {
const response = await axios.get("/factory",);
return response
},
select: (response)=>{
const factoryDataList = response.data;
const newFactoryDataList = changeFactoryData(factoryDataList);
return newFactoryDataList
}
})
return { data, isFetching }
}
export default useUserQuery;
useQuery 옵션 중 하나인 select를 사용하면 response 이후의 추가적인 로직을 사용할 수 있습니다.
useMutation을 사용하고 나서 기존에 있던 서버 데이터를 간단히 재호출 시킬 수 있습니다.
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query'
function useUserMutation(){
const queryClient = useQueryClient()
const {mutate, isPending} = useMutation({
mutationFn: async(reqBody) => {
const response = await axios.post("/user", reqBody)
return response
},
onSuccess: () => {
// 성공 시 userList의 queryKey를 가진 query를 새롭게 재호출
queryClient.invalidateQueries({ queryKey: ['userList'] })
},
})
return {mutate, isPending}
}
위에서 잠시 예시로 나왔던 Mutation입니다.
queryClient.invalidateQueries를 사용하여 재호출을 원하는 queryKey를 인자로 넣으면 해당 queryKey를 가지는 모든 query들을 재호출합니다.
예전에 renderToggle로 Post 요청의 트리거를 걸어 API를 재호출하지 않아도 되게 되었습니다.
이번 TanStack-Query 도입은 굉장히 어려웠습니다.
가장 어려웠던 부분은 설계 부분이였는데요.
먼저 기존에 있던 API 호출들의 데이터 흐름을 파악을 해야했고 공통으로 사용되는 부분을 묶어 Query/Mutation 커스텀 훅을 만들어야했습니다.
또한 폴더의 구조는 어떻게 가져갈 것이며, API URL은 매개변수로 받을 것인지 mutation을 Create, Upate, Delete로 각각 나눠서 관리할 것인지와 같은 고민을 많이하게 되었습니다.
v4에서 v5로 넘어가는 단계에 변화된 사항도 찾아보아야했습니다.
- cacheTime에서 gcTime으로 명칭변경.
- useQuery의 remove 메소드 삭제
- useQuery의 onSuccess, onError, onSettled 삭제 등...
무엇보다 TanStack-Query에서 제공하는 staleTime과 gcTime(cacheTime)을 사용해보지 못한 것이 아쉬웠습니다.
어떠한 부분에서 사용을 하면 좋을지 조금 더 공부하고 도입을 해보려합니다.
아래는 TanStack-Query 도입으로 인한 개선사항입니다.
- 클라이언트와 서버의 State 분리
- 각 컴포넌트 당 200줄이 넘는 코드 길이 감소로 가독성 향상
- 재사용 가능한 커스텀 훅 생성
라이브러리 도입을 생각하시고 계신 분들에게 추천드립니다.
감사합니다.
[번역] React Query API의 의도된 중단
https://velog.io/@cnsrn1874/breaking-react-querys-api-on-purpose
TanStack-Query 공식문서
https://tanstack.com/query/latest/docs/framework/react/overview