Error Handling

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
10/79

안녕하세요 여러분! 오늘 우리가 함께 살펴볼 주제는 바로 프론트엔드 개발에서 절대 피할 수 없는, 하지만 아주 중요한 '에러 처리(Error Handling)'입니다.

Next.js 애플리케이션에서 에러는 크게 두 가지 카테고리로 나눌 수 있어요. 바로 예상된 에러(expected errors)처리되지 않은 예외(uncaught exceptions)입니다. 이 페이지에서는 Next.js 앱에서 이 두 가지 종류의 에러를 어떻게 우아하게 처리할 수 있는지 단계별로 살펴볼 거예요.

💡 강사님의 보충 설명:
"예상된 에러"란 사용자가 비밀번호를 틀리게 입력하거나, 권한이 없는 페이지에 접근하려고 할 때처럼 우리가 미리 시나리오를 예측하고 대비할 수 있는 에러를 말해요. 반면에 "처리되지 않은 예외"는 서버가 갑자기 죽어버리거나, 코드에 오타가 있어서 런타임에 앱이 뻗어버리는 등 우리가 미처 대비하지 못한 돌발 상황을 의미합니다. 이 두 가지를 구분해서 대응하는 것이 탄탄한 앱을 만드는 핵심이에요!


예상된 에러 처리하기 (Handling expected errors)

예상된 에러는 애플리케이션이 정상적으로 동작하는 과정에서도 충분히 발생할 수 있는 에러들을 말해요. 예를 들면 서버 사이드 폼 검증(server-side form validation) 과정에서 발생하는 오류나, API 요청이 실패하는 경우가 있겠죠. 이런 에러들은 명시적으로 잘 처리해서 클라이언트(사용자)에게 적절한 피드백으로 돌려주어야 합니다.

서버 함수 (Server Functions)

서버 함수(Server Functions)에서 예상된 에러를 처리할 때는 useActionState 훅(hook)을 사용하는 것을 추천합니다.

이런 예상된 에러를 다룰 때는 try/catch 블록을 사용해서 에러를 냅다 던져버리는(throw) 방식은 피하는 게 좋아요. 그 대신, 예상된 에러를 하나의 '반환 값(return value)'으로 모델링하는 것이 좋습니다.

💡 강사님의 꿀팁:
"왜 try/catch를 쓰지 말라는 거지?" 하고 의문이 드실 수 있어요. try/catch로 에러를 throw 해버리면, 실행 흐름이 중단되고 가장 가까운 Error Boundary로 넘어가 버립니다. 그러면 사용자는 폼 옆에 빨간 글씨로 "비밀번호가 틀렸습니다"를 보는 대신, 화면 전체가 에러 페이지로 바뀌는 끔찍한 경험을 할 수도 있어요. 그래서 예상 가능한 실패는 데이터(객체) 형태로 예쁘게 반환해 주는 것이 훨씬 사용자 친화적이랍니다!

'use server'

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')

  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: { title, content },
  })
  const json = await res.json()

  if (!res.ok) {
    return { message: 'Failed to create post' }
  }
}
'use server'

export async function createPost(prevState, formData) {
  const title = formData.get('title')
  const content = formData.get('content')

  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    body: { title, content },
  })
  const json = await res.json()

  if (!res.ok) {
    return { message: 'Failed to create post' }
  }
}

이렇게 작성한 액션(action) 함수를 useActionState 훅에 전달하면, 반환된 state를 사용해서 에러 메시지를 화면에 보여줄 수 있어요.

'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = {
  message: '',
}

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="title">Title</label>
      <input type="text" id="title" name="title" required />
      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>Create Post</button>
    </form>
  )
}
'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = {
  message: '',
}

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="title">Title</label>
      <input type="text" id="title" name="title" required />
      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>Create Post</button>
    </form>
  )
}

서버 컴포넌트 (Server Components)

서버 컴포넌트 안에서 데이터를 가져올(fetch) 때, 응답 결과를 이용해서 조건부로 에러 메시지를 렌더링하거나 다른 페이지로 redirect(리다이렉트) 시킬 수 있습니다.

💡 강사님의 보충 설명:
서버 컴포넌트는 서버에서 실행되기 때문에, 에러가 발생했을 때 아예 다른 경로로 사용자를 보내버리는 redirect 기능이 아주 유용하게 쓰입니다. 데이터가 없으면 '데이터가 없습니다'라고 텍스트만 보여주거나, 아니면 아예 다른 에러 안내 페이지로 보내버리는 거죠!

