193 lines
4.7 KiB
Markdown
193 lines
4.7 KiB
Markdown
# 14. 에러 핸들링
|
|
|
|
> 대상: 프론트엔드 개발자
|
|
> 최종 업데이트: 2026-03-20
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
| 번호 | 항목 |
|
|
|------|------|
|
|
| 14.1 | [API 에러 핸들러](#141-api-에러-핸들러) |
|
|
| 14.2 | [에러 클래스](#142-에러-클래스) |
|
|
| 14.3 | [컴포넌트에서 에러 처리](#143-컴포넌트에서-에러-처리) |
|
|
| 14.4 | [Next.js 에러 파일](#144-nextjs-에러-파일) |
|
|
| 14.5 | [에러 표시 패턴](#145-에러-표시-패턴) |
|
|
|
|
---
|
|
|
|
## 14.1 API 에러 핸들러
|
|
|
|
`src/lib/api/error-handler.ts`에서 HTTP 상태별 처리:
|
|
|
|
| 상태코드 | 처리 | 동작 |
|
|
|---------|------|------|
|
|
| 401 | 인증 만료 | `/login`으로 리다이렉트 |
|
|
| 403 | 권한 없음 | ApiError throw |
|
|
| 400 + `duplicate_id` | 품목코드 중복 | DuplicateCodeError throw |
|
|
| 422 | Validation 에러 | 상세 필드 에러 포함 ApiError throw |
|
|
| 기타 | 일반 에러 | ApiError throw |
|
|
|
|
### 422 Validation 에러 폴백
|
|
|
|
API 응답 포맷이 2가지 존재하므로 폴백 처리:
|
|
|
|
```typescript
|
|
// data.errors (Laravel 기본) || data.error.details (커스텀 포맷)
|
|
const validationErrors = data.errors || data.error?.details;
|
|
```
|
|
|
|
이 폴백은 다음 3곳에 동일하게 적용:
|
|
- `error-handler.ts` (Server Action 경로)
|
|
- `client.ts` (클라이언트 API 경로)
|
|
- `index.ts` (직접 fetch 경로)
|
|
|
|
---
|
|
|
|
## 14.2 에러 클래스
|
|
|
|
### ApiError
|
|
|
|
```typescript
|
|
class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public message: string,
|
|
public errors?: Record<string, string[]> // 필드별 에러 메시지
|
|
) {}
|
|
}
|
|
```
|
|
|
|
### DuplicateCodeError
|
|
|
|
```typescript
|
|
class DuplicateCodeError extends ApiError {
|
|
constructor(
|
|
public message: string,
|
|
public duplicateId: number,
|
|
public duplicateCode: string
|
|
) {}
|
|
}
|
|
```
|
|
|
|
### getErrorMessage 유틸리티
|
|
|
|
```typescript
|
|
import { getErrorMessage } from '@/lib/api/error-handler';
|
|
|
|
// 에러 객체에서 사용자 친화적 메시지 추출
|
|
const message = getErrorMessage(error);
|
|
// ApiError: "[422] 입력값을 확인해주세요."
|
|
// Error: error.message
|
|
// unknown: "알 수 없는 오류가 발생했습니다"
|
|
```
|
|
|
|
---
|
|
|
|
## 14.3 컴포넌트에서 에러 처리
|
|
|
|
### 기본 패턴 (Server Action 호출)
|
|
|
|
```typescript
|
|
try {
|
|
const result = await saveItem(formData);
|
|
if (!result.success) {
|
|
toast.error(result.error || '저장 실패');
|
|
return;
|
|
}
|
|
toast.success('저장 완료');
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error; // redirect는 재throw
|
|
toast.error(getErrorMessage(error));
|
|
}
|
|
```
|
|
|
|
### Validation 에러 상세 표시
|
|
|
|
```typescript
|
|
if (error instanceof ApiError && error.errors) {
|
|
const firstKey = Object.keys(error.errors)[0];
|
|
const firstError = error.errors[firstKey];
|
|
toast.error(`${error.message}\n${firstKey}: ${firstError[0]}`);
|
|
}
|
|
```
|
|
|
|
### redirect 에러 구분
|
|
|
|
Next.js의 `redirect()`는 내부적으로 에러를 throw하므로 catch에서 구분 필요:
|
|
|
|
```typescript
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
catch (error) {
|
|
if (isNextRedirectError(error)) throw error; // 반드시 재throw
|
|
// 실제 에러만 처리
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 14.4 Next.js 에러 파일
|
|
|
|
| 파일 | 용도 | 'use client' | 위치 우선순위 |
|
|
|------|------|-------------|-------------|
|
|
| `error.tsx` | 런타임 에러 경계 | 필수 | 특정 라우트 > 그룹 > locale > root |
|
|
| `not-found.tsx` | 404 페이지 | 불필요 | 특정 라우트 > locale > root |
|
|
| `global-error.tsx` | 루트 레이아웃 에러 | 필수 | root만 |
|
|
| `loading.tsx` | 로딩 상태 (Suspense) | 불필요 | 특정 라우트 > 그룹 |
|
|
|
|
### error.tsx 필수 구조
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
export default function Error({
|
|
error,
|
|
reset,
|
|
}: {
|
|
error: Error & { digest?: string };
|
|
reset: () => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<h2>오류가 발생했습니다</h2>
|
|
<p>{error.message}</p>
|
|
<button onClick={reset}>다시 시도</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### global-error.tsx 필수 구조
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
// 반드시 <html>, <body> 포함
|
|
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<h2>오류가 발생했습니다</h2>
|
|
<button onClick={reset}>다시 시도</button>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 14.5 에러 표시 패턴
|
|
|
|
| 상황 | 방법 |
|
|
|------|------|
|
|
| API 호출 실패 | `toast.error(getErrorMessage(error))` |
|
|
| 폼 검증 실패 | Zod + react-hook-form 자동 표시 |
|
|
| 필드별 서버 에러 | `error.errors` 파싱 후 toast |
|
|
| 치명적 에러 | `error.tsx` 에러 바운더리 |
|
|
| 네트워크 에러 | toast + 재시도 안내 |
|
|
|
|
**금지**: `alert()`, `confirm()`, `prompt()` 사용 금지 -> `toast` (sonner) 또는 `Dialog` 사용
|