[월간기록]회사에서 경험한 리액트 쿼리 리팩터링 경험

SANGHYUN KIM·2023년 7월 15일
1
  1. 직장에서 코드리뷰(라고 불리기에는 한 달에 한 번)시간에 공유한 내용을 다시 정리한 글입니다.
  2. query에 대한 기능 설명은 생략하겠습니다. 공식문서와 TKDodo의 블로그 글을 읽는 것이 더 도움이 됩니다.
  3. 회사 프로젝트에서는 v3.xxx를 사용 중입니다.

어떤 상황?

서비스 어플의 백 오피스 유지보수가 주 업무이며 최근에는 react 프로젝트의 클래스형을 함수형으로 리뉴얼 오픈했다. 리뉴얼 당시에는 시간이 부족했고 TS를 적용하지 않았기에 조금씩 type을 적용하면서 리팩터링하려고 계획했다.

이 때 2가지 상황이 동시에 발생했다.

먼저, 백오피스 사용자가 검색 버튼을 누르면 새로운 데이터를 무조건 받아오고 싶어한다. 그러나 리뉴얼 당시 이런 요구를 몰랐기에 신경을 쓰지 못 했고 필터링과 검색 키워드를 쿼리 키값에 넣어 관리를 하고 있기에 동일한 쿼리 키면 통신이 되지 않는다. 이러한 상황을 오픈 후에 알게되었다. 급하게 수정이 필요했기에 모든 쿼리 키값에 Date값을 넣어서 강제적으로 모든 쿼리키값을 다 다르게 만들어줬다.

두 번째는 TS 확장자로 변경을 하니 쿼리 부분의 타입 흐름이 매우 부자연스럽고 까다롭게 느껴진다. 훅으로 몇번 감싸다보니 타입 흐름이 잘 되지 않고 자동완성 기능 또한 지원되지 않았다.(실력이 부족한 것은 덤)

이 위 두 상황으로 인하여 가장 먼저 통신 쪽을 리팩터링 및 TS를 적용하기로 했다.

기존 쿼리의 형태는?

기존 query의 형태는 카카오의 My구독의 React Query 전환기을 참고해서 작성되었다고 한다.
그렇기에 공통 옵션을 가지는 범용 쿼리를 만들고 이를 customHook으로 작성하면서 queryFn을 탑재하고 사용처에서 쿼리 키값과 옵션을 설정해주는 형태로 이루어져있다.

// customHook.js - 공통 option값 설정을 위하여 생성
import { useQuery as useQueryOrigin } from 'react-query'

export function useQuery(queryKey, queryFn, options) {
  return useQueryOrigin(queryKey, queryFn, {
    ...options,
    useErrorBoundary: condtion ? true : false
  });
}

// hook.js - customHook을 import하여 hook을 저장하는 파일에서 사용
function useGetSomething({queryKey, options}) => {
	return useQuery(queryKey, fetchFunction, {...options})
}

// component.jsx - 컴포넌트 위치 호출 
const { data, remove } = useGetSomthing({
	queryKey; [queryKeyFromAnotheFile, variables]
	options: {
		onSuccess: () => {
			setState() // 성공한 데이터를 상태값을 저장하고 있음
    },
		onError: () => {
			void()
    },
	}
})

구조는 파악이 잘 되었으니 다음 작업으로는 라이브러리 자체에서는 타입을 어떻게 작성하고 반환하는지 확인했다.

리액트 쿼리의 내부 타입 구조 확인

