[Next.js/app router] 에러를 관리해보자!

프로젝트 구현에 본격적으로 들어가기 전에 에러를 관리할 방법들을 정리해보려한다

Next.js 공식문서 Error Handling

 

에러 타입에 따라 분류하기

1. 예상된 에러 (Expected Errors)

서비스 정상 작동 중에 발생할 수 있는 에러를 의미한다

ex. 폼 검증 실패, API 요청 오류 등

 

2. 예상치 못한 예외 (Uncaught Exceptions)

정상 흐름에서 발생하지 않아야하는 예외 상황을 의미한다

 

 

서버액션에서 에러 처리하기

서버액션에서 발생하는 에러의 경우 try/catch문을 지양하고 특히 form을 사용하는 경우 useFormState (React 18 이하), useActionState (React 19 이상)를 사용해 모델링하는 것을 권장한다

 

useFormState 사용 예시

// app/actions.ts
'use server';

import { redirect } from 'next/navigation';

export async function createUser(prevState: any, formData: FormData) {
  // API 호출
  const res = await fetch('https://api.example.com/users', {
    method: 'POST',
    body: formData
  });
  
  const data = await res.json();
  
  // 예상된 에러를 검증
  if (!res.ok) {
    return { success: false, message: '유효한 이메일을 입력해주세요.' };
  }
  
  // 성공시 리다이렉트
  redirect('/dashboard');
}
'use client';

import { useFormState } from 'react-dom';
import { createUser } from '@/app/actions';

const initialState = { success: false, message: '' };

export function Signup() {
  const [state, formAction] = useFormState(createUser, initialState);
  
  return (
    <form action={formAction}>
      <input type='email' name='email' required />
      
      { /* 예상된 에러에대한 메세지 표시 */ }
      {!state.success && state.message && (
        <p className='error'>{state.message}</p>
      )}
      
      <button type='submit'>가입하기</button>
    </form>
  )
}

 

 

useFormState를 사용하지 않는 경우

useState, useTransition 등을 활용할 수 있다

// app/actions.ts
export async function deleteItem(id) {
  const res = await fetch(`/api/items/${id}`, { method: 'DELETE' })
  
  // 예상된 에러 처리
  if (!res.ok) {
    return { success: false, message: '삭제 실패' }
  }
  
  return { success: true }
}
'use client'

import { useState } from 'react'
import { deleteItem } from '@/app/actions' // 서버 액션 import

export default function DeleteItemButton({ id, itemName }) {
  const [error, setError] = useState('')
  const [success, setSuccess] = useState(false)

  async function handleClick() {
    setError('')
    setSuccess(false)
    
    const result = await deleteItem(id)
    
    if (!result.success) {
      setError(result.message)
    } else {
      setSuccess(true)
    }
  }

  return (
    <div>
      <button onClick={handleClick}>
        삭제하기
      </button>
      
      {error && (
        <p>{error}</p>
      )}
      
      {success && (
        <p>성공적으로 삭제되었습니다.</p>
      )}
    </div>
  )
}

 

 

컴포넌트에서 에러 처리하기

서버 컴포넌트와 클라이언트 컴포넌트 모두 응답 상태에따라 조건부렌더링을 활용할 수 있다

클라이언트 컴포넌트의 경우 상황에따라 useState 등의 훅을 활용해야하고, 서버 컴포넌트의 경우 Next.js의 redirect(), notFound() 등을 바로 활용할 수 있다는 특징이 있다

 

서버 컴포넌트 예시

// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'

export default async function PostPage({ params }) {
  const res = await fetch(`https://api.example.com/posts/${params.id}`)

  if (!res.ok) {
    if (res.status === 404) {
      notFound() // 404 에러 페이지 표시
    }
    
    // 다른 오류의 경우 오류 메시지 표시
    return <div>콘텐츠를 로드하는 데 문제가 발생했습니다</div>
  }

  const post = await res.json()

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

 

클라이언트 컴포넌트 예시

'use client'

import { useState, useEffect } from 'react'

export default function UserProfile({ userId }) {
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    async function fetchUserData() {
      try {
        setLoading(true)
        const res = await fetch(`/api/users/${userId}`)
        
        if (!res.ok) {
          throw new Error('사용자 데이터를 불러올 수 없습니다')
        }
        
        const userData = await res.json()
        setData(userData)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchUserData()
  }, [userId])

  if (loading) return <div>로딩 중...</div>
  if (error) return <div className="error">{error}</div>

  return (
    <div className="user-profile">
      <h2>{data.name}의 프로필</h2>
      {/* 사용자 데이터 표시 */}
    </div>
  )
}

 

 

예상치 못한 예외 처리하기

Next.js에서는 Error boundary를 사용해 예외를 처리할 수 있다

특정 경로에 대한 에러 처리를 위해서 해당 디렉토리에 error.tsx를 추가하거나 전역 에러 처리를 위한 global-error.tsx를 추가해 처리한다

// app/error.tsx
'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // 에러 로깅 서비스에 에러 보고
    console.error('예상치 못한 에러 발생:', error)
  }, [error])

  return (
    <div className="error-container">
      <h2>문제가 발생했습니다</h2>
      <p>죄송합니다. 예상치 못한 오류가 발생했습니다.</p>
      <button
        onClick={() => reset()}
        className="retry-button"
      >
        다시 시도하기
      </button>
    </div>
  )
}
// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div className="global-error">
          <h1>서비스에 문제가 발생했습니다</h1>
          <p>죄송합니다. 시스템 오류가 발생했습니다.</p>
          <button onClick={() => reset()}>새로고침</button>
        </div>
      </body>
    </html>
  )
}