에러가 발생하면 애플리케이션 자체가 중단되므로 에러 핸들링을 통해 여러 상황에서 발생할 수 있는 에러들을 대응해야 한다. 에러는 여러 종류가 있지만 대표적으로는 네트워크 오류, 프론트 로직에 의한 런타임 오류 등이 있다.
일반적으로 API 요청 실패시 async/await를 사용했다면 try…catch 문에서, promise를 사용했다면 catch 문에서 에러를 캐치하고 핸들링한다. 하지만 웹 애플리케이션의 서비스가 커지면 요청해야 할 API의 개수가 늘어나고 그에 대응해야 하는 에러의 경우 역시 많아질 것이다. 이에 대비하여 보다 편리하고 선언적인 방식으로 에러 핸들링의 필요성을 느끼게 되었다.
중복 코드 및 유지 보수의 비효율성
issue-tracker 프로젝트를 진행하면서 이슈, 레이블, 마일스톤 데이터에 대한 CRUD 에러 외에도 로그인과 회원가입, 사용자의 인증 토큰 유효기간 만료에 대한 여러 에러를 대응해야 했다. 이렇게 수많은 API 요청에 대해 일일이 try…catch문(async/await을 사용했다)을 작성하는 것은 중복 코드가 늘어날 뿐만 아니라 생산적인 코드 작성이라고 보기 어려웠다.
에러 핸들링 방식에 대한 수정이 필요할 때도 에러를 초기화하는 로직을 변경하고자 할 때 에러 UI를 보여주는 모든 파일을 찾아가 변경해주어야 하므로 유지 보수 측면에서도 비효율적이었다.
여러 컴포넌트에서 공통적으로 발생할 수 있는 에러
다음과 같은 사용자의 인증 토큰과 관련된 에러, 네트워크 에러는 모든 컴포넌트에서 발생할 수 있다. 따라서 공통적으로 발생하는 에러를 일괄적으로 관리할 필요성을 느끼게 되었다.
const ERROR_MESSAGE: ErrorMessageType = {
1000: '요청에 Authorization 헤더가 존재하지 않습니다.',
1001: '유효하지 않은 토큰입니다.',
1002: '유효하지 않은 refresh_token입니다.',
1003: '권한이 없는 사용자입니다.',
1004: '요청에 refresh_token 쿠키가 존재하지 않습니다.'
}
선언적인 에러 핸들링
API 호출시 하위 노드에서 에러가 발생했을 때 이러한 fallback UI를 렌더링하겠다는 비동기적 에러 처리를 선언적으로 명시할 수 있다.
try {
showButton();
} catch (error) {
// ...
}
<ErrorBoundary fallback={<ErrorUI/>}>
<Button />
</ErrorBoundary>
더 나은 사용자 경험
에러 발생시 보여주는 fallbackUI에서 유저가 API 호출을 재시도(refetch)할 수 있다.
React-query와의 조합
대표적인 서버 상태 관리 라이브러리인 react-query를 사용하는 경우, ErrorBoundary에서 에러를 캐치한 뒤 reset 하면 쿼리를 retry 할 수 있다. 이때 react-query의 useQueryErrorResetBoundary
훅을 사용하면, react-query의 QueryErrorRestBoundary 하위에 있는 쿼리 오류를 리셋한다. (미설정시 전역으로 재설정), 단 reqct-query 설정에 useErrorBoundary: true 속성을 세팅해줘야 오류 발생시 ErrorBoundary에서 감지할 수 있다.
const ErrorHandler = () => {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary onReset={reset}>
{children}
</ErrorBoundary>
)
}
ErrorBoundary 내부에서 처리되지 않는 에러코드는 각 mutation의 onError 내부에서 useNotifyError 훅을 통해 alert 창을 띄워 메시지를 보여주었다.
클래스형 컴포넌트 지원
ErrorBoundary를 만들기 위해서는 클래스의 getDerivedStateFromError
메서드를 사용해야 하는데 아직까지 hook에서는 이러한 에러 관련된 라이프 사이클이 구현되어 있지 않다. 공식 사이트의 ErrorBoundary를 보면 클래스형 컴포넌트만을 지원하는 것을 알 수 있다.
⇒ ErrorBoundary를 커스텀하여 최근 리액트 개발의 주류인 함수형 컴포넌트로 ErrorBoundary를 작성할 수 있게 되었다.
에러 바운더리에서 처리하지 못하는 에러
이벤트 핸들러
try/catch 구문 사용하기
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
try {
// error가 발생하는 어떤 로직
} catch (error) {
this.setState({ error });
}
}
render() {
// error가 발생하면 다음 컴포넌트를 렌더링한다.
if (this.state.error) {
return <h1>Caught an error.</h1>
}
return <button onClick={**this.handleClick**}>Click Me</button>
}
}
비동기 코드 (setTimeout 또는 requestAnimationFrame 콜백, 서버 통신 라이브러리 콜백 함수)
서버 측 렌더링
ErrorBoundary 자체(자식 요소X)에서 발생하는 오류
⇒ 에러 바운더리는 비동기에 대한 에러 핸들링이 불가능하다.
⇒ 에러 바운더리는 언제나 하위 단계의 에러만 잡아낼 수 있다. 이벤트 핸들러, 비동기코드, SSR 모두 컴포넌트 범위를 벗어난 곳에서 생긴 에러다.
const LabelTable = () => {
const [labelData, setData] = useState<LabelTypes[]>([]);
const [isError, setIsError] = useState<boolean>(false);
useEffect(() => {
const fetch = async () => {
try {
const data = await getLabelData();
setData(data);
} catch (err) {
setIsError(true);
}
};
fetch();
}, []);
if (isError) return <div>Error UI</div>;
return (...)
getHasError
이면 shouldThrowError
메서드에서 result.isError 이면서 errorResetBoundary가 false이고 fetching이 끝났다면 result.error를 throw하는 코드가 작성되어 있다.export const getHasError = ({
result,
errorResetBoundary,
useErrorBoundary,
query,
}) => {
return (
result.isError &&
!errorResetBoundary.isReset() &&
!result.isFetching &&
shouldThrowError(useErrorBoundary, [result.error, query])
)
}
⇒ 프로젝트에서는 Tanstack query를 사용해서 에러를 throw하고 ErrorBoundary를 핸들링한다.같은 에러코드이더라도 다른 fallback 화면을 처리한다.
통상적으로 400은 클라이언트 요청 오류(Bad Request), 500은 서버오류(Service Unavailable)를 의미한다. 하지만 같은 400 에러라 할지라도 다른 fallback 화면을 처리해야 하는 경우가 생기게 된다. 기존에는 response.data.message로 에러를 구분했지만 서버측에서 메시지를 변경하게 되면 클라이언트의 코드를 수정해야 하는 문제가 생겼다.
이런 방식은 컴포넌트 수가 많아질 수록 에러 핸들링에 혼란을 가져오게 된다. 따라서 서버측과 협의하여 각 에러에 고유한 Custom Errocode를 부여하고 이에 따른 에러 핸들링을 시도했다.
react-error-boundary
라이브러리를 참고하여 작성한 코드다.
CustomErrorBoundary의 prop은 하위에 들어올 리액트 노드들인 children과 Error 발생시 보여줄 컴포넌트인 fallbackRender이다.
첫번째 prop인 children 중에 에러가 발생하면 CustomErrorBoundary는 에러를 캐치한다. 가장 가까운 catch 문에서 에러를 핸들링 하므로 API 요청 함수내에서 try…catch 문을 작성하지 않아야 CustomErrorBoundary 까지 에러가 전파되어 성공적인 핸들링이 가능하다.
두번째 prop인 fallbackRender는 에러 발생시 렌더링하고 싶은 컴포넌트를 전달하는 선택적 prop이다. fallbackRender의 타입은 함수로 인자는 resetErrorBoundary, errorCode 이고 리액트 함수형 컴포넌트 (에러 UI)를 반환한다.
Custom ErrorBoundary 컴포넌트에 fallbackRender prop이 전달되어 생성되면 render 메서드는 함수 타입인 fallbackRender prop에 fallbackRenderProps를 인자로 전달하면서 호출한다.
이로 인해 CustomErrorBoundary 컴포넌트를 사용할 때 fallbackRender props에서 fallbackRenderProps인 resetErrorBoundary, errorCode를 매개변수로 꺼내서 에러 UI 컴포넌트에 전달하는 식으로 사용할 수있다.
함수에 전달되는 콜백함수 인자가 매개변수에서 특정값을 꺼내 사용하는 예제를 더 자세히 다뤄보자.
콜백함수에 매개변수를 전달하는 예제
func 함수는 매개변수 callback이 무엇이든 간에 num 값을 인자로 전달하며 호출한다.
이로 인해 func 함수에 전달되는 callback 인자는 num 값(10)을 꺼내서 사용할 수 있다.
const func = (callback) => {
const num = 10;
callback(num);
}
// callback에 전달된 인수 10을 콜백함수에서 매개변수로 꺼내서 사용할 수 있다.
func((num)=> console.log(num))
useState
의 함수형 업데이트
우리는 상태의 함수 업데이트 방식에서 다음과 같은 코드를 접해본 적이 있다.
이전 상태 값이 무엇인지 모르더라도 해당 값을 가져와서 처리를 할 수 있다.
setState 함수 내에서는 전달된 callback 함수에 prevState를 인자로 넣어 호출함을 예측할 수 있다.
const MyComponent = () => {
const [state, setState] = state(0);
const count = () => {
setState(prev => prev+1);
}
return <button onClick={count}>Up</button>
}
CustomErrorBoundary의 fallbackRender prop
fallbackRender prop을 전달할 때 ErrorBoundary 컴포넌트 내부에서 만든 resetErrorBoundary, errorCode를 꺼내와서 사용할 수 있다.
이로 인해 errorCode에 따라 다른 에러 UI를 렌더링하거나 버튼을 클릭하면 resetErrorBoundary를 실행해서 에러를 초기화하고 API 호출을 재시도 할 수 있다.
class ErrorBoundary extends React.Component{
constructor(props) {
super(props);
this.resetErrorBoundary = this.resetErrorBoundary.bind(this);
this.state = initErrorState;
}
resetErrorBoundary() {
if (this.state.error !== null) {
this.props.onReset?.();
this.setState(initErrorState);
}
}
render() {
if (this.state.error) {
const fallbackRenderProps = {
resetErrorBoundary: this.resetErrorBoundary,
errorCode: data.errorCode
};
return **this.props.fallbackRedner?.(fallbackRenderProps);**
}
return this.props.childeren;
}
}
export const FallbackLabelTable = () => (
<ErrorBoundary
fallbackRender={({resetErrorBoundary, errorCode}) => {
if(errorCode === 500) {
return <ServerErrorTable />;
}
return <ErrorTable resetErrorBoundary={resetErrorBoundary} />;
}}
>
<Suspense fallback={<LabelTableSkeleton />}>
<LabelTable />
</Suspense>
</ErrorBoundary>
);
import { QueryClient, QueryErrorResetBoundary, useQueryClient } from '@tanstack/react-query';
type FallbackRenderPropsType = {
resetErrorBoundary: () => void;
errorCode: number;
};
type FallbackRenderType = (props: FallbackRenderPropsType) => React.ReactElement<React.FunctionComponent>;
interface CustomErrorBoundaryTypes {
fallbackRender?: FallbackRenderType;
children: React.ReactNode;
}
const resetErrorQuery = (queryClient: QueryClient) => {
const queryCache = queryClient.getQueryCache();
const queryKey = queryCache.getAll().find((q) => q.state.status === 'error')?.queryKey;
queryClient.resetQueries({ queryKey });
};
const CustomErrorBoundary = ({ children, fallbackRender }: CustomErrorBoundaryTypes) => {
const queryClient = useQueryClient();
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
fallbackRender={fallbackRender}
onReset={() => {
reset();
resetErrorQuery(queryClient);
}}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
);
};
export default CustomErrorBoundary;
ErrorBoundary 컴포넌트로 감싼 컴포넌트에 에러가 발생하면 CustomErrorBoundary가 이를 캐치한다. Suspense는 fallback 으로 넘겨준 컴포넌트를 API 요청하는 동안 로딩 UI로 렌더링한다.
import ErrorBoundary from '@/components/ErrorBoundary';
const App = () => (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<ServiceLoading />}>
<Routers />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
export default App;
export const FallbackLabelTable = () => (
<CustomErrorBoundary
fallbackRender={({ resetErrorBoundary }) => <ErrorTable type="label" resetErrorBoundary={resetErrorBoundary} />}
>
<Suspense fallback={<LabelTableSkeleton />}>
<LabelTable />
</Suspense>
</CustomErrorBoundary>
);
export default FallbackLabelTable;
현재 서버가 닫힌 상태로 개발 모드에서 msw로 에러핸들링을 해야 한다.
msw로 생성한 mock API에서 다음과 같이 에러를 생성한다.
// mocks/handlers/label.ts
export const labelHandlers = [
rest.get('api/labels', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({errorCode:1002})
}),
ctx.status는 일반적으로 전달되는 status 코드로 400, 500 등이 있다.
더 세부적인 에러 핸들링을 위해 백엔드와 커스텀 에러코드를 정했으므로 같은 400 status 여도 에러마다 각각 다른 errorCode를 갖는다. 이를 data에 전달하기 위해 ctx.json에 errorCode를 담은 객체를 인수로 넣는다.