본문 바로가기
SPA/React

onCaughtError, onUncaughtError로 완성하는 React 19 에러 로깅 정리

by J4J 2026. 7. 25.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 React 19에 새로 추가된 onCaughtError, onUncaughtError와 기존 onRecoverableError까지 함께 활용해 프로덕션 에러 로깅을 완성하는 방법에 대해 알아보는 시간을 가져보려고 합니다.

 

 

 

Error Boundary가 잡은 에러를 놓치지 않으려면

 

React 18까지는 Error Boundary가 에러를 잡았을 때 그 에러를 컴포넌트 단위로만 처리할 수 있었고, 여러 Error Boundary의 에러를 한 곳에서 일괄적으로 받아 외부 로깅 서비스로 전달할 방법이 없었습니다.

 

Error Boundary는 componentDidCatch 생명주기에서 에러를 받을 수 있었지만, 이는 클래스 컴포넌트 내부에 국한된 처리였고 루트 단위로 일괄 설정할 수 있는 옵션은 아니었습니다.

 

그래서 Sentry 같은 에러 트래킹 도구들은 window.onerror나 console.error 자체를 가로채는 방식으로 우회하여 에러를 수집해야 했습니다.

 

제가 처음 프로덕션 배포 이후 에러 트래킹을 붙여보려고 했을 때 가장 아쉬웠던 점 중 하나는, Error Boundary가 이미 에러를 잡아서 화면까지 안전하게 처리했는데도 그 정보를 로깅 서비스로 넘기려면 결국 별도의 우회 로직을 추가해야 한다는 것이었습니다.

 

 

 

그러면 React 19는 이 문제를 어떻게 해결했을까요?

 

React 19는 createRoot와 hydrateRoot에 onCaughtError, onUncaughtError라는 두 개의 옵션을 새로 추가하여, Error Boundary와 관련된 에러를 루트 단위에서 한 곳으로 모아 받을 수 있게 했습니다.

 

참고로 hydration mismatch처럼 React가 자동으로 복구하는 에러를 위한 onRecoverableError 옵션은 React 18부터 이미 존재했으며, React 19는 여기에 나머지 두 옵션을 추가하여 에러 처리 옵션 3개를 완성한 것입니다.

 

다음으로는 이 옵션들을 실제로 어떻게 설정하는지 살펴보겠습니다.

 

 

반응형

 

 

createRoot 옵션으로 onCaughtError, onUncaughtError 등록하기

 

onCaughtError와 onUncaughtError는 모두 error, errorInfo 두 개의 인자를 받는 함수 형태로 정의합니다.

 

errorInfo에는 에러가 발생한 컴포넌트 트리 정보를 담은 componentStack 값이 포함되어 있어, 어떤 컴포넌트에서 에러가 시작되었는지 확인할 수 있습니다.

 

onCaughtError, onUncaughtError를 설정하지 않은 기본 상태에서는 Error Boundary가 잡은 에러는 console.error로, 어디에서도 잡히지 않은 에러는 reportError로 전달됩니다.

 

여기서 주의할 점은 onCaughtError, onUncaughtError를 설정하는 순간 이 기본 로그 출력이 사라진다는 것입니다.

 

이 두 옵션은 기본 로그에 내용을 추가하는 것이 아니라, 기본 로그 자체를 대체하는 콜백이기 때문입니다.

 

 

 

// ❌ 콜백 내부에서 아무 것도 하지 않는 경우
const root = createRoot(document.getElementById('root') as HTMLElement, {
  onCaughtError: () => {},
})

 

이렇게 작성하면 Error Boundary가 에러를 잡아도 콘솔에 아무 로그도 남지 않아, 문제가 발생했다는 사실 자체를 놓치게 됩니다.

 

 

 

// ✅ 콜백 내부에서 직접 로그를 남기는 경우
const root = createRoot(document.getElementById('root') as HTMLElement, {
  onCaughtError: (error, errorInfo) => {
    console.error('[Caught Error]', error, errorInfo.componentStack)
  },
  onUncaughtError: (error, errorInfo) => {
    console.error('[Uncaught Error]', error, errorInfo.componentStack)
  },
})

 

그러므로 onCaughtError, onUncaughtError를 등록할 때는 콜백 내부에서 console.error 호출이나 로깅 서비스 전송 코드를 직접 작성하는 것이 좋습니다.

 

 

 

