카테고리 없음

에러 바운더리와 에러 처리 중앙화

Ahyeon, Jung 2024. 8. 20. 03:18
함수형 컴포넌트와 클래스형 컴포넌트
    -  컴포넌트의 생명주기
에러 바운더리 개요
에러 바운더리 사용해보기
    -  동시다발적 에러 잡기
    -  동시다발적 에러 각각 잡기
에러 바운더리
   -  에러 바운더리가 잡는 에러
   -  에러 바운더리가 잡지 못하는 에러를 잡아보자
   -  ※ 주의할 점 ※ 비동기를 에러 바운더리로 처리하는 것은 비선언적이다
Context API로 에러 처리 중앙화하기
더 알아보기
   -  useQuery의 onError가 deprecated되었으니 에러 바운더리를 사용하세요
   -  바운더리 개념을 서버로 옮겨보자

 

함수형 컴포넌트와 클래스형 컴포넌트

오히려 이제는 함수형 컴포넌트를 주로 사용하게 되어, 클래스형 컴포넌트는 구시대적인 산물이 되었다. 클래스형 컴포넌트를 사용하는 기업을 만날 수도 있으니 공부하라고 하지만, 와닿지 않아 여전히 한켠에 의문 덩어리로 남아있었다. 지금 생각해보면 공부를 해야하는 이유를 잘못 파악하고 있어서 궁금증이 생기지 않았다. 클래스형 컴포넌트는, 함수형 컴포넌트가 추상화하지 못한 것들을 할 수 있기 때문에, 알고 있어야 한다. 그 중 하나가 에러 바운더리다.

 

일단, 많이 볼 수 있는 함수형 컴포넌트는 다음과 같이 생겼다.

interface FunctionalComponentProps extends React.HTMLProps<HTMLDivElement> {
  label?: string;
}

const FunctionalComponent = ({
  label = "함수형 컴포넌트",
  className,
  ...props
}: FunctionalComponentProps) => {
  
  return (
    <div className={className} {...props}>
      {label}
    </div>
  );
};

export default FunctionalComponent;

 

이 컴포넌트를 클래스형 컴포넌트로 바꾸면, 다음과 같이 생겼다.

import { Component } from "react";

interface ClassComponentProps extends React.HTMLProps<HTMLDivElement> {
  label?: string;
}

class ClassComponent extends Component<ClassComponentProps> {
  static defaultProps = {
    label: "클래스형 컴포넌트",
  };

  render() {
    const { label, className, ...props } = this.props;
    
    return (
      <div className={className} {...props}>
        {label}
      </div>
    );
  }
}

export default ClassComponent;

 

클래스형 컴포넌트는 제너릭 타입을 통해 props와 state의 타입을 지정하고 React.Component 클래스를 확장하여 정의한다. 그리고 this를 통해 props에 접근할 수 있으며, 반드시 render 메서드를 정의하여 실제 DOM 요소로 변환될 JSX를 반환한다.

 

컴포넌트의 생명주기

클래스형 컴포넌트와 함수형 컴포넌트의 가장 큰 차이점 중 하나는 컴포넌트의 생명주기를 처리가 가능하다는 점이다. 클래스형 컴포넌트는 컴포넌트의 생명주기 메서드를 통해 다양한 시점에서 작업을 수행할 수 있다. 즉, 컴포넌트의 생성, 업데이트, 소멸 과정에서 수행할 동작들을 지정할 수 있다.

import React, { Component } from 'react';

