1. Next.js 에러의 두 가지 큰 분류

Next.js의 에러는 크게 두 가지로 나눌 수 있습니다. 마치 감기와 교통사고의 차이처럼, 예상할 수 있는 에러와 예상치 못한 에러가 있습니다.

  1. 예상된 에러 (Expected Errors)
    • 사용자가 잘못된 데이터를 입력했을 때
    • 서버에서 데이터를 가져오는데 실패했을 때
    • 권한이 없는 페이지에 접근했을 때
    • 네트워크 연결이 끊겼을 때
  2. 예상치 못한 에러 (Uncaught Exceptions)
    • 코드에 버그가 있을 때
    • 잘못된 문법을 사용했을 때
    • 존재하지 않는 변수를 참조했을 때
    • 메모리 부족 등 시스템 문제

2. 예상된 에러 처리하기: 우아하게 실패하는 방법

예상된 에러는 앱이 정상적으로 작동하는 중에도 충분히 일어날 수 있는 일들입니다. 이런 에러들은 사용자에게 친절하게 안내해주는 것이 중요합니다.

서버 액션에서의 에러 처리

  1. 잘못된 방법 (try-catch 사용)
    'use server'
    
    // 이렇게 하지 마세요!
    export async function createPost(formData) {
      try {
        const res = await fetch('<https://api.example.com/posts>', {
          method: 'POST',
          body: formData
        })
        return { success: true }
      } catch (error) {
        throw error // 이렇게 에러를 던지면 안 됩니다
      }
    }
  2. 올바른 방법 (에러를 반환값으로 처리)
    'use server'
    
    export async function createPost(prevState, formData) {
      const title = formData.get('title')
      const content = formData.get('content')
    
      // 입력값 검증
      if (!title || title.length < 5) {
        return {
          message: '제목은 5글자 이상 입력해주세요',
          type: 'error'
        }
      }
    
      const res = await fetch('<https://api.example.com/posts>', {
        method: 'POST',
        body: JSON.stringify({ title, content })
      })
    
      if (!res.ok) {
        return {
          message: '게시글 작성에 실패했습니다. 잠시 후 다시 시도해주세요.',
          type: 'error'
        }
      }
    
      return {
        message: '게시글이 성공적으로 작성되었습니다!',
        type: 'success'
      }
    }
  3. 클라이언트에서 에러 표시하기
    'use client'
    
    import { useActionState } from 'react'
    import { createPost } from '@/app/actions'
    
    export function PostForm() {
      const [state, formAction, isPending] = useActionState(createPost, {})
    
      return (
        <form action={formAction}>
          <input name="title" placeholder="제목을 입력하세요" />
          <textarea name="content" placeholder="내용을 입력하세요" />
    
          {state?.message && (
            <div className={state.type === 'error' ? 'text-red-500' : 'text-green-500'}>
              {state.message}
            </div>
          )}
    
          <button disabled={isPending}>
            {isPending ? '작성 중...' : '게시글 작성'}
          </button>
        </form>
      )
    }

3. 404 에러 처리: 페이지를 찾을 수 없을 때

웹사이트에서 가장 흔한 에러 중 하나가 바로 404 에러입니다. Next.js에서는 이를 아주 우아하게 처리할 수 있습니다.

  1. notFound 함수 사용하기
    import { notFound } from 'next/navigation'
    
    export default async function BlogPost({ params }) {
      const post = await getPost(params.id)
    
      // 게시글이 없으면 404 페이지로 이동
      if (!post) {
        notFound()
      }
    
      return (
        <article>
          <h1>{post.title}</h1>
          <p>{post.content}</p>
        </article>
      )
    }
  2. 커스텀 404 페이지 만들기
    // app/not-found.js
    export default function NotFound() {
      return (
        <div className="flex flex-col items-center justify-center min-h-screen">
          <h1 className="text-6xl font-bold">404</h1>
          <p className="text-xl mt-4">앗! 페이지를 찾을 수 없습니다</p>
          <a href="/" className="mt-6 text-blue-500 hover:underline">
            홈으로 돌아가기
          </a>
        </div>
      )
    }

4. 예상치 못한 에러 처리: Error Boundary 활용하기

