회사에서 tanstack-query의 버전을 4에서 5로 업그레이드하기로 결정하였고, 업그레이드를 하면서 로딩 및 에러 처리를 선언적으로 처리하기 위해 Suspense와 ErrorBoundary를 사용하기로 하였다.
suspense는 비교적 사용법이 간단하지만 error-boundary는 그렇지 않았다. 대부분의 글이 fallback으로 단순한 UI를 그리는 예시가 많았고 react-error-boundary의 각 props의 역할이 무엇인지 알려주는 글이 많지 않아 해당 글을 작성하게 되었다.
따라서 이번 포스팅에서는 서스펜스와 에러바운더리의 사용법에 대해 설명하려고한다.
다음 글에서는 실제로 회사에서 서스펜스와 에러바운더리를 어떻게 사용하고 있는지 사례 소개 및 suspensive 소스 코드를 뜯어볼 것이다!
Suspense는 자식 요소의 로딩이 완료될 때 까지 fallback을 표시한다.
원리를 간단히 설명하자면 자식 요소가 Promise를 throw 하면 가장 가까운 Suspense에서 이를 감지하고 fallback UI를 그린다.
Promise가 완료되면 다시 자식 요소를 렌더링한다.
Suspense를 사용하는 간단한 예시 코드는 아래와 같다.
<Suspense fallback={<div>로딩중...</div>}>
<SomeComponent />
</Suspense>
에러 바운더리는 에러가 발생했을 때 UI가 전부 깨지는 것을 방지하고 대체 UI를 보여주기 위해 사용한다.
react-error-boundary를 사용한다면 아래와 같이 fallback에 에러 발생시 대체 UI를 전달 할 수 있다.
export default function ParentCompoent() {
return (
<ErrorBoundary fallback={<div>에러 발생!</div>}>
<Suspense fallback={<div>로딩중...</div>}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
}
기본적으로 에러 바운더리는 클래스 컴포넌트로 만들어야 하는데 이를 함수형 컴포넌트에서 쉽게 사용할 수 있도록 제공하는 라이브러리가 있다.
대표적인 라이브러리로는 react-error-boundary와 suspensive가 있다.
회사에서는 최종적으로 suspensive를 사용하게 되었지만 react-error-boundary를 사용할 줄 안다면 suspensive는 더 쉽게 사용할 수 있으므로 해당 글에서는 react-error-boundary로 예시를 소개한다.
만약 tanstack-query를 사용하면서 suspense와 error-boundary를 사용하지 않는다면 주로 아래와 같이 명령형으로 에러 및 로딩 처리를 하게된다.
export default function SomeComponent() {
const { data, isLoading, isError } = useQuery();
if (isLoading) {
<div>로딩중...</div>;
}
if (isError) {
<div>에러 발생!</div>;
}
return <div>{data.map()}</div>;
}
위 방식에 문제는 없지만 아쉬운 점이 있다.
그것은 SomeComponent 입장에서는 로딩일 때와 에러일 때의 로직이 함께 공존하면서 로직을 명령형으로 작성하게 된다.
만약, 서스펜스와 에러 바운더리를 사용한다면 아래와 같이 선언적으로 처리할 수 있다.
이렇게 되면 선언적으로 작성함과 동시에 로딩, 에러 처리를 부모 컴포넌트에 위임함으로써 관심사를 분리할 수 있다.
export default function ChildComponent() {
const { data } = useSuspenseQuery();
return <div>{data.map()}</div>;
}
export default function ParentCompoent() {
return (
<ErrorBoundary fallback={<div>에러입니다...</div>}>
<Suspense fallback={<div>로딩중...</div>}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
}
앞서 설명했다시피 에러 바운더리를 함수형 컴포넌트에서 쉽게 사용하기 위해 react-error-boundary를 사용한다.
서스펜스처럼 단순하게 에러 UI만 보여준다면 사용법이 어렵지 않다.
아래와 같이 fallback을 전달하면 자식 컴포넌트에서 에러가 발생했을 때 ErrorBoundary의 fallback을 그리게된다.
export default function ParentCompoent() {
return (
<ErrorBoundary fallback={<div>에러입니다...</div>}>
<Suspense fallback={<div>로딩중...</div>}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
}
위와 같이 간단하게 처리할 수 있는 에러도 있지만 그렇지 않은 경우도 있다.
예를 들어, 특정 API 호출이 실패했을 때 재시도할 수 있는 UI를 보여주고 싶다거나 특정 연산 과정 중 에러가 발생했을 때 다시 시도할 수 있도록 UI를 제공하고 싶을 수도 있다.
즉, 자식 컴포넌트를 리렌더링하고 싶을 때는 어떻게 할 수 있을까?
이 때는 react-error-boundary에서 제공하는 props를 이용하면 된다.
react-error-boundary의 props에 대해 간단히 알아보자.
타입은 아래와 같다.
import { ComponentType, ErrorInfo, PropsWithChildren, ReactNode } from "react";
export type FallbackProps = {
error: any;
resetErrorBoundary: (...args: any[]) => void;
};
type ErrorBoundarySharedProps = PropsWithChildren<{
onError?: (error: Error, info: ErrorInfo) => void;
onReset?: (details: {
reason: "imperative-api";
args: any[];
} | {
reason: "keys";
prev: any[] | undefined;
next: any[] | undefined;
}) => void;
resetKeys?: any[];
}>;
export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & {
fallback?: never;
FallbackComponent: ComponentType<FallbackProps>;
fallbackRender?: never;
};
export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & {
fallback?: never;
FallbackComponent?: never;
fallbackRender: (props: FallbackProps) => ReactNode;
};
export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & {
fallback: ReactNode;
FallbackComponent?: never;
fallbackRender?: never;
};
export type ErrorBoundaryProps = ErrorBoundaryPropsWithFallback | ErrorBoundaryPropsWithComponent | ErrorBoundaryPropsWithRender;
export {};
fallback UI를 그리기 위해 3가지의 props(fallback, FallbackComponent, fallbackRender) 와 나머지로 구분해서 설명하겠다. 제공한다.
export default function ParentCompoent() {
return (
<ErrorBoundary fallback={<div>에러 발생!/div>}>
<Suspense fallback={<div>로딩중...</div>}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
}
fallback과 유사하며 주로 리액트 컴포넌트를 만들어서 전달할때 사용한다.
fallback과 차이점으로는 클래스형 컴포넌트도 전달 할 수 있다.
가장 많이 쓰게되는 fallback props로 동적인 UI를 표시할 수 있으며 resetErrorBoundary 와 error를 제공한다.
<ErrorBoundary
fallbackRender={({ resetErrorBoundary, error }) => (
<div>
{error.message.includes("NetworkError") ? (
<div>
네트워크 오류가 발생했습니다.
</div>
) : (
<div>
서버 오류가 발생했습니다.
</div>
)}
</div>
)}
>
</ErrorBoundary>
fallbackRender에서는 resetErrorBoundary를 사용할 수 있는데 resetErrorBoundary는 에러 바운더리를 리셋할 때 사용한다.
에러가 발생해서 에러바운더리의 fallback UI가 나타났을 때 해당 메서드를 호출하면 에러바운더리 내의 에러를 리셋하고 자식 컴포넌트를 리렌더링한다.
간단히 UI만 표시할 때 -> fallback
에러를 리셋하고 싶을 때 -> fallbackRender
에러바운더리 내부에서 에러가 발생했을 때 실행되는 콜백함수다.
컴포넌트에서 에러가 발생했을 때 추가적인 로직을 넣을 수 수 있다. 예) 로깅 및 센트리 알림
에러바운더리가 reset될때 실행된다.
에러바운더리가 reset 되는 조건은 다음과 같다.
key 배열을 가지며 해당 배열의 key 값이 변경되면 ErrorBoundary를 reset한다.
주로 에러 바운더리 외부에서 특정 에러 바운더리를 리셋할 때 사용한다.
만약 resetErrorBoundary를 호출하지 않으면 resetKeys가 변화했을 때 에러 바운더리를 리셋한다.
여기까지가 react-error-boundary의 props에 대한 설명이고 그럼 이제 어떻게 사용할 수 있을까?
우선 다양한 에러 처리를 테스트 하기 위해 아래와 같은 자식 컴포넌트를 하나 만든다.
해당 컴포넌트는 최초 렌더링 시 무조건 200을 반환하는 API를 호출한다.
그 다음 2개의 버튼을 그리는데 하나는 성공하는 API 버튼으로 클릭시 항상 200을 반환하는 API를 호출한다.
나머지 하나의 버튼은 항상 500을 반환하는 API를 호출하는 버튼이다.
소스코드는 다음과 같다.
여기서 staleTime과 gcTime을 0으로 설정한 이유는 tanstack-query의 캐시 정책을 생각하지 않고 테스트하기 위함이며 staleTime과 gcTime이 있을때에 대해서는 더 아래에서 설명하겠다.
export default function Demo() {
// 무조건 200을 반환하는 API
const { refetch: refetchSuccess } = useSuspenseQuery({
queryKey: ["success"],
queryFn: async () => {
const res = await axios({
method: "GET",
url: "https://xxx.xxx/success",
});
return res;
},
staleTime: 0,
gcTime: 0,
});
// 무조건 500을 반환하는 API
const { refetch: refetchFail } = useQuery({
queryKey: ["fail"],
queryFn: async () => {
const res = await axios({
method: "GET",
url: "https://xxx.xxx/fail",
});
return res;
},
enabled: false,
throwOnError: true,
staleTime: 0,
gcTime: 0,
});
const handleFail = () => {
refetchFail();
};
const handleSuccess = () => {
refetchSuccess();
setClickCount((prev) => prev + 1);
};
return (
<div className="border p-6 m-6">
자식 컴포넌트
<div>현재 clickCount는 {clickCount} 입니다.</div>
<div className="flex gap-4 items-center justify-center p-2">
<button className="bg-[#00ff00] text-black" onClick={handleFail}>
실패하는 API
</button>
<button className="bg-[#00ff00] text-black" onClick={handleSuccess}>
성공하는 API
</button>
</div>
</div>
);
}
기본적으로 아래와 같이 동작한다.
성공하는 API와 실패하는 API를 Promise와 setTimeout으로도 구현할 수 있지만 실제 데이터 통신을 하고 싶다면 위 사이트에서 mock API를 만들어서 사용할 수 있다.
아래와 같이 HTTP Status 와 여러 가지를 설정할 수 있다.
만약 서비스내에서 에러가 발생했을때 단순하게 에러 UI만 보여준다면 아래와 같이 작성할 수 있다.
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<ErrorBoundary
fallback={<div className="text-red-900">에러 발생!</div>}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
이제 실패하는 API를 호출하면 아래와 같이 ErrorBoundary의 fallback을 보여준다.
로컬에서 에러바운더리를 사용할 때 에러가 발생하면 아래와 같은 오버레이가 나오는데 이건 로컬에서만 발생하는거라 prod로 나가면 뜨지 않으므로 걱정하지 않아도 된다!
위에서는 에러가 발생했을 때 단순히 에러 UI만을 보여주었다.
하지만 에러가 발생했을 때 해당 컴포넌트를 리렌더링할 순 없을까?
에러바운더리의 fallbackRender의 resetErrorBoundary를 사용한다면 에러바운더리내 에러가 발생한 컴포넌트를 리렌더링 할 수 있다.
fallbackRender를 사용한다면 아래와 같이 작성 할 수 있다.
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러 발생!
<button
onClick={resetErrorBoundary}
className="bg-[#00ff00] text-black"
>
다시 시도 버튼
</button>
</div>
)}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
이제 에러가 발생하면 다시 시도 버튼이 나타날 것이고 해당 버튼을 누르면 resetErrorBoundary 메서드를 호출한다.
앞서 설명했다시피 resetErrorBoundary를 호출하면 에러가 발생한 컴포넌트를 리렌더링하므로 정상적인 UI가 노출될 것이다.
아래는 예시 영상이다.
첫 번째로 소개한 방법은 에러 바운더리 내에서 에러를 리셋하는 방법이고 이번에는 에러 바운더리 외부에서 에러를 리셋하는 방법이다.
앞서 설명했던 것처럼 ErrorBoundary의 props에는 resetKeys가 있다. 에러 바운더리는 resetKeys의 key 배열에 변화를 감지하면 에러를 리셋하고 자식 컴포넌트를 리렌더링한다.
결국 key가 변경되어야 자식 컴포넌트가 리렌더링되므로 해당 key를 직접 관리해야한다.
예시 코드는 아래와 같으며 외부에서 count 증가시키기 버튼을 누르면 count State 값이 변경된다.
에러 바운더리는 resetKeys의 배열이 [0]에서 [1]로 변화했으므로 에러를 리셋한다.
export default function Home() {
const [count, setCount] = useState(0);
const resetHandler = () => {
setCount((prev) => prev + 1);
};
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black" onClick={resetHandler}>
외부에서 count 증가 시키기
</button>
<ErrorBoundary
fallbackRender={() => (
<div>
에러 발생! 에러 바운더리 내부 버튼 :
<button
onClick={resetHandler}
className="bg-[#00ff00] text-black"
>
다시 시도
</button>
</div>
)}
resetKeys={[count]}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
영상은 아래와 같다.
사실 위의 로직을 그대로 작성했을 때 아래와 같이 재시도 버튼 혹은 count를 증가시키는 버튼을 눌러도 자식 컴포넌트가 리렌더링 안되는것 처럼 보이는 분들이 계실것이다.
그 이유는 tanstack-query의 gcTime의 기본값이 v5기준 5분이기 때문이다.
즉, 최초에 에러가 발생했을 때 에러인 상태로 쿼리 클라이언트가 해당 쿼리를 저장하고 있다.
useQuery를 호출하는 컴포넌트가 리렌더링 될때마다 서버에 API를 호출하는 것이 아니라 gcTime이 지난 후 쿼리 클라이언트에 해당 쿼리가 없을때 API를 호출한다.
따라서 쿼리의 결과값은 남아있으며 재시도를 하지 않는다.
내가 작성한 코드가 동작한 이유는 gcTime이 0이였기때문에 API 호출 즉시 쿼리 캐시에서 제거되고 리렌더링시 쿼리 캐시가 없기 때문에 서버에 재요청을 하기 때문이다.
만약, gcTime이 0이 아니거나 리렌더링시 API를 재요청을 하고 싶다면 tanstack-query에서 제공해주는 QueryErrorResetBoundary의 reset을 에러바운더리의 onReset에 reset을 전달해주어야한다.
https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black">
외부에서 count 증가 시키기
</button>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러 발생! 에러 바운더리 내부 버튼 :
<button
onClick={resetErrorBoundary}
className="bg-[#00ff00] text-black"
>
다시 시도
</button>
</div>
)}
onReset={reset}
>
<ClapSuspenseBoundary fallback={<div>안녕하세요.</div>}>
<Demo />
</ClapSuspenseBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</div>
</div>
);
}
위와 같이 작성하고 Demo 컴포넌트 내부의 쿼리의 gcTime을 엄청 길게 설명해보자.
그래면 아래와 같이 정상작동할 것이다.
위의 방법으로도 쿼리 에러를 리셋할 수 있지만 만약 에러바운더리의 fallback의 depth가 깊다면 props drilling이 일어난다.
이럴 때 사용할 수 있는 것이 useQueryErrorResetBoundary다.
useQueryErrorResetBoundary를 사용한다면 가장 가까운 에러바운더리내 모든 쿼리 오류를 리셋한다. 이를 사용하면 props drilling을 해결할 수 있다.
export default function Home() {
const { reset } = useQueryErrorResetBoundary();
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black">
외부에서 count 증가 시키기
</button>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<div>
에러 발생! 에러 바운더리 내부 버튼 :
<button
onClick={resetErrorBoundary}
className="bg-[#00ff00] text-black"
>
다시 시도
</button>
</div>
)}
onReset={reset}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
react-error-boundary의 ErrorBoundary를 바로 사용하지 않고 해당 컴포넌트를 한번 Wrapping해서 사용할 수 있다.
프로젝트 내부적으로 공통으로 처리해야 하는 로직이 생기면 래핑된 컴포넌트를 한번만 수정하면 된다.
위와 같은 이유로 에러바운더리를 래핑해서 사용한다.
import { ErrorBoundary, ErrorBoundaryProps } from "@suspensive/react";
type Props = ErrorBoundaryProps;
export default function ClapErrorBoundary({
children,
...props
}: Props) {
// 여기서 공통 로직을 처리 할 수 있다.
return (
<ErrorBoundary {...props}>
{children}
</ErrorBoundary>
);
}
외부에서 에러바운더리를 리셋하고 싶을때 resetKeys를 별도로 관리해야한다.
에러 바운더리가 여러개라면 이것을 관리하는 것이 귀찮고 실수가 일어날 수 있다.
에러 바운더리를 구조화하다보면 에러 컴포넌트의 depth가 깊어질 수 있다.
이 때, 에러바운더리의 resetErrorBoundary를 props로 계속 전달해줘야한다.
위의 단점을 해결해주는 것이 또 다른 라이브러리 Suspensive이다.
ErrorBoundaryGroup를 사용한다면 직접 key를 관리할 필요가 없다.
as-is(react-error-boundary)
export default function Home() {
const [count, setCount] = useState(0);
const resetHandler = () => {
setCount((prev) => prev + 1);
};
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black" onClick={resetHandler}>
외부에서 count 증가 시키기
</button>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary, error }) => <div></div>}
onReset={reset}
resetKeys={[count]}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
to-be(suspensive)
ErrorBoundaryGroup과 ErrorBoundaryGroup.Consumer를 사용하면 직접 키를 관리 하지 않아도 된다.
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<ErrorBoundaryGroup>
<ErrorBoundaryGroup.Consumer>
{({ reset }) => (
<button className="bg-[#00ff00] text-black" onClick={reset}>
외부에서 count 증가 시키기
</button>
)}
</ErrorBoundaryGroup.Consumer>
<ErrorBoundary
fallback={({ reset }) => (
<div>
에러 발생!
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</ErrorBoundaryGroup>
</div>
</div>
);
}
react-error-boundary에서 중첩된 컴포넌트에서 error 혹은 reset을 사용하려고하면 props로 전달하여야한다.
as-is(react-error-boundary)
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black">
외부에서 count 증가 시키기
</button>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => (
<Child1 resetErrorBoundary={resetErrorBoundary} />
)}
>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
// 자식1은 단순히 props를 전달하는 역할
function Child1(props: { resetErrorBoundary: VoidFunction }) {
return <Child2 {...props} />;
}
function Child2(props: { resetErrorBoundary: VoidFunction }) {
return (
<div>
<h2>자식2</h2>
<button onClick={props.resetErrorBoundary}>초기화</button>
</div>
);
}
to-be(suspensive)
suspensive를 사용하면 useErrorBoundaryFallbackProps로 reset을 할 수 있다.
export default function Home() {
return (
<div className="flex justify-center items-center h-screen">
<div className="border p-6">
<div>부모 컴포넌트</div>
<button className="bg-[#00ff00] text-black">
외부에서 count 증가 시키기
</button>
<ErrorBoundary fallback={<Child1 />}>
<Suspense fallback={<div>안녕하세요.</div>}>
<Demo />
</Suspense>
</ErrorBoundary>
</div>
</div>
);
}
// props 전달안함
function Child1() {
return <Child2 />;
}
function Child2() {
const { reset, error } = useErrorBoundaryFallbackProps();
return (
<div>
<h2>자식2</h2>
<button onClick={reset}>초기화</button>
</div>
);
}
여기까지가 에러바운더리 props에 대한 설명과 사용법이다.
카카오페이지의 에러 바운더리를 참고한다면 에러 바운더리 계층을 만들어서 더욱 구조화시킬 수 있다.
다음 글에서는 서스펜스와 에러바운더리의 작동원리 및 suspensive 소스 코드 뜯어보기를 할 예정이다.
tanstack-query의 QueryErrorResetBoundary