class LifecycleDemo extends Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    console.log('Constructor: 컴포넌트가 생성되었습니다.');
  }

  // 컴포넌트가 마운트된 직후 (처음 렌더링된 후)
  componentDidMount() {
    console.log('componentDidMount: 컴포넌트가 DOM에 마운트되었습니다.');
    // 여기서 API 호출이나 타이머 설정 등을 할 수 있습니다.
  }

  // 컴포넌트가 새로운 props 또는 state로 업데이트되기 직전
  shouldComponentUpdate(nextProps, nextState) {
    console.log('shouldComponentUpdate: 컴포넌트가 업데이트되어야 할지 결정합니다.');
    // true를 반환하면 업데이트가 진행되고, false를 반환하면 업데이트가 중단됩니다.
    return true;
  }

  // 컴포넌트가 업데이트된 직후
  componentDidUpdate(prevProps, prevState) {
    console.log('componentDidUpdate: 컴포넌트가 업데이트된 후입니다.');
    // 이전 상태와 비교하여 추가 작업을 수행할 수 있습니다.
  }

  // 컴포넌트가 DOM에서 제거되기 직전
  componentWillUnmount() {
    console.log('componentWillUnmount: 컴포넌트가 DOM에서 제거됩니다.');
    // 여기서 구독 해제, 타이머 제거 등의 정리 작업을 수행합니다.
  }

  // 컴포넌트 렌더링 메서드
  render() {
    console.log('Render: 컴포넌트가 렌더링됩니다.');
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Increment
        </button>
      </div>
    );
  }
}

export default LifecycleDemo;

 

메서드명 설명 단계
constructor 컴포넌트 클래스의 생성자 함수
컴포넌트를 만들 때 처음으로 생성되며 state의 초기값을 지정
마운트
getDerivedStateFromProps props와 state  값을 동기화할 때 사용(리액트v16.3 이후) 마운트, 업데이트
render 컴포넌트를 정의. JSX 반환 마운트, 업데이트
componentDidMount 컴포넌트를 생성하고 첫 렌더링이 끝났을 때 호출되는 함수 마운트
shouldComponentUpdate 컴포넌트 리렌더링 여부를 결정 업데이트
getSnapshotBeforeUpdate 변경된 요소를 DOM 객체에 반영하기 직전에 호출되는 함수 업데이트
componentDidUpdate 컴포넌트 업데이트 작업이 끝난 리렌더링 후에 호출되는 함수 업데이트
componentWillUnmount 컴포넌트가 DOM에서 제거되기 직전에 호출 언마운트

 

쉽게 말해서, 컴포넌트의 생명주기별 메서드를 통해 수행할 동작을 지정해줄 수 있다.

 

함수형 컴포넌트에는 이러한 생명주기 메서드가 존재하지 않아 라이프사이클 제어가 필요하지 않는 경우에 편리하게 사용할 수 있다. 즉, 훨씬 가볍고 직관적인 사용이 가능하다. 또한 훅의 도입을 통해 복잡한 생명주기 제어가 필요한 경우 유연하게 동작을 정의할 수 있다. 예를 들어, useEffect 같은 경우 컴포넌트가 생성되는 시점의 수행(componentDidMount)이나, 의존성 배열을 활용한 업데이트되는 시점의 동작(componentDidUpdate), 그리고 컴포넌트가 소멸되는 시점의 수행(componentWillUnmount) 등을 지정할 수 있다.

 

에러 바운더리 개요

컴포넌트의 생명주기를 활용하는 에러 바운더리의 경우, 하위 컴포넌트에 에러가 발생했을 때 호출되는 메서드다. 일반적인 렌더링 과정과는 조금 다른, 오류 발생 시점에서의 생명주기 메서드이다. 컴포넌트 트리의 하위에 위치한 자식 컴포넌트에서 렌더링 중에 자바스크립트 에러가 발생하면, 리액트에서 자동으로 가장 가까운 에러 바운더리로 에러를 전달하고, 에러 바운더리가 있는 컴포넌트에서 componentDidCatch 메서드가 호출되어 에러를 처리한다. 

 

일반적으로 에러여부를 boolean 값으로 관리하다가 에러가 전파되면 getDerivedStateFromError 메서드를 활용하여 에러가 발생했을 때 상태를 업데이트하고 에러가 true가 되면 상태를 확인하여 Fallback UI를 보여주는 방식으로 구현된다. 

