리액트에서 에러 처리를 위한 react-error-boundary 사용하기

리액트에서 에러 처리를 위한 react-error-boundary 사용하기

원문: Kent C. Dodds, "Use react-error-boundary to handle errors in React"

이 코드는 무엇이 잘못되었을까요?

import * as React from 'react'
import ReactDOM from 'react-dom'

function Greeting({subject}) {
  return <div>Hello {subject.toUpperCase()}</div>
}

function Farewell({subject}) {
  return <div>Goodbye {subject.toUpperCase()}</div>
}

function App() {
  return (
    <div>
      <Greeting />
      <Farewell />
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

프로덕션 환경에 이 코드를 적용한다면 사용자는 서글픈 흰색 화면을 보게 될 것입니다.

Chrome window with nothing but white

이 코드를 (개발 도중에) create-react-app의 에러 오버레이와 함께 실행한다면 이런 화면을 보게 됩니다.

TypeError Cannot read property 'toUpperCase' of undefined

문제는 subject prop을 (문자열 형태로) 전달하거나 subject prop의 기본값을 설정해야 한다는 점입니다. 물론 이 예시는 다소 작위적입니다만 런타임 에러는 항상 일어나기 때문에 이러한 에러를 우아하게 처리하는 편이 좋습니다. 그러니 이 에러를 잠시 그대로 두고 React가 이 같은 런타임 에러를 처리하기 위하여 어떤 도구를 제공하는지 살펴봅시다.

try/catch?

이런 종류의 에러를 다루는 순진한 접근법은 try/catch 문을 추가하는 것입니다.

import * as React from 'react'
import ReactDOM from 'react-dom'

function ErrorFallback({error}) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{color: 'red'}}>{error.message}</pre>
    </div>
  )
}

function Greeting({subject}) {
  try {
    return <div>Hello {subject.toUpperCase()}</div>
  } catch (error) {
    return <ErrorFallback error={error} />
  }
}

function Farewell({subject}) {
  try {
    return <div>Goodbye {subject.toUpperCase()}</div>
  } catch (error) {
    return <ErrorFallback error={error} />
  }
}

function App() {
  return (
    <div>
      <Greeting />
      <Farewell />
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

이 방법은 "일단 되긴 하네요."

하지만 우스운 생각일 수도 있지만 애플리케이션의 모든 컴포넌트를 try/catch 블록으로 감싸고 싶지 않다면 어떨까요? 일반적인 JavaScript에서는 단순히 호출할 함수를 try/catch로 감싸기만 하면 해당 함수가 호출하는 함수에서 발생하는 모든 에러를 모두 잡을 수 있습니다. 여기서도 시도해 보죠.

import * as React from 'react'
import ReactDOM from 'react-dom'

function ErrorFallback({error}) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{color: 'red'}}>{error.message}</pre>
    </div>
  )
}

function Greeting({subject}) {
  return <div>Hello {subject.toUpperCase()}</div>
}

function Farewell({subject}) {
  return <div>Goodbye {subject.toUpperCase()}</div>
}

function App() {
  try {
    return (
      <div>
        <Greeting />
        <Farewell />
      </div>
    )
  } catch (error) {
    return <ErrorFallback error={error} />
  }
}

ReactDOM.render(<App />, document.getElementById('root'))

불행하게도 이 방식은 동작하지 않는데요. 그 이유는 GreetingFarewell을 우리가 호출하고 있지 않기 때문입니다. React가 이들을 호출하고 있죠. JSX에서 함수 컴포넌트를 사용할 때 우리는 그저 해당 함수들을 type으로 가지는 React 엘리먼트를 만들고 있는 것뿐입니다. React에게 "App이 렌더링되면 호출해야 할 다른 컴포넌트들이 있어요"라고 말하는 것이지 실제로 호출하는 것은 아니기 때문에 try/catch는 동작하지 않습니다.

솔직히 그리 실망스럽지는 않습니다. try/catch는 본질적으로 명령형이고 저는 애플리케이션에서 발생하는 에러를 선언적으로 처리하는 방법을 더 선호하기 때문입니다.

React 에러 경계

