테마 전환

Next.js 404·500 페이지 커스터마이징 완벽 가이드: 구현부터 디자인 최적화까지

Easton editorial illustration: monorepo project desk

금요일 오후 3시, 제품 매니저가 갑자기 단체 채팅방에 스크린샷 한 장을 올렸습니다. “이거 우리 사이트 맞아요? 너무 못생긴 것 아닌가요?”

열어 보니 흰 바탕에 검은 글씨뿐인 썰렁한 “404 This page could not be found” 화면이었습니다. 민망했습니다.

그런데 데이터는 더 심각했습니다. 기본 404 페이지를 본 사용자 중 40%가 곧바로 탭을 닫았습니다.

Next.js 프로젝트를 만들 때 우리는 늘 ‘정상적인’ 페이지에만 집중합니다. 홈은 예뻐야 하고, 목록은 부드러워야 하며, 상세 페이지는 완벽해야 합니다. 오류 페이지요? 사용자가 자주 보지도 않는데 누가 신경 쓰겠습니까.

하지만 이 수치를 보고 정신이 번쩍 들었습니다. 오류 페이지는 있어도 그만인 장식이 아니라 사용자를 붙잡을 마지막 기회입니다. 사용자가 깨진 링크를 눌러 들어왔는데, 아무런 디자인도 없는 흰 페이지에 차가운 ‘페이지를 찾을 수 없습니다’ 문구만 보인다고 상상해 보세요. 탐색 메뉴도 검색창도 안내도 없습니다. 사용자는 ‘이 사이트 믿을 만한가?‘라고 생각할 것입니다.

다행히 Next.js App Router는 완전한 오류 처리 메커니즘을 제공합니다. not-found.tsx는 404를, error.tsx는 런타임 오류를, global-error.tsx는 애플리케이션 전체의 최종 오류를 처리합니다. 간단해 보이나요? 실제로는 함정이 적지 않습니다.

저도 처음 설정했을 때 HTTP 상태 코드가 계속 404가 아니라 200으로 반환되어 Google이 제 404 페이지를 제대로 처리하지 못했습니다. 또 한 번은 global-error.tsx의 스타일이 아무리 해도 적용되지 않았는데, 문서를 한참 뒤진 끝에 CSS 모듈 import를 지원하지 않는다는 사실을 알게 됐습니다.

이 글에서는 Next.js 오류 페이지를 단계별로 완성합니다. not-found.tsx의 기본 사용법부터 error.tsx의 오류 경계, 실제로 사용자를 붙잡는 404 페이지 디자인까지 다룹니다. 코드는 모두 완성된 형태이고 제가 직접 겪은 함정도 정리했으니 그대로 활용하면 됩니다.

Next.js 오류 처리 메커니즘 완전 분석

App Router를 처음 접했을 때 저는 이 세 파일의 차이를 좀처럼 이해하지 못했습니다. not-found.tsx, error.tsx, global-error.tsx는 이름은 비슷해 보여도 역할은 완전히 다릅니다.

세 가지 오류 파일의 역할

간단히 말하면 다음과 같습니다.

  • not-found.tsx - 404 전용으로, 페이지가 존재하지 않을 때 표시됩니다
  • error.tsx - 데이터 로딩 실패나 코드 오류 같은 런타임 오류를 처리합니다
  • global-error.tsx - 마지막 안전망으로, 루트 레이아웃까지 실패했을 때 트리거됩니다

왜 파일이 세 개나 필요하냐고 생각할 수 있습니다. error.tsx 하나면 충분하지 않을까요?

Next.js의 오류 처리는 계층형 구조입니다. 러시아 인형처럼 겹겹이 쌓여 있습니다. error.tsx는 같은 레벨과 하위 라우트의 오류만 잡을 수 있고, 자신이 속한 layout.tsx의 오류는 잡지 못합니다. 루트 레이아웃에 문제가 생기면 어떻게 해야 할까요? 이때 global-error.tsx가 마지막으로 처리합니다.

not-found.tsx는 조금 특별합니다. 우선순위가 error.tsx보다 높습니다. notFound() 함수를 직접 호출하면 Next.js는 error.tsx를 건너뛰고 곧바로 not-found.tsx를 렌더링합니다.

파일 위치가 중요합니다

세 파일은 서로 다른 라우트 레벨에 둘 수 있으며, 위치에 따라 적용 범위가 결정됩니다.

루트 레벨 오류 파일(app/ 디렉터리):

