- 직장에서 코드리뷰(라고 불리기에는 한 달에 한 번)시간에 공유한 내용을 다시 정리한 글입니다.
- query에 대한 기능 설명은 생략하겠습니다. 공식문서와 TKDodo의 블로그 글을 읽는 것이 더 도움이 됩니다.
- 회사 프로젝트에서는 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 적용하기 - 리액트 쿼리, 타입스크립트
확인도 했으니 이제는 리액트 쿼리에 타입을 지정했다.
수정사항이 많지 않은 방향으로 진행을 하고 싶었기에 현재 작성되어 있는 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차 시도 이후에 발견한 것이 있는데....
그래서, 중첩 구조를 탈피하고 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
})
이렇게 변경함으로써, 제너릭 반복 명시를 줄이고 자동완성 또한 잘 되는 것을 확인했다.
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 }
}
그러면 maintainer가 제안하는 방법은?
// Good - 상태에 넣지 말고 그대로 반환하자
export function useTodos() {
const { data: todos } = useQuery({
queryKey: ['todos', 'list'],
queryFn: fetchTodos,
})
const todoCount = todos?.length ?? 0
return { todos, todoCount }
}
지금 구조를 변형시킬 때 추후 방향까지 같이 반영하면 그 다음 유지보수 시 작업이 줄어들지 않을까?
// 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>
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값을 확인하고 사용
}
}),
})
{
success: true | false;
message: null | undefinded | string
data: data object | null
}
실제로 에러로 판단되어야 할 사항이 200번대 응답으로 오고 success가 false이면서 message를 주는 응답들이 있다. Global callback으로 전부 처리하려고 선배와 머리를 맞대고 고민했지만 생각이 나지 않아서 나는 queryFn에 집중을 했다.
queryFn에 집중한 이유는....
그래서 queryFn을 활용에 대해서 검색을 하게 되었고 다음 2개의 Github issue를 발견했다.
위 두 글에서도....
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 };
}
지금보니 나는 리액트 쿼리를 몰랐다.
입사 전 리액트 쿼리를 경험했고 useEffect를 통해서 다시 불러오는 것을 줄여주는 것이 장점인 줄 알았다.
그래서 README를 작성할 때, 위 이유가 부족하다고 생각해서 팀원이 서비스가 실제로 운영된다고 생각했을 때, 서버의 트래픽을 최소화시켜주는 것이 비용, 성능적인 측면에서 중요하다고 생각했고, 데이터를 캐싱하여 stale한 경우만 재요청할 수 있는 TanStack Query를 사용했습니다.
라고 대신 아이디어를 내주었다.
하지만 저 캐싱에 대한 개념도 잘 몰랐고 재요청에 대한 경험을 제대로 알지 못하다보니 면접 스터디에서도 제대로 대답을 못 했다.
왜 공감을 못 했는지 지금 생각해보자면 내가 이번에 겪은 상황만큼 고민하고 공식문서를 읽고 구조에 대해서 이야기를 많이 해보지 않았기 떄문인 것 같다.
그래서 나는 정말 지금까지 리액트 쿼리를 몰랐던 거다. 다행이 이번 경험을 통해서 조금은 알게되었다고 자신있게 이야기할 수 있다.
이제 부족한 부분을 더 채우러 가자.