이런 경우 에러 경계 기능이 유용하게 쓰일 수 있습니다. "에러 경계"는 위에서 설명했던 런타임 에러를 다루기 위해 작성하는 특수한 컴포넌트입니다. 어떠한 컴포넌트가 에러 경계가 되기 위해서는

1. 클래스 컴포넌트여야 합니다. 🙁

2. getDerivedStateFromError 또는 componentDidCatch 중 하나를 반드시 구현해야 합니다.

다행히도 react-error-boundary 라이브러리가 있습니다. 이 라이브러리는 React 애플리케이션에서 선언적으로 런타임 에러를 처리하기 위한 모든 도구를 제공하기 때문에 더 이상 ErrorBoundary를 직접 작성할 필요는 없습니다.

react-error-boundary를 추가하여 ErrorBoundary 컴포넌트를 렌더링해 봅시다.

import * as React from 'react'
import ReactDOM from 'react-dom'
import {ErrorBoundary} from 'react-error-boundary'

function ErrorFallback({error}) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{color: 'red'}}>{error.message}</pre>
    </div>
  )
}

function Greeting({subject}) {
  return <div>Hello {subject.toUpperCase()}</div>
}

function Farewell({subject}) {
  return <div>Goodbye {subject.toUpperCase()}</div>
}

function App() {
  return (
    <div>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <Greeting />
        <Farewell />
      </ErrorBoundary>
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

완벽하게 동작합니다.

에러 복구

이 방식의 좋은 점은 ErrorBoundary 컴포넌트를 마치 try/catch 블록과 같은 방식으로 생각할 수 있다는 것입니다. 여러 React 컴포넌트를 감싸 많은 에러를 한 번에 처리할 수도 있고 좀 더 세분화된 에러 처리와 복구를 위해 트리 상의 특정 부분으로 범위를 좁힐 수도 있습니다. react-error-boundary는 이를 관리하는 데 필요한 모든 도구를 제공합니다.

더 복잡한 예시를 보겠습니다.

function ErrorFallback({error, resetErrorBoundary}) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre style={{color: 'red'}}>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  )
}

function Bomb({username}) {
  if (username === 'bomb') {
    throw new Error('💥 CABOOM 💥')
  }
  return `Hi ${username}`
}

function App() {
  const [username, setUsername] = React.useState('')
  const usernameRef = React.useRef(null)

  return (
    <div>
      <label>
        {`Username (don't type "bomb"): `}
        <input
          placeholder={`type "bomb"`}
          value={username}
          onChange={e => setUsername(e.target.value)}
          ref={usernameRef}
        />
      </label>
      <div>
        <ErrorBoundary
          FallbackComponent={ErrorFallback}
          onReset={() => {
            setUsername('')
            usernameRef.current.focus()
          }}
          resetKeys={[username]}
        >
          <Bomb username={username} />
        </ErrorBoundary>
      </div>
    </div>
  )
}

위 코드가 어떻게 동작하는지 경험해 보세요.

"bomb"을 입력하면 Bomb 컴포넌트가 ErrorFallback 컴포넌트로 대체되는 것을 알 수 있으며 (resetKey prop에 들어있기 때문에) username을 바꾸거나 resetErrorBoundary에 연결되어 있는 "Try again"을 클릭하여 복구할 수 있습니다. onReset은 상태를 사용자 이름으로 되돌려 다시 에러가 발생하지 않도록 합니다.

모든 에러를 처리하기

안타깝게도 React가 에러 경계에게 넘겨주지 않거나 넘겨줄 수 없는 몇 가지 에러들이 있습니다. React 문서를 인용하자면

"에러 경계는 아래의 경우에 발생하는 에러들을 잡지 않습니다."

  • 이벤트 핸들러 (더 알아보기)

  • 비동기 코드 (예를 들어 setTimeout 또는 requestAnimationFrame 콜백 함수들)

  • 서버 사이드 렌더링

  • (에러 경계의 자식이 아닌) 에러 경계 자체가 던진 에러

대부분의 경우 사람들은 error 상태를 관리하며 에러가 발생했을 경우 다음과 같이 뭔가 다른 것을 렌더링하려 합니다.

