[react-query] useQuery와 Promise.all을 활용한 리렌더링 최적화
본 게시글의 내용은 React v17까지만 해당합니다. React v18부터는 Promise에도 automatic batching을 지원하기 때문에 2개 이상의 API를 동시 호출해야 할 떼 useQuery 여러번 또는 useQueries를 사용해도 리렌더링이 한 번밖에 되지 않습니다. 🥲
회사에 들어온 지도 4개월이 다 되어간다.
마침 내가 입사한 시기가 운이 좋게도 jQuery로 되어 있던 레거시 프로젝트를 Nextjs로 막 옮기려고 하고 있던 참이었어서 덕분에 일종의 마이그레이션을 경험할 수 있었다. 프로젝트를 옮기면서 동시에 기능에 필요한 API들도 큰 변경이 있었는데, 바로 API 모듈화였다.
아래와 같이 되어 있는 페이지를 상상해보자.
한 페이지를 완성하기 위해 A, B, C, D, E 총 5개의 각기 다른 성격의 데이터가 필요하다고 할 때, 기존에는 5개의 다른 데이터를 모두 하나의 API에 담았기 때문에 한 번의 호출만 필요했다.
하지만 API 모듈화를 진행하게 되면 각기 다른 성격의 데이터마다 다른 API에 실어서 보내야 하기 때문에 같은 페이지를 구성한다고 할 때 총 5번의 API 호출이 필요하게 된다.
페이지 렌더링에 필요한 API 호출 횟수가 1회에서 5회로 증가하면서 내가 신경써야 하는 것들이 아래와 같이 생겨났다. (달리 말하면 충족해야 하는 조건이라고도 할 수 있다...🥲)
✨ 5개의 API들은 병렬로 호출되어야 한다.
✨ 5개의 API 통신이 모두 성공해야만 페이지를 보여줄 수 있다. (즉, 모든 API의 성공이 보장되어야 한다.)
✨ mutation 시 react-query로 업데이트 최적화를 적용해놨기 때문에 query Key로 refetch를 할 수 있도록 useQuery를 반드시 사용해야 한다.
✨ (희망사항) 5개의 API를 따로 따로 호출해도 리렌더링은 한 번만 됐으면 좋겠다!
react-query 문서에서 스쳐지나가 듯이 병렬로 API를 호출하는 훅을 본 기억이 있었기에 다시 parralel이라는 단어로 검색해보니 useQueries라는 훅이 있었다. 훅 이름 그대로 여러 개의 useQuery들을 병렬로 호출한다는 내용은 있는데 그 외의 별다른 내용은 없어서 일단 사용해 보기로 했다.
Mock API 만들기
postman으로 간단하게 mock api를 만들었다.
// api.tsx
export const api = axios.create({
baseURL: 'your mock server url'
});
export const MockAPI = () => {
return api.get("/api/rerender",)
}
useQuery로 5번 호출해보기
우선 API를 호출할 때 useQuery를 5번 호출하면 리렌더링이 몇 번 발생하는지를 확인해보았다. 리렌더링 횟수를 카운팅하기 위해 useQuery의 option에 API 호출이 성공하면 count + 1을 해주는 코드를 추가했다.
const [count, setCount] = useState(0)
console.log("rerender", count)
const queryKey = new Array(5).fill("rerender-test").map((el, i) => [el, String(i)])
const queryFn = async () => {
try {
const res = await MockAPI();
return res.data.data;
}
catch (error) {
error;
}
}
const queryOption = {
onSuccess: () => setCount(prev => prev + 1)
}
useQuery(queryKey[0], queryFn, {...queryOption});
useQuery(queryKey[1], queryFn, {...queryOption});
useQuery(queryKey[2], queryFn, {...queryOption});
useQuery(queryKey[3], queryFn, {...queryOption});
useQuery(queryKey[4], queryFn, {...queryOption});
// ...
return (
<>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<p>
Rerender: <span style={{ color: "blue", fontWeight: "700" }}>{count}</span>
</p>
</div>
</>
)
이렇게 코드를 작성하고 우선 공식문서에서 설명하고 있는 것처럼 useQuery를 여러 번 실행했을 때 API들이 병렬로 호출되는지 확인하기 위해 네트워크 탭을 열었고 5개의 useQuery가 모두 병렬로 실행되고 있음을 확인할 수 있었다!
그 다음 리렌더링 횟수를 확인하기 위해 콘솔 탭을 열었고, useQuery 실행 횟수만큼 리렌더링이 발생하고 있다는 걸 확인할 수 있었다. 🥲
useQuery 5번
✅ 5개의 API들은 병렬로 호출되어야 한다.
❌ 5개의 API 통신이 모두 성공해야만 페이지를 보여줄 수 있다. (즉, 모든 API의 성공이 보장되어야 한다.)
✅ mutation 시 react-query로 업데이트 최적화를 적용해놨기 때문에 query Key로 refetch를 할 수 있도록 useQuery를 반드시 사용해야 한다.
❌ (희망사항) 5개의 API를 따로 따로 호출해도 리렌더링은 한 번만 됐으면 좋겠다!
상단에 적은 조건 중 두 가지만 충족한다. 그래도 만약 useQuery를 사용해야 한다면 리렌더링을 최소화하기 위해 실행 횟수 자체를 줄여야 한다는 걸 알 수 있었다.
useQueries
useQueries는 react-query에서 제공하는 API 중 하나로, 여러 개의 useQuery를 병렬로 실행해주는 훅이다. 만약 useQueries로 API를 호출했을 때 호출하는 모든 API의 성공을 보장할 수 있다면 위에 적은 조건들은 모두 충족하는 것이다.
위에 작성한 useQuery 5번 실행하는 코드를 useQueries를 사용한 코드로 바꾼 후 화면을 다시 새로고침 해보았다.. (나는 아직 react-query를 tanstack/react-query로 업그레이드하지 않았기 때문에 공식 문서에 나와 있는 사용 방법과는 조금 다르다.)
// ✨ before
useQuery(queryKey[0], queryFn, {...queryOption});
useQuery(queryKey[1], queryFn, {...queryOption});
useQuery(queryKey[2], queryFn, {...queryOption});
useQuery(queryKey[3], queryFn, {...queryOption});
useQuery(queryKey[4], queryFn, {...queryOption});
// ✨ after
useQueries(queryKey.map(key => {
return {
queryKey: key,
queryFn: queryFn,
...queryOption
}
}))
..?
리렌더링 횟수는 오히려 배가 됐다. 훅의 이름 그대로 useQuery를 여러 개 실행시켜줄 뿐이고, 문서에 나와 있는 것처럼 실행해야 하는 useQuery의 갯수를 미리 알 수 없을 때 (동적으로 실행해야 할 때)를 위한 훅인 듯 하다. 따라서 충족하는 조건도 useQuery를 5번 실행했을 때와 동일하다!
useQueries
✅ 5개의 API들은 병렬로 호출되어야 한다.
❌ 5개의 API 통신이 모두 성공해야만 페이지를 보여줄 수 있다. (즉, 모든 API의 성공이 보장되어야 한다.)
✅ mutation 시 react-query로 업데이트 최적화를 적용해놨기 때문에 query Key로 refetch를 할 수 있도록 useQuery를 반드시 사용해야 한다.
❌ (희망사항) 5개의 API를 따로 따로 호출해도 리렌더링은 한 번만 됐으면 좋겠다!
Promise.all
사실 마지막 세 번째 조건인 react-query를 사용해야 한다는 조건만 빼면 나머지 1) 병렬 호출과 2) 모든 API의 성공 보장은 자바스크립트의 프로미스 메소드를 사용하면 해결된다. Promise.all()은 만약 파라미터로 주어진 객체가 모두 프로미스일 때 하나의 프로미스라도 거부되면 Promise.all() 자체도 거부되기 때문에 모든 API의 성공을 보장할 수 있다.
또한 파라미터로 주어진 프로미스들을 모두 처리한 후 한 번에 결과를 주기 때문에 리렌더링도 한 번만 되지 않을까..?하는 기대를 해보았다.
그럼 위에서 useQueries로 작성한 코드를 Promise.all()로 바꿔보자!
useEffect(() => {
Promise.all(new Array(5).fill(0).map(_ => {
const res = MockAPI();
return res;
}))
.then(_ => setCount(prev => prev + 1))
}, [])
🥹
콘솔에서 확인해보니 예상했던 대로(??) 리렌더링이 한 번밖에 발생하지 않았다..! react-query를 사용해야 하는 조건이 남긴 했지만 query Function을 Promise.all로 쪼물딱쪼물딱 만들면 되겠다는 생각이 들었다.
Promise.all
✅ 5개의 API들은 병렬로 호출되어야 한다.
✅ 5개의 API 통신이 모두 성공해야만 페이지를 보여줄 수 있다. (즉, 모든 API의 성공이 보장되어야 한다.)
❌ mutation 시 react-query로 업데이트 최적화를 적용해놨기 때문에 query Key로 refetch를 할 수 있도록 useQuery를 반드시 사용해야 한다.
✅ (희망사항) 5개의 API를 따로 따로 호출해도 리렌더링은 한 번만 됐으면 좋겠다!
Promise.all을 활용해 useQuery의 queryFn 만들기
Promise.all의 이행이 완료되면 이행 결과가 담겨 있는 리스트를 반환하도록 fetcher 함수를 만들고, useQuery의 queryFn 위치에 해당 함수를 파라미터로 사용했다.
const fetcher = async () => {
try {
const resultList = await Promise.all(new Array(5).fill(0).map(_ => {
const res = MockAPI();
return res;
}))
return resultList;
}
catch (error) {
error;
}
}
const queryResult = useQuery("rerender-test", fetcher, {...queryOption});
console.log(queryResult);
그리고 브라우저에서 확인해보니..!
API들도 너무 이쁘게 병렬로 호출되고~~모든 API의 성공도 보장하고~~
또한 useQuery를 한 번밖에 실행하지 않았으니 리렌더링도 한 번밖에 발생하지 않았다! 이로써 모든 조건들을 충족할 수 있게 되었다.
useQuery + Promise.all로 만든 query function
✅ 5개의 API들은 병렬로 호출되어야 한다.
✅ 5개의 API 통신이 모두 성공해야만 페이지를 보여줄 수 있다. (즉, 모든 API의 성공이 보장되어야 한다.)
✅ mutation 시 react-query로 업데이트 최적화를 적용해놨기 때문에 query Key로 refetch를 할 수 있도록 useQuery를 반드시 사용해야 한다.
✅ (희망사항) 5개의 API를 따로 따로 호출해도 리렌더링은 한 번만 됐으면 좋겠다!
커스텀훅 만들기_최종_최최최종_진짜진자찐막
내가 담당하는 페이지들도 그렇고 대부분의 페이지에 필요한 데이터들은 모두 API 모듈화가 적용될 예정이기 때문에 페이지마다 fetcher 함수를 만들어서 쓰는 건 비효율적이라는 생각이 들어 필요한 곳에서 가져다 쓸 수 있도록 커스텀훅으로 만들어서 재사용하기로 했다.
// useQueries.tsx
export const useQueries = (
queryKey: string | string[],
apis: {
[key: string]: (params?: string | number | { [key:string]: any }) => Promise<AxiosResponse<any, any>>;
},
queryOptions?: {},
params?: any[]
) => {
const [errorCode, setErrorCode] = useState<number[]>([]);
const fetchQueries = async () => {
try {
const resultList: any[] = await Promise.all(
Object.keys(apis).map(async (key, i) => {
const res = params[i] ? await apis[key](params[i]) : await apis[key]();
return !res.data.code ? [key, res.data] : setErrorCode((prev) => [...prev, res.data.code]);
})
)
return Object.fromEntries(resultList);
} catch (error) {
error;
}
}
const queryResult = useQuery(queryKey, fetchQueries, {
...queryOptions,
});
return { ...queryResult, errorCode };
}
queryKey
: useQuery의 queryKey로 사용한다.
apis
: 데이터 fetch API들을 key-value의 형태로 묶은 오브젝트이다. useQuery가 반환해주는 data에서 key 이름으로 각 API의 response data에 접근할 수 있게 하기 위해 (한마디로 데이터를 이쁘게 정리하기 위해) 무조건 오브젝트의 형태로만 넘길 수 있도록 했다.
queryOptions (optional)
: useQuery의 option으로 사용한다.
params (optional)
: hoxy나 데이터를 fetch할 때 request url의 쿼리스트링이나 request body로 제공해야 하는 정보가 있는 경우에만 사용한다.
우리팀은 특정할 수 있는 원인으로 인해 request 오류가 발생한 경우에는 서버에서 커스텀 에러 코드를 함께 보내주는데, 프론트엔드에서는 500번대 에러를 제외한 그 외의 나머지 에러들은 모두 resolve가 되도록 처리해놨기 때문에 try문에서 예외처리를 하고 커스텀훅이 커스텀 에러코드의 상태도 함께 반환하도록 하였다.
request할 때 정보를 함께 전달해야 하는 케이스도 확인하기 위해 포스트맨으로 mock api를 한 개 더 만들고 export하는 API도 수정했다.
// api.tsx
export const MockAPI = {
test1: () => api.get("/api/rerender"),
test2: (name: string) => api.get("/api/test", { params: { name } })
}
그리고 API를 호출하는 페이지에서 useQueries 커스텀 훅을 import한 후 아래와 같이 코드를 수정한 후,
// index.tsx
import { useQueries } from "../../hooks/useQueries";
// ...
const apis = {
api1 : MockAPI.test1,
api2 : MockAPI.test2,
api3 : MockAPI.test1,
api4 : MockAPI.test2,
api5 : MockAPI.test1,
};
const queryOption = { onSuccess: () => setCount(prev => prev + 1) };
const params = [null, "zubetcha", null, "zubetcha", null];
const result = useQueries("rerender", apis, queryOption, params)
console.log(result)
// ...
브라우저에서 확인해보면..!
useQuery가 반환해주는 data가 파라미터로 넘긴 apis의 key이름으로 이쁘게 잘 정리까지 되어 있는 걸 볼 수 있다!
마치며
프로젝트를 하면서 항상 최적화 해야지~해야지~ 입으로는 말하면서도 뭐부터, 어떻게 해야 할지 감이 안 왔었는데 처음부터 고민하면서 시도하고, 마침내 내가 원하는 바를 이룰 수 있어서 뿌듯하고 뜻깊었다. 앞으로도 다양한 방면으로 최적화를 해내고 싶다는 욕심도 생겼다. 아좌좌..~!