app/
├── layout.tsx
├── not-found.tsx        ← 전역 404 페이지
├── error.tsx            ← 전역 오류 처리
├── global-error.tsx     ← 루트 레이아웃 최종 처리
└── page.tsx

라우트 레벨 오류 파일(특정 라우트 아래):

app/
├── blog/
│   ├── [slug]/
│   │   ├── page.tsx
│   │   ├── not-found.tsx    ← 블로그 글 전용 404
│   │   └── error.tsx         ← 블로그 전용 오류 페이지

사용자가 /blog/존재하지-않는-글에 접속하면 Next.js는 루트의 app/not-found.tsx보다 app/blog/[slug]/not-found.tsx를 우선 표시합니다. 따라서 모듈마다 서로 다른 스타일의 오류 페이지를 만들 수 있습니다.

notFound() 함수: 프로그래밍 방식으로 404 트리거하기

not-found.tsx 파일만 만들어서는 충분하지 않습니다. 언제 이 파일을 트리거해야 하는지도 알아야 합니다.

가장 흔한 상황은 ID로 데이터를 가져왔는데 데이터가 없는 경우입니다.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  if (!res.ok) return null
  return res.json()
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // not-found.tsx 트리거
  }

  return <article>{post.title}</article>
}

주의할 함정이 하나 있습니다. 반드시 JSX를 반환하기 전에 notFound()를 호출해야 합니다. 일부 콘텐츠를 먼저 반환하면 스트리밍 응답이 이미 시작되므로 HTTP 상태 코드가 404가 아니라 200으로 고정됩니다.

저도 처음에는 다음과 같이 작성해 이 문제를 겪었습니다.

// 잘못된 예
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  return (
    <div>
      {!data ? notFound() : <Content data={data} />}  // 이미 JSX 안에 들어왔습니다!
    </div>
  )
}

404 페이지는 표시됐지만 HTTP 상태 코드는 200이었습니다. 검색 엔진은 이를 정상 페이지로 색인하므로 SEO가 완전히 망가집니다.

올바른 작성법은 다음과 같습니다.

export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // 먼저 판단하고 호출합니다
  }

  return <Content data={data} />  // 데이터가 있을 때만 JSX를 반환합니다
}

먼저 데이터를 검증하고 문제가 발견되면 즉시 notFound()를 호출한 다음 JSX를 반환해야 합니다. 그래야 상태 코드가 올바른 404가 됩니다.

not-found.tsx: 커스텀 404 페이지 실전

이론을 마쳤으니 이제 직접 만들어 보겠습니다. 먼저 기본형 404 페이지를 만든 뒤 기능을 하나씩 추가하겠습니다.

기본형: 우선 동작하게 만들기

가장 간단한 not-found.tsx는 다음과 같습니다.

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
        <p className="text-xl text-gray-600 mb-8">
          죄송합니다. 요청하신 페이지가 없습니다
        </p>
        <Link
          href="/"
          className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          홈으로 돌아가기
        </Link>
      </div>
    </div>
  )
}

파일을 저장하고 http://localhost:3000/존재하지-않는-페이지 같은 경로에 접속하면 결과를 확인할 수 있습니다.

기본 흰 바탕과 검은 글씨보다는 훨씬 낫습니다. 하지만 여전히 너무 단순합니다. 사용자가 이 페이지에 들어왔을 때 ‘홈으로 돌아가기’ 버튼밖에 없다면, 특정 콘텐츠를 찾고 싶었던 사람은 어떻게 해야 할까요?

개선형: 사용자에게 더 많은 선택지 제공하기

좋은 404 페이지는 여러 개의 ‘출구’를 제공해야 합니다. 저는 보통 다음 요소를 추가합니다.

  1. 검색창 - 사용자가 직접 찾게 합니다
  2. 인기 링크 - 인기 콘텐츠로 안내합니다
  3. 브랜드 요소 - Logo와 브랜드 색상을 사용해 일관성을 유지합니다

전체 코드는 다음과 같습니다.

