같은글 노션링크 | 코드 변경 전후를 명확하게 보실수있습니다.
1.1 에러핸들링? 예외처리?
1.2 프론트엔드에서 발생하는 에러
1.3 에러핸들링 도입이유
2.1 어떤 도구를 사용할까?
2.2 내 앱에서 발생가능한 에러
2.2.1 전역에러
2.2.2 지역에러
3.1 기본구현코드
3.2 동작원리
3.2.1 Suspense for Data Fetching
3.2.2 Suspense와 ErrorBoundary 동작원리
4.1 전역 에러바운더리
4.2 API 에러바운더리
4.3 [트러블슈팅] Suspense가 프로미스를 캐치하지 못할때
에러란 소프트웨어가 의도한대로 동작하지 않는 경우, 이를 초래한 원인을 말한다.
예외란 예상하기 어렵거나 예상이 불가능한 에러를 말한다. 예외가 발생하면 JS는 에러객체를 던지고 프로그램을 종료시킨다.
자바스크립트에서 예외는 런타임에러(앱이 동작하는 중에 발생하는 에러)이다.
에러핸들링이란 에러가 발생할 수 있는 지점에 예외구문을 설정해 프로그램이 예상치 못하게 종료되거나 동작하지 못하는 상황을 방지하는 것이다. 즉 예외로 인해 발생한 에러 객체를 처리하는 것이다. 에러핸들링을 하지않으면 유저가 프로그램을 사용하던 중에 갑자기 프로그램이 종료되므로 흐름이 끊겨 불편함을 느끼고, 이탈하게 될것이다.
자바스크립트로 짠 프로그램이 동작할때, 발생할 수 있는 에러는 발생시점을 기준으로 두가지가 있다. 런타임 에러와 컴파일 에러이다. 런타임 에러는 앱이 동작하는 중에 발생하는 에러이고 컴파일에러는 런타임단계 이전에 소스코드를 컴퓨터언어로 변환하는과정에서 문법이 틀렸을때 컴파일러가 해석하지 못해 발생하는 에러이다.
프로그램 동작중에 실시간으로 타입이 결정되는JS 특징때문에 모든 에러는 컴파일 단계가 아닌 런타임 환경에서 발생하는 특징이 있다. 그러나 TypeScript, Eslint 사용으로 컴파일 단계에서 타입에러, 문법에러 등을 잡아주면, 프론트엔드에서 주로 발생하는 에러는 네트워크에러, 사용자 입력에서, API 관련 오류, 브라우저 에러, OS 업데이트에 의한 에러, 악의적 목적의 접근에 의한 에러, thrid-party library 오류로 볼 수 있다.
발생가능한 에러를 특정 기준에 따라 다음과 같이 분류해볼수 있다.
1.환경 기준
예상가능한에러
예상 불가능한 에러
2.사용자와 상호작용
사용자가 해결가능한 에러
사용자가 해결불가능한 에러
기존에 프로젝트를 진행하면서 에러핸들링을 제대로 해본 적이 없었다. 기능구현에 급급했기 때문이고, 항상 catch문 안에 console.log(error) 처리를 했다. 이제는 그 태도에 변화를 주고자 에러핸들링 방법을 찾아 학습하고, 도입하게 됐다.
💡 2장부터는 발생가능한 에러 중에서 주로 API 통신 에러를 어떻게 핸들링할지에 대해서 다룬다.에러를 처리하는 다양한 방법이 있다.
try / catch 문
fetch('<https://api.example.com/data>')
.then((response) => {
if (!response.ok) {
// http 통신 응답의 상태코드가 200-299가 아닌 경우( ex, 404, 500)
// Handle the error here, e.g., by throwing an exception or returning an error object.
throw new Error('Network response was not ok');
}
return response.json();
})
.then((data) => {
})
.catch((error) => {
// Handle any errors that occurred during the fetch or data processing.
console.error('Fetch error:', error);
});
Axios interceptor - 인증에러, 네트워크 에러 처리 등 전역에러처리 가능
axios.interceptors.response.use(
response => response,
error => {
if (error.response.status === 401) {
// Handle unauthorized access
redirectUserToLogin();
} else {
// Handle other errors
displayErrorMessage(error.message);
}
return Promise.reject(error);
}
);
react-query onError
const { mutate } = useMutation(uploadImage, {
onError: (err: { response: { status: number } }) => {
if (err.response.status === 413) {
toast.error('이미지 사이즈가 너무 커요. 다른 사진으로 다시 시도해주세요. ');
} else {
toast.error('이미지 업로드에 실패했어요. 다시 시도해주세요.');
}
},
});
import { QueryClient } from 'react-query';
const { handleError } = useApiError(); // 전역에러 처리 커스텀 훅
const queryClient = new QueryClient({
defaultOptions: {
onError: handleError,
},
})
Error Bounady - 하위 컴포넌트의 에러를 포착하고 fallback UI를 보여준다.
import { ReactErrorBoundary } from 'react-error-boundary'
const FallbackComponent = () => {
return <div>에러페이지</div>
}
export const App = () => {
return (
<ReactErrorBoundary fallback={</FallbackComponent>}>
<MainPage/>
</ReactErrorBoundary>
)
}
난 이중에서 에러 바운더리를 도입하고자 한다.
리액트쿼리 온에러 옵션을 사용하는 것도 흥미롭긴하지만 리액트쿼리를 사용할 계획이 없다. Axios 인터셉터를 사용하는 것보다 소스코드 내에서 명확하게 ‘나 에러 핸들링하는 녀석이오~’ 티를 내는 에러처리가 가독성면에서도 보기 좋다고 판단했다. 또한 선언적인 프로그래밍을 추구하는 리액트를 사용중이므로, 에러처리 또한 선언적으로 해도 괜찮겠다고 판단했다.
공식문서에 따르면 에러바운더리는 클래스문법으로 구현할 수 있다. 그러나 나는, react-error-boundary 라는 라이브러리를 채택했다. 이 라이브러리는 에러바운더리들의 Fallback Component를 직관적으로 작성할 수 있게 도와준다!
전역에러, 지역에러로 나눠서 에러바운더리의 관심사를 분리했다.
2.2.1 전역에러