export default async function Page() {
  const res = await fetch(`https://...`)
  const data = await res.json()

  if (!res.ok) {
    return 'There was an error.'
  }

  return '...'
}
export default async function Page() {
  const res = await fetch(`https://...`)
  const data = await res.json()

  if (!res.ok) {
    return 'There was an error.'
  }

  return '...'
}

찾을 수 없음 (Not found)

특정 라우트 세그먼트(route segment) 안에서 notFound 함수를 호출하고, not-found.js 파일을 활용하면 404 페이지 UI를 보여줄 수 있어요.

💡 강사님의 꿀팁:
데이터베이스에서 특정 id나 slug로 게시글을 찾는데 결과가 null이 나왔다고 가정해볼게요. 이럴 때 notFound()를 호출하면 Next.js가 알아서 가장 가까운 곳에 있는 not-found.tsx 파일을 렌더링해 줍니다. 정말 깔끔하게 404 처리가 가능해지죠!

import { getPostBySlug } from '@/lib/posts'

export default async function Page({ params }: { params: { slug: string } }) {
  const { slug } = await params
  const post = getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return <div>{post.title}</div>
}
import { getPostBySlug } from '@/lib/posts'

export default async function Page({ params }) {
  const { slug } = await params
  const post = getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return <div>{post.title}</div>
}
export default function NotFound() {
  return <div>404 - Page Not Found</div>
}
export default function NotFound() {
  return <div>404 - Page Not Found</div>
}

처리되지 않은 예외 처리하기 (Handling uncaught exceptions)

처리되지 않은 예외(Uncaught exceptions)란, 애플리케이션의 정상적인 흐름에서는 절대 일어나서는 안 되는 버그나 치명적인 문제들을 말합니다. 이런 에러들은 에러를 던져서(throwing errors) 처리해야 하며, 이렇게 던져진 에러는 '에러 경계(error boundaries)'가 잡아주게 됩니다.

중첩된 에러 경계 (Nested error boundaries)

Next.js는 처리되지 않은 예외를 다루기 위해 에러 경계(error boundaries)를 사용해요. 에러 경계는 자식 컴포넌트에서 발생한 에러를 낚아채서(catch), 앱 전체가 뻗어버리는 대신 미리 준비해 둔 '대체 UI(fallback UI)'를 화면에 보여주는 역할을 합니다.

💡 강사님의 보충 설명:
에러 경계는 일종의 '안전망'이라고 생각하시면 됩니다. 어떤 컴포넌트에서 폭탄(에러)이 터지면 그 폭탄이 앱 전체를 날려버리기 전에, 부모 레벨에 쳐놓은 안전망(Error Boundary)이 그걸 감싸서 막아주는 원리죠.

라우트 세그먼트 안에 error.js 파일을 만들고 React 컴포넌트를 export 하면 에러 경계를 생성할 수 있어요:

⚠️ 주의! 강사님의 핵심 포인트!
아래 코드의 첫 줄을 보세요! 'use client'가 있죠? error.js 파일은 무조건 클라이언트 컴포넌트여야 합니다. 에러를 잡고, 사용자가 "다시 시도하기(Try again)" 버튼을 누르는 등의 상호작용(interaction)을 처리해야 하기 때문이에요. 절대 잊지 마세요!

'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function ErrorPage({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // 에러 리포팅 서비스에 에러를 기록하는 로직을 여기에 작성하세요
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // 해당 세그먼트를 다시 렌더링해서 복구를 시도합니다
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  )
}
'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function ErrorPage({ error, reset }) {
  useEffect(() => {
    // 에러 리포팅 서비스에 에러를 기록하는 로직을 여기에 작성하세요
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // 해당 세그먼트를 다시 렌더링해서 복구를 시도합니다
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  )
}

발생한 에러는 트리 구조상 가장 가까운 부모 에러 경계로 방울처럼 떠올라갑니다(bubble up). 이 특성 덕분에 라우트 계층 구조(route hierarchy)의 각기 다른 레벨에 error.tsx 파일들을 배치함으로써 아주 세밀하게(granular) 에러를 통제할 수 있어요.

Nested Error Component Hierarchy

💡 강사님의 보충 설명:
위 이미지를 보면 더 명확해져요! 가장 하위 컴포넌트에서 에러가 나면 바로 위의 error.js가 발동하고, 레이아웃 등 다른 부분은 그대로 유지됩니다. 사용자는 화면 일부만 에러 상태로 보게 되니 훨씬 쾌적한 경험을 할 수 있죠.