개발자가 직접 타입 할당 필요 없이 React Query는 알아서 잘 타입을 추론해줍니다(Types in React Query generally flow through very well so that you don't have to provide type annotations for yourself) - React Query TypeScript

공식문서 및 stackblitz에 확인한 결과 queryFn에다가 반환타입을 잘 지정해주면 타입이 아래와 같이 자연스럽게 추론된다.

const fetchGroups = (): Promise<Group[]> =>
  axios.get('/groups').then((response) => response.data)

const { data } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// const data: Group[] | undefined

위 구조를 봤을 때 제너릭을 통하여 타입을 넘겨지고 있는 것을 확인했고 어떤 제너릭 구조를 가지고 있는지 아래 링크를 통하여 확인할 수 있었다.
React Query and TypeScript / react-query에 typescript 적용하기 - 리액트 쿼리, 타입스크립트

확인도 했으니 이제는 리액트 쿼리에 타입을 지정했다.

타입 지정 1차 - 현재 구조를 유지하면서 제너릭을 통한 타입 지정

수정사항이 많지 않은 방향으로 진행을 하고 싶었기에 현재 작성되어 있는 hook에서 제너릭을 통하여 타입을 넘기기로 했다.

// customHook.tsx - 제너릭 명시
export function useQuery<T, U, V, W>(queryKey: T, queryFn: U, options: Object<V>) {
  return useQueryOrigin<T, U, V, W>(queryKey, queryFn, {
    ...options,
    useErrorBoundary: condtion ? true : false
  });
};

// hook.tsx - customHook을 import하여 hook을 저장하는 파일에서 사용
function useGetSomething<T, U, V, W>({queryKey, options}) => {
	return useQuery<T, U, V, W>(queryKey, fetchFunction, {...options})
};

// component.tsx - 컴포넌트 위치 호출
const { data, remove } = useGetSomthing<T, U, V, W>({
	queryKey; [queryKeyFromAnotheFile, variables]
	options: {
		onSuccess: () => {
			setState() // 성공한 데이터를 상태값을 저장하고 있음
    },
		onError: () => {
			void()
    },
	}
});

그러나, 1차 시도 이후에 발견한 것이 있는데....

  • 위와 같은 구조로 하다보니 모든 api에 앞에 타입추론을 위하여 제너릭을 반복적으로 넣어줘야 하는 상황 발생 → 사용성 저하
    • 또한, 현재 작성한 코드와 동일한 상황(카카오의 “My구독의 React Query”를 보고 같은 방법으로 진행)을 겪은 사람 또한 사용성이 좋지 않다고 생각
  • customhook이라서 실제로 호출하는 페이지에서 query의 옵션을 칠 때 자동완성 미흡, 타입 넘김이 부드럽지 않음

타입 지정 2차 - customHook을 탈피하여 react query함수를 바로 useHook으로 대입

그래서, 중첩 구조를 탈피하고 option값은 queryOption에서 설정을 전부 해주고 아래와 같이 줄였다.

// hook.tsx - customHook을 제거하고 query 자체를 library로부터 import하여 hook에 지정
import { useQuery } from 'react-query'

function useGetSomething({queryKey, setState}) => {
	return useQuery(queryKey, fetchFunction, {
		onSuccess: () => {
			setState() // 성공한 데이터를 상태값을 저장하고 있음
    },
		onError: () => {
			void()
    },
	})
}

// component.tsx - 컴포넌트 위치 호출 
const { data, remove } = useGetSomthing({ 
	queryKey; [queryKeyFromAnotheFile, variables],
	setState: setState
})

이렇게 변경함으로써, 제너릭 반복 명시를 줄이고 자동완성 또한 잘 되는 것을 확인했다.

2차 시도 이후….

React Query API의 의도된 중단이라는 글을 보게 되었다.
다음 있을 major update에서 useQuery의 onSuccess와 onError 콜백 함수가 삭제된다고 한다

maintainer의 말에 따르면:

  • 비동기 상태를 저장하는 행위 자체가 잘 못됨(저장 되는 순간 그 데이터가 정말 서버와 같은 값?)
    // Bad
    export function useTodos() {
      const [todoCount, setTodoCount] = React.useState(0)
      const { data: todos } = useQuery({
        queryKey: ['todos', 'list'],
        queryFn: fetchTodos,
        //😭 제발 이러지 마세요
        onSuccess: (data) => {
          setTodoCount(data.length)
        },
      })
    
      return { todos, todoCount }
    }
    
  • 상태에 저장을 하게 되면 초기 렌더링 → 서버 값이 내려온 순간의 상태 → 서버의 값을 상태에 저장하는 순간. 즉, 중간에 한번 렌더링이 한번 더 되는 순간이 생겨서 나중에 어떤 결과가 나올지 예상하기 어려움
  • 콜백 자체가 실행되지 않을 수 있음 → 서버의 부하를 덜어주기 위해서 서버의 값을 자체적으로 특정시간동안 캐싱하여 보관할 수 있음(서버 요청을 하지 않음) → 캐싱 시간에 한번 더 호출이 되면 onSuccess의 콜백이 실행되지 않음

그러면 maintainer가 제안하는 방법은?

// Good - 상태에 넣지 말고 그대로 반환하자
export function useTodos() {
  const { data: todos } = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  const todoCount = todos?.length ?? 0

  return { todos, todoCount }
}

지금 구조를 변형시킬 때 추후 방향까지 같이 반영하면 그 다음 유지보수 시 작업이 줄어들지 않을까?

3차 - 비동기 상태를 그대로 반환하는 작업

// hook.tsx - 결과값 자체 또는 변환값을 반환
function useGetSomething({queryKey}) => {
	const { data, remove } =  useQuery(queryKey, fetchFunction, {...options})
	const gridData = convertGridData(data);	

	return { data, gridData, remove };
}

// component.tsx - 컴포넌트 위치 호출 
const { data, gridData, remove } = useGetSomething({
    queryKey: [QUERY_KEY.list, { ...search }]
  });

<Grid data={gridData}> // 바로 넘겨줘서 렌더링
	.....
</Grid>

3.1 차 -그러면 onError는 어떻게 처리?

onError기능을 queryCallback에서 삭제했지 query option에서는 삭제하지 않았다.

option을 이용한 global callback을 할 수 있음
또한 같은 위치에서 errorBoundary 설정을 통하여 error로 처리할 지 말지 설정 가능하다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      toast.error(`Something went wrong: ${error.message}`)},
		useErrorBoundary: (err) => {
			return error > 500 // http status값을 확인하고 사용
		}
  }),
})

