본문 바로가기

웹 프론트엔드

ErrorBoundary로 Toast, ErrorFallback 등 공통적인 에러를 처리해보자

서언

현재 꼭꼭 서비스를 운영하고 있습니다. 안정적인 서비스 운영, 보다 나은 유저 경험을 제공하기 위해 (1) 섬세하고, (2) 빈틈 없는 에러 처리가 필수라는 생각이 들었습니다. (1) 섬세하다는 것은 상황에 맞는 에러를 적절히 보여 준다는 것을 의미하고, (2) 빈틈 없다는 것은 모든 API 요청에 에러 케이스를 고려한다는 것을 의미합니다. 그러면 고민이 심화됩니다.

 

(1) 에러를 어떤 기준으로 분류하여 처리할 수 있을까?

(2) 모든 요청에 에러를 붙인다면, 반복을 줄일 수 없을까?

 

이를 고민하여 나름대로 에러를 분류해보았고, ErrorBoundary(에러 경계) 라는 도구를 활용해 선언적이고 추상화된 에러 처리를 시도해보았습니다. 본 글은 에러 처리 전략, ErrorBoundary를 다룹니다.

에러의 분류

웹 사이트를 개발하며 개발자는 어떤 에러 처리를 보여주려고 해야할까요? 유저는 에러를 어떻게 마주하고 싶을까요? 저희 꼭꼭 프로젝트에서 정의한 3가지 에러는 다음과 같습니다.

 

  1. GET이 실패한 상황
    data fetching에 실패하여 데이터 자체를 보여줄 수 없는 경우
  2. 데이터 변경 HTTP 메서드가 실패한 상황
    사용자의 액션에 정상적으로 반응하지 못 하는 경우
  3. 요청 권한이 없는 상황
    로그인이 끊겨 401 unauthorized 를 마주하는 경우

 

이외에 다른 케이스가 있어도 상관 없습니다. 이번 포스팅에서는 공통 에러를 어떻게 처리할 것인지 다룰 것이기 때문에 케이스가 더 많거나 적다면, 코드 단에서 케이스를 추가/삭제해주면 됩니다.

에러 처리 전략

위 3가지 에러를 어떻게 해결할지 고민해보았습니다.

1. GET이 실패한 상황

GET이 실패했다면 원하는 UI를 보여줄 수 없게 됩니다. 이런 상황에서 토스트 메시지만 보여준다면 사용자는 어떻게 해야할까요? 네트워크 문제로 데이터 fetching에 실패했고, 한 페이지 내에서 3가지 GET을 모두 실패했다고 하면, 사용자는 어떠한 데이터도 볼 수 없고, 3단으로 올라오는 토스트 메시지만을 마주할 수 있겠죠. 그래서 데이터 fetching에 실패한 상황에 토스트 메시지만을 보여주는 것은 적절하지 않다고 생각했습니다.

 

  1. 데이터가 들어가야 할 컴포넌트를 비워두는 것보다, 이 곳에 ErrorFallback 컴포넌트를 띄워주는 것이 낫다.
  2. 사용자로 하여금 새로고침 하도록 만들지 않고, 다시 데이터를 fetching할 수 있는 reset 버튼을 제공해야 한다.

 

즉, GET이 실패한 경우 reset 기능이 있는 에러 컴포넌트(ErrorFallback)를 띄워줘야겠다고 판단했습니다.

2. 데이터 변경 HTTP 메서드가 실패한 상황

POST, PUT, DELETE와 같은 변경 메서드는 사용자 액션에 의해 발생됩니다. 액션의 성공/실패 여부를 즉각 피드백 해줄 필요가 있습니다. 그러므로 토스트 UI를 띄워줍니다. 하지만, 액션이 실패했다고 하여 ErrorFallback을 보여줄 필요가 있을까요? 사용자가 실패했다는 것을 인지하게 하고, 다시 요청하게끔 유도만 하면 될 것입니다.

 

즉, 이 경우에는 토스트 UI만 발동시킵니다.

3. 요청 권한이 없는 상황 (Unauthorized)

API 요청을 했지만, 토큰이 만료되어 요청 권한이 없는 경우가 있습니다. 제가 진행하고 있는 프로젝트의 경우 로그인 페이지로 라우팅 해주어 다시 로그인하도록 유도합니다. 이는 인가가 필요한 모든 API call에 대해 처리해주어야할 에러 케이스입니다.

 

