React Error Boundary로 비동기 에러 잡아내기

서나무·2022년 12월 9일
3

React

목록 보기
2/5
post-thumbnail

이 글은 공부하면서 작성한 글입니다. 이 글에 나와있는 방법이 절대적인 정답은 아니기 때문에 스스로의 판단하에 적용여부를 결정하시기 바랍니다.

React를 사용해서 개발을 하면서 항상 드는 의문이 있었다.

"비동기 에러를 처리하는 더 좋은 방법이 없을까? 정말 이게 최선일까?"

내가 비동기 에러를 처리하던 방법

내가 비동기 에러를 처리하던 방법을 설명하기 위해 간단한 코드를 작성했다.

api > users.js

// 사용자 목록
const users = [
  {
    id: 'A',
    name: 'UserA',
    email: 'A@example.com'
  },
  {
    id: 'B',
    name: 'UserB',
    email: 'B@example.com'
  },
  {
    id: 'C',
    name: 'UserC',
    email: 'C@example.com'
  }
];

// 사용자 목록을 가져옴
export const getAllUsers = (rejected = false) => new Promise((resolve, reject) => {
  setTimeout(() => {
    // reject 여부 옵션이 true면
    if (rejected) {
      // Promise reject 처리 (실패)
      reject(new Error('Error!'));
      return;
    }
    // reject 여부 옵션이 false면
    // Promise resolve 처리 (성공)
    resolve(users);
  }, 2000);
});

실제로 서버와 통신하는 코드를 작성할 수 없어서, rejected 여부를 옵션으로 받아서 이에 따라 성공, 실패 처리를 하는 함수를 만들었다.

성공시 사용자 목록을 반환하고, 실패시 Error 클래스의 인스턴스를 생성해서 에러를 발생시킨다.

views > example.jsx

import { useState } from "react";
import { getAllUsers } from "../api/users";

export default function Example() {
  const [users, setUsers] = useState([]);
  // fetch 버튼 클릭 이벤트
  const onClickFetch = async () => {
    try {
      // 사용자 정보를 가져옴
      const response = await getAllUsers(true);
      setUsers(response); // 실행 X
    } catch (error) {
      // rejected 옵션이 true이므로 catch로 에러를 잡음
      window.alert(error); // 실행 O
    }
  };
  return (
    <div>
      <button onClick={onClickFetch}>Fetch</button>
      <ul>
        {
          users.map((user) => (
            <li key={user.id}>
              <p>Name: {user.name}</p>
              <p>Email: {user.email}</p>
            </li>
          ))
        }
      </ul>
    </div>
  )
}

getAllUsers 함수를 사용하는 Example 컴포넌트다.

서버와 통신하는 로직 자체는 파일을 따로 분리했지만, 통신 후 에러가 발생하면 catch로 에러를 잡아서 처리해주는 코드는 화면 컴포넌트 내부에 작성할 수 밖에 없다.

항상 catch로 에러를 잡아서 처리하는 코드를 작성하면, 나중에 에러를 처리하던 방식이 alert에서 confirm으로 바뀌게 되었을 때 모든 코드를 찾아가서 수정해야 하는 불상사가 날 수 있다. 실제로 경험도 해봤다.

그래서 getAllUsers 함수를 호출한 후에 작성하는 코드들은 전부 서버 통신이 성공적으로 처리 되었을 때만 실행되는 코드임을 보장하고, 에러 발생시 다른 모듈에게 에러 처리를 위임하면 좋겠다는 생각이 들었다.

const onClickFetch = async () => {
  const response = await getAllUsers(false);
  // 비동기 처리 이후에 작성한 코드는
  // 반드시 비동기 처리가 성공했을 경우 실행되는 것을 보장받음
  setUsers(response);
};

이렇게 하면 Example 컴포넌트에서는 에러가 발생했을 때를 고려하지 않고, 명확히 해당 컴포넌트가 처리해야 할 부분에 집중할 수 있다.

이런 생각을 하게된 것은 Nestjs를 경험해봤었기 때문이다. Nestjs는 에러를 던지면, 에러 유형에 맞게 response를 보내준다.

그래서 프론트엔드에서도 에러가 발생하면 에러 담당 컴포넌트가 에러 유형에 맞게 처리를 해주도록 코드를 작성해보고 싶었다.