예상치 못한 에러는 앱을 완전히 멈추게 할 수 있습니다. 이때 Error Boundary를 사용하면 에러가 발생해도 전체 앱이 죽지 않고 특정 부분만 에러 화면을 보여줄 수 있습니다.

  1. 기본 Error Boundary 만들기
    // app/dashboard/error.js
    'use client' // Error boundary는 반드시 클라이언트 컴포넌트여야 합니다
    
    import { useEffect } from 'react'
    
    export default function Error({ error, reset }) {
      useEffect(() => {
        // 에러 로깅 서비스에 에러 전송
        console.error('에러 발생:', error)
      }, [error])
    
      return (
        <div className="flex flex-col items-center justify-center p-8">
          <h2 className="text-2xl font-bold mb-4">문제가 발생했습니다!</h2>
          <p className="text-gray-600 mb-6">
            예상치 못한 오류가 발생했습니다. 불편을 드려 죄송합니다.
          </p>
          <button
            onClick={reset}
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
          >
            다시 시도하기
          </button>
        </div>
      )
    }
  2. 전역 에러 처리하기
    // app/global-error.js
    'use client'
    
    export default function GlobalError({ error, reset }) {
      return (
        <html>
          <body>
            <div className="min-h-screen flex items-center justify-center">
              <div className="text-center">
                <h1 className="text-4xl font-bold mb-4">시스템 오류</h1>
                <p className="mb-6">심각한 오류가 발생했습니다.</p>
                <button onClick={reset} className="btn-primary">
                  페이지 새로고침
                </button>
              </div>
            </div>
          </body>
        </html>
      )
    }

5. 자주 발생하는 Next.js 에러와 해결법

5-1. Module not found 에러

  1. 증상
    Module not found: Can't resolve '@/components/Header'
  2. 원인
    • 파일 경로가 잘못됨
    • 파일명 대소문자 불일치
    • import 경로 오타
  3. 해결법
    // 잘못된 예
    import Header from '@/components/header' // 파일명은 Header.js인데 소문자로 import
    
    // 올바른 예
    import Header from '@/components/Header'

5-2. Hydration 에러

  1. 증상
    Error: Hydration failed because the initial UI does not match what was rendered on the server
  2. 원인
    • 서버와 클라이언트의 렌더링 결과가 다름
    • 브라우저 전용 API를 서버에서 사용
    • 조건부 렌더링의 불일치
  3. 해결법
    // 잘못된 예
    export default function Component() {
      return (
        <div>
          {typeof window !== 'undefined' && <ClientOnlyComponent />}
        </div>
      )
    }
    
    // 올바른 예
    'use client'
    import { useEffect, useState } from 'react'
    
    export default function Component() {
      const [isClient, setIsClient] = useState(false)
    
      useEffect(() => {
        setIsClient(true)
      }, [])
    
      return (
        <div>
          {isClient && <ClientOnlyComponent />}
        </div>
      )
    }

5-3. Invalid Hook Call 에러

  1. 증상
    Error: Invalid hook call. Hooks can only be called inside of the body of a function component
  2. 원인
    • 서버 컴포넌트에서 훅 사용
    • 조건문 안에서 훅 호출
    • 일반 함수에서 훅 사용
  3. 해결법
    // 잘못된 예 - 서버 컴포넌트에서 useState 사용
    export default function ServerComponent() {
      const [count, setCount] = useState(0) // 에러!
      return <div>{count}</div>
    }
    
    // 올바른 예 - 클라이언트 컴포넌트로 변경
    'use client'
    import { useState } from 'react'
    
    export default function ClientComponent() {
      const [count, setCount] = useState(0)
      return <div>{count}</div>
    }

6. API 라우트 에러 처리

API 라우트에서도 에러 처리는 매우 중요합니다.

  1. 기본적인 에러 처리
    // app/api/users/route.js
    export async function GET(request) {
      try {
        const users = await fetchUsers()
    
        if (!users) {
          return Response.json(
            { error: '사용자를 찾을 수 없습니다' },
            { status: 404 }
          )
        }
    
        return Response.json(users)
      } catch (error) {
        console.error('사용자 조회 실패:', error)
        return Response.json(
          { error: '서버 오류가 발생했습니다' },
          { status: 500 }
        )
      }
    }
  2. 상세한 에러 응답
    export async function POST(request) {
      try {
        const body = await request.json()
    
        // 유효성 검사
        if (!body.email) {
          return Response.json(
            {
              error: '이메일은 필수 입력 항목입니다',
              field: 'email'
            },
            { status: 400 }
          )
        }
    
        const user = await createUser(body)
        return Response.json(user, { status: 201 })
    
      } catch (error) {
        if (error.code === 'P2002') { // Prisma unique constraint 에러
          return Response.json(
            { error: '이미 사용 중인 이메일입니다' },
            { status: 409 }
          )
        }
    
        return Response.json(
          { error: '사용자 생성 실패' },
          { status: 500 }
        )
      }
    }