2.2.2 지역에러

Error Boundary는 하위 컴포넌트에서 발생한 자바스크립트 에러를 잡아내 fallback UI를 보여주는 기능이다.
클래스형 컴포넌트의 getDerivedStateFromError, componentDidCatch 생명주기를 사용해 에러바운더리를 구현할 수 있다. getDerivedStateFromError 는 에러가 발생한 후 폴백 UI를 렌더링하는 데 사용하고 componentDidCatch를 에러 정보를 기록하는데 사용하라고 가이드하고 있다.
아직 함수형 컴포넌트에서는 이러한 생명주기와 똑같은 기능이 아직 없어 ErrorBoundary 구현에는 클래스형 컴포넌트를 사용하고 있다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// 폴백 UI를 커스텀하여 렌더링할 수 있습니다.
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Suspense의 동작원리를 알면 Error Boundary 동작원리도 쉽게 이해할 수 있다. 함보자~.
3.2.1 Suspense for Data Fetching
먼저, Suspense는 React 16.6에 처음 등장한 기능으로 주로 JS 번들의 Lazy Loading을 위한 기능이었다.
*Lazy Loading(지연로딩): 필요한 자원을 미리 가져오기 않고 필요할때 가져오는 전략
React.lazy로 컴포넌트를 동적으로 임포트하고, 해당 컴포넌트를 Suspense로 감싸면 번들이 분리된다. 해당 컴포넌트가 렌더링 될 필요가 있을때만, 리액트가 비동기적으로 번들을 임포트해서 렌더링한다. 이를 통해 SPA 단점인 초기의 느린 로딩 속도를 어느정도 개선할 수 있다.
import { lazy, Suspense } from 'react';
const ProfilePage = lazy(() => import('./ProfilePage')); // Lazy Loading
function App() {
return (
<div>
<Suspense fallback={<Spinner />}> // 프로필컴포넌트를 불러오는 동안 스피너를 표시합니다
<Profile />
</Suspense>;
</div>
);
}
사실 웹에서 필요한 모든 자원들은 Lazy Loading의 대상이 될 수 있다. JS번들 분할, 이미지가 대표적인 Lazy Loading 대상이었다면, axios, fetch 등을 사용해 서버에 요청을 보내 가져오는 데이터 역시 그 대상으로 확장된 것이다. 데이터를 미리 다 불러오지 않고 필요할 때 불러와 화면을 채우게 하니까.
이에 Suspense는 React 18에서 무엇이든 기다릴 수 있는 기능으로 확장되었다. Suspense는 이제 이미지, 스크립트, 그 밖의 비동기 작업을 기다리는데에 모두 사용 될 수 있는 기능이 된 것이다.
따라서, Suspense for Data Fetching이란 Lazy Loading하는 데이터에 Suspense의 컨셉을 도입한 것이다.
3.2.2 Suspense와 ErrorBoundary 동작원리
Suspense return하위컴포넌트(children) returnError Boundary return 동작을 유사하게 구현한 코드
// 인자로 promise를 받는다.
export const promiseWrapper = (promise: any) => {
let status = 'pending'; // 초기값: 펜딩
let result: any;
const s = promise.then(
(value: any) => {
status = 'success';
result = value;
},
(error: any) => {
status = 'error';
result = error;
},
);
return () => { // promise 상태에 따라
switch (status) {
case 'pending': // 로딩상태 - Suspense
throw s;
case 'success': // 성공상태 - 하위컴포넌트
return result;
case 'error': // 실패상태 - Error Boundary
throw result;
default:
throw new Error('Unknown status');
}
};
};
💡 단, React의 Error Boundary는 본래 이벤트 핸들러의 에러를 포착하지 않는다고 한다.
이 경우, 항상 try/catch를 거치도록해서 catch에서 잡힌 error를 throw 해주면 잡을 수 있다고 한다.
react-query는 `useErrorboundary` 옵션을 제공하고 있고, 이걸 사용해야만 에러바운더리가 API 에러를 캐치할 수 있다.
내가 사용할 react-error-boundary 도 `useErrorBoundary` 메서드를 제공한다.
Suspense + Error Boundary를 이용한 선언형 비동기 처리 예시
function GamePage() {
const { data } = apiClient.read('api/games');
return <div>{data.name}</div>
}
function App() {
return (
<ErrorBoundary fallback={<Error/>}> // 실패 UI
<Suspense fallback={<Spinner/>}> // 로딩 UI
<GamePage/> // 성공 UI
</Suspense>
</ErrorBoundary>
)
}
하위 컴포넌트는 오로지 성공시 보여줄 UI에 집중하고, 로딩이나 에러발생시 그것은 부모컴포넌트가 담당하게 된다.
RootErrorFallback// src/components/@helper/ErrorBoundary/RootErrorFallback.tsx
function RootErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
...
// 에러모달 - 네트워크 에러와 서버 에러
useEffect(() => {
if (isTimeOutError(error)) {
resetErrorBoundary();
showModal({
type: 'alert',
props: {
title: '앗...😰',
message: ERROR_MESSAGE[408],
},
});
return;
}
if (isNetworkError(error)) {
resetErrorBoundary();
showModal({
type: 'alert',
props: {
title: '앗...😰',
message: ERROR_MESSAGE[998],
},
});
return;
}
if (isServerError(error)) {
resetErrorBoundary();
showModal({
type: 'alert',
props: {
title: '앗...😰',
message: ERROR_MESSAGE[500],
},
});
return;
}
}, []);
// 404에러 - 폴백컴포넌트
if (isNotFoundError(error)) {
return (
<PageLayout>
<Fallback
error={error}
resetErrorBoundary={resetErrorBoundary}
mainText={ERROR_MESSAGE[404]}
subText="아래 버튼을 눌러 게임페이지로 돌아가세요"
btnAriaLabel="메인 페이지로 이동"
onClick={handleMoveToHomePage}
/>
</PageLayout>
);
}
// 원인불명의에러 - 폴백컴포넌트
return (
<PageLayout>
<Fallback
error={error}
resetErrorBoundary={resetErrorBoundary}
mainText={ERROR_MESSAGE[999]}
subText="아래 버튼을 누르면 개발자 이메일이 복사돼요. 문제 상황을 알려주시면 소정의 선물을 드립니다."
btnAriaLabel="개발자 이메일 복사"
onClick={handleCopyButtonClick}
/>
</PageLayout>
);
}
export default RootErrorFallback;
src/index.tsx: RootErrorFallback 사용하는 쪽
root.render(
<>
<Global styles={reset} />
<Router>
<ModalProvider>
<Suspense fallback={<Loading whiteBoard={false} />}> // 로딩
<ErrorBoundary FallbackComponent={RootErrorFallback}> // 실패
<App /> // 성공
<Analytics />
</ErrorBoundary>
</Suspense>
</ModalProvider>
</Router>
</>,
);
MovieErrorFallback현재 디렉토리 구조는 다음과 같다. MovieGameResult 컴포넌트에서 비동기 통신이 이뤄진다. 해당 컴포넌트를 MovieErrorFallback 으로 감싸면된다.
MovieGamePage.tsx // 페이지컴포넌트
|
---------------------------------
| |
Slot.tsx MovieGameResult.tsx // 비동기통신 컴포넌트**
|
---------------
| |
BackDrop.tsx useMovieData.ts // 비동기요청하는 훅
MovieGamePage.tsx : MovieErrorFallback 사용하는 쪽
export default function MovieGamePage() {
...
const { selectedMovie, type, country, isLoading, isError, resetDataAndLoading,} = useMovieData({
selected,
});
return (
<section>
...
{isLoading && (
<Modal whiteBoard>
<Spinner />
</Modal>
)}
{selectedMovie ? (
<Modal whiteBoard>
<div css={randomResult.outer}>
<Text typography="p">뽑기결과</Text>
<Text typography="h5" css={randomResult.movieNm}>
{!isError
? selectedMovie?.movieNm
: '랜덤영화를 뽑지 못헀어요 ㅠㅠ'}
</Text>
<div css={randomResult.bottom}>
<Text typography="p">
#{country} #{selected.year} #{type}
</Text>
<Button css={randomResult.initButton} onClick={initGame}>
{!isError ? '처음으로' : '다시 시도하기'}
</Button>
</div>
</div>
</Modal>
) : null}
...
</section>
);
}export default function MovieGamePage() {
...
return (
<section>
...
<Suspense fallback={<Loading whiteBoard height={150} />}>
<ErrorBoundary FallbackComponent={MovieErrorFallback} onReset={initEntrtyNSelection}>
<MovieGameResult
selected={selected}
initEntrtyNSelection={initEntrtyNSelection}
/>
</ErrorBoundary>
</Suspense>
...
</section>
);
}**MovieErrorFallback.tsx : API 에러바운더리**
function MovieErrorFallback({ error, resetErrorBoundary, onReset }: FallbackProps) {
if (isRootError(error)) { // 전역에러라면 상위 에러바운더리로 위임
throw error;
}
const handleResetError = () => {
resetErrorBoundary();
onReset && onReset();
};
if (!error.response.data)
return (
<BackDrop whiteBoard>
<div css={gameResult.box}>
<p css={errorMessage}>앗! 랜덤영화를 <br /> 뽑지 못헀어요 ㅠㅠ</p>
<Button css={gameResult.initButton} onClick={handleResetError} aria-label="다시뽑기">
다시 뽑기
</Button>
</div>
</BackDrop>
);
}
useMovieData.ts : 비동기 통신요청하는 훅
function useMovieData({ selected }: Props) {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<MovieData | null>(null);
const getMovieData = async () => {
try {
setIsLoading(true);
const response = await CacheApi.getMovieData({
targetDt: formatDate(),
multiMovieYn: selected.type,
repNationCd: selected.country,
});
if (response) setData(response);
setIsLoading(false);
} catch (error: unknown) {
setIsError(true);
console.log(error);
}
};
return {
selectedMovie,
country,
type,
isLoading,
isError,
resetDataAndLoading,
};
}
export default useMovieData;더이상 로딩, 에러 상태를 신경쓰지 않아도 된다.
API에러가 지역에러경계에 catch 되도록하기 위해서, showBoundary 함수를 catch 문 안에 작성해줬다. 해당 비동기통신 요청에서 error가 발생했을때 지역에러바운더리에 잘 잡힘
function useMovieData({ selected }: Props) {
const [movieData, setMovieData] = useState<MovieData | null>(null);
const { showBoundary } = useErrorBoundary();
const getMovieList = async () => {
try {
const response = await CacheApi.getMovieData({
targetDt: formatDate(),
multiMovieYn: selected.type,
repNationCd: selected.country,
});
if (response) setMovieData(response);
} catch (error) {
showBoundary(error);
}
};
...
}
4.3.1 문제상황
Suspense의 fallback UI 의 적용이 안됐다.
Suspense는 하위 컴포넌트가 던지는 promise 상태에 따라 동작한다.
Suspense가 하위 컴포넌트의 promise를 제대로 감지 못하는 것 같았고, 하위 컴포넌트가 promise를 명확하게 던지게 만들어야 겠다고 판단했다.
4.3.2 해결
비동기 요청함수가 뱉는 promise를 인자로 받아 해당 promise의 상태를 던져주는 promiseWrapper 함수를 적용해 해결했다.
// hooks/useMovieData.ts
function useMovieData({ selected }: { selected: Record<string, string> }) {
// 비동기 요청함수 - 프로미스를 리턴한다.
const getMovieList = async () => {
try {
return await CacheMovieApi.getMovieData({
targetDt: formatDate(selected.year),
multiMovieYn: selected.type,
repNationCd: selected.country,
});
} catch (error) {
showBoundary(error); // 본인에게 가장 가까운 상위 에러경계를 보이도록
}
};
useEffect(() => {
if (selected.country && selected.type && selected.year) {
// 1. 비동기 요청 함수는 promise를 뱉는다
const promise = getMovieList();
// 2. promise를 Wrapper함수에 담아서 Suspense가 promise 상태를 감지하게 만든다.
setMovieList(promiseWrapper(promise));
}
}, [selected.country, selected.type, selected.year]);
const movies = movieList?.boxOfficeResult.weeklyBoxOfficeList;
const selectedMovie = movies && movies[initNum(movies.length)].movieNm;
return {
selectedMovie
}
}
// utils/promiseWrapper.ts
export const promiseWrapper = (promise: any) => {
let status = 'pending';
let result: any;
const s = promise.then(
(value: any) => {
status = 'success';
result = value;
},
(error: any) => {
status = 'error';
result = error;
},
);
return () => {
switch (status) {
case 'pending':
throw s;
case 'success':
return result;
case 'error':
throw result;
default:
throw new Error('Unknown status');
}
};
};
msw 를 브라우저에 띄우고, 모킹된 에러응답을 반환하게 하면 쉽게 UI를 확인할 수 있다.
에러폴백컴포넌트
에러 모달