// app/not-found.tsx
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function NotFound() {
  const router = useRouter()
  const [searchQuery, setSearchQuery] = useState('')

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    if (searchQuery.trim()) {
      router.push(`/search?q=${encodeURIComponent(searchQuery)}`)
    }
  }

  const popularLinks = [
    { href: '/blog', label: '기술 블로그' },
    { href: '/projects', label: '프로젝트' },
    { href: '/about', label: '소개' },
  ]

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
      <div className="max-w-2xl w-full px-6 py-12 text-center">
        {/* 큰 404 */}
        <h1 className="text-9xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600 mb-4">
          404
        </h1>

        {/* 친절한 안내 문구 */}
        <p className="text-2xl font-medium text-gray-800 mb-2">
          이런, 페이지가 길을 잃었네요
        </p>
        <p className="text-gray-600 mb-8">
          링크가 만료되었거나 페이지가 이동되었을 수 있습니다.<br/>
          걱정하지 마세요. 아래 방법으로 계속 둘러볼 수 있습니다:
        </p>

        {/* 검색창 */}
        <form onSubmit={handleSearch} className="mb-8">
          <div className="flex gap-2 max-w-md mx-auto">
            <input
              type="text"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              placeholder="찾고 싶은 내용을 검색하세요..."
              className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              type="submit"
              className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
            >
              검색
            </button>
          </div>
        </form>

        {/* 인기 링크 */}
        <div className="mb-8">
          <p className="text-sm text-gray-600 mb-4">또는 인기 페이지를 방문해 보세요:</p>
          <div className="flex flex-wrap justify-center gap-3">
            {popularLinks.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                className="px-5 py-2 bg-white text-gray-700 rounded-lg border border-gray-200 hover:border-blue-500 hover:text-blue-600 transition-colors"
              >
                {link.label}
              </Link>
            ))}
          </div>
        </div>

        {/* 홈으로 돌아가기 */}
        <Link
          href="/"
          className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
        >
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
          </svg>
          홈으로 돌아가기
        </Link>
      </div>
    </div>
  )
}

파일 맨 위의 'use client'에 주목하세요. 왜 필요할까요? 검색창은 useStateuseRouter를 사용하는데, 두 기능 모두 클라이언트 기능이므로 반드시 클라이언트 컴포넌트로 선언해야 합니다.

이 버전은 훨씬 낫습니다. 404 페이지를 본 사용자는 다음과 같이 행동할 수 있습니다.

  • 원하는 내용을 바로 검색할 수 있습니다
  • 인기 링크를 눌러 다른 콘텐츠를 둘러볼 수 있습니다
  • 그래도 찾지 못하면 홈으로 돌아갈 수 있습니다

이탈률을 상당히 낮출 수 있습니다.

고급 팁: 404 오류 추적하기

사용자가 어떤 존재하지 않는 페이지를 방문하는지 알고 싶다면, 그리고 그중 일부가 실제로 만들어야 할 페이지인지 확인하고 싶다면 분석 이벤트를 추가할 수 있습니다.

'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'

export default function NotFound() {
  const pathname = usePathname()

  useEffect(() => {
    // 분석 도구로 전송
    if (typeof window !== 'undefined') {
      // Google Analytics 예제
      window.gtag?.('event', 'page_not_found', {
        page_path: pathname,
      })

      // 또는 자체 서버로 전송
      fetch('/api/analytics/404', {
        method: 'POST',
        body: JSON.stringify({ path: pathname }),
      }).catch(() => {}) // 실패해도 사용자 경험에는 영향을 주지 않습니다
    }
  }, [pathname])

  return (
    // ...404 UI
  )
}

일정 기간이 지난 뒤 데이터를 살펴보면 다음과 같은 사실을 발견할 수 있습니다.

  • 삭제된 이전 페이지를 찾는 사용자가 많음 → 301 리디렉션을 고려합니다
  • 특정 URL 오타가 매우 자주 발생함 → 자동 교정을 추가합니다
  • 사용자가 계속 특정 콘텐츠를 찾음 → 해당 콘텐츠를 새로 만들어야 합니다

error.tsx와 global-error.tsx: 500 오류 처리

not-found.tsx는 ‘페이지가 존재하지 않는’ 상황만 처리합니다. 코드 오류, API 장애, 데이터베이스 연결 실패는 어떻게 할까요? 이때 error.tsx가 필요합니다.

error.tsx 기본 사용법

error.tsx는 반드시 클라이언트 컴포넌트여야 하므로 파일 첫 줄에 'use client'를 작성해야 합니다.

왜 클라이언트여야 할까요? React의 오류 경계(Error Boundary)는 클라이언트에서만 실행되기 때문입니다.

// app/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full px-6 py-8 bg-white rounded-lg shadow-lg">
        <div className="text-center">
          <div className="text-6xl mb-4">⚠️</div>
          <h2 className="text-2xl font-bold text-gray-900 mb-2">문제가 발생했습니다!</h2>
          <p className="text-gray-600 mb-6">
            죄송합니다. 페이지를 불러오는 중 문제가 발생했습니다
          </p>

          <button
            onClick={() => reset()}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
          >
            다시 시도
          </button>

          <Link
            href="/"
            className="block mt-4 text-sm text-gray-500 hover:text-gray-700"
          >
            홈으로 돌아가기
          </Link>
        </div>
      </div>
    </div>
  )
}