import { Component, ErrorInfo, ReactNode } from "react";

import Fallback from "./Fallback";

interface ErrorBoundaryProps {
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("에러 발생:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <Fallback />;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

 

에러 바운더리 사용해보기

먼저, 쉽게 이해하기 위해 정상적으로 렌더링되면 주먹밥쿵야의 이미지를 보여주고, ErrorComponent 에러가 나서 에러 바운더리가 잡히면 Fallback 컴포넌트인 양파쿵야를 반환했다. 

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          /* <ErrorComponent /> */
          <ValidComponent />
        </div>
      </ErrorBoundary>
    </main>
  );
}

export default App;

 

에러가 난 컴포넌트에서 트리의 부모 컴포넌트를 따라 올라가면서 componentDidCatch 메서드를 가진 에러 바운더리를 찾고, 해당 Fallback UI를 보여주게 될 것이다.

 

동시다발적 에러 잡기

그렇다면, 여러개의 컴포넌트에서 동시에 에러가 나면 어떻게 될지 알아보자.

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          <ValidComponent />
          <ValidComponent />
          <ValidComponent />
        </div>
      </ErrorBoundary>
    </main>
  );
}

export default App;

 

위와 같이 모두 유효한 컴포넌트라면, 정상적으로 세개의 주먹밥쿵야를 반환한다.

 

여기서 하나의 컴포넌트에서 에러가 발생한다면 어떻게 될까?

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          <ValidComponent />
          <ValidComponent />
          <ErrorComponent />
        </div>
      </ErrorBoundary>
    </main>
  );
}

export default App;

 

하나의 컴포넌트에서 에러가 나면, Fallback UI인 양파쿵야만 보여준다. 에러 바운더리의 에러 상태를 확인했을 때 에러가 발생했다면 Fallback UI를, 에러가 발생하지 않았다면 자식 컴포넌트를 그대로 렌더링한다. 따라서, 자식 컴포넌트 중 하나의 컴포넌트에서 에러가 발생한다면 모든 자식 컴포넌트가 다같이 렌더링이 되지 않는다.

 

여러 개의 컴포넌트에서 에러가 발생한다면 어떻게 될까?

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          <ErrorComponent />
          <ErrorComponent />
          <ErrorComponent />
          <ValidComponent />
          <ValidComponent />
          <ValidComponent />
        </div>
      </ErrorBoundary>
    </main>
  );
}

export default App;

 

마찬가지로, Fallback UI인 양파쿵야 하나만 반환하게 된다. 단순히 에러 상태 여부를 통해 캐치하므로, 에러가 몇개든 똑같은 결과를 반환한다.

동시다발적 에러 각각 잡기

하나의 컴포넌트에 문제가 생겼는데도, 형제 컴포넌트가 모두 렌더링이 안되는 것은 불합리하다. 각각 분리하여 정상적인 컴포넌트는 정상적인 결과를 보여주고, 에러가 난 컴포넌트만 따로 Fallback을 보여줄 수 있는 방법은 없을까?

 

가장 가까운 componentDidCatch 메서드를 가진 컴포넌트로 에러가 전달된다는 점을 활용하면 된다. 즉, 지역적으로 ErrorBounday로 감싸주면 해당 에러를 각각 처리할 수 있다.

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          <ErrorBoundary>
              <ValidComponent />
          </ErrorBoundary>
          <ErrorBoundary>
              <ValidComponent />
          </ErrorBoundary>
          <ErrorBoundary>
              <ErrorComponent />
          </ErrorBoundary>
        </div>
      </ErrorBoundary>
    </main>
  );
}

 

에러 컴포넌트에서 에러가 나면, 부모 트리를 타고 올라가 가장 가까운 에러 바운더리의 에러 상태가 업데이트 된다. 같은 깊이의 에러 바운더리들은 자식 컴포넌트인 <ValidComponent />에서 에러가 나타나지 않았기 때문에 정상적으로 자식 컴포넌트를 업데이트 해준다.

 