한 가지 명심해야 할 점! 에러 경계는 이벤트 핸들러(event handlers) 내부에서 발생하는 에러는 잡지 못해요. 에러 경계는 앱 전체가 죽는 것을 막고 대체 UI를 보여주기 위해 렌더링 도중(during rendering)에 발생하는 에러를 잡도록 설계되었거든요.

일반적으로 이벤트 핸들러나 비동기 코드에서 발생하는 에러는 렌더링이 다 끝난 이후에 실행되기 때문에 에러 경계가 처리하지 못합니다.

이런 경우를 다루려면 에러를 수동으로 catch한 다음, useStateuseReducer를 사용해 에러 상태를 저장하고 UI를 업데이트해서 사용자에게 알려주어야 해요.

💡 강사님의 꿀팁:
"버튼을 클릭했는데 에러가 났어요! 근데 error.js가 안 떠요!" 현업에서 정말 많이 받는 질문입니다. 버튼 onClick 함수 안에서 에러가 터져도 React는 그 사실을 모릅니다. 그래서 아래 코드처럼 별도의 에러 상태를 useState로 잡아서 화면에 그려줘야 해요.

'use client'

import { useState } from 'react'

export function Button() {
  const [error, setError] = useState(null)

  const handleClick = () => {
    try {
      // 실패할 수도 있는 어떤 작업을 수행합니다
      throw new Error('Exception')
    } catch (reason) {
      setError(reason)
    }
  }

  if (error) {
    /* 대체 UI를 렌더링합니다 */
  }

  return (
    <button type="button" onClick={handleClick}>
      Click me
    </button>
  )
}

참고로, useTransition에서 제공하는 startTransition 내부에서 처리되지 않은 에러가 발생하면, 이 에러는 가장 가까운 에러 경계로 떠올라가서 잡히게 됩니다. (이건 알아두면 꽤 유용한 트릭이에요!)

'use client'

import { useTransition } from 'react'

export function Button() {
  const [pending, startTransition] = useTransition()

  const handleClick = () =>
    startTransition(() => {
      throw new Error('Exception')
    })

  return (
    <button type="button" onClick={handleClick}>
      Click me
    </button>
  )
}

글로벌 에러 (Global errors)

자주 쓰이는 방식은 아니지만, 루트(root) 앱 디렉토리에 global-error.js 파일을 만들어서 루트 레이아웃에서 발생하는 에러를 처리할 수도 있어요. 심지어 다국어 처리(internationalization)를 적용하고 있을 때도 가능하죠. 글로벌 에러 UI가 활성화되면 기존의 루트 레이아웃이나 템플릿을 완전히 대체해버리기 때문에, global-error.js 파일 내부에는 반드시 자체적인 <html><body> 태그를 정의해주어야 합니다.

💡 강사님의 꿀팁:
global-error.js는 앱의 최상단 껍데기(Root Layout) 자체에 문제가 생겼을 때 등장하는 최후의 보루입니다. 그래서 기존의 <html>, <body>가 아예 렌더링되지 않을 수도 있기 때문에, 직접 태그를 넣어줘야 하는 구조인 거예요! 평소에는 그냥 각 라우트별 error.js를 사용하시면 충분합니다.

'use client' // Error boundaries must be Client Components

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    // global-error 파일은 반드시 html과 body 태그를 포함해야 합니다.
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  )
}
'use client' // Error boundaries must be Client Components

export default function GlobalError({ error, reset }) {
  return (
    // global-error 파일은 반드시 html과 body 태그를 포함해야 합니다.
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  )
}

API 레퍼런스 (API Reference)

이 페이지에서 언급된 기능들에 대해 더 깊이 공부하고 싶으시다면 아래 API 레퍼런스를 꼭 읽어보세요!

  • redirect
    • redirect 함수에 대한 API 레퍼런스입니다.
  • error.js
    • error.js 특수 파일에 대한 API 레퍼런스입니다.
  • notFound
    • notFound 함수에 대한 API 레퍼런스입니다.
  • not-found.js
    • not-found.js 파일에 대한 API 레퍼런스입니다.

전체 문서의 논리적인 구조를 보고 싶으시다면 /docs/sitemap.md를 참고해 주세요.

사용 가능한 모든 문서의 색인(index)은 /docs/llms.txt에서 확인하실 수 있습니다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글