React 공식문서에 나와있는 Error Boundary

먼저 공식문서에서 제공해주는 방법을 찾아봤다.

에러 경계는 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신 폴백 UI를 보여주는 React 컴포넌트입니다. 에러 경계는 렌더링 도중 생명주기 메서드 및 그 아래에 있는 전체 트리에서 에러를 잡아냅니다.
출처: [React] 에러 경계(Error Boundaries)

공식문서에 나와있는 Error Boundary 컴포넌트는 렌더링 도중에 발생하는 에러를 잡아낸다. 그리고 비동기적 코드에서 발생한 오류를 잡아내지 못한다고 나와있다.

비동기적 코드에서 발생한 오류를 잡지 못한다면 아무런 소용이 없었다.

다른 방법이 있을거라는 생각으로 검색을 해본 결과 stackoverflow에서 unhandledrejection 이벤트를 사용하면 된다는 글을 보게되었다.

unhandledrejection 이벤트

unhandledrejection 이벤트는 웹 브라우저 환경에서 사용할 수 있는 이벤트다.

처리되지 못한 Promise 거부, 즉 reject가 발생하면 이 이벤트가 발생한다.

views > example.jsx

export default function Example() {
  // 생략

  // Promise 거부가 발생하면 실행
  const captureReject = (e) => {
    // 이벤트 버블링 방지
    e.preventDefault();
    console.log(e.reason);
    console.log(e.reason instanceof Error);
  };

  useEffect(() => {
    // 이벤트 등록
    window.addEventListener('unhandledrejection', captureReject);
    return () => {
      // 컴포넌트가 사라질 때 이벤트 삭제
      window.removeEventListener('unhandledrejection', captureReject);
    }
  }, []);
  return (
    <div>
      {/* 생략 */}
    </div>
  )
}

catch로 에러를 처리하지 사용하지 않고, unhandledrejection 이벤트를 사용했다.

reason에 에러 인스턴스가 담기는데, instanceofError 클래스의 인스턴스인지 확인해보면 true로 나온다.

확인 결과 unhandledrejection 이벤트를 등록하면 하위 컴포넌트에서 발생한 비동기 오류를 상위 컴포넌트에서 잡아낼 수 있다는 것을 알게되었다.

커스텀 Error Boundary

boundary > errorBoundary.jsx

import { useEffect } from "react";

export default function ErrorBoundary({ children }) {
  const captureReject = (e) => {
    e.preventDefault();

    // e.reason이 Error의 인스턴스일 경우
    if (e.reason instanceof Error) {
      window.alert(e.reason.message);
      return;
    }

    console.log('처리하지 못한 비동기 오류입니다.');
  };

  useEffect(() => {
    window.addEventListener('unhandledrejection', captureReject);
    return () => {
      window.removeEventListener('unhandledrejection', captureReject);
    }
  }, []);

  return children;
}

위에서 Example 컴포넌트에서 사용한 방법과 같으며, Example 컴포넌트에서 작성했던 unhandledrejection 이벤트 관련 코드는 지우면 된다.

이제 ErrorBoundary 컴포넌트로 Example 컴포넌트를 감싸주면 된다.

App.js

function App() {
  return (
    <ErrorBoundary>
      <Example />
    </ErrorBoundary>
  )
}

export default App;

이제 Example 컴포넌트에서 발생한 비동기 에러를 ErrorBoundary 컴포넌트에서 잡아내 처리를 해줄 수 있게 되었다! 👏👏👏

마지막으로

사실 아직까지도 "이 방법이 최선일까?"하는 의문이 남아있다.

unhandledrejection 이벤트로 잡아낸 에러에 대해 회복할 수 없는 에러라고 표현한 글이 있었기 때문이다.

대개 이런 에러는 회복할 수 없기 때문에 개발자로서 할 수 있는 최선의 방법은 사용자에게 문제 상황을 알리고 가능하다면 서버에 에러 정보를 보내는 것입니다.
출처: [모던 JavaScript 튜토리얼] 프라미스와 에러 핸들링

그래도 내가 원하는 방식으로 비동기 에러를 처리하는 방법을 생각해보고 적용해보는 과정이 재미있었다.

더 좋은 방법이 있다면 댓글로 알려주세요! 🤗

profile
주니어 프론트엔드 개발자

0개의 댓글