즉, 요청 권한이 없는 경우 로그인 페이지로 라우팅 해줍니다.

문제 상황

이전에는 모든 요청 마다 개별적으로 에러를 처리해 주었습니다. 토스트가 필요한 요청에는 토스트 처리를 해주고, ErrorFallback이 필요한 요청에는 분기처리를 통해 ErrorFallback을 띄워주었습니다. 이는 몇 가지 단점이 있었습니다.

 

  1. 휴먼 에러의 가능성.
    일부 API call 에 대해 에러 처리를 까먹거나, 의도와 다른 방식으로 처리하게 되는 경우가 있었습니다.
  2. 관리 포인트의 증가
    에러 처리 방식이 변경되는 경우, 변경해 주어야 할 부분이 많아졌습니다.
  3. 에러 처리 수단의 비일관성
    어떤 에러는 catch 문에서 에러를 처리해 주어야 했고, 어떤 에러는 컴포넌트 내에서 isError 조건문에 따라 컴포넌트를 렌더링 해주어야 했습니다.
  4. 명령적인 에러 처리
    catch 문 내에서 구체적으로 에러 핸들링을 지시하거나, isError 일때 구체적인 컴포넌트를 명시하는 등 명령적인 코드가 많아졌습니다.

 

그래서 저는 공통적으로 처리할 수 있는 에러 케이스를 위와 같이 3가지로 정의했고, 공통적인 에러를 어떻게 한번에 처리할 수 있을까 고민했습니다. 그래서 도입한 것이 ErrorBoundary(에러 경계)입니다.

ErrorBoundary(에러 경계) 도입

도입 이유

ErrorBoundary는 합성 컴포넌트입니다. 하위 컴포넌트, 즉 children 내부에서 throw 된 에러를 잡아주는 역할을 합니다. ErrorBoundary는 컴포넌트이기 때문에 여러 장점이 있습니다.

 

  1. 선언적인 에러 처리가 가능합니다. 컴포넌트 내에서 에러 케이스에 대한 분기 처리해주지 않아도 됩니다. 그러므로 컴포넌트는 성공 케이스만 작성해주고, 에러 케이스는 에러 바운더리에 위임할 수 있게 되죠.
  2. 컴포넌트이기 때문에, sideEffect를 처리할 수도 있고 에러 컴포넌트를 반환할 수 있습니다. 즉, 모든 에러를 throw 받아서 추상화된 에러 바운더리에서 케이스별로 처리해줄 수 있습니다.
  3. react-query에서 useErrorBoundary 옵션을 켜서, 모든 에러가 throw 되도록 만들 수 있습니다. 그러므로 공통 에러는 throw 되는 곳에서 잡고, 세부적인 에러는 onError로 후처리 해줄 수 있습니다.

 

그러면 이제부터 기본적인 에러바운더리 코드를 살펴보고, 이를 어떻게 커스텀하였는지 살펴보겠습니다.

Simple 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;
  }
}

이 코드는 react 공식문서 에서 받아온 코드입니다. class component라 어색할 수 있습니다. 하지만 class component 답게 섬세한 lifecycle을 다룰 수 있죠.

 

  • getDerivedStateFromError : 말 그대로 Error로부터 상태를 업데이트하는 메서드입니다. 에러를 throw 받은 시점(Render Phase)에 발동하는 메서드입니다. 두가지 역할이 있는 것이죠. (1) throw 된 에러를 catch 한다. (2) return 한 값을 기반으로 setState 를 실행한다.
  • componentDidCatch : render 이후 sideEffect를 다루는 메서드입니다. render 메서드가 실행된 후 발동하는 메서드입니다. error 로그를 기록하는 데 쓰일 수 있다고 공식문서에 나와있습니다. 저는 이 메서드를 많이 활용해볼 계획입니다.

 

라이프 사이클을 잘 파악해야 해당 메서드를 적절히 사용할 수 있습니다. render 시점, getDerivedStateFrom~ 메서드의 시점, componentDid~ 메서드의 시점을 비교해봅니다.

 

getDerivedStateFrom~ → render → componentDid~ 순서입니다.

ErrorBoundary 확장 방향

위 에러바운더리는 단순해서 서비스에서 사용할 수 없습니다. 더 많은 역할을 하길 원합니다.

 

  1. 에러와 상태를 reset 할 수 있는 ErrorFallback을 받아온다.
  2. Toast UI, 401 에러 처리 등 공통 에러를 다룰 수 있도록 한다.

