React Query로 Hook 개선하기

 현재 프로젝트로부터 Redux를 걷어내고 React Query를 도입하여 서버 상태를 관리하고 있습니다. React Query를 사용하면서 data fetching, caching 그리고 syncronization 기능을 매우 편리하게 구현할 수 있었습니다. 이러한 장점 때문에 redux뿐만 아니라 useEffect안에서 api를 호출하고 useState를 사용해 서버 상태를 관리하던 코드도 점진적으로 React Query로 대체하고 있는 중입니다. 그중 React Query로 DX를 개선했을 뿐만 아니라 성능과 UI/UX 측면에서도 개선한 사례가 있어서 소개하고자 합니다.

작업 페이지와 Hook 구조

 저는 다음과 같은 작업 페이지를 개선했습니다. 간단히 설명해서 Next 버튼을 누르면 다음 작업을 불러오고 Prev 버튼을 누르면 이전 작업을 불러올 수 있는 페이지입니다.

 Next와 Prev 버튼을 누르면, 다음 작업ID(taskId)를 불러오는 api를 호출합니다. useTaskPaging 훅이 이 로직을 담당하고 있고 총 13개의 파일이 해당 훅을 재사용 하고 있습니다.

const useTaskPaging = <T, A>(sessionId: string) => {
  const [taskId, setTaskId] = useState(0);
  const [task, setTask] = useState<T | undefined>(undefined);

  const getTaskIdOf = async (taskId: number, pageType: TPage) => {
    const { api, errorMsg } = PAGE_API_MAP[pageType];
    try {
      const response = await api(sessionId, taskId);
      setTaskId(response.data.task_id);

      return response.data.task_id;
    } catch (err) {
      throw new Error(errorMsg);
    }
  };

  useEffect(() => {
    getTaskIdOf(taskId, 'current');
  }, []);

  return {
    task,
    taskId,
    handleClickPrevBtn,
    handleClickNextBtn,
  };
};

 만약 현재 작업페이지의 작업ID가 10이라면, Next버튼을 눌러 작업ID 11을 불러올 수 있습니다.

 백엔드 설계상 다음 작업ID 불러오기와 이전 작업ID 불러오기 API가 따로 존재합니다. 따라서 map을 사용해 어느 버튼을 눌렀는지에 따라 알맞은 API를 매핑해줘야 합니다.

코드
const handleClickNextBtn = async () => {
  await saveTaskResult<A>(taskId, { annotation });
  await getTaskResult(taskId, 'next');
};
type TPage = 'current' | 'prev' | 'next';

type TPageApi = {
  api: (sessionId: string, taskId: number) => Promise<AxiosResponse<TTaskResponse, AxiosError>>;
  errorMsg: string;
};

const PAGE_API_MAP: Record<TPage, TPageApi> = {
  current: {
    api: sessionAPI.getInitialTask,
    errorMsg: '수행 가능한 Task가 없습니다.',
  },
  prev: {
    api: sessionAPI.getPrevTask,
    errorMsg: '이전 Task가 없습니다.',
  },
  next: {
    api: sessionAPI.getNextTask,
    errorMsg: '다음 Task가 없습니다.',
  },
};

현재 Hook의 문제점

 useTaskPaging으로 원하는 기능을 잘 구현했습니다. 하지만, 몇가지 문제점이 있습니다.

1. Single Source of Truth 위배

 아시다시피 서버로부터 받은 응답값을 useState로 보관하고 있습니다. 동일한 데이터를 서버와 클라이언트 두 군데에서 관리한다는 것은 Single Source of Truth 원칙을 위배합니다. 만약 불러온 데이터가 수시로 바뀌는 값이라면 API를 호출한 직후에는 서버와 클라이언트가 같은 값을 가지지만, 그 이후에는 두 데이터가 동일하다는 것을 보장할 수 없습니다.

2. 추상화되지 않은 api 요청 코드

 저희 프로젝트에선 api 모듈을 다음처럼 관리합니다.

 useTaskPaging에서 이 api 모듈을 직접 가져와 호출하기 때문에 생기는 아쉬운 점이 몇가지 있습니다.