중요한 매개변수는 두 가지입니다.

  • error - 포착한 오류 객체로, messagedigest(오류 해시)를 포함합니다
  • reset - 호출하면 해당 라우트 세그먼트를 다시 렌더링해 복구를 시도하는 함수입니다

‘다시 시도’ 버튼을 누르면 reset()이 오류가 발생한 컴포넌트를 다시 실행합니다. 네트워크 변동으로 생긴 오류라면 재시도만으로 해결될 수 있습니다.

프로덕션 환경의 오류 정보 처리

여기에는 보안 문제가 하나 있습니다. 개발 환경에서는 error.message에 “Database connection failed: invalid credentials” 같은 자세한 오류 정보가 표시됩니다.

프로덕션 환경에서는 이렇게 하면 안 됩니다. 이러한 정보가 민감한 데이터를 노출할 수 있기 때문입니다.

Next.js는 프로덕션 환경에서 자동으로 정보를 가리며, error 객체에는 다음만 남습니다.

  • message - 세부 정보가 없는 일반적인 오류 안내
  • digest - 로그를 찾을 때 사용하는 오류 해시

실제 오류 세부 정보는 서버 로그에 기록됩니다. digest로 서버 로그를 검색할 수 있습니다.

'use client'

export default function Error({ error }: { error: Error & { digest?: string } }) {
  return (
    <div>
      <h2>문제가 발생했습니다</h2>
      <p>{error.message}</p>
      {error.digest && (
        <p className="text-xs text-gray-400 mt-4">
          오류 ID: {error.digest}
        </p>
      )}
    </div>
  )
}

사용자가 ‘오류 ID: abc123’ 화면을 캡처해 보내면 이 ID로 서버 로그를 검색해 전체 스택 정보를 확인할 수 있습니다.

모니터링 서비스에 오류 기록하기

운영 중 오류가 났는데 사용자 신고만 기다릴 수는 없습니다. Sentry나 Datadog 같은 모니터링 서비스에 능동적으로 기록해야 합니다.

'use client'

import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Sentry로 오류 전송
    Sentry.captureException(error)
  }, [error])

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h2>문제가 발생했습니다!</h2>
        <button onClick={() => reset()}>다시 시도</button>
      </div>
    </div>
  )
}

오류가 발생하면 useEffect가 한 번 실행되어 전체 오류 정보를 Sentry로 전송합니다. Sentry 대시보드에서는 다음 정보를 확인할 수 있습니다.

  • 오류 스택
  • 사용자 브라우저 정보
  • 오류가 발생한 라우트
  • 발생 시각

운영 중 문제가 생기면 사용자 불만을 기다릴 필요 없이 5분 안에 알 수 있습니다.

global-error.tsx: 마지막 안전망

error.tsx는 강력하지만 사각지대가 있습니다. 자신이 속한 layout.tsx에서 발생한 오류는 잡을 수 없습니다.

이때 global-error.tsx가 필요합니다. 애플리케이션 전체를 감싸며 루트 레이아웃 오류까지 처리합니다.

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div style={{ padding: '50px', textAlign: 'center' }}>
          <h2>사이트에 심각한 오류가 발생했습니다</h2>
          <p>현재 복구 중입니다. 잠시 후 다시 시도해 주세요</p>
          <button onClick={() => reset()}>다시 시도</button>
        </div>
      </body>
    </html>
  )
}

세 가지 핵심 사항을 기억하세요.

  1. 반드시 <html><body> 태그를 포함해야 합니다
    루트 레이아웃이 실패하면 global-error.tsx가 이를 완전히 대체하므로 완전한 HTML 구조를 직접 제공해야 합니다.

  2. CSS 모듈이나 전역 스타일을 import할 수 없습니다
    Next.js는 global-error.tsx의 CSS import를 무시합니다. 인라인 스타일이나 <style> 태그만 사용할 수 있습니다.

  3. 트리거될 가능성은 매우 낮습니다
    루트 레이아웃은 보통 단순해 오류가 날 가능성이 작습니다. global-error.tsx는 실제로 실행될 일이 많지 않은 ‘보험’에 가깝습니다.

그래도 만들어 두는 것을 권합니다. 정말 오류가 발생했을 때 빈 화면보다는 훨씬 낫습니다.

완전한 global-error.tsx 예제