props로 ErrorFallback을 받아오기

import { Component, ComponentType, PropsWithChildren } from 'react';

export interface FallbackProps {
  error: Error;
  resetErrorBoundary?: () => void;
}

interface ErrorBoundaryProps {
  fallback: ComponentType<FallbackProps>;
  onReset?: () => void;
}

interface ErrorBoundaryState {
  error: Error | null;
}

const initialState: ErrorBoundaryState = {
  error: null,
};

class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> {
  state: ErrorBoundaryState = {
    error: null,
  };

  resetErrorBoundary = () => {
    this.props.onReset?.();
    this.setState(initialState);
  };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  render() {
    const { fallback: FallbackComponent } = this.props;

    if (this.state.error) {
      return (
        <FallbackComponent error={this.state.error} resetErrorBoundary={this.resetErrorBoundary} />
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

ErrorFallback 함수의 props로 error와 resetErrorBoundary 함수를 넘겨줍니다. 아래와 같이 에러가 발생했음을 알리고, 유저로 하여금 reset 버튼을 눌러 다시 시작할 수 있도록 합니다.

reset 함수는 error 상태를 초기화 해주면서, 다른 클라이언트 상태를 초기화해주는 함수를 props로 받습니다. 저희 프로젝트의 경우, react-query에서 cache 해둔 remote data를 clear 하는 함수를 props로 내려줍니다.

 

위에서 3가지 에러 케이스를 언급했습니다. 그 중에서 (1) GET이 실패한 상황은 완벽하게 커버하고 있는 것 같습니다. 하지만 저는 ErrorBoundary에게 더 많은 역할을 위임하고 싶었습니다. (2) Toast도 제어할 수 있고, (3) 로그인 페이지로 라우팅도 할 수 있게끔 모든 공통 에러를 ErrorBoundary에 위임시켜 관리포인트를 줄이고 에러 처리 일관성을 지키고 싶었습니다.

Toast UI, 401 에러 처리 등 공통 에러를 처리하기

ErrorBoundary 코드가 길 수 있습니다. 전체적인 컨셉은 이러합니다.

 

  1. getDerivedStateFromError 메서드에서 catch 한 에러를 케이스 분류하여 errorCase 상태에 저장합니다.
  2. render 메서드에서 errorCase에 따라 다른 컴포넌트를 반환합니다.
  3. componentDidCatch 메서드에서 errorCase에 따라 다른 부수효과를 실행시킵니다.

 

전체 코드를 첨부하고, 아래에서 나눠서 파악해보겠습니다. 코드가 길지만, 3가지로 나누어 살펴보면 어렵지 않습니다.

interface ErrorBoundaryProps {
  fallback: React.ComponentType<ErrorFallbackProps>;
  onReset?: () => void;
}

type ErrorBoundaryState =
  | {
      error: null;
      errorCase: null;
    }
  | {
      error: Error;
      errorCase: null;
    }
  | {
      error: AxiosError<{ message: string }>;
      errorCase: 'unauthorized' | 'get';
	  };

const initialState: ErrorBoundaryState = {
  error: null,
  errorCase: null,
};

class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> {
  state: ErrorBoundaryState = {
    error: null,
    errorCase: null,
  };

  resetErrorBoundary = () => {
    this.props.onReset?.();
    this.setState(initialState);
  };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    if (!(error instanceof AxiosError<{ message: string }>)) {
      return { error, errorCase: null };
    }

    if (error.response?.status === 401) {
      return {
        error,
        errorCase: 'unauthorized',
      };
    }

    if (error.response?.config.method === 'get') {
      return {
        error,
        errorCase: 'get',
      };
    }

    return { error, errorCase: null };
  }

  static contextType = ToastContext;

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    const { displayMessage } = this.context as ToastContextType;

    const { error: errorState, errorCase } = this.state;

    if (errorCase === 'unauthorized') {
      localStorage.removeItem('user-token');

      displayMessage('다시 로그인해주세요', true);

      return;
    }

    if (errorCase === 'get') {
      displayMessage(errorState.response?.data.message || '알 수 없는 에러가 발생했습니다.', true);

      return;
    }

    if (errorCase === null) {
      displayMessage('알 수 없는 에러가 발생했습니다.', true);
    }
  }

  render() {
    const { fallback: FallbackComponent, children } = this.props;

    const { error, errorCase } = this.state;

    if (errorCase === 'unauthorized') {
      return <Navigate to={PATH.LOGIN} />;
    }

    if (errorCase === 'get') {
      return <FallbackComponent error={error} resetErrorBoundary={this.resetErrorBoundary} />;
    }

    return children;
  }
}

 

 

getDerivedStateFromError는 에러를 받아서 상태로 저장하는 메서드로, errorCase를 분류하는 역할을 하고 있습니다. ErrorBoundaryState는 3가지 경우로 나뉩니다. error가 없거나, error가 axios 요청 실패에 의해 AxiosError 타입을 가지거나 그 이외의 Error인 경우입니다.

 

또한 errorCase는 위에서 정의한 대로 get(GET 요청이 실패한 경우), unauthorized(401 에러인 경우)로 나뉩니다.

type ErrorBoundaryState =
  | {
      error: null;
      errorCase: null;
    }
  | {
      error: Error;
      errorCase: null;
    }
  | {
      error: AxiosError<{ message: string }>;
      errorCase: 'unauthorized' | 'get';
	  };

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
  if (!(error instanceof AxiosError<{ message: string }>)) {
    return { error, errorCase: null };
  }

  if (error.response?.status === 401) {
    return {
      error,
      errorCase: 'unauthorized',
    };
  }

  if (error.response?.config.method === 'get') {
    return {
      error,
      errorCase: 'get',
    };
  }

  return { error, errorCase: null };
}

 

 

render 메서드에서는 errorCase가 unauthorized라면 로그인 페이지로 라우팅 시키고, get이라면 ErrorFallback을 띄워줍니다. 그리고 나머지 경우는 정상적으로 children을 보여줍니다.

render() {
  const { fallback: FallbackComponent, children } = this.props;

  const { error, errorCase } = this.state;

  if (errorCase === 'unauthorized') {
    return <Navigate to={PATH.LOGIN} />;
  }

  if (errorCase === 'get') {
    return <FallbackComponent error={error} resetErrorBoundary={this.resetErrorBoundary} />;
  }

  return children;
}

 

 

componentDidCatch는 error를 catch하고 render 이후의 sideEffect를 다루는 메서드입니다. errorCase가 unauthorized라면 localStorage에서 토큰을 삭제하고, 토스트를 띄웁니다. 나머지 경우에도 메시지만 다르며 토스트를 띄우는 것은 동일합니다.

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  const { displayMessage } = this.context as ToastContextType;

