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'지시어 필요 (클라이언트 컴포넌트) error와resetprops를 받음- 하위 컴포넌트에서 발생한 에러를 캐치
에러 전파:
- 가장 가까운
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} />; }
에러 처리 흐름:
- 서버 컴포넌트에서 에러 발생
- 가장 가까운
error.tsx가 에러 캐치 ErrorDisplay컴포넌트 렌더링- 사용자가
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: 정상 로딩
- 사용자가
/dashboard접속 page.tsx의parseCrimeData()실행- 데이터 로딩 중
loading.tsx표시 - 데이터 로드 완료 후 페이지 렌더링
시나리오 2: 에러 발생
- 사용자가
/dashboard접속 parseCrimeData()실행 중 에러 발생 (예: CSV 파일 없음)error.tsx가 에러 캐치ErrorDisplay컴포넌트 표시- 사용자가 "다시 시도" 클릭 →
reset()호출 → 재시도
시나리오 3: 동적 라우트 에러
- 사용자가
/detail/존재하지않는범죄접속 findCrimeByCategoryMiddle()가undefined반환notFound()호출 → 404 페이지 표시- (에러가 아닌 경우이므로
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.tsx와 error.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.tsx | not-found.tsx |
|---|---|---|
| 처리 대상 | 예외(Exception) | 404 Not Found |
| 발생 시점 | 예외가 throw될 때 | notFound() 호출 시 |
| 컴포넌트 타입 | 클라이언트 컴포넌트 ('use client') | 서버/클라이언트 모두 가능 |
| Props | error, 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를 넥세트에서 어떻게 처리하는지, 왜 넥스트에서는 따로 에러 바운더리나, 서스펜스 바운더리를 어렵게 설정하지 않아도 되는지 알 수 있엇습니다.
다음 글에서는 진짜로 배포를 하고자 합니다.