리액트스러운 비동기 처리를 찾아서, Errorboundary 편

📝 FE 개발 경험
프론트엔드에서 비동기처리는 async await만 알아도 문제없이 잘 개발할 수 있다.
그런데 FE개발자라면 이것만으로는 부족하다라는 것을 잘 알 것이다.
서버로부터 응답(혹은 에러)을 기다리는 상태와 예상치 못한 상황에 대한 처리를 해줘야 하기 때문이다.
보통에는 다음과 같이 에러를 처리했다.
try{ await 비동기처리() }catch(err){ 에러처리를_위한_로직 } {loading && <div>로딩 중...</div>} {error && <div>에러 발생!</div>} {data && <div>{data.name}</div>}
위와 같은 코드에서 충분히 관리가 되는 정직한 상태(에러)라면 문제가 없을 것이다.
그런데 나는 이런 상황을 경험했다.
✅ 잘 작동되고 있던 상황

❌ 에러도 아니고 뭣도 아닌 상황

위 상황은 백엔드에서 내려주는 데이터 구조가 변해서 발생하는 문제다.
그런데 너무 당연하게도 런타임에서 이런 상황은
try catch
는 이런 상황을 catch 할 수 없다.
(왜냐면, 에러는 또 아니니까….)결국 우리는 너무 익숙한 화면을 보게된다.

그리고 우리의 웹은 에러화면 혹은 하얀 배경으로 가득차게 된다.
특정 컴포넌트의 렌더링 과정에서 에러가 발생하면서 전체 웹 서비스가 죽는 것처럼 보이는 마음 아픈 상황이었다.
또한 하나의 문제가 또 있다.
보통 axios 인스턴스의
interceptors
를 활용하여 상태 코드에 따라 에러를 처리하는 경우도 있다. 하지만 모든 모든 에러를 interceptors
에서 처리할 수는 없다. 그렇다고 모든 비동기 처리 영역에 try catch
문을 활용하는 것도 썩 좋은 방법도 아닌 것 같다.그리고 위와 같은 에러핸들링이 정말 리액트스러운 방법일지 의문이다.
명령형 보다는 선언적, 즉
how
보다는 what
에 집중하는 방법과는 거리가 굉장히 멀기 때문이다.만약 위 비동기처리를 리액트스럽게 구현한다면, 컴포넌트에서는 순수히 모든 것이 완벽한(성공한) 상태만을 신경써서 개발하고, 에러와 로딩 상태는 또 다른 무엇이 해당 상태를 관리해주면 가장 이상적인 모습일 것이다.
📝 React 16, Errorboundary
우선 에러를 좀 더 리액트스럽게 해결할 수 있도록 도입된
Errorboundary
다.Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed. Error boundaries catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them.
- Introducing Error Boundaries 리액트 공식 문서
위에서 설명된 것처럼 Errorboundary는 하위 컴포넌트 트리에서 에러가 발생하면, 해당 에러를 catch하고 기존 하위 컴포넌트 대신 fallback 컴포넌트를 그린다.
그렇기 때문에 일부 컴포넌트에서 발생한 에러가 전체 웹에 영향을 끼치지 않는다.
이 Errorboundary를 활용하기 위해서는
static getDerivedStateFromError()
메서드를 활용해야 하며,이 메서드는 하위의 자손 컴포넌트에서 오류가 발생했을 때 호출된다. 인자로 어떤 에러가 발생했는지 전달해주며 state 값을 갱신할 수 있다. 다만, 해당 과정은 렌더과정에서 호출 됨으로 부수효과(Sentry 에러 보고 등)을 발생시키면 안된다. (👉 1)
부수적인 효과는
componentDidCatch()
에서 작업하면 된다. 해당 메서드 또한 자손 컴포넌트에서 에러가 발생했을 경우 호출되며, 위의 메서드와 달리 커밋 단계에서 호출된다. (👉 2)
✅ 기본적인 Errorboundary 코드
// 기본적인 React Errorboundary 코드 class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) { // state를 갱신하여 다음 렌더링에서 대체 UI를 표시합니다. return { hasError: true }; } componentDidCatch(error, info) { // Example "componentStack": // in ComponentThatThrows (created by App) // in ErrorBoundary (created by App) // in div (created by App) // in App logComponentStackToMyService(info.componentStack); } render() { if (this.state.hasError) { // 별도로 작성한 대체 UI를 렌더링할 수도 있습니다. return <h1>Something went wrong.</h1>; } return this.props.children; } }
(👉 1)
getDerivedStateFromError
에서 부수효과를 발생시키면 안되는 이유
1. 동기적으로 실행되어야 하는 메서드에서 비동기적인 작업을 수행하면 컴포넌트 렌더링이 지연되거나 다른 문제가 발생할 수 있음
2. 해당 메서드에서 예기치 못한 에러가 발생하면 React는 이중으로 오류를 처리하지 못하고 웹 전체가 망가질 수 있음
3. 해당 메서드에서 에러가 발생하면 에러 바운더리에 대한 신뢰가 없어짐(👉 2) 리액트의 주요한 렌더링 과정
1. 렌더 단계 (Render Phase):
- 컴포넌트의
render
메서드가 호출되고, 가상 DOM 생성
- 재귀적으로 렌더링 (상위 → 하위 컴포넌트)
2. 가상 DOM 비교 (Virtual DOM Reconciliation):
- 렌더 단계에서 생성한 가상 DOM과 이전 렌더링 주기에서 생성된 이전 가상 DOM과 비교
- 변경된 부분을 찾아내고, 실제 DOM에 반영할 최소한의 작업만 수행
3. 커밋 (Commit) 단계:
- 가상 DOM 비교 단계에서 생성된 변경 사항을 실제 DOM에 반영
- 변경된 내용이 실제 브라우저 화면에 반영✅ 에러바운더리 in action
Errorboundary
실제로 적용해보기 위해 간단한 웹을 만들어 보았다.
위 웹은 각 3가지 종류의 카테고리 구역(오늘, 이번주, 테크 포스트, 자유 게시판)을 가지고 있다.
그리고 각 구역은 각각 임시로 백엔드에 비동기적으로 데이터를 fetching한다. (해당 플젝은
msw
를 활용하여 임시 백엔드를 구현했다)그런데 만약에 이 구역 중 하나에서 에러가 발생하면 어떻게 될까?
해당 상황을 가정해보기 위해서, 이번주 게시판에 관련한 백엔드에서 랜덤하게 500번 에러를 발생시키도록 했다.
참고로 컴포넌트 에러를 재현하기 위해 코드는 옵셔널 체이닝 없이
{data.map((post) => ( ~ )))}
로 구성했다.
위의 gif와 같이 이번주 포스트 페칭에 대한 실패가 전체 웹 서비스를 고장내버린다.
사실 위 상황에서 발생하는 에러는 비동기 때문에 발생하는 에러는 아니다.
(렌더링하는 함수 실행 컨텍스트에 에러가 발생한 비동기 스택이 존재하지 않기 때문에)
데이터를 fetching 하는 녀석이
data = […]
를 제공해야 했는데, data 객체
에 접근할 수 없어서 undifined
이기 때문에 발생하는 에러이다. 그리고 이 에러는 결국 실행 컨텍스트에 쌓여 있는 렌더 함수들에 에러를 전파해버린다. (컴포넌트도 함수다. 그리고 그 컴포넌트는 트리구조에 따라서 재귀적으로 호출된다.)그러면 우선 렌더링 과정에서 발생하는 에러가 전체 웹에 영향을 미치지 못하도록 구성해보자.
위에서 작성한 기본적인
Errorboundary
를 활용하면 해당 문제는 쉽게 해결할 수 있다.
이렇게만 처리해도 이번주 테크 포스트에서 발생한 에러가 위의 영향을 주지 않는다.

