logo

DowanKim

7. 넥스트에서 에러, 서스펜스, 404는 어떻게 처리하나요

2025년 12월 12일

정적 인터랙티브 대시보드

Next.js App Router에서 로딩 및 에러 처리는 어떻게 하나

1. loading.tsx - 자동 로딩 UI

Next.js App Router는 loading.tsx를 자동으로 Suspense boundary로 감싸 로딩 상태를 처리합니다.

동작 원리:

  • 서버 컴포넌트가 데이터를 불러오는 동안 자동으로 표시
  • 파일 기반 라우팅: 각 라우트 폴더에 loading.tsx를 두면 해당 라우트의 로딩 UI로 사용
  • React Suspense를 내부적으로 활용

파일 구조:

app/
  dashboard/
    page.tsx          # 메인 페이지
    loading.tsx       # 로딩 UI
    error.tsx         # 에러 UI
  detail/
    [category_middle]/
      page.tsx
      loading.tsx
      error.tsx

2. error.tsx - 에러 경계(Error Boundary)

error.tsx는 React Error Boundary를 자동으로 생성해 에러를 처리합니다.

특징:

  • 반드시 'use client' 지시어 필요 (클라이언트 컴포넌트)
  • errorreset props를 받음
  • 하위 컴포넌트에서 발생한 에러를 캐치

에러 전파:

  • 가장 가까운 error.tsx가 에러를 처리
  • 상위로 전파되지 않음

구현 내용

1. 공통 로딩 컴포넌트 생성

파일: components/LoadingSpinner.tsx