1. 스네이크 케이스 노출 - 변수명 컨벤션 위배

 서버에서는 taskIdtask_id라는 값으로 저장하고 있습니다. 이를 useTaskpaging에서 직접 불러오면 response.data.task_id로 필요한 데이터를 조회할 수 있습니다. 그러나, 변수명 컨벤션에 맞지 않는 스네이크 케이스가 노출되고 있다는 점이 마음에 들지 않습니다.

2. 불필요한 디테일 노출 - 낮은 추상화 레벨

 추상화 레벨을 높인다면, 개발자는 `useTaskPaging`에서 `taskId`를 불러오고 있다는 정보만 알면 충분합니다. 그러나 response.data.task_id로 조회하는 것, sessionAPI api 모듈을 쓴다는 것 그리고 에러 처리를 어떻게 하는지에 대한 정보까지 전부 보이고 있습니다. 그리고 버튼을 누를때 매핑되는 api정보도 되도록이면 숨기고 싶지만, 현재는 노출된 상태입니다.

3. 버튼을 누를때마다 API 호출 - 캐싱 전략 부재

 이렇게 구현한다면 Next나 Prev버튼을 누를때마다 매번 API를 호출하게 됩니다.

 현재 작업ID 10에서 API를 호출하여 작업ID 11을 받은 상황을 가정해봅시다. 이때 이전 작업ID 불러오기를 통해 작업ID 10으로 돌아와서 다시 다음 작업ID를 불러오려고 합니다. 응답값이 11이라는 것을 캐싱했다면 굳이 API를 재호출할 필요가 없습니다. 하지만, 위 코드에선 이 서버 데이터를 클라이언트에서 캐싱하고 있지 않기 때문에 API매번 재호출하고 있습니다.

 또한 사용자가 버튼을 연속으로 매우 빠르게 클릭할 수 있습니다. 빠르게 버튼을 연달아 누르면 다음 작업을 불러오기까지 불필요한 api를 여러번 요청하여 서버에 부하를 주게 됩니다.

4. API 로딩 정보 부재

 사용자는 다음 작업을 불러오기까지 약간의 딜레이를 겪게 됩니다. 로딩 UI가 없어서 정보를 불러오는데 걸리는 시간이 체감상 더 느린 것 같이 느껴지고, 현재 정보를 불러오고 있는 상태인지 알 수 없어서 사용자 경험을 저하시킵니다.

Hook에 React Query 도입하기

 이제 본격적으로 React Query를 도입하여 앞서 언급한 문제들을 해결해보겠습니다.

추상화와 모듈화

 먼저 추상화와 Query 모듈화를 위해 다음과 같은 파일 구조를 설계했습니다.

함수명에 Next가 있지만, 편의상 생략하겠습니다.

queries.ts

 queries 파일엔 useTaskIdQueryrequestTaskId가 있습니다.

코드
export const useNextTaskIdQuery = (
  params: TPrevOrNextTaskIdParams,
  option?: UseQueryOptions<number>
) => {
  const { sessionId, taskId } = params;
  return useQuery<number>(
    ['taskId', sessionId, taskId, taskId + 2],
    () => requestNextTaskId({ sessionId, taskId }),
    option
  );
};
const requestNextTaskId = async (params: TPrevOrNextTaskIdParams) => {
  const { sessionId, taskId } = params;
  try {
    const res = await sessionAPI.getNextTask(sessionId, taskId);
    return res.data.task_id;
  } catch (err) {
    throw new Error('다음 Task가 없습니다.');
  }
};

requestTaskId

 requestTaskId는 기존 useTaskPaging훅에서 사용하던 api 호출 코드입니다. useTaskPaging로부터 해당 코드를 걷어냄으로써 useTaskPaging는 디테일한 정보들을 알 필요가 없어졌고 더이상 기본적인 axios 에러 처리를 담당하지 않습니다.