7. 이벤트 핸들러에서의 에러 처리

Error Boundary는 이벤트 핸들러의 에러를 잡지 못합니다. 따라서 별도로 처리해야 합니다.

  1. 수동 에러 처리
    'use client'
    import { useState } from 'react'
    
    export function UploadButton() {
      const [error, setError] = useState(null)
      const [isLoading, setIsLoading] = useState(false)
    
      const handleUpload = async () => {
        setError(null)
        setIsLoading(true)
    
        try {
          const response = await uploadFile()
          if (!response.ok) {
            throw new Error('파일 업로드 실패')
          }
          // 성공 처리
        } catch (err) {
          setError(err.message)
        } finally {
          setIsLoading(false)
        }
      }
    
      return (
        <div>
          <button onClick={handleUpload} disabled={isLoading}>
            {isLoading ? '업로드 중...' : '파일 업로드'}
          </button>
          {error && (
            <p className="text-red-500 mt-2">{error}</p>
          )}
        </div>
      )
    }

8. 에러 모니터링과 로깅

프로덕션 환경에서는 에러를 추적하고 모니터링하는 것이 중요합니다.

  1. 에러 로깅 서비스 연동
    // app/error.js
    'use client'
    
    import * as Sentry from '@sentry/nextjs'
    import { useEffect } from 'react'
    
    export default function Error({ error, reset }) {
      useEffect(() => {
        // Sentry에 에러 전송
        Sentry.captureException(error)
      }, [error])
    
      return (
        <div>
          <h2>오류가 발생했습니다</h2>
          <button onClick={reset}>다시 시도</button>
        </div>
      )
    }
  2. 커스텀 에러 로깅
    // utils/errorLogger.js
    export function logError(error, context = {}) {
      const errorInfo = {
        message: error.message,
        stack: error.stack,
        timestamp: new Date().toISOString(),
        userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : 'server',
        ...context
      }
    
      // 개발 환경에서는 콘솔에 출력
      if (process.env.NODE_ENV === 'development') {
        console.error('Error logged:', errorInfo)
      } else {
        // 프로덕션에서는 로깅 서비스로 전송
        sendToLoggingService(errorInfo)
      }
    }

9. 에러 예방을 위한 베스트 프랙티스

에러를 처리하는 것도 중요하지만, 애초에 에러가 발생하지 않도록 예방하는 것이 더 중요합니다.

  1. TypeScript 사용하기
    // 타입 정의로 많은 에러를 미리 방지
    interface User {
      id: string
      name: string
      email: string
    }
    
    async function getUser(id: string): Promise<User | null> {
      // TypeScript가 반환 타입을 체크해줍니다
      const user = await fetchUser(id)
      return user
    }
  2. 입력값 검증하기
    // Zod를 사용한 스키마 검증
    import { z } from 'zod'
    
    const userSchema = z.object({
      name: z.string().min(2, '이름은 2글자 이상이어야 합니다'),
      email: z.string().email('올바른 이메일 형식이 아닙니다'),
      age: z.number().min(1).max(120)
    })
    
    export async function createUser(data) {
      try {
        const validatedData = userSchema.parse(data)
        // 검증된 데이터로 처리 진행
      } catch (error) {
        if (error instanceof z.ZodError) {
          return { error: error.errors[0].message }
        }
      }
    }
  3. 로딩 상태 관리
    'use client'
    import { Suspense } from 'react'
    
    function LoadingSpinner() {
      return <div className="spinner">로딩 중...</div>
    }
    
    export default function Page() {
      return (
        <Suspense fallback={<LoadingSpinner />}>
          <AsyncComponent />
        </Suspense>
      )
    }