조금 더 보기 좋게 스타일을 추가해 보겠습니다.

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <style>{`
          * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
          }
          body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
          }
          .container {
            text-align: center;
            color: white;
            padding: 2rem;
          }
          h2 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
          }
          p {
            font-size: 1.2rem;
            margin-bottom: 2rem;
            opacity: 0.9;
          }
          button {
            padding: 12px 32px;
            font-size: 1rem;
            background: white;
            color: #667eea;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 600;
          }
          button:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
          }
        `}</style>

        <div className="container">
          <h2>😵 시스템에 심각한 오류가 발생했습니다</h2>
          <p>정말 죄송합니다. 사이트에 예기치 않은 문제가 생겼습니다<br/>담당 팀이 알림을 받아 긴급히 처리하고 있습니다</p>
          <button onClick={() => reset()}>새로고침</button>
          <p style={{ fontSize: '0.875rem', marginTop: '2rem', opacity: 0.7 }}>
            오류 ID: {error.digest || 'unknown'}
          </p>
        </div>
      </body>
    </html>
  )
}

Tailwind도 CSS 파일 import도 사용할 수 없으므로 <style> 태그 안에 스타일을 작성해야 합니다. 조금 원시적이지만 제대로 동작합니다.

오류 페이지 디자인 모범 사례: 사용자를 떠나지 않게 만들기

코드를 모두 작성했어도 바로 끝내지는 마세요. 기술 구현은 첫 단계일 뿐이며, 사용자가 실제로 머무를지를 결정하는 것은 디자인입니다.

Spotify, Figma, Mailchimp 같은 대기업의 404 페이지를 살펴보고 몇 가지 공통점을 정리했습니다.

필수 요소: 사용자에게 출구 제공하기

제대로 된 오류 페이지에는 적어도 다음 요소가 필요합니다.

1. 명확하지만 겁주지 않는 오류 안내

❌ 이렇게 쓰지 마세요.

Error 404: The requested resource could not be located on the server.

누가 이해할 수 있을까요? 사용자는 ‘이게 무슨 말이지? 사이트가 고장 난 건가?‘라고만 생각할 것입니다.

✅ 이렇게 쓰는 편이 좋습니다.

이런, 페이지가 길을 잃었네요
링크가 만료되었거나 페이지가 이동되었을 수 있습니다

기술 용어로 사용자를 겁주지 말고 사람이 이해할 수 있는 말로 설명하세요.

2. 주요 탐색 메뉴 또는 홈 링크

가장 기본적인 ‘탈출구’입니다. 적어도 사용자가 안전한 곳으로 돌아갈 수 있다는 사실은 알 수 있어야 합니다.

<Link href="/" className="text-blue-600">홈으로 돌아가기</Link>

3. 검색창

사용자가 URL을 잘못 입력했거나 링크가 만료되었을 수 있습니다. 검색창을 제공해 원하는 내용을 직접 찾게 하세요.

Spotify의 404 페이지에도 “Search for what you’re looking for”라는 간단하고 직관적인 문구와 함께 큰 검색창이 있습니다.

4. 추천 콘텐츠 또는 인기 페이지

사용자가 이미 방문했으니 볼거리를 제공해야 합니다.

  • 블로그 사이트 → 최신 글 추천
  • 전자상거래 사이트 → 인기 상품 추천
  • SaaS 제품 → 핵심 기능으로 이동하는 링크 표시

Netflix의 404 페이지는 인기 시리즈를 추천합니다. 많은 사용자가 이를 눌러 시청을 시작하면서 원래 무엇을 찾으려 했는지 잊기도 합니다.

5. 브랜드 일관성 유지

Logo, 색상, 글꼴 모두 사이트의 다른 부분과 일관되어야 합니다.

오류 페이지도 브랜드 경험의 일부입니다. 디자인이 전혀 없는 흰 페이지를 보면 사용자는 ‘이 사이트 믿을 만한가?‘라고 생각합니다.

디자인 전략: 어색한 상황 부드럽게 만들기

기능뿐 아니라 분위기도 중요합니다.

유머로 어색함 풀기

Figma의 404 페이지에는 UI 컴포넌트가 화면을 이리저리 돌아다녀 좀처럼 클릭할 수 없는 작은 애니메이션이 있습니다. 문구는 “Hmm, we can’t find that page.”입니다.

가볍고 유쾌하기 때문에 사용자는 ‘사이트가 망가졌네’라고 느끼기보다 웃게 됩니다.