실제 프로젝트에서는 main.tsx에서 createRoot를 호출하는 시점에 다음과 같이 두 옵션을 함께 등록해 둘 수 있습니다.

 

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'

const root = createRoot(document.getElementById('root') as HTMLElement, {
  onCaughtError: (error, errorInfo) => {
    console.error('[Caught Error]', error, errorInfo.componentStack)
  },
  onUncaughtError: (error, errorInfo) => {
    console.error('[Uncaught Error]', error, errorInfo.componentStack)
  },
})

root.render(
  <StrictMode>
    <App />
  </StrictMode>,
)

 

이제 이 콜백들이 실제로 어떻게 호출되는지 각각 살펴보겠습니다.

 

 

 

 

onCaughtError로 Error Boundary가 처리한 에러 로깅하기

 

onCaughtError는 Error Boundary가 실제로 에러를 잡았을 때만 호출됩니다.

 

React가 클래스로만 지원하는 기능이므로, 직접 클래스를 작성하는 대신 react-error-boundary 라이브러리를 사용해 Error Boundary를 구성하겠습니다.

 

// terminal
$ npm install react-error-boundary

 

Error Boundary는 렌더링 중 발생한 에러만 잡을 수 있어, 버튼의 onClick 핸들러 안에서 바로 에러를 던지는 방식으로는 확인할 수 없습니다.

 

이벤트 핸들러에서 발생한 에러는 Error Boundary가 관여하는 범위 밖에 있기 때문입니다.

 

그래서 버튼 클릭으로 상태를 변경하고, 그 상태에 따라 컴포넌트가 렌더링 도중에 에러를 던지도록 구성합니다.

 

// pages/caught-error/index.tsx
import { useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

function BrokenWidget() {
  const [shouldThrow, setShouldThrow] = useState(false)

  if (shouldThrow) {
    throw new Error('caught-error 데모: 렌더링 중 발생한 에러입니다.')
  }

  return (
    <button
      className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
      onClick={() => setShouldThrow(true)}
    >
      에러 발생시키기
    </button>
  )
}

export default function CaughtErrorPage() {
  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">onCaughtError 데모</h1>
      <ErrorBoundary fallback={<p>문제가 발생했습니다.</p>}>
        <BrokenWidget />
      </ErrorBoundary>
    </main>
  )
}

 

버튼을 클릭하면 shouldThrow 상태가 true로 바뀌면서 BrokenWidget이 다시 렌더링되고, 이 과정에서 에러가 던져집니다.

 

ErrorBoundary가 이 에러를 잡아 fallback으로 지정해 둔 "문제가 발생했습니다"라는 문구를 화면에 보여주는 동시에, main.tsx에 등록해 둔 onCaughtError 콜백이 실행되어 콘솔에 로그가 남는 것을 확인할 수 있습니다.

 

 

 

여기서 주의할 점은 react-error-boundary의 onError prop과 root의 onCaughtError가 같은 에러에 대해 둘 다 호출된다는 것입니다.

 

ErrorBoundary 컴포넌트에 onError prop을 추가로 등록하면, 하나의 에러에 대해 onError와 onCaughtError가 각각 로그를 남기게 되어 리포팅이 중복될 수 있습니다.

 

그러므로 두 콜백을 모두 등록하기보다는, 로깅 서비스로 에러를 전송하는 역할은 onCaughtError 한 곳으로 통일하는 것이 좋습니다.

 

다음으로는 Error Boundary 자체가 없는 경우, 즉 onUncaughtError가 다루는 상황을 살펴보겠습니다.

 

 

 

 

onUncaughtError로 Error Boundary 밖에서 발생한 에러까지 잡기

 

onUncaughtError는 어떤 Error Boundary도 에러를 잡지 못했을 때 호출됩니다.

 

Error Boundary가 존재하고 실제로 에러를 잡은 경우에는 onCaughtError만 호출되며, onUncaughtError는 호출되지 않습니다.

 

즉 하나의 에러에 대해 onCaughtError와 onUncaughtError가 함께 호출되는 경우는 없습니다.

 

 

 

// pages/uncaught-error/index.tsx
import { useState } from 'react'

export default function UncaughtErrorPage() {
  const [shouldThrow, setShouldThrow] = useState(false)

  if (shouldThrow) {
    throw new Error('uncaught-error 데모: Error Boundary 없이 발생한 에러입니다.')
  }

  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">onUncaughtError 데모</h1>
      <button
        className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
        onClick={() => setShouldThrow(true)}
      >
        에러 발생시키기
      </button>
    </main>
  )
}

 