모든 컴포넌트에서 각각 에러가 난다면 어떻게 될까?

function App() {
  return (
    <main>
      <h1>에러 바운더리 실험하기</h1>
      <ErrorBoundary>
        <div className="container">
          <ErrorBoundary>
              <ErrorComponent />
          </ErrorBoundary>
          <ErrorBoundary>
              <ErrorComponent />
          </ErrorBoundary>
          <ErrorBoundary>
              <ErrorComponent />
          </ErrorBoundary>
        </div>
      </ErrorBoundary>
    </main>
  );
}

 

.container 하위의 에러 바운더리가 모두 에러 상태로 업데이트되어 각각 Fallback UI를 반환하게 된다.


에러 바운더리

다시 에러 바운더리의 순서를 정리해보자.

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("에러 발생:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <Fallback />;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

 

  1. 렌더링 도중 에러가 발생하면, 에러가 발생한 컴포넌트의 부모 컴포넌트 트리를 따라 올라가면서, componentDidCatch 및 getDerivedStateFromError 메서드를 구현한 에러 바운더리 컴포넌트로 이동한다.
  2. 에러 바운더리가 발견되면 getDerivedStateFromError 메서드를 호출하여 상태를 업데이트하고, componentDidCatch 메서드를 호출하여 에러와 관련된 추가 작업을 수행할 수 있게 한다.
  3. 상태가 업데이트되면, 해당 에러 바운더리는 fallbackUI를 렌더링하여 사용자에게 에러가 발생했음을 알린다.

즉, 에러 바운더리는 전체 애플리케이션을 중단시키지 않고, 에러를 관리하고 복구하는 데 도움을 준다. react-error-noundary 라이브러리를 활용하면 에러 바운더리를 쉽게 사용할 수 있다.

 

react-error-boundary - npm (npmjs.com)

 

react-error-boundary

Simple reusable React error boundary component. Latest version: 4.0.13, last published: 6 months ago. Start using react-error-boundary in your project by running `npm i react-error-boundary`. There are 1284 other projects in the npm registry using react-er

www.npmjs.com

 

에러 바운더리가 잡는 에러

에러 바운더리는 리액트의 생애주기 안에서 에러가 발생하면 에러 바운더리에 전달한다. 즉, 컴포넌트의 렌더링에 대한 에러를 잡는 것이다. 렌더링이 끝난 후, 이벤트 핸들러, 비동기 함수, 서버 사이드 렌더링 등에서 발생하는 에러는 잡지 못한다. 

 

비동기 함수

try {
  function throwErrorFn() {
    throw new Error();
  }
  setTimeout(throwErrorFn, 1000);
} catch (e) {
  console.log(e);
}

 

위의 코드는 1000초가 지나 throwErrorFn이 실행되는 시점에 이미 try ... catch 문의 컨텍스트가 종료된 시점이기 때문에 에러가 발생하더라도 catch 문에 적용되지 않는다. 이 때 await/ await을 통해 동기적으로 에러가 발생할 때까지 try ... catch 문의 컨텍스트를 유지하여 에러를 잡을 수 있다.

 

이와 같은 맥락에서, ErrorBoundary가 마운트된 이후에 비동기 함수가 실행되므로, ErrorBounday의 컨텍스트인 생명 주기 메서드 내의 실행 흐름에서 벗어난 시점이기 때문에 잡히지 않는다.

 

이벤트 핸들러

리액트 컴포넌트에 추가한 이벤트 핸들러는 document 혹은 root container에 이벤트 위임되어 SyntheticEvent 객체에 등록(위임)된다. 그리고 이 시점에서 DOM 트리가 마운트딘 이후에, 에러 바운더리의 컨텍스트는 종료된다.

즉, 이벤트가 발생하면 Window(리액트 17부터는 root)에서 타겟 요소까지 전달되는 캡쳐링 단계와, 다시 최상위로 올라가는 버블링 단계를 거친다. 이 과정에서 이벤트 핸들러는 최상위 컴포넌트에 속한다. 따라서 최상위 컴포넌트 내에 있는 ErrorBoundary의 컨텍스트 외부에서 다뤄지기 때문에 잡히지 않는다.

 

서버 사이드 렌더링

 

에러 바운더리는 getDerivedStateFromError를 기반으로, 에러 바운더리의 상태를 변경하여 동작한다. 이는 상태 변화가 존재하는 브라우저 환경에서만 실행되기 때문에, 서버 사이드에서 에러를 포착할 수 없다.

 

에러 바운더리가 잡지 못하는 에러를 잡아보자

비동기 함수 내에서 에러가 발생했을 때, 리렌더링을 시켜주면서 에러를 발생시키면 된다. 즉, 생명주기 외의 에러가 발생하면 리렌더링을 통해 컴포넌트의 렌더링 에러로 전환하여 리액트가 에러 바운더리로 에러를 전달하도록 하는 것이다.

import { useEffect, useState } from "react";

import ValidComponent from "./ValidComponent";

const ErrorComponent = () => {
  const [hasError, setHasError] = useState(false);

  const onClick = () => {
    setHasError(true);
  };

  useEffect(() => {
    if (hasError) {
      throw new Error();
    }
  }, [hasError]);

  return (
    <div onClick={onClick}>
      <ValidComponent />
    </div>
  );
};

export default ErrorComponent;

 

위의 코드는, 에러 컴포넌트를 렌더링 과정에서 에러가 발생하는 것이 아니라, 상태를 통해 컴포넌트를 클릭했을 때 리렌더링을 시키면서 에러를 발생시키도록 변환하였다.

 

이를 통해서 리액트는 생명주기 외의 에러도 에러 바운더리를 통해 잡을 수 있게 되었다.

 

※ 주의할 점 ※ 비동기를 에러 바운더리로 처리하는 것은 비선언적이다

상태를 통해서 비동기에서 에러 발생 시 리렌더링 과정에서 에러를 던지면 에러 바운드리가 잡아낼 수 있다. 그러나 이는 리액트의 선언적인 관점에서 옳지 않다. 에러 바운더리는 클라이언트에서 발생하는 문제인지 명확한 확인을 위함이기 때문에 API의 에러까지 에러 바운더리로 잡는 것은 적합하지 않다. 특히 컴포넌트 내에서 에러 상태가 되면 대 UI를, 혹은 기존의 컴포넌트를 보여주는 상태를 통해 처리하는 것이 선언적 방식에 더 부합하다.

Context API로 에러 처리 중앙화하기

결론은, 에러 바운더리로 비동기 함수를 잡으려는 시도는 가능은 하나, 좋은 코드는 아니었다. 하지만 그렇다고 API 마다 에러가 발생하면 대체 UI를 지정해주는 것이 개인적으로 번거롭다고 느꼈다.

 

다음은 현재 프로젝트의 팀 메인 페이지다. 이 페이지에서는 현재 다른 팀원들의 상태를 응답하는 api, 일주일 간의 가시화 결과를 응답하는 api, 답변을 기다리는 요청들을 응답하는 api, 팀 멤버 목록을 응답하는 api, 팀원들의 전체 백로그를 응답하는 api를 동시에 요청하고 있다.

 

베스트 케이스는 API 에러 발생 시의 대체 UI를 각각 지정해주는 것이다. 

 

좀 더 편안하게 관리하고 싶다면, contextAPI를 이용할 수도 있다.

// ErrorProvider.tsx
const ErrorContext = createContext(null);

const ErrorProvider = ({ children }) => {
  const [error, setError] = useState(null);

  const throwError = (message) => {
    setError(new Error(message));
  };

  const resetError = () => {
    setError(null);
  };

  return (
    <ErrorContext.Provider value={{ error, throwError, resetError }}>
      {children}
    </ErrorContext.Provider>
  );
};

// useError.ts
const useError = () => useContext(ErrorContext);

 

ErrorProvider로 API를 사용하는 컴포넌트를 감싸주고 각각의 API의 try catch문에서 에러가 발생했을때 throwError()를 호출하면 된다.

더 알아보기

useQuery의 onError가 deprecated되었으니 에러 바운더리를 사용하세요

tanstack-query는 버전 5부터 useQuery의 onError, onSuccess를 deprecated하였다. 

 

그리고 그에 대한 대안으로 세가지를 제시한다.

  • 전역 상태를 통해 에러를 전역으로 관리한다.
  • 에러 바운더리를 통해 에러를 관리한다.
  • API 호출 결과를 통해 에러를 관리한다.

TanStack Query v5 정식 버전 살펴보기 (리액트 쿼리) | moonkorea

 

TanStack Query v5 정식 버전 살펴보기 (리액트 쿼리)

리액트 쿼리 v5가 정식 출시됐는데요, 이번 글에서는 마이그레이션 가이드를 참고해서 주요 변경 사항들을 살펴봅니다.

www.moonkorea.dev

이들은 앞서 결국 언급한 방법들과 일치한다.

 

에러 바운더리를 사용하는 것은 tanstack-query를 통해 비동기 에러를 관리하는데 더하여, 컴포넌트 트리 내의 에러를 잡는 것이다.

 

전역으로 관리하라는 것은 앞서 언급한것과 같이 에러 상태를 전역으로 설정하여 중앙화하는 것이다.

 

 

API 결과에 따른 fallback UI를 보여주는 것은, 데이터 패칭 상태를 쉽게 관리할 수 있는 tanstack-query를 활용하여 각각의 상태에 따라 보여주는 것이다. 즉, isLoading, isError, isSuccess 등의 상태를 활용하여 UI를 보여주는 것이다. 결론적으로는 tanstack-query를 통한 에러 상태에 따라 대체 UI를 반환하고, 에러 바운더리로 감싸는 것이 가장 자연스러운 연결이라고 볼 수 있다. 이 에러 상태에 따라 대체 UI를 반환하는 것이 더 선언적이고 직관적인 방식으로 관리하는 것이기에 onError를 deprecated한 것으로 보인다.

 

바운더리 개념을 서버로 옮겨보자

서버 사이드 렌더링은 서버에서 DOM 트리를 전부 만들어서 보내주는 방식이다. 즉, 에러 바운더리를 지역적으로 설정할 수 있는게 아닌, 에러가 나서 아예 아무것도 오지 않거나 에러가 나지 않아서 렌더링이 된 HTML이 응답으로 오는 방식이었다. 

 

하지만 서버 컴포넌트와 클라이언트 컴포넌트를 분리하면서, 서버에서 서버 컴포넌트를 렌더링해서 보내주고, 클라이언트는 서버에서 만들어준 서버 트리 내에 placeholder로 위치해있는 클라이언트 컴포넌트를 렌더링해준 후 hydrate하게 된다. 이 서버 컴포넌트와 에러 바운더리를 활용하면, 에러가 생긴 서버 컴포넌트만 fallback UI로 보여주고, 나머지는 정상적인 상태로 렌더링해줄 수 있다. 특히 서버 컴포넌트에 에러가 생기면, 에러가 생긴 컴포넌트가 돔 트리로 오기 때문에 에러 바운더리에서 자동으로 잡힌다.

const ServerComponent = async () => {
  const data = await fetchData();
  if (!data) {
    throw new Error('데이터를 가져올 수 없습니다.');
  }
  return <div>{data}</div>;
};

export default ServerComponent;

const ClientComponent = () => (
  <ErrorBoundary FallbackComponent={() => <div>양파쿵야</div>}>
    <ServerComponent />
  </ErrorBoundary>
);

export default ClientComponent;