✅ 개선 가능한 부분
- 컴포넌트 렌더링 재시도
상황에 따라서 컴포넌트 함수가 다시 렌더링 될 수 있도록 하는 함수를 붙이는 것도 가능할 것이다.
(500번대 에러라고 한다면, 재시도시 정상적인 접근이 가능할 수도 있기 때문에)
해당 작업을 위해서는 단순히
Errorboundary
에 있는 hasErrror State를 변경해주면 된다.
그렇게 되면 children으로 가지고 있는 컴포넌트들을 remount할 것이기 때문에 다시 데이터를 페칭할 것이다.... handleErrorReset() { this.setState({ hasError: false }); } ...
2. 상위 에러바운더리로 에러 던지기
특정 에러를 가까운 에러바운더리에서 처리하지 않고 상위에 중첩된(혹은 전역) 에러바운더리에서 처리해야할 경우가 있을 수도 있다. (관련된 컴포넌트에서 의존성이 있다거나… 등)
이럴경우 간단히
rethorwError
와 같은 state를 활용해서 상위 컴포넌트로 에러를 한번 더 던져버릴 수 있다.
(Errorboundary
도 컴포넌트이기 때문에 그냥 렌더링 과정에서 에러를 던지면 된다)... render() { if (this.state.rethorwError) { throw this.state.error; } ... } ...
- 에러 종류에 따른 분기 처리
FE에서 만날 수 있는 에러의 종류는 정말 많다. 에러의 종류에 따라서 상위 에러바운더리로 던지는 처리도 할 수 있을 것이다. 대표적인 예시로 서버와 통신하는 경우에 있어서 분기처리도 가능하다.
일반적으로 GET요청은 에러바운더리에서 처리해야하는 것이 적절해보이지만, Mutate 관련 요청은 에러바운더리에서 처리하는 것이 적절한지는 고민 해볼 필요는 있다.
예를들어 게시글을 다 작성하고 등록하기 버튼을 눌렀는데, 내가 작성한 글들이 전부 날아가고 에러바운더리 폴백 화면이 뜬다면 좋은 UX는 아닐 것이다. 그래서 Mutate와 관련된 요청은 Toast 창으로 “요청에 실패했어요, 다시 시도해주세요” 정도로 알람을 띄우는 것이 좋을 것이다.
(기본적으로 비동기와 관련된 에러는 에러바운더리에서 잡을 수 없다고는 했으나, 언제나 방법은 있다)
✅ 불가능한 에러 처리 on E.R
- Event handlers (learn more)
- Asynchronous code (e.g.
setTimeout
orrequestAnimationFrame
callbacks)
- Server side rendering
- Errors thrown in the error boundary itself (rather than its children)
위의 리스트는 리액트 공식 문서에 나와있는
Errorboundary
로는 해결이 불가능한 녀석들이다.기본 적인
Errorboundary
는 렌더링 과정에서 발생한 에러만 catch한다는 것이고, 위 에러들은 클라이언트 측에서 화면을 렌더링하는데 있어서는 문제가 없다. 몇몇 문제는 try catch와 같은 다른 접근법으로 에러를 잡아야 겠지만 일부 비동기 처리와 관련한 에러는 React Query를 활용하면 쉽게 에처 처리를 할 수 있다.
위 문제를 해결해 줄
React Query
와 비동기 로딩 상태를 관리해줄 Suspense
, 그리고 이 모든 것들을 기본제공하는 Next.js 13에 대해서 추가로 글을 작성하고자 한다.