이 페이지에는 Error Boundary가 없기 때문에, 버튼을 클릭하면 에러가 어디에서도 잡히지 않고 그대로 전파됩니다.

 

React는 렌더링 중 잡히지 않은 에러가 발생하면 화면 전체를 unmount하므로, 버튼을 클릭한 순간 페이지가 빈 화면으로 바뀌는 것을 확인할 수 있습니다.

 

동시에 main.tsx에 등록해 둔 onUncaughtError 콜백이 실행되어 콘솔에 로그가 남습니다.

 

이 화면이 바로, 실제 서비스에서 최상위 Error Boundary를 하나 정도는 두는 것이 좋은 이유를 보여주는 예시입니다.

 

다음으로는 React가 자동으로 복구하는 에러까지 포함해 정리해 보겠습니다.

 

 

 

 

onRecoverableError, hydrateRoot와 함께 완성하는 에러 로깅

 

onRecoverableError는 React 18부터 존재했던 옵션으로, hydration mismatch처럼 React가 자동으로 복구를 시도하는 상황에서 호출됩니다.

 

서버에서 렌더링한 결과와 클라이언트에서 렌더링한 결과가 다를 때, React는 해당 서브트리를 클라이언트에서 다시 렌더링하면서 이 콜백으로 원인을 함께 전달합니다.

 

이때 일부 recoverable error는 error 객체의 cause 속성에 원본 원인이 함께 담겨 오므로, errorInfo.componentStack과 error.cause를 함께 로깅해 두면 어떤 컴포넌트에서 무엇이 불일치했는지 더 구체적으로 추적할 수 있습니다.

 

onCaughtError, onUncaughtError가 Error Boundary와 관련된 에러를 다룬다면, onRecoverableError는 Error Boundary와 무관하게 React 스스로 복구하는 에러를 다룬다는 차이가 있습니다.

 

 

 

다만 onRecoverableError가 호출되는 상황은 hydration mismatch로 한정되지 않습니다.

 

concurrent 렌더링 도중 일시적인 오류로 React가 동기 렌더링으로 재시도하여 복구에 성공한 경우나, Suspense 경계 내부에서 복구 가능한 에러가 발생한 경우에도 동일하게 onRecoverableError가 호출됩니다.

 

이 때문에 onRecoverableError는 서버 사이드 렌더링과 무관하게, createRoot만 사용하는 프로젝트에서도 등록해 둘 가치가 있는 옵션입니다.

 

 

 

 

onRecoverableError를 별도로 등록하지 않으면, onUncaughtError와 마찬가지로 기본적으로 reportError를 통해 전역 에러 이벤트로 리포트됩니다.

 

onRecoverableError를 등록하는 순간 이 기본 리포트 동작은 사라지므로, 앞서 살펴본 두 옵션과 마찬가지로 콜백 내부에 로그나 리포팅 코드를 직접 작성해야 합니다.

 

 

 

이제 onCaughtError, onUncaughtError, onRecoverableError 세 옵션을 모두 등록한 최종 형태를 정리하면 다음과 같습니다.

 

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'

const root = createRoot(document.getElementById('root') as HTMLElement, {
  onCaughtError: (error, errorInfo) => {
    console.error('[Caught Error]', error, errorInfo.componentStack)
  },
  onUncaughtError: (error, errorInfo) => {
    console.error('[Uncaught Error]', error, errorInfo.componentStack)
  },
  onRecoverableError: (error, errorInfo) => {
    console.error('[Recoverable Error]', error, errorInfo.componentStack)
  },
})

root.render(
  <StrictMode>
    <App />
  </StrictMode>,
)

 

세 옵션 모두 createRoot뿐 아니라 hydrateRoot에도 동일한 형태로 적용할 수 있습니다.

 

서버 사이드 렌더링을 사용하는 프로젝트라면 hydrateRoot를 호출하는 지점에, createRoot만 사용하는 프로젝트라면 createRoot를 호출하는 지점에 이 세 옵션을 함께 등록하여, 렌더링 단계에서 발생할 수 있는 에러를 빠짐없이 로깅 서비스로 전달할 수 있습니다.

 

 

 

 

 

 

 

이상으로 React 19에 새로 추가된 onCaughtError, onUncaughtError와 기존 onRecoverableError까지 함께 활용해 프로덕션 에러 로깅을 완성하는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글