단, 지나치면 안 됩니다. 기술 기업은 유머를 활용할 수 있지만 금융이나 의료 사이트에서는 전문적이지 않다는 인상을 줄 수 있습니다.

보상 제공하기(전자상거래에 적합)

일부 전자상거래 사이트는 404 페이지에 “페이지가 길을 잃었어요. 대신 10% off 보상 코드를 드립니다” 같은 작은 쿠폰을 제공합니다.

실망했던 사용자가 할인 코드를 받아 기분이 좋아지고 쇼핑몰을 둘러보다 실제 구매까지 이어질 수 있습니다.

모바일 최적화도 잊지 마세요

트래픽의 40%가 모바일에서 발생하므로 오류 페이지도 모바일에 맞춰야 합니다.

  • 버튼은 손가락으로 누르기 충분히 크게 만듭니다(최소 44x44px)
  • 모바일 화면은 작으므로 텍스트를 너무 많이 넣지 않습니다
  • 가장 중요한 링크를 한눈에 보이는 맨 위에 둡니다

데스크톱에서는 아름답지만 모바일에서는 버튼이 너무 작아 ‘홈으로 돌아가기’를 세 번이나 눌러야 했던 404 페이지를 본 적이 있습니다. 사용자 경험이 완전히 망가집니다.

실제 사례: 좋은 예와 나쁜 예 비교

나쁜 예 - 어느 정부 사이트:

  • 흰 바탕과 검은 글씨로 “Error 404 Not Found”만 표시
  • 링크가 전혀 없음
  • 검색창이 없음
  • Logo가 없음

사용자는 이 화면을 보면 100% 이탈합니다.

좋은 예 - Airbnb:

  • 큰 제목: “We can’t seem to find the page you’re looking for”
  • 검색창: “Try searching for hotels in Paris”
  • 추천 링크: Homes, Experiences, Online Experiences
  • Airbnb의 브랜드 색상과 글꼴 유지

사용자가 원하는 페이지를 찾지 못하더라도 추천 콘텐츠에 관심을 갖고 계속 머물게 됩니다.

데이터로 확인하기

제 블로그에서 A/B 테스트를 진행했습니다.

버전 A(기본 404):

  • 이탈률: 78%
  • 평균 체류 시간: 3초

버전 B(검색창과 추천 글이 있는 커스텀 404):

  • 이탈률: 42%
  • 평균 체류 시간: 35초

이탈률이 거의 절반으로 떨어졌습니다. 사용자의 20%는 추천 글을 눌러 계속 읽었습니다.

이것이 디자인의 힘입니다. 똑같이 ‘페이지가 존재하지 않는다’고 알려도 한쪽은 사용자를 떠나게 하고 다른 한쪽은 붙잡습니다.

자주 발생하는 문제와 경험에서 얻은 해결법

여러 프로젝트를 만들며 수많은 문제를 겪었습니다. 여기서는 가장 자주 발생하는 문제와 해결책을 정리해 같은 함정을 피하도록 돕겠습니다.

문제 1: notFound()가 404가 아니라 200을 반환합니다

증상:

notFound()를 호출했고 404 페이지도 정상적으로 표시되지만 브라우저 개발자 도구에는 HTTP 상태 코드가 200으로 표시됩니다. Google이 이 페이지를 정상 페이지로 색인해 SEO가 엉망이 됩니다.

원인:

스트리밍 응답이 이미 시작되어 HTTP 상태 코드가 200으로 고정되었습니다. JSX를 반환하기 시작한 뒤에는 너무 늦습니다.

해결 방법:

어떤 JSX도 반환하기 전에 notFound()를 호출합니다.

// ❌ 잘못된 예: 이미 JSX 안에 들어왔습니다
export default async function Page({ params }) {
  const data = await fetchData(params.id)
  return <div>{!data ? notFound() : <Content data={data} />}</div>
}

// ✅ 올바른 예: 먼저 검증한 뒤 반환합니다
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // 기다리지 말고 즉시 호출합니다
  }

  return <Content data={data} />
}

먼저 판단하고 호출한 뒤 렌더링한다는 순서를 기억하세요.

문제 2: global-error.tsx의 스타일이 적용되지 않습니다

증상:

global-error.tsx에서 Tailwind CSS나 CSS 모듈을 import했지만 페이지에는 스타일이 전혀 나타나지 않습니다.

원인:

Next.js는 global-error.tsx의 모든 CSS import를 무시합니다. 알려진 제약입니다.

해결 방법:

인라인 스타일이나 <style> 태그만 사용합니다.

// ❌ 잘못된 예: import가 적용되지 않습니다
import './styles.css'  // 적용되지 않음

