React

[react-query] useQuery와 Promise.all을 활용한 리렌더링 최적화

zubetcha 2022. 7. 13. 00:12

 

 

본 게시글의 내용은 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이름으로 이쁘게 잘 정리까지 되어 있는 걸 볼 수 있다!

 

마치며 

프로젝트를 하면서 항상 최적화 해야지~해야지~ 입으로는 말하면서도 뭐부터, 어떻게 해야 할지 감이 안 왔었는데 처음부터 고민하면서 시도하고, 마침내 내가 원하는 바를 이룰 수 있어서 뿌듯하고 뜻깊었다. 앞으로도 다양한 방면으로 최적화를 해내고 싶다는 욕심도 생겼다. 아좌좌..~!