1. Next.js 에러의 두 가지 큰 분류
Next.js의 에러는 크게 두 가지로 나눌 수 있습니다. 마치 감기와 교통사고의 차이처럼, 예상할 수 있는 에러와 예상치 못한 에러가 있습니다.
- 예상된 에러 (Expected Errors)
- 사용자가 잘못된 데이터를 입력했을 때
- 서버에서 데이터를 가져오는데 실패했을 때
- 권한이 없는 페이지에 접근했을 때
- 네트워크 연결이 끊겼을 때
- 예상치 못한 에러 (Uncaught Exceptions)
- 코드에 버그가 있을 때
- 잘못된 문법을 사용했을 때
- 존재하지 않는 변수를 참조했을 때
- 메모리 부족 등 시스템 문제
2. 예상된 에러 처리하기: 우아하게 실패하는 방법
예상된 에러는 앱이 정상적으로 작동하는 중에도 충분히 일어날 수 있는 일들입니다. 이런 에러들은 사용자에게 친절하게 안내해주는 것이 중요합니다.
서버 액션에서의 에러 처리
- 잘못된 방법 (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 // 이렇게 에러를 던지면 안 됩니다 } } - 올바른 방법 (에러를 반환값으로 처리)
'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' } } - 클라이언트에서 에러 표시하기
'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에서는 이를 아주 우아하게 처리할 수 있습니다.
- 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> ) } - 커스텀 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를 사용하면 에러가 발생해도 전체 앱이 죽지 않고 특정 부분만 에러 화면을 보여줄 수 있습니다.
- 기본 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> ) } - 전역 에러 처리하기
// 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 에러
- 증상
Module not found: Can't resolve '@/components/Header' - 원인
- 파일 경로가 잘못됨
- 파일명 대소문자 불일치
- import 경로 오타
- 해결법
// 잘못된 예 import Header from '@/components/header' // 파일명은 Header.js인데 소문자로 import // 올바른 예 import Header from '@/components/Header'
5-2. Hydration 에러
- 증상
Error: Hydration failed because the initial UI does not match what was rendered on the server - 원인
- 서버와 클라이언트의 렌더링 결과가 다름
- 브라우저 전용 API를 서버에서 사용
- 조건부 렌더링의 불일치
- 해결법
// 잘못된 예 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 에러
- 증상
Error: Invalid hook call. Hooks can only be called inside of the body of a function component - 원인
- 서버 컴포넌트에서 훅 사용
- 조건문 안에서 훅 호출
- 일반 함수에서 훅 사용
- 해결법
// 잘못된 예 - 서버 컴포넌트에서 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 라우트에서도 에러 처리는 매우 중요합니다.
- 기본적인 에러 처리
// 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 } ) } } - 상세한 에러 응답
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는 이벤트 핸들러의 에러를 잡지 못합니다. 따라서 별도로 처리해야 합니다.
- 수동 에러 처리
'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. 에러 모니터링과 로깅
프로덕션 환경에서는 에러를 추적하고 모니터링하는 것이 중요합니다.
- 에러 로깅 서비스 연동
// 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> ) } - 커스텀 에러 로깅
// 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. 에러 예방을 위한 베스트 프랙티스
에러를 처리하는 것도 중요하지만, 애초에 에러가 발생하지 않도록 예방하는 것이 더 중요합니다.
- 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 } - 입력값 검증하기
// 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 } } } } - 로딩 상태 관리
'use client' import { Suspense } from 'react' function LoadingSpinner() { return <div className="spinner">로딩 중...</div> } export default function Page() { return ( <Suspense fallback={<LoadingSpinner />}> <AsyncComponent /> </Suspense> ) }