export default function GlobalError() {
  return <div className="bg-blue-500">오류</div>  // Tailwind도 적용되지 않음
}

// ✅ 올바른 예: <style> 태그 사용
export default function GlobalError() {
  return (
    <html>
      <body>
        <style>{`
          .error-container {
            background: #3b82f6;
            color: white;
            padding: 2rem;
          }
        `}</style>
        <div className="error-container">오류</div>
      </body>
    </html>
  )
}

조금 원시적이지만 제대로 동작합니다. 저는 보통 스타일을 별도의 문자열 상수로 분리해 코드를 더 깔끔하게 만듭니다.

문제 3: 중첩 라우트의 not-found.tsx가 동작하지 않습니다

증상:

app/blog/[slug]/not-found.tsx에 커스텀 404를 만들었지만 /blog/존재하지-않는-글에 접속하면 여전히 루트의 404가 표시됩니다.

원인:

보통 두 가지 중 하나입니다.

  1. 파일 위치가 잘못되었습니다
  2. page.tsx에서 notFound()를 호출하지 않았습니다

해결 방법:

파일 구조를 확인합니다.

app/
├── not-found.tsx          ← 전역 404
└── blog/
    └── [slug]/
        ├── page.tsx       ← 여기서 반드시 notFound()를 호출해야 함
        └── not-found.tsx  ← 블로그 전용 404

그런 다음 page.tsx에서 직접 호출합니다.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // 같은 레벨의 not-found.tsx 트리거
  }

  return <article>{post.title}</article>
}

/asdfghjkl처럼 완전히 존재하지 않는 라우트에 접속하면 루트의 app/not-found.tsx가 트리거됩니다.

중첩 라우트의 not-found.tsx는 해당 page.tsxnotFound()를 직접 호출할 때만 트리거됩니다.

문제 4: error.tsx가 일부 오류를 잡지 못합니다

증상:

데이터베이스 연결이 실패했지만 error.tsx가 트리거되지 않고 빈 화면이나 루트 오류 페이지가 표시됩니다.

원인:

error.tsx는 같은 레벨과 하위 라우트의 오류만 잡을 수 있습니다. 자신이 속한 layout.tsx의 오류는 잡지 못합니다.

또한 notFound()error.tsx를 건너뛰고 곧바로 not-found.tsx를 트리거합니다.

해결 방법:

레이아웃 문제로 의심되면 상위 라우트나 루트에 error.tsx를 추가합니다.

app/
├── error.tsx              ← 루트 레이아웃의 하위 컴포넌트 오류 포착
├── global-error.tsx       ← 루트 레이아웃 자체의 오류 포착
└── dashboard/
    ├── layout.tsx         ← 여기서 난 오류는 아래 error.tsx가 잡지 못함
    └── error.tsx          ← page.tsx와 하위 라우트 오류만 포착

레이아웃 오류까지 잡아야 한다면 global-error.tsx를 사용하세요.

문제 5: 프로덕션 환경에서 오류 정보가 보이지 않습니다

증상:

개발 환경에서는 오류 정보가 자세하지만 프로덕션 환경의 error.message에는 “Application error”만 표시됩니다.

원인:

민감한 정보 유출을 막기 위한 Next.js의 보안 메커니즘입니다.

해결 방법:

error.digest로 서버 로그에서 전체 정보를 찾습니다.

'use client'

export default function Error({ error }) {
  return (
    <div>
      <p>문제가 발생했습니다: {error.message}</p>
      <p className="text-xs text-gray-400">
        오류 ID: {error.digest}  {/* 사용자에게 이 값을 보여 줍니다 */}
      </p>
    </div>
  )
}

사용자가 캡처 화면을 보내면 digest를 사용해 서버 로그(Vercel, Sentry, Datadog)에서 전체 스택을 찾을 수 있습니다.

또는 error.tsxuseEffect에서 오류를 모니터링 서비스로 직접 전송하면 사용자 신고를 기다릴 필요가 없습니다.

결론

Next.js의 오류 처리는 세 단계로 나뉩니다.

  • not-found.tsx → 존재하지 않는 페이지를 위한 404
  • error.tsx → 런타임 오류
  • global-error.tsx → 루트 레이아웃의 마지막 안전망

기술 구현은 어렵지 않습니다. 진짜 과제는 디자인입니다. 좋은 오류 페이지는 이탈률을 78%에서 42%로 낮출 수 있습니다. 막연한 주장이 아니라 제가 직접 측정한 데이터입니다.