export default function LoadingSpinner() { return ( <div className="bg-gray-50 min-h-screen flex items-center justify-center"> <div className="text-center"> <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mb-4"></div> <p className="text-gray-600 text-lg">데이터를 불러오는 중...</p> </div> </div> ); }
  • Tailwind CSS의 animate-spin으로 스피너 애니메이션

2. 공통 에러 표시 컴포넌트

파일: components/ErrorDisplay.tsx

'use client'; import { useEffect } from 'react'; import Link from 'next/link'; interface ErrorDisplayProps { error: Error & { digest?: string }; reset: () => void; } export default function ErrorDisplay({ error, reset }: ErrorDisplayProps) { useEffect(() => { // 에러를 로깅 서비스에 전송할 수 있습니다 console.error('Error:', error); }, [error]); return ( <div className="bg-gray-50 min-h-screen flex items-center justify-center px-4"> <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center"> {/* 에러 아이콘 */} <div className="mb-4"> <svg className="mx-auto h-12 w-12 text-red-500" ...> {/* 경고 아이콘 SVG */} </svg> </div> <h2 className="text-2xl font-bold text-gray-900 mb-2"> 문제가 발생했습니다 </h2> <p className="text-gray-600 mb-6"> 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요. </p> {/* 에러 메시지 표시 */} {error.message && ( <div className="mb-6 p-4 bg-red-50 rounded-lg"> <p className="text-sm text-red-800 font-mono"> {error.message} </p> </div> )} {/* 액션 버튼 */} <div className="flex gap-4 justify-center"> <button onClick={reset} className="px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800" > 다시 시도 </button> <Link href="/dashboard" className="px-4 py-2 bg-gray-100 text-gray-900 rounded-lg hover:bg-gray-200" > 대시보드로 이동 </Link> </div> </div> </div> ); }
  • useEffect로 에러 로깅
  • reset()으로 에러 상태 초기화 및 재시도
  • 사용자 친화적 메시지와 액션 버튼 제공

3. 페이지별 로딩 파일

파일: app/dashboard/loading.tsx

import LoadingSpinner from '@/components/LoadingSpinner'; export default function Loading() { return <LoadingSpinner />; }

파일: app/detail/[category_middle]/loading.tsx

import LoadingSpinner from '@/components/LoadingSpinner'; export default function Loading() { return <LoadingSpinner />; }

동작 방식:

  • Next.js가 자동으로 Suspense boundary 생성
  • page.tsx의 데이터 페칭 중 loading.tsx 표시
  • 서버 컴포넌트의 async 함수 실행 중 자동 활성화

4. 페이지별 에러 파일

파일: app/dashboard/error.tsx

'use client'; import ErrorDisplay from '@/components/ErrorDisplay'; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return <ErrorDisplay error={error} reset={reset} />; }

파일: app/detail/[category_middle]/error.tsx

'use client'; import ErrorDisplay from '@/components/ErrorDisplay'; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return <ErrorDisplay error={error} reset={reset} />; }

에러 처리 흐름:

  1. 서버 컴포넌트에서 에러 발생
  2. 가장 가까운 error.tsx가 에러 캐치
  3. ErrorDisplay 컴포넌트 렌더링
  4. 사용자가 reset() 호출 시 재시도

5. CSV 파싱 함수의 에러 처리 강화

파일: lib/csvParser.ts

export function parseCrimeData(): CrimeRecord[] { try { const filePath = path.join(process.cwd(), 'data', 'crime_data.csv'); // 1. 파일 존재 여부 확인 if (!fs.existsSync(filePath)) { throw new Error(`CSV 파일을 찾을 수 없습니다: ${filePath}`); } const fileContents = fs.readFileSync(filePath, 'utf-8'); // 2. 파일 내용 검증 const lines = fileContents .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); if (lines.length === 0) { throw new Error('CSV 파일이 비어있습니다.'); } // 3. 필수 컬럼 검증 const headers = lines[0].split(',').map(header => header.trim()); const categoryMajorIdx = headers.indexOf('범죄대분류'); const categoryMiddleIdx = headers.indexOf('범죄중분류'); if (categoryMajorIdx === -1 || categoryMiddleIdx === -1) { throw new Error('CSV 파일의 필수 컬럼(범죄대분류, 범죄중분류)을 찾을 수 없습니다.'); } // 4. 데이터 파싱 (각 행의 유효성 검사 포함) const records: CrimeRecord[] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; if (!line) continue; const values = line.split(',').map(value => value.trim()); const categoryMajor = values[categoryMajorIdx]; const categoryMiddle = values[categoryMiddleIdx]; // 필수 필드 검증 if (!categoryMajor || !categoryMiddle) { console.warn(`${i + 1}: 범죄 대분류 또는 중분류가 없습니다. 건너뜁니다.`); continue; } // 숫자 변환 시 에러 처리 for (const { slot, idx } of timeSlotColumns) { const count = parseInt(values[idx] || '0', 10); if (isNaN(count)) { console.warn(`${i + 1}, 시간대 ${slot}: 숫자 변환 실패. 0으로 처리합니다.`); } timeSlots[slot] = isNaN(count) ? 0 : count; } // ... 요일별 데이터 파싱도 동일한 방식 records.push({ categoryMajor, categoryMiddle, timeSlots, daysOfWeek: daysOfWeekData, total: timeSlotTotal + dayTotal, }); } // 5. 최종 결과 검증 if (records.length === 0) { throw new Error('CSV 파일에서 유효한 데이터를 찾을 수 없습니다.'); } return records; } catch (error) { // 6. 에러를 명확한 메시지로 변환 if (error instanceof Error) { throw new Error(`데이터 파싱 중 오류가 발생했습니다: ${error.message}`); } throw new Error('알 수 없는 오류가 발생했습니다.'); } }

에러 처리 전략:

  1. 파일 존재 여부 확인
  2. 파일 내용 검증 (빈 파일 체크)
  3. 필수 컬럼 검증
  4. 데이터 파싱 중 유효성 검사
  5. 숫자 변환 실패 시 경고 및 기본값 처리
  6. 최종 결과 검증
  7. 명확한 에러 메시지 제공

실제 동작 시나리오

시나리오 1: 정상 로딩

  1. 사용자가 /dashboard 접속
  2. page.tsxparseCrimeData() 실행
  3. 데이터 로딩 중 loading.tsx 표시
  4. 데이터 로드 완료 후 페이지 렌더링

시나리오 2: 에러 발생

  1. 사용자가 /dashboard 접속
  2. parseCrimeData() 실행 중 에러 발생 (예: CSV 파일 없음)
  3. error.tsx가 에러 캐치
  4. ErrorDisplay 컴포넌트 표시
  5. 사용자가 "다시 시도" 클릭 → reset() 호출 → 재시도

시나리오 3: 동적 라우트 에러

  1. 사용자가 /detail/존재하지않는범죄 접속
  2. findCrimeByCategoryMiddle()undefined 반환
  3. notFound() 호출 → 404 페이지 표시
  4. (에러가 아닌 경우이므로 error.tsx는 동작하지 않음)

Next.js의 자동 처리 메커니즘

Suspense Boundary (로딩)

app/dashboard/
  ├── page.tsx          ← 서버 컴포넌트
  └── loading.tsx      ← 자동으로 Suspense로 감싸짐

Next.js가 내부적으로 다음과 같이 처리:

<Suspense fallback={<Loading />}> <Dashboard /> </Suspense>

Error Boundary (에러)

app/dashboard/
  ├── page.tsx          ← 서버 컴포넌트
  └── error.tsx        ← 자동으로 ErrorBoundary로 감싸짐

Next.js가 내부적으로 다음과 같이 처리:

<ErrorBoundary fallback={<Error error={error} reset={reset} />}> <Dashboard /> </ErrorBoundary>

구현 시 주의사항

1. error.tsx는 반드시 클라이언트 컴포넌트

'use client'; // 필수

2. 에러 전파 범위

  • error.tsx는 같은 레벨과 하위 레벨의 에러만 처리
  • 상위로 전파되지 않음
  • 여러 레벨에 error.tsx를 둘 수 있음

3. 로딩 상태의 범위

  • loading.tsx는 같은 레벨의 page.tsx와 하위 라우트의 로딩을 처리
  • 상위 라우트의 로딩은 상위 loading.tsx가 처리

개선 효과

사용자 경험

  • 로딩 중 명확한 피드백
  • 에러 발생 시 이해하기 쉬운 메시지와 복구 옵션

개발자 경험

  • 파일 기반으로 간단한 설정
  • 공통 컴포넌트 재사용으로 유지보수 용이
  • 타입 안전성 확보

안정성

  • 다양한 에러 케이스 처리
  • 명확한 에러 메시지로 디버깅 용이
  • 프로덕션 환경에서도 안정적 동작

결론

Next.js App Router의 loading.tsxerror.tsx를 활용하면 로딩과 에러 처리를 간단히 구현할 수 있습니다. 공통 컴포넌트를 만들고 각 페이지에 적용하면 일관된 사용자 경험을 제공할 수 있습니다.

또한 동적 라우트에서도 error.tsx자체는 정상 작동하지만, notFound는 에러가 아닙니다. 그러므로 not-found.tsx가 따로 설정해야합니다. 현재는 not-found.tsx가 없어 디폴트 404 페이지가 보이게 됩니다.

동적 라우트에서도 error.tsx는 정상 작동합니다. 다만 에러와 404는 구분해야 합니다.

동적 라우트의 에러 처리 구분

1. error.tsx - 실제 예외(Exception) 처리

동적 라우트에서도 error.tsx는 정상 작동합니다.

에러가 발생하는 경우:

// app/detail/[category_middle]/page.tsx export default async function DetailPage({ params }: DetailPageProps) { const { category_middle } = await params; // ❌ 이 경우 error.tsx가 처리 const records = parseCrimeData(); // 파일이 없거나 파싱 실패 시 예외 발생 // → error.tsx가 에러를 캐치하고 ErrorDisplay 표시 }

예시 시나리오:

  • CSV 파일이 없음 → parseCrimeData()에서 예외 발생 → error.tsx 처리
  • CSV 파일 형식이 잘못됨 → 파싱 중 예외 발생 → error.tsx 처리
  • 메모리 부족 등 시스템 에러 → error.tsx 처리

2. not-found.tsx - 404 Not Found 처리

notFound()는 예외가 아니라 404 상태이므로 not-found.tsx가 처리합니다.

404가 발생하는 경우:

// app/detail/[category_middle]/page.tsx export default async function DetailPage({ params }: DetailPageProps) { const { category_middle } = await params; const categoryName = decodeURIComponent(category_middle); const records = parseCrimeData(); const crimeData = findCrimeByCategoryMiddle(records, categoryName); // ❌ 이 경우 not-found.tsx가 처리 if (!crimeData) { notFound(); // 에러가 아니라 404 상태 // → not-found.tsx가 표시됨 } }

예시 시나리오:

  • 존재하지 않는 범죄 유형 접근 → notFound() 호출 → not-found.tsx 처리
  • 잘못된 URL 경로 → notFound() 호출 → not-found.tsx 처리

차이점 정리

구분error.tsxnot-found.tsx
처리 대상예외(Exception)404 Not Found
발생 시점예외가 throw될 때notFound() 호출 시
컴포넌트 타입클라이언트 컴포넌트 ('use client')서버/클라이언트 모두 가능
Propserror, reset없음
예시파일 읽기 실패, 파싱 에러존재하지 않는 데이터

실제 동작 예시

시나리오 1: 실제 에러 발생

// parseCrimeData()에서 파일을 찾을 수 없음 const records = parseCrimeData(); // → Error: CSV 파일을 찾을 수 없습니다 // → error.tsx가 캐치 → ErrorDisplay 표시

시나리오 2: 데이터 없음 (404)

const crimeData = findCrimeByCategoryMiddle(records, '존재하지않는범죄'); if (!crimeData) { notFound(); // → not-found.tsx 표시 }

추가한 not-found.tsx

파일: app/detail/[category_middle]/not-found.tsx

import NotFoundDisplay from '@/components/NotFoundDisplay'; export default function NotFound() { return <NotFoundDisplay />; }

파일: components/NotFoundDisplay.tsx

import Link from 'next/link'; export default function NotFoundDisplay() { return ( <div className="bg-gray-50 min-h-screen flex items-center justify-center px-4"> <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center"> {/* 404 아이콘 및 메시지 */} <h2 className="text-2xl font-bold text-gray-900 mb-2"> 페이지를 찾을 수 없습니다 </h2> <p className="text-gray-600 mb-6"> 요청하신 범죄 유형을 찾을 수 없습니다. </p> <Link href="/dashboard">대시보드로 이동</Link> </div> </div> ); }

이렇게 suspense, error, 404를 넥세트에서 어떻게 처리하는지, 왜 넥스트에서는 따로 에러 바운더리나, 서스펜스 바운더리를 어렵게 설정하지 않아도 되는지 알 수 있엇습니다.

다음 글에서는 진짜로 배포를 하고자 합니다.