4차 - 200번대 응답이지만 success값이 false인 것

{
	success:  true | false;
	message: null | undefinded | string
	data: data object | null
}

실제로 에러로 판단되어야 할 사항이 200번대 응답으로 오고 success가 false이면서 message를 주는 응답들이 있다. Global callback으로 전부 처리하려고 선배와 머리를 맞대고 고민했지만 생각이 나지 않아서 나는 queryFn에 집중을 했다.

queryFn에 집중한 이유는....

  1. queryFn이 통신을 담당하고 반환값을 react-query에 전달(데이터 보관으로 연결)하고
  2. queryFn의 결과로 react-query가 error인지 success인지 구별한다.

그래서 queryFn을 활용에 대해서 검색을 하게 되었고 다음 2개의 Github issue를 발견했다.

  1. Should I pass query and mutation objects into useEffect or not? · TanStack/query · Discussion #5437
  2. Throwing Error in queryFn returns uncaught and unmounts entire app · TanStack/query · Discussion #5337

위 두 글에서도....

  • queryFn 자체에서 통신 결과만 return하는 것이 아닌 자체적으로 가공하거나 error를 던지는 케이스가 존재하며
  • 특히 두 번째 글의 queryFn안에 있는 로직이 현재 우리가 받는 api의 로직과 비슷하다고 생각했다.

maintainer의 블로그에서도 queryFn에서의 결과값을 한번 처리, 즉 중간 처리 작용이 있는 것을 확인했다.

import { z } from 'zod'

// 👀 define the schema
const todoSchema = z.object({
  id: z.number(),
  name: z.string(),
  done: z.boolean(),
})

const fetchTodo = async (id: number) => {
  const response = await axios.get(`/todos/${id}`)
  // 🎉 parse against the schema
  return todoSchema.parse(response.data)
}

const query = useQuery({
  queryKey: ['todos', id],
  queryFn: () => fetchTodo(id),
})

따라서, success값이 false이면 프론트에서 error를 던져주면서 message를 포함시키자

// fetchFunction.ts - 200번대의 실패값을 프론트에서 에러로 처리
const fetchFunction = async(): Promise<Group[]> =>
  const result = await axios.get('/groups').then((response) => response.data);
	if (result.success){
		return result;
	}else{
		throw new Error(result.message); // errorBoundary로 넘어가지 않게 설정
	}
}

// hook.tsx
function useGetSomething({queryKey}) => {
	const { data, remove } =  useQuery(queryKey, fetchFunction, {...options})
	const gridData = convertGridData(data);	

	return { data, gridData, remove };
}

4차까지 수정을 해온 결과

  • 라이브러리가 원하는 형태를 추구할 수 있음(안티패턴 탈피)
  • 비동기 상태를 상태로 저장하지 않음 → 렌더링 횟수 줄어듦
  • 추상화가 잘 되어서 react가 추구하는 선언식 방식에 부합한 상태(구현 및 명령식을 컴포넌트 위치에서 삭제하고 hook에서 구현)
  • 200번대 error를 global하게 처리하여 코드양 감소 및 추후 major 업데이트 시 작업 요소 경감

끝맺음

지금보니 나는 리액트 쿼리를 몰랐다.
입사 전 리액트 쿼리를 경험했고 useEffect를 통해서 다시 불러오는 것을 줄여주는 것이 장점인 줄 알았다.
그래서 README를 작성할 때, 위 이유가 부족하다고 생각해서 팀원이 서비스가 실제로 운영된다고 생각했을 때, 서버의 트래픽을 최소화시켜주는 것이 비용, 성능적인 측면에서 중요하다고 생각했고, 데이터를 캐싱하여 stale한 경우만 재요청할 수 있는 TanStack Query를 사용했습니다.라고 대신 아이디어를 내주었다.
하지만 저 캐싱에 대한 개념도 잘 몰랐고 재요청에 대한 경험을 제대로 알지 못하다보니 면접 스터디에서도 제대로 대답을 못 했다.
왜 공감을 못 했는지 지금 생각해보자면 내가 이번에 겪은 상황만큼 고민하고 공식문서를 읽고 구조에 대해서 이야기를 많이 해보지 않았기 떄문인 것 같다.
그래서 나는 정말 지금까지 리액트 쿼리를 몰랐던 거다. 다행이 이번 경험을 통해서 조금은 알게되었다고 자신있게 이야기할 수 있다.

이제 부족한 부분을 더 채우러 가자.

profile
꾸준히 공부하자

0개의 댓글