Table of contents
원문: 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'))
프로덕션 환경에 이 코드를 적용한다면 사용자는 서글픈 흰색 화면을 보게 될 것입니다.
이 코드를 (개발 도중에) create-react-app의 에러 오버레이와 함께 실행한다면 이런 화면을 보게 됩니다.
문제는 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'))
불행하게도 이 방식은 동작하지 않는데요. 그 이유는 Greeting
과 Farewell
을 우리가 호출하고 있지 않기 때문입니다. 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>
)
}
불행하게도 이런 방식에서는 에러를 다루는 방법을 두 가지나 관리해야 합니다.
런타임 에러
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-script
나 gatsby
, codesandbox
와 같이 해당 기능을 지원하는 개발 서버를 사용하고 있다면 말이죠), 프로덕션 환경에서는 나타나지 않을 것입니다. 네, 짜증 나는 일이죠. PR은 언제나 환영입니다.