React v18의 Error Boundary, tanstack-query의 onError 옵션 등 다양한 에러처리 방식을 알게 되었는데, 이번 프로젝트에서 tanstack-query를 사용하지 않고 있기 때문에, 전역에러바운더리와 지역 에러바운더리를 만들어 선언적인 에러핸들링을 시도해봤다.
처음엔, 클래스형 에러바운더리 컴포넌트를 커스텀해서 써보는 것을 계획했으나. 일주일 정도를 헤매다 어려워서 포기했다. class, typescript, extends 에 대해 잘 알고 쓰는게 아니다보니, 학습범위에 대한 부담이 커졌고, 불안감도 커졌다. 구현기간이 길어지면서 몸과 마음이 망가지는 걸 느끼고, 우여곡절끝에 react-error-boundary 라이브러리를 사용했다. (괜히 바퀴를 만드느라 고생한 것 같다. 클래스형 에러바운더리를 커스텀해서 사용하는 건 훗날의 나에게 던졌다.)
또, 찾아보면서 느낀건, 에러핸들링을 하는 방법이 딱 이래야만한다고 정형화 돼있지 않은것이었다. 앞으로 다양한 에러핸들링 방식들을 접하고 싶다 :)
sentry도 나중에 적용해보기로 !
관련 학습
참고
김맥스 | Suspense for Data Fetching의 작동 원리와 컨셉 (feat.대수적 효과)
코드샌드박스 | Suspense 원리
토스 | SLASH 21-프론트엔드 웹 서비스에서 우아하게 비동기 처리하기
시지프 | 혹시 무분별하게 Suspense 를 사용하고 계신가요? (react-query)
시지프 | ErrorBoundary로 Toast, ErrorFallback 등 공통적인 에러를 처리해보자
DaleSeo | React Suspense 소개 (feat. React v18)
React Suspense for Data Fetching with Axios in React 18
custom class Error Boundary