useTaskIdQuery

 useTaskIdQuery는 useQuery를 위한 query입니다. 이곳에선 requestTaskId를 호출하는 useQuery를 반환하고 메모리에 저장하기 위한 queryKey가 정의되어 있습니다.

 이 쿼리를 useTaskPaging뿐만 아니라 다른 훅에서도 사용할 가능성이 있습니다. 이를 고려하여 useTaskIdQuery는 기본적인 요소인 queryKey와 호출 api 함수만을 가지고 있습니다. useTaskIdQuery 기본적인 사항만 갖춘채로 순수하게 보존됩니다.  이를 호출하는 hook에서 option을 전달하여 각각 사용처에 맞게 커스텀하여 사용할 수 있게 모듈화하였습니다. 물론 useTaskIdQuery에 반드시 공통적으로 쓰여야하는 option이 있다면 이 함수에 직접 심어넣을 수도 있습니다.

추후엔 카카오 테크에서 소개한 기본 쿼리를 위한 기본 Query 구조를 사용하여 option을 공통화하는 방법도 도입하면 좋을 것 같습니다.

useTaskPagingFetch.ts

 useTaskPaging에 맞게 커스텀하여 useTaskIdQuery를 사용하기 위해서 optionparameter를 전달해주는 useTaskPagingFetch함수를 만들었습니다.

코드
export const useTaskIdFetch = (params: TTaskIdFetchParams, options?: UseQueryOptions<number>) => {
  const router = useRouter();

  const { sessionId, taskId, pageType } = params;
  const commonOptions = {
    retry: false,
    onError: (err: unknown) => {
      notify('error', 'Error!', `${err}`, 1);
      router.back();
    },
  };

  const { data: initialTaskId = 0, isLoading: isInitialTaskIdLoading } = useInitialTaskIdQuery(
    { sessionId },
    {
      enabled: pageType === 'current',
      ...commonOptions,
    }
  );

  const { data: prevTaskId = 0, isLoading: isPrevTaskIdLoading } = usePrevTaskIdQuery(
    { sessionId, taskId },
    {
      enabled: pageType === 'prev',
      staleTime: 60 * 1000,
      ...commonOptions,
    }
  );

  const { data: nextTaskId = 0, isLoading: isNextTaskIdLoading } = useNextTaskIdQuery(
    { sessionId, taskId },
    {
      enabled: pageType === 'next',
      staleTime: 60 * 1000,
      ...commonOptions,
    }
  );

  const returnData = (() => {
    if (pageType === 'current') return { taskId: initialTaskId, isLoading: isInitialTaskIdLoading };
    if (pageType === 'prev') return { taskId: prevTaskId, isLoading: isPrevTaskIdLoading };
    return { taskId: nextTaskId, isLoading: isNextTaskIdLoading };
  })();

  return {
    taskId: returnData.taskId,
    isTaskIdLoading: returnData.isLoading,
  };
};

 사용자가 어떤 버튼을 클릭했는지에 따라 api를 호출하기 위해 enabled를 설정해줬고, staleTime, retry 또는 onError 옵션도 같이 전달해주는 것을 확인할 수 있습니다.

useTaskPaging.ts

 이제 useTaskPaging에선 useTaskIdFetch를 호출하여 dataisLoading을 불러오기만 하면 됩니다. 이때, Next인지 Prev인지에 따라 queryKey 상태를 변경하여 알맞은 query문을 enabled할 수 있습니다.

코드
const useTaskPaging = <T, A>(sessionId: string) => {
  const [queryKey, setQueryKey] = useState<{ taskId: number; pageType: TPage }>({
    taskId: 0,
    pageType: 'current',
  });

  const { taskId, isTaskIdLoading } = useTaskIdFetch({
    sessionId,
    ...queryKey,
  });

  const handleClickNextBtn = () => {
      // ... 
      setQueryKey({ taskId, pageType: 'next' });
  }

  // ...
}

 전과 비교했을 때 코드의 추상화 레벨이 훨씬 높아졌습니다.

Before

 기존은 API 매핑 테이블과 에러 처리에 필요한 코드들, 그리고 useEffect, useState가 필요했습니다.

After

 개선 후엔 모듈들을 사용하여 각각 모듈들에게 역할을 위임하고 useTaskPaging의 추상화 레벨을 높일 수 있었습니다.

 코드가 매우 간단해졌을 뿐만 아니라 Single Source of Truth 원칙도 지킬 수 있습니다.

결론

 이제 개발자는 useTaskPaging훅을 보고 taskId를 불러오는 역할을 담당하는 것만을 알 수 있습니다. 그 외의 디테일은 모듈화된 코드를 보고 알 수 있습니다.

 query 캐싱 전략 및 디테일한 에러 처리 등 여러 옵션을 바꾸려면 useTaskPaging가 아닌 useTaskPagingFetch파일을 수정해야 합니다.  기본적인 query에 대한 사항(queryKey, 공통 옵션, fetch함수)이나 기본 axios 에러를 바꾸려면 queries 파일을 수정해야 합니다.

 이를 통해 useTaskPaging가 비대해지는 것을 막고 기본 쿼리를 순수하게 보존한 채 여러 훅에서 재사용할 수 있습니다.

 물론 이 구조를 사용한다면 보일러 플레이트는 전보다 증가했다는 것을 알 수 있습니다. 하지만, 지금 단계에선 커보이지만, 프로젝트 구조가 커지다보면 이 보일러 플레이트를 통해 앞서 언급한 이점이 더 극대화 될 것이라 예상합니다.

중간 점검

 지금까지 작업한 결과를 한번 확인해봅시다.


 React Query를 도입했지만 여러 문제점들이 보이고 있습니다.

 Next, Prev를 누를 때 작업 텍스트가 잠시 비어지는 현상이 발생합니다. 또한, 다음 작업으로 여러번 갔다가 이전으로 돌아오면 전에 불러왔던 정보지만 api 호출을 다시 하고 있습니다.

 비어지는 현상은 react-query 옵션인 keepPreviousData를 사용하면 해결할 수 있습니다. 그러나, 불러오는 동안 로딩창이 없어서 기존처럼 UX가 좋진 않습니다.

 이러한 문제점을 어떻게 해결했는지 설명드리겠습니다.

queryKey 공통화를 통한 캐싱 성능 개선

 어쨌든 React Query를 사용해 Next와 Prev를 누를때 불러온 작업ID값이 캐싱되어 API 호출을 줄일 수 있습니다. queries.ts 파일의 useNextTaskIdQueryusePrevTaskIdQuery를 보면 queryKey를 각각 아래처럼 등록해놓았습니다.

['taskId', 'next', sessionId, taskId]
['taskId', 'prev', sessionId, taskId]

 하지만, 이럴 경우 위에서 언급한 문제가 발생합니다. 그림을 통해 자세히 설명하겠습니다.

 현재 작업ID10입니다. 그리고 다음 작업을 불러올 때마다 서버 응답값이 메모리에 어떻게 캐싱되는지 확인해보겠습니다.

 Next 버튼을 누르고 난 뒤 상황입니다. 지정해준 queryKey 옆에 서버로 부터 온 응답값이 메모리에 캐싱된 것을 확인할 수 있습니다. 1번 시나리오는 현재 작업ID 10에서 Next를 눌러 작업ID 11['taskId', 'next', sessionId, 10]에 저장하였습니다.

 다시 Prev 버튼을 눌러 작업ID 10까지 오겠습니다. 그런데 이미 불러왔던 작업ID 정보지만 queryKey를 api마다 달리 주었기 때문에 메모리에 캐싱이 중복으로 쌓이고 있습니다.

 메모리에 캐싱이 두배로 쌓일 뿐만 아니라 api호출도 두배로 늘고 사용자에게 매번 딜레이를 보여주게 됩니다.

 저는 이 문제를 비록 다른 api일지라도 queryKey를 동일하게 사용함으로써 해결하였습니다.

