Next.js로 개발하다 보면 갑자기 빨간색 에러 메시지가 화면을 가득 채우는 경험,
한 번쯤은 해보셨을 겁니다. 당황스럽고 막막하기만 한 그 순간, "도대체 뭐가 문제인 거야?"라는 생각이 들죠. 오늘은 Next.js에서 발생할 수 있는 모든 에러를 체계적으로 정리하고, 각각의 해결 방법을 누구나 이해할 수 있도록 쉽게 설명해드리겠습니다.
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 // 이렇게 에러를 던지면 안 됩니다
}
}
JavaScript
복사
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'
}
}
JavaScript
복사
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>
)
}
JavaScript
복사
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>
)
}
JavaScript
복사
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>
)
}
JavaScript
복사
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>
)
}
JavaScript
복사
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>
)
}
JavaScript
복사
5. 자주 발생하는 Next.js 에러와 해결법
5-1. Module not found 에러
1.
증상
Module not found: Can't resolve '@/components/Header'
Plain Text
복사
2.
원인
•
파일 경로가 잘못됨
•
파일명 대소문자 불일치
•
import 경로 오타
3.
해결법
// 잘못된 예
import Header from '@/components/header' // 파일명은 Header.js인데 소문자로 import
// 올바른 예
import Header from '@/components/Header'
JavaScript
복사
5-2. Hydration 에러
1.
증상
Error: Hydration failed because the initial UI does not match what was rendered on the server
Plain Text
복사
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>
)
}
JavaScript
복사
5-3. Invalid Hook Call 에러
1.
증상
Error: Invalid hook call. Hooks can only be called inside of the body of a function component
Plain Text
복사
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>
}
JavaScript
복사
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 }
)
}
}
JavaScript
복사
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 }
)
}
}
JavaScript
복사
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>
)
}
JavaScript
복사
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>
)
}
JavaScript
복사
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)
}
}
JavaScript
복사
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
}
TypeScript
복사
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 }
}
}
}
JavaScript
복사
3.
로딩 상태 관리
'use client'
import { Suspense } from 'react'
function LoadingSpinner() {
return <div className="spinner">로딩 중...</div>
}
export default function Page() {
return (
<Suspense fallback={<LoadingSpinner />}>
<AsyncComponent />
</Suspense>
)
}
JavaScript
복사