function Greeting() {
  const [{status, greeting, error}, setState] = React.useState({
    status: 'idle',
    greeting: null,
    error: null,
  })

  function handleSubmit(event) {
    event.preventDefault()
    const name = event.target.elements.name.value
    setState({status: 'pending'})
    fetchGreeting(name).then(
      newGreeting => setState({greeting: newGreeting, status: 'resolved'}),
      newError => setState({error: newError, status: 'rejected'}),
    )
  }

  return status === 'rejected' ? (
    <ErrorFallback error={error} />
  ) : status === 'resolved' ? (
    <div>{greeting}</div>
  ) : (
    <form onSubmit={handleSubmit}>
      <label>Name</label>
      <input id="name" />
      <button type="submit" onClick={handleClick}>
        get a greeting
      </button>
    </form>
  )
}

불행하게도 이런 방식에서는 에러를 다루는 방법을 두 가지나 관리해야 합니다.

  1. 런타임 에러

  2. fetchGreeting 에러

다행히도 react-error-boundary는 이런 상황에 도움이 되는 간단한 hook도 제공합니다. 다음은 이를 사용하여 이런 상황을 피할 수 있는 방법의 한 예시입니다.

function Greeting() {
  const [{status, greeting}, setState] = React.useState({
    status: 'idle',
    greeting: null,
  })
  const {showBoundary} = useErrorBoundary()

  function handleSubmit(event) {
    event.preventDefault()
    const name = event.target.elements.name.value
    setState({status: 'pending'})
    fetchGreeting(name).then(
      newGreeting => setState({greeting: newGreeting, status: 'resolved'}),
      error => showBoundary(error),
    )
  }

  return status === 'resolved' ? (
    <div>{greeting}</div>
  ) : (
    <form onSubmit={handleSubmit}>
      <label>Name</label>
      <input id="name" />
      <button type="submit" onClick={handleClick}>
        get a greeting
      </button>
    </form>
  )
}

fetchGreeting promise가 reject 되면 handleError 함수에 에러가 전달되어 호출되며 react-error-boundary가 이를 평소처럼 가장 가까운 에러 경계로 전파합니다.

혹은 에러를 돌려주는 어떤 hook을 사용하고 있다고 가정해 봅시다.

function Greeting() {
  const [name, setName] = React.useState('')
  const {status, greeting, error} = useGreeting(name)
  if (error) throw error

  function handleSubmit(event) {
    event.preventDefault()
    const name = event.target.elements.name.value
    setName(name)
  }

  return status === 'resolved' ? (
    <div>{greeting}</div>
  ) : (
    <form onSubmit={handleSubmit}>
      <label>Name</label>
      <input id="name" />
      <button type="submit" onClick={handleClick}>
        get a greeting
      </button>
    </form>
  )
}

이 경우 error가 참으로 평가되는 값으로 설정되면 가장 가까운 에러 경계로 전파될 것입니다.

둘 중 어떤 경우든 해당 에러들을 다음과 같이 처리할 수 있습니다.

const ui = (
  <ErrorBoundary FallbackComponent={ErrorFallback}>
    <Greeting />
  </ErrorBoundary>
)

이제 런타임 에러와 fetchGreeting이나 useGreeting 코드에서 발생하는 비동기 오류도 처리해줄 것입니다.

결론

에러 경계는 React의 기능으로 수년간 있어왔지만 여전히 런타임 에러를 에러 경계로 처리하고 다른 에러 상태는 컴포넌트 내에서 처리하는 이상한 상황에 놓여있습니다. 에러 경계 컴포넌트를 재사용하여 두 가지 모두를 처리하는 편이 훨씬 좋은데도 말이죠. 아직 react-error-boundary를 사용해보지 않았다면 꼭 한 번 꼼꼼히 살펴보시기 바랍니다!

행운을 빌어요!

아 참, 한 가지 더. 현재 에러가 에러 경계에 의해 처리되었음에도 에러 오버레이를 맞닥뜨릴 수 있습니다. 이는 개발 환경에서만 일어나며 (react-scriptgatsby, codesandbox와 같이 해당 기능을 지원하는 개발 서버를 사용하고 있다면 말이죠), 프로덕션 환경에서는 나타나지 않을 것입니다. 네, 짜증 나는 일이죠. PR은 언제나 환영입니다.