['taskId', sessionId, taskId, taskId + 2]
['taskId', sessionId, taskId - 2, taskId]

 queryKey를 이처럼 바꾼다면 전과 비교해서 어떻게 효율적으로 동작할 수 있는지 그림을 통해 확인해보겠습니다.

 똑같이 작업ID 10에서 13까지 Next로 이동하였습니다. 지금까지는 memory에 캐싱된 양이 똑같습니다.

 이제 다시 작업ID 10까지 Prev로 이동하겠습니다.

 1번, 2번에선 캐싱된 값을 재사용하고 3번에서만 값을 새롭게 페칭하여 캐싱하는 것을 확인할 수 있습니다!

 위 예시에선 별로 차이가 나지 않아 보이지만, N번 Next를 누르고 다시 제자리로 온다 했을 때 전자는 Nx2 번의 api 호출과 Nx2 개의 캐싱이 메모리에 쌓이게 됩니다.

 하지만, 후자의 경우 N+1 번의 api 호출과 N+1 개의 캐싱이 메모리에 쌓입니다. api호출과 메모리 사용률을 대략 2배 줄일 수 있게 되었습니다.

 또한, 전자와 비교해서 Next 버튼을 여러번 누른 뒤 다시 돌아올 때 훨씬 빠르게 작업ID를 불러올 수 있습니다.

 또한 앞서 사용자가 Next 버튼을 연달아 누르는 문제를 언급했는데 이는 Throttle 기법을 통해 해결하였습니다.

import { useRef } from 'react';

type ThrottleFunction<T extends any[]> = (...args: T) => any;

const useThrottle = <T extends any[]>(
  func: ThrottleFunction<T>,
  wait: number
): ThrottleFunction<T> => {
  let shouldThrottle = useRef(false);

  return function throttled(...args) {
    if (shouldThrottle.current) {
      return;
    }

    shouldThrottle.current = true;
    setTimeout(() => {
      shouldThrottle.current = false;
    }, wait);

    func.apply(args);
  };
};

export default useThrottle;

 useThrottlehandleClickNextBtn을 감싸 500ms동안 한번의 호출만 가능하도록 하여 문제를 해결하였습니다.

UI/UX 개선

 react-query를 도입하면서 Next를 누를 때 잠시 작업 텍스트가 비어지는 현상이 발생했습니다. 이는 react-query 옵션인 keepPreviousData로 해결하였습니다.

 이제 Loading창만 보여주면 됩니다. 이는 단순하게 uesQuery의 반환값인 isLoading을 활용하면 됩니다.

 하지만, keepPreviousData를 사용한다면 isLoading이 아닌 isFetching을 사용해야 합니다. 왜냐하면 isLoading은 data를 가져와 받아온 상태인지에 대한 정보입니다. 간단히 말해 현재 data를 들고 있으면 isLoadingtrue입니다.

 저는 keepPreviousData를 사용했기 때문에 isLoading이 처음 빼고 항상 true인 상태이므로 현재 페칭중인지를 알려주는 isFetching을 활용할 수 있습니다.

 어쨌든 이를 활용해서 loading spinner UI를 사용자에게 보여주겠습니다.

마치며

 먼저 react-query를 그냥 사용하는 것이 아니라 모듈로 분리함으로써 이를 사용하는 훅의 추상화 레벨을 높였습니다. 또한, 모듈화를 통해 쿼리 사용처마다 알맞게 커스터마이징 할 수 있도록 구현하였습니다.

 보일러플레이트는 다소 비대해졌을지 몰라도 프로젝트 규모가 커질수록 오히려 관리가 용이해질 것이라 예상합니다. 당장 사용해야 하는 훅이 전부 비대한 상태인 것보단 나을테니까요.

 그리고 비록 다른 api를 사용할지라도 queryKey를 공통화할 수 있다는 점을 사용해서 api 호출과 메모리 사용률을 2배 줄일 수 있었습니다. 추가적으로 throttle 기법을 사용해 사용자가 불필요한 api를 연달아 호출하는 현상을 방지하였습니다.

 끝으로 keepPreviousDataisLoading 또는 isFetching을 사용해 사용자에게 loading spinner를 보여주었습니다. 이를 통해 사용자에게 더 나은 UX를 제공할 수 있었습니다.

Reference