React 9분 읽기

ErrorBoundary를 잘 사용하고 있나요?

리액트로 서비스를 개발할 때 에러처리를 할 수 있는 방법은 다양하다. if문을 사용해서 데이터가 없을 때를 분기 처리하거나, ErrorBoundary를 사용해서 fallback UI를 보여주는 것처럼 여러 방법이 있다.

❗️리액트에서 에러처리

리액트로 서비스를 개발할 때 에러처리를 할 수 있는 방법은 다양하다. if문을 사용해서 데이터가 없을 때를 분기 처리하거나, ErrorBoundary를 사용해서 fallback UI를 보여주는 것처럼 여러 방법이 있다.

이번에는 ErrorBoundary에 대해서 얘기해보고자 한다.

에러 바운더리는 컴포넌트 랜더링 단계에서 오류가 발생했을 때 그 컴포넌트 대신 fallback UI를 보여주는 역할을 한다.

기본적으로 리액트는 16버전 이후부터 이런 오류에 대해 에러 바운더리를 사용하지 않으면 모든 컴포넌트 트리가 언마운트 되도록 했다. 정상 동작하지 않는 컴포넌트를 랜더링 하는 것이 모든 컴포넌트를 보여주지 않는 것보다 더 좋지 않다는 판단이라고 한다. 이런 리액트의 생각에 아주 동의한다.

그럼 if문과 ErrorBoundary를 사용했을 때 차이가 뭘까?

우선 if문을 사용했을 때를 보자.

const Component = () => {
  const { data, isError } = useGetUserListQuery();
  
  if(!data || isError) return null;
  
  
  return data.map((user) => <Item key={user.id} />
}

react-query를 사용한다면 대충 위와 같은 형태가 될 것이다. 그런데 만약 내가 짠 이 코드를 리액트 개발자가 아닌 서버 개발자가 읽는다고 했을 때 어떤 생각을 할까?

data라는 값이 falsy 하거나, isError 라는 값이 truthy할 때, null을 리턴한다.

위 문장에서 알 수 있듯이 if문으로 처리하는 것은 보통 명령형적으로 코드를 작성하는 경우다. 이때는 코드가 어떻게 동작할지 설명하게 된다. 즉, 내가 이 코드를 통해 궁극적으로 하고 싶은 게 무엇인지 의미를 알 수 없다.

그럼 이제 ErrorBoundary를 사용해보자.

const Component = () => {
  const { data } = useGetUserListQuery() as { data: User[] };
  
  return data.map((user) => <Item key={user.id} />                  
}
                  
const App = () => {
  return <>
    <ErrorBoundary fallback={<>error occurred</>}>
      <Component />
    </ErrorBoundary>
  </>
}

위 코드에서 if문이 없다는 것을 알 수 있다. 대신, useGetUserListQuery라는 훅이 리턴하는 값을 type assertion을 통해 타입 지정을 할 수 있다. 이것이 근본적인 차이라고 생각한다.

if문을 통한 에러처리와 ErrorBoundary는 명령형으로 에러를 처리하냐, 선언형으로 에러를 처리하냐의 차이다.

명령형으로 작성할 경우, 컴포넌트는 반드시 에러 처리에 대한 코드를 포함하게 된다. 반면, 선언형으로 작성할 경우 에러 발생 시 UI를 ErrorBoundary에게 위임했기 때문에 에러 처리에 대한 코드를 작성하지 않는다. 즉, ErrorBoundary를 사용한다면, 컴포넌트가 랜더링된다는 것은 data가 존재한다는 것을 보장하는 것이다. 그렇기에 자신있게 type assertion을 사용할 수 있다.

이렇게 선언적으로 컴포넌트를 작성하는 것이 좀 더 리액트스러운 컴포넌트라고 생각한다.


🤔 에러 바운더리는 왜 사용해야할까

그렇다면 에러 바운더리를 왜 사용해야할까? 단순히 빈 화면이 출력되면 안되니까? 짧은 개발 경험으로 고민하고 결론이 난 것은, 개발자도 예측하지 못한 에러가 발생했을 때 서비스를 유지하기 위해서라고 생각한다. Fallback UI를 보여주는 이유도, 예측하지 못한 에러가 발생했을 때 그 컴포넌트를 랜더링 하는 것은 안전하지 않으니 비교적 안전하다고 장담할 수 있는 컴포넌트를 대신 랜더링해서 다른 컴포넌트에 영향을 주지 않겠다는 의미다.

에러의 종류는 크게 두가지라고 본다.

  • 예측할 수 있는 에러
  • 예측할 수 없는 에러

예측할 수 있는 에러는 예를 들면, 유저가 이메일 형식에 맞지 않게 입력했거나, 이용할 수 없는 권한으로 요청한 경우 등이 있을 수 있다. 이런 경우는 이미 기획, 디자인 단계에서 모두 정의가 되어 있을 것이다. 하지만, 예측할 수 없는 에러도 발생한다. 이는 대부분 개발자의 코드 작성 문제일 것이다. 서버 개발자가 설계한 인터페이스와 다르게 작성했을 수도 있고, 배열이 아닌데 map메소드를 사용하려 했을 수도 있다.

이러한 경우는 우리가 사용자에게 할 수 있는 피드백이 없다. 예측 가능한 경우에는 이메일을 형식에 맞게 입력하세요. 와 같은 피드백이 가능하지만, 예측할 수 없는 에러는 map은 함수가 아닙니다.와 같은 피드백을 전달할 수 없다. 이때 사용할 수 있는 것이 ErrorBoundary 일 것이다.


에러 바운더리를 어떻게 사용하면 좋을까?

왜 에러 바운더리를 사용하는 지 알았으니 어떻게 사용하면 좋을 지 고민해야한다. 에러 바운더리를 단순히 최상위에서 사용하고 알 수 없는 에러라고 표시하는 것은 좋은 방법이 아니다. 어떤 컴포넌트에 에러가 발생했다고 해서 다른 기능을 사용하지 못하게 막는 것은 사용자 입장에서 별로 좋지 않은 경험이 될 것이다.

단순히 내가 팔로우한 유저 목록의 데이터를 못 받아왔다고 해서 게시물을 작성하지 못하진 않을 것이다. 이럴 때는 유저 목록 데이터만 Fallback UI로 보여주면 되지 않을까? 이렇게 에러 바운더리는 꼭 최상위에서 사용하지 않고 컴포넌트 단위로 사용해도 된다.

그렇다면, 에러 바운더리를 모든 컴포넌트를 감싸서 사용하는 것은 어떨까? 에러가 나는 부분만 못쓰게 하고 다른 기능은 모두 사용할 수 있도록 하는 것이 유저 경험에 좋지 않을까? 이것도 좋은 생각은 아니다.

위 사진에서, 유저의 목록을 받아오지 못한 경우에 에러를 리스트 전체에 에러 바운더리 처리하는 것이 아니라 유저 정보에 에러 바운더리 처리를 한다면 어떻게 될까?

위 이미지의 초록색 박스를 안 보이게 처리하고 follow는 가능하도록 하는 것이다. 그럼 유저는 follow 기능을 사용하니까 더 좋은 사용자 경험일까? 거의 대부분의 경우에 버그가 발생할 것이라고 본다.

두번째 이미지처럼 유저 정보 부분을 fallback UI 처리를 하면 사용자는 그 부분이 어떤 유저인지 알 수 있는 방법이 없다. 즉, 사용자는 누구인지도 모르면서 follow하는 것이다. 이게 정상적인 기능이라고 생각하는가? 아마 버튼을 클릭해도 알 수 없는 에러에 대한 피드백이 올 것이다. 받아올 수 없는 유저 정보에 어떤 요청을 하는 것을 서버가 가만히 놔둘 것이라고 생각하면 안 된다. 그리고 요청이 성공한다고 해도 문제다. 사용자는 누굴 팔로우 했는지 알지 못한다. 이게 좋은 사용자 경험일까?

이렇게 우리는 에러 바운더리를 사용할 때 어느 위치에 적용하면 좋을 지 고민해야한다. 이런 고민은 기획 단계보다는 개발자가 더 쉽게 생각할 수 있다. 예측 불가능한 경우 대부분 개발 단계에서 발생할 가능성이 높기 때문이다. 개발자는 어떤 데이터가 어떤 기능과 연관되어야 할 지 설계하기 때문에 어느 기능까지 제공하고 어느 기능까지 막을 것인지 쉽게 알 수 있다.

이런 고민은 Fault Tolerance에 대한 고민이다. 이에 대한 좋은 글이 있으니 반드시 읽어보길 권한다. 이 글은 리액트 공식 문서에서도 링크되어 있다.