들어가며
서버 상태 관리 라이브러리 직접 만들기 - 2편에서는 rollup을 활용해서 빌드를 했다. 이번에는 서버 상태 관리 라이브러리 직접 만들기 - 1편에서 만든 useQuery의 기능을 확장해보고, useMutation훅을 구현해보자.
useQuery 기능의 확장
자동 리패칭
- 인터넷이 다시 연결되었을 때 리패칭
// useQuery.ts
export default function useQuery({
useEffect(() => {
if (!refetchOnReconnect) return;
window.addEventListener("online", fetchData);
return () => window.removeEventListener("online", fetchData);
}, [fetchData, refetchOnReconnect]);
// ..
})
online 이벤트는 브라우저가 네트워크에 다시 연결되었을 때 트리거된다.
- 화면에 포커스되었을 때 리패칭
// useQuery.ts
export default function useQuery({
useEffect(() => {
if (!refetchOnWindowFocus) return;
window.addEventListener("focus", fetchData);
return () => window.removeEventListener("focus", fetchData);
}, [fetchData, refetchOnWindowFocus]);
// ..
})
focus 이벤트는 특정 요소에 포커스되었을 때 트리거된다.
다만 여기엔 짚고 넘어갈 점이 있다. 지금은 online이나 focus 이벤트가 발생하면 곧바로 fetchData를 부른다. 즉 인터넷이 다시 연결되거나 창에 포커스가 갈 때마다 무조건 서버에 다시 요청한다. react query라면 staleTime을 두고 "데이터가 아직 신선하면 재요청을 건너뛰는" 최적화를 하는데, 여기서는 단순함을 위해 그런 판정 없이 매번 새로 불러오도록 했다. 요청이 자주 발생하는 서비스라면 이 부분에 staleTime 개념을 얹어 최근에 받아온 데이터는 재요청을 생략하도록 다듬을 수 있다.
Suspense와 ErrorBoundary
Suspense는 자식 요소를 로드하기 전에 대체 UI를 보여주는 컴포넌트이다. 그리고 ErrorBoundary는 자식 요소에서 에러가 발생했을 때 대체 UI를 보여주는 컴포넌트이다. 자식 요소에서 직접 isLoading && <Loading />, isError && <Error />과 같이 명령적으로 로딩과 에러에 대한 상태를 다뤄야하는 것을, 선언적으로 다룰 수 있게 도와주는 컴포넌트이다. 이전에 토스 컨퍼런스 영상에서도 관련된 내용을 찾아볼 수 있다.
useQuery를 사용하는 측에서 이 두 컴포넌트를 사용할 수 있게 어떻게 해야할까?
- Suspense 지원
// useQuery.ts
export default function useQuery({
if (isSuspense && status === "pending" && !data)
throw getQueryPromise(queryKey);
// ..
})
Suspense를 지원하기 위해서는 pending 상태의 Promise 객체를 throw하면 된다. 그럼 Suspense는 이 Promise 객체를 주기적으로 확인하고, resolve상태가 될 때까지 대체 UI를 보여준다.
여기서 궁금해질 수 있다. throw하는 이 Promise는 어디서 만들어질까? 바로 1편에서 만든, 싱글톤 Map에 저장해둔 그 Promise다. useQuery가 데이터를 패칭할 때 queryFn()이 반환한 Promise를 setQueryPromise(queryKey, promise)로 Map에 넣어뒀는데, getQueryPromise(queryKey)로 이걸 그대로 꺼내서 throw하는 것이다. 그래서 같은 queryKey를 쓰는 컴포넌트가 여러 개여도 던지는 Promise는 하나로 묶인다.
그다음 자연스레 드는 걱정이 하나 있다. "Promise를 throw하면 Suspense가 대체 UI를 보여주고, resolve되면 컴포넌트를 다시 렌더링하는데, 그럼 다시 렌더링될 때 또 Promise를 throw해서 무한 루프에 빠지는 거 아니야?" 나도 처음엔 이게 걱정됐다.
답은 throw 조건에 있다. 위 코드를 다시 보면 status === "pending" && !data일 때만 throw한다. 사이클을 따라가 보자.
- 처음 렌더링 → data가 없고 status가 pending → Promise를 throw → Suspense가 fallback을 보여준다.
- Promise가 resolve →
setQueryData로 data가 채워지고 status가 success로 바뀐다. - Suspense가 컴포넌트를 다시 렌더링 → 이번엔 data가 있으니까 throw 조건이 거짓 → throw하지 않고 정상적으로 data를 반환한다.
즉, data가 채워졌다는 사실 자체가 무한 throw를 끊어주는 브레이크다. 이 조건 하나가 없으면 정말로 무한 루프에 빠진다.
- ErrorBoundary 지원
// useQuery.ts
export default function useQuery({
if (isErrorBoundary && status === "error") throw getQueryData(queryKey);
// ..
})
ErrorBoundary를 지원하기 위해서는 Promise가 아닌 무언가를 throw하면 된다. 그럼 ErrorBoundary는 이 컴포넌트 내부의 getDerivedStateFromError에서 에러를 캐치한다.
초기값 있는 경우와 없는 경우에 대한 타입 지원
useQuery로 데이터를 불러오기 전까지 사용자에게 먼저 데이터를 보여주고 싶다. 이에 대한 대응을 해보자.
- 오버로딩으로 Discriminated Type 지원
export default function useQuery<T>(
props: UseQueryProps<T> & { initialData: Partial<T> },
): UseQueryCommonResult & {
data: T;
};
export default function useQuery<T>(
props: UseQueryProps<T> & { initialData?: undefined },
): UseQueryCommonResult & {
data: T | undefined;
};
export default function useQuery<T>({
// ..
// 구현체
})
첫 번째 선언 함수는 초기 데이터를 전달했을 때에 대한 타입이다. 이 때 모든 응답값을 전달해주지 않아도 될 수 있으므로 Partial 유틸리티 타입을 활용했다. 우리가 실제로 초기 로딩에 필요한 데이터만 초기값으로 전달하도록 하기 위함이다. 그리고 이 때 data의 타입은 undefined가 아니라 항상 데이터가 있는 T이다. 한 가지 솔직히 인정할 점은, 이건 약간의 타입 거짓말이 섞여 있다는 거다. 실제로 넘긴 값은 Partial<T>(일부 필드만 있는 값)인데 반환 타입은 완전한 T라고 단언하고 있으니까. 그래서 초기값에 없는 필드에 접근하면 타입상으로는 문제없어 보여도 런타임에는 undefined일 수 있다. 초기값에는 "화면을 처음 그릴 때 꼭 필요한 필드"만 담고, 나머지는 실제 데이터가 온 뒤에 쓴다는 전제로 감수한 트레이드오프다.
두 번째 선언 함수는 초기 데이터를 전달하지 않았을 때 에 대한 타입이다. 이 때 data의 타입은 undefined 혹은 T이다. 서버로부터 데이터를 불러오기 전까지는 undefined, 불러오고 나서는 T인 데이터이다.
useMutation
useMutation은 useQuery에 비해 구현이 간단하다.
export default function useMutation<TRequest, TResponse>({
mutationFn,
...props
}: UseMutationProps<TRequest, TResponse>) {
const [status, setStatus] = useState<Status>("idle");
const mutate = async (variables: TRequest, options?: MutateOptions) => {
try {
setStatus("pending");
props?.onMutate?.();
options?.onMutate?.();
await mutationFn(variables);
props?.onSuccess?.();
options?.onSuccess?.();
setStatus("success");
} catch (error) {
setStatus("error");
props?.onError?.(error);
options?.onError?.(error);
throw error;
} finally {
props?.onSettled?.();
options?.onSettled?.();
}
};
return { mutate, status };
}
useMutation은 서버에 GET 이외의 메서드 POST/PUT/DELETE/PATCH를 요청할 때 사용하는 훅이다. 뮤테이션은 쿼리와 달리 훅을 실행하자마자 호출하지 않고, 사용하는 측에서 원할 때 호출한다. 사용자가 폼을 제출했을 때, 버튼을 눌렀을 때 등의 상황이 있다. 그렇기 때문에 useMutation을 호출했을 때 즉시 뮤테이션 함수를 실행하지 않고, useMutation훅이 반환하는 mutate함수를 실행했을 때 뮤테이션 함수를 실행한다.
그리고 이 뮤테이션을 실행한 후, 성공한 후, 혹은 실패한 후에 실행할 함수는 useMutation훅의 props로, 혹은 mutate함수의 두 번째 인자인 options로 전달할 수 있다. 그래서 위에 코드를 살펴보면 props.onMutate를 실행하고 option.onMutate를 실행하는 것을 확인할 수 있다.
useMutation을 활용한 낙관적 업데이트
위 useMutation훅을 활용해서 낙관적 업데이트를 구현해보자.
const { mutate: mutatePatchCartItem, status: patchCartItemStatus } =
useMutation<PatchCartItemsParams, void>({
mutationFn: CartItemApi.patchCartItems,
});
// ..
const increaseCartItem = async (productId: number) => {
mutatePatchCartItem(
{
cartItemId: cartItem.id,
quantity: cartItem.quantity + 1,
},
{
onMutate: () => {
const prevCartItems = getQueryData(
QUERY_KEY.cartItem,
) as GetCartItemsResponse;
const currentCartItemIndex = prevCartItems.content.findIndex(
(item) => item.product.id === productId,
);
const newCartContent = [...prevCartItems.content];
newCartContent[currentCartItemIndex] = {
...newCartContent[currentCartItemIndex],
quantity: newCartContent[currentCartItemIndex].quantity + 1,
};
setQueryData(QUERY_KEY.cartItem, {
...prevCartItems,
content: newCartContent,
});
},
onError: () => {
refetchCartItems();
showToast({
variant: "error",
message: ERROR_MESSAGE.api,
});
},
},
);
// ..
}
- CartItemApi.patchCartItems이라는 데이터를 PATCH하는 함수를 useMutation에 전달하며 훅을 호출한다.
- 카트 아이템을 증가시키는 increaseCartItem함수 내부에서는 mutate함수를 호출한다.
- onMutate는 뮤테이션 함수를 호출하고 즉시 실행되는 함수이다. 여기서 우리는 서버에 잘 전달되었는지, 서버에서 정상값을 전달받았는지에 여부와 관계없이 우선 클라이언트에서는 전달이 잘 되었다고 가정하고 ‘낙관적 업데이트’를 수행할 것이다. 서버에 값이 잘 전달되었을 때의 상태값을 설정한다.
- 만약, 서버에서 에러를 전달했다면 이를 onError에서 받아 서버에 다시 데이터를 요청한다(refetchCartItems). 그리고 사용자에게 토스트를 띄어(showToast) 서버에 에러가 발생했음을 알린다.
맺으며
이로써 react query에서 내가 주로 사용하는 데이터 캐싱과 더불어 suspense, refetchOnMount, useMutation의 낙관적 업데이트 등의 기능은 모두 구현할 수 있었다. react query를 사용할 때는 마법과도 같았다. ‘이것도 지원해준다고?’ 라는 생각의 연속이었다. 그런데 직접 코드로 구현하고 나니 마법이 아니라는 것을 알게되었다. 캐싱을 활용하고, 적절한 순간에 Promise를 throw하고, 함수에 직관적인 이름과 인터페이스를 붙인다. 이런 작업이 모여 마법같은 라이브러리가 된 것이다. 그리고 이런 구현 경험을 통해 react-query를 사용할 때 어떤 원리로 동작하는지 더 깊이 이해할 수 있게 되었다.
재밌었다. 내 이름의 react query 라이브러리를 배포하는 것도 너무 뿌듯했다. 다음에 개인 프로젝트를 하게 된다면 꼭 내가 만든 @guesung/query를 써보고 싶다는 생각을 했다.
다음 번에는 또 뭘 직접 만들어볼까?