검색창, 추천 링크 몇 개, 사람다운 안내 문구 하나면 충분합니다.

지금 사용 중인 Next.js 프로젝트를 확인해 보세요. 오류 페이지가 여전히 기본 스타일인가요? 30분만 투자해 바꿔 보세요. 사용자가 고마워할 것입니다.

문제가 생기면 댓글을 남겨 주세요. 가능한 한 답변하겠습니다. 이 글이 도움이 됐다면 필요한 분에게 공유해 주세요.

커스텀 Next.js 404 페이지 만들기

검색창과 추천 링크를 포함한 Next.js App Router용 커스텀 404 오류 페이지를 단계별로 만드는 방법입니다

  1. 1

    Step 1: not-found.tsx 파일 만들기

    app 디렉터리에 전역 404 페이지로 사용할 not-found.tsx 파일을 만듭니다
  2. 2

    Step 2: 기본 UI 컴포넌트 추가하기

    Next.js Link 컴포넌트를 import하고 오류 안내와 홈으로 돌아가기 버튼이 있는 기본 화면을 만듭니다
  3. 3

    Step 3: 'use client' 선언 추가하기

    상태 관리나 검색창 같은 상호작용 기능이 필요하면 파일 맨 위에 'use client' 선언을 추가합니다
  4. 4

    Step 4: 검색 기능 구현하기

    useState로 검색 입력값을 관리하고 useRouter로 검색 페이지 이동을 구현합니다
  5. 5

    Step 5: 인기 링크 추가하기

    추천 페이지 링크 배열을 만들고 Link 컴포넌트로 탐색 옵션을 렌더링합니다
  6. 6

    Step 6: 스타일 최적화 적용하기

    Tailwind CSS 또는 다른 스타일링 방식을 사용해 페이지를 꾸미고 브랜드 일관성을 유지합니다
  7. 7

    Step 7: 페이지 컴포넌트에서 404 트리거하기

    동적 라우트의 page.tsx에서 데이터가 없을 때 notFound() 함수를 호출해 404 페이지를 트리거합니다
  8. 8

    Step 8: 테스트하고 검증하기

    존재하지 않는 경로에 접속해 동작을 테스트하고 브라우저 개발자 도구에서 HTTP 상태 코드가 404인지 확인합니다

FAQ

Next.js의 not-found.tsx, error.tsx, global-error.tsx는 무엇이 다른가요?
not-found.tsx는 페이지가 없는 404 오류를 전담하고, error.tsx는 데이터 로딩 실패 같은 런타임 오류를 처리하며, global-error.tsx는 루트 레이아웃 오류까지 잡는 마지막 안전망입니다. 세 파일이 3단계 오류 방어 체계를 이룹니다.
notFound()를 호출했는데도 HTTP 상태 코드가 404가 아니라 200인 이유는 무엇인가요?
JSX를 반환한 뒤 notFound()를 호출해 스트리밍 응답이 이미 시작되었기 때문입니다. 이때 상태 코드는 200으로 고정됩니다. 반드시 JSX를 반환하기 전에 notFound()를 호출해 데이터를 먼저 검증한 다음 컴포넌트를 렌더링해야 합니다.
global-error.tsx에서 Tailwind CSS나 CSS 파일을 import할 수 없는 이유는 무엇인가요?
global-error.tsx는 루트 레이아웃을 완전히 대체해야 하므로 Next.js가 그 안의 CSS import를 무시합니다. 스타일은 인라인 방식이나 <style> 태그로만 추가할 수 있으며, 이는 프레임워크의 설계 제약입니다.
사용자가 접속한 존재하지 않는 페이지를 어떻게 추적하나요?
not-found.tsx에서 useEffect와 usePathname hook을 사용해 404 경로를 Google Analytics나 자체 서버로 전송합니다. 이 데이터를 분석하면 새로 만들어야 할 콘텐츠나 301 리디렉션을 설정해야 할 이전 페이지를 찾을 수 있습니다.
사용자 이탈률을 낮추려면 커스텀 404 페이지에 어떤 요소를 넣어야 하나요?
좋은 404 페이지에는 1) 기술 용어를 피한 명확하고 친절한 오류 안내, 2) 홈이나 주요 탐색 링크, 3) 사용자가 직접 찾을 수 있는 검색창, 4) 추천 콘텐츠나 인기 페이지, 5) 사이트와 일관된 브랜드 요소가 필요합니다. 이런 요소로 이탈률을 78%에서 42%까지 낮출 수 있습니다.

6분 읽기 · 게시일: 2026년 1월 5일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog