조회는 useQuery, 업데이트는 useMutation을 쓰는 게 항상 옳을까

김채은·2024년 3월 27일
5
post-thumbnail

배경

React Query의 사용법에 대한 자료들을 둘러보면 useQuery 는 데이터를 조회할 때, useMutation 은 데이터를 업데이트할 때 사용하는 게 정론이다. 하지만 사내 프로젝트를 진행하면서 데이터를 조회할 때 useQuery 를 사용하는 것이 항상 좋은 것은 아니라는 것을 알게 됐다.

조회 페이지의 요구사항 (General)
1. 초기 화면에서는 데이터를 조회하지 않는다.
2. 필터를 통해 params를 선택한다.
3. 조회 버튼을 누를 시 데이터를 조회(서버)한다.
4. 페이지 변경 시 데이터를 조회(서버)한다.

React Query 외에도 React Hook Form 라이브러리가 해당 컴포넌트에 엮여있는데, 이 라이브러리의 사용은 위 요구사항에 적합하다. 비제어 컴포넌트를 사용하기 때문에 필터 값을 바꾸는 도중에는 값에 관심이 없고*, submit 이벤트가 발생했을 때만 필터 값을 서버로 보내 데이터를 조회할 수 있도록 한다.

*특정 컴포넌트에서 watch 를 사용하는 경우는 있다.

하지만 useQuery 는 컴포넌트가 렌더링할 때 기본적으로 데이터를 조회한다. 아래 코드처럼 useList라는 쿼리 훅으로 데이터를 가져와 Info 컴포넌트의 props로 넘겨주어 렌더링하는 구조다.

// Component.tsx

const [filterData, setFilterData] = useState<ParamType>({
  startDate: '',
  endDate: '',
  page: 1,
});

// useQuery hook
const { data } = useList(filterData);

return (
	// ...
	
	<Info list={data}/>
	
);

이 경우 처음 렌더링이 될 때 반드시 한 번의 데이터 조회가 일어난다. 이것을 방지하기 위해서 useList 훅에서 enabled: false 처리를 해준다.

// useList.ts

const useList = (params: ParamType) =>
  useQuery(
	  ['list', { ...params }], 
	  () => getList(params), 
	  { enabled: false }
	);

이렇게 되면 처음 렌더링이 되었을 때 조회를 방지할 수 있다. 하지만 조회 버튼을 눌렀을 때 데이터를 불러와야하므로 filterData 라는 상태를 계속 관리하며, filterData 의 상태가 변경됨을 모니터링하며 refetch를 해주는 useEffect 코드가 생긴다.

// Component.tsx

const { data, refetch } = useList(filterData);

useEffect(() => {
	if (filterData.startDate){
		refetch();
	}
}, [filterData]);

// Filter useForm에서 handleSubmit의 param이 되는 핸들러 함수
const onFilterSubmit = (data: FieldValues) => {
  setFilterData({
    ...data,
    startDate: `${data.startDate}T00:00:00`,
    endDate: `${data.endDate}T00:00:00`,
    page: 1,
  });
}

새로운 데이터 조회는 조회 버튼 클릭 또는 페이지 버튼 클릭이라는 이벤트에만 발생하기 때문에 이러한 흐름이 어색하게 느껴지고 설명 없이는 이해하기 힘들다.

따라서 이러한 요구사항에는 함수 호출을 할 때 서버로 요청을 보낼 수 있는 useMutation 이 적합하다고 판단하였고, 리팩터링을 진행하게 됐다.

도입

// useList.ts

const useList = () => 
	useMutation((params: ParamType) => getList(params));

  
// Component.tsx
  
const { data, mutate } = useList();

const onFilterSubmit = (data: FieldValues) => {
  mutate({
    ...data,
    startDate: `${data.startDate}T00:00:00`,
    endDate: `${data.endDate}T00:00:00`,
    page: 1,
  });
  setPage(1);
}

return (
  // ...
  
  <Filter onSubmit={onFilterSubmit} />
  <Info list={data} />
  
);

이렇게 바꾸면 기존에 onFilterSubmit 에서 받아오던 데이터를 filterData 라는 상태로 관리하지 않고, 바로 mutate 를 통해 서버로 보낼 수 있다. 또한 응답은 기존에 컴포넌트에 props로 보내주던 형태 그대로 가져오며, 컴포넌트 내에서 상태처럼 사용할 수 있다.

문제

문제는 페이지의 변경이 일어날 때도 새롭게 서버로 조회 요청을 보내야 한다는 것이다. 하지만 조회에 필요한 필터 값은 필터 컴포넌트에서만 받아올 수 있다. 필터와 페이지네이션 컴포넌트가 완전히 분리돼있어서 페이지 변경 시 데이터를 가져오는 과정이 매우 복잡해졌다.

// Component.tsx

return (
	// ...
	
	<Filter onSubmit={onFilterSubmit} />
	<Pagination 
		totalPage={data?.totalPage} 
		onChange={(page) => setPage(page)} 
		page={page} 
	/>
	
);

Filter와 Pagination 컴포넌트 관계 도식화

이를 해결하기 위해 생각해본 방안과 문제점은 다음과 같다.

  • 방안 1: 필터 값을 관리하는 filterData 상태를 관리하여 페이지 변경 이벤트 시 사용한다.
    • 문제점: 기존에 리팩터링 진행 원인으로 제기되었던 불필요한 상태 관리 문제가 재발생한다.
  • 방안 2: 필터 값을 관리하는 useForm 을 상위 컴포넌트에서 정의하고, getValue 로 필터 값을 불러온다.
    • 문제점 1: 기존 코드에서 변경 공수가 크고, 상위 컴포넌트가 비대해진다.
    • 문제점 2: 필터 옵션만 바꾸고 조회를 누르지 않은 채로 페이지를 변경하면 바뀐 옵션의 결과값이 조회된다(이 부분은 팀원 분께서 찾아주셨다!).
  • 방안 3: hidden input을 필터 컴포넌트 안에 넣고 페이지가 변경될 때 임의로 submit 이벤트를 발생시킨다.
    • 문제점: 기존 코드에서 변경 공수가 크고, 한눈에 봤을 때 플로우를 이해하기 어렵다.

해결

최종적으로 도출한 방안은 조회 이후 유지되는 data 라는 상태를 이용하는 것이다. 페이지가 변경되면 현재의 필터 값을 계속 유지한 채로 페이지 값만 변경해서 재요청을 하면 된다. 해당 데이터를 요청할 때 보냈던 필터 값을 응답값과 함께 가지고 있으면, 페이지가 변경됐을 때 그 필터 값만 가져와서 재요청을 할 수 있다고 생각했다.

처음에는 백엔드에서 응답으로 보냈던 params를 그대로 리턴해주어야 한다고 생각했는데, 이 역시 API 변경이 필요하기 때문에 더 큰 공수가 필요하다.
그래서 처음에 택한 방법은 응답 값을 가져오는 함수에서 데이터를 정제해서 리턴할 때, 보냈던 params를 그대로 포함해서 보내는 것이다.

// getList.ts

const getList = async (params: ParamType) => {
  const response = await typedGet<GetType>(API_URL, { params });
  return { ...response.data.data, params };
};

그러면 기존에 보냈던 필터 값은 유지하고, 페이지만 변경해서 mutate 를 실행할 수 있다.

// Component.tsx

const onPageChange = (page: number) => {
	setPage(page);
	data &&
      mutate({
      ...data.params,
      page
    });
}

return (
	// ...
	
	<Pagination 
		totalPage={data?.totalPage} 
		onChange={onPageChange} 
		page={page} 
	/>

);

깃허브 댓글: 저도 몰랐는데 useMutation이 반환하는 variables가 가장 최근 fetch된 파라미터 스냅샷을 제공하더라구요.

그런데 팀원 분께서 PR에 이러한 리뷰를 남겨주셨다. 공식 문서를 찾아봐도 variables를 리턴한다는 내용은 없는데... 여쭤보니 GPT의 도움을 받아 v5 문서를 참고하셨다고 한다. 해당 프로젝트는 v4 버전을 쓰고 있는데, 이 역시 variables를 지원을 하고 있으나 공식 문서에는 추가 되지 않은 상태다. 🤔

const {
  data,
  error,
  isError,
  isIdle,
  isLoading,
  isPaused,
  isSuccess,
  failureCount,
  failureReason,
  mutate,
  mutateAsync,
  reset,
  status,
} = useMutation({...});
const {
  data,
  error,
  isError,
  isIdle,
  isPending,
  isPaused,
  isSuccess,
  failureCount,
  failureReason,
  mutate,
  mutateAsync,
  reset,
  status,
  submittedAt,
  variables,
} = useMutation({...});

여하튼, fetch 함수를 직접적으로 수정하는 부분은 나 역시도 걸렸던 부분이라 variables를 이용하여 더욱 직관적인 코드를 작성할 수 있어서 좋았다. 적용법은 아래와 같다.

// Component.tsx

const { mutate, variables } = useList();

const onPageChange = (page: number) => {
	setPage(page);
	variables &&
      mutate({
      ...variables,
      page
    });
}

메인테이너 의견

(2024. 10. 08. 추가)
이러한 방향에 대한 의문이 종종 있는 것 같아 메인테이너의 의견을 추가한다.

당연히 사용할 수 있습니다. 하지만, mutation의 상태가 인스턴스에 공유되지 않고, auto/smart 리패칭은 지원되지 않습니다.

전체 상태 공유와 smart 리패칭은 위의 요구사항에서 불필요한 기능이라고 생각하기 때문에, 충분히 도입 할 수 있다고 생각한다.

결론

조회할 때는 useQuery , 업데이트할 때는 useMutation 이라는 일반적인 상황에 맞추어 코드를 작성하다 보니 이러한 상황이 발생한 것 같다. 꼭 똑같은 상황이 아니더라도, 요구사항에 맞추어서 유연하게 스택을 선택하는 게 중요하다는 생각이 들었다.

처음 사내 프로젝트를 맡았고 내가 진행해온 프로젝트가 아니기 때문에 기존 컨벤션에 맞추어 코드를 짰었다. 그런데 팀원 분이 PR에 '중복 상태 관리'에 대해 의견을 남겨주셨고, 그것을 바탕으로 개선점을 고민하다보니 결국 요구사항과 스택의 간극이 있다는 데까지 생각이 닿게 되었다. 관성적으로 코드를 쓰려고 하는 게 아니라, 항상 '왜'에 대해 생각하는 것이 중요하다는 것을 또다시 느꼈다.

P.S.
문제 상황에 대해 100% 완벽한 해결방안은 아닐지 몰라도, 제가 생각했을 때 최선의 방향으로 코드를 개선해보았습니다. 더 좋은 개선 방향에 대해 의견을 남겨주시면 많은 도움이 될 것 같습니다.

profile
배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧

2개의 댓글