  const { error: errorState, errorCase } = this.state;

  if (errorCase === 'unauthorized') {
    localStorage.removeItem('user-token');

    displayMessage('다시 로그인해주세요', true);

    return;
  }

  if (errorCase === 'get') {
    displayMessage((errorState.response?.data as any).message, true);

    return;
  }

  if (errorCase === null) {
    displayMessage('알 수 없는 에러가 발생했습니다.', true);
  }
}

이제 위에서 정의한 공통 에러 케이스를 모두 에러바운더리에 위임할 수 있었습니다. 이제 최상위에서 ErrorBoundary로 감싸주기만 한다면, 모든 공통 에러를 처리할 수 있습니다. 추가 에러를 처리해주고 싶다면, 이는 컴포넌트 단에서 처리해주는 것이 적절할 것입니다.

결론 및 한계점

에러 처리 영역은 중요하지만, 도외시하기 쉽습니다. 저는 어떻게 간단하면서, 섬세하게 에러를 처리할 수 있을지 고민했습니다. 그러면서 ErrorBoundary를 도입하였고, 선언적이며, 추상화된 에러 처리를 구현했습니다.

하지만 제가 제시한 방식도 여전히 몇 가지 숙제가 남아있습니다. 그 중 하나는 에러 케이스 분류가 투박하다는 것입니다. 모든 get은 다 동일하지 않고, 모든 401 응답 코드는 다 동일하지 않습니다. 현재 프로덕트에는 적용되지만, 미래에도 같을 것이라 기대하기 어렵습니다. 이는 백엔드와의 소통을 통해 커스텀 응답 코드를 정의하거나, 프론트에서 세분화하여 해결할 수 있겠습니다.

 

더 나은 방식에 대하여 토론하고 싶으신 분들은 댓글로 남겨주세요.

 

꼭꼭 에러 처리 코드 보러가기

작성자 : https://github.com/euijinkk