docs: [guide] 프론트엔드 개발 가이드 6개 챕터 추가 (모듈분리, 페이지패턴, 폼검증, 모바일, 에러처리, 배포)

This commit is contained in:
유병철
2026-03-20 15:00:31 +09:00
parent c6297514fd
commit 17bc89b35c
7 changed files with 1036 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
# SAM ERP 프론트엔드 개발 가이드
> **대상**: SAM ERP 프론트엔드 신규/기존 개발자
> **최종 업데이트**: 2026-03-13
> **최종 업데이트**: 2026-03-20
---
@@ -19,6 +19,12 @@
| [07-hooks.md](./07-hooks.md) | 공통 Hooks |
| [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) |
| [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 |
| [10-module-separation.md](./10-module-separation.md) | 멀티테넌트 모듈 분리 아키텍처 |
| [11-page-patterns.md](./11-page-patterns.md) | 페이지 패턴 (리스트, 상세/폼, 검색 모달) |
| [12-form-validation.md](./12-form-validation.md) | 폼 검증 (Zod 스키마, 트러블슈팅) |
| [13-mobile-patterns.md](./13-mobile-patterns.md) | 모바일 반응형 패턴 |
| [14-error-handling.md](./14-error-handling.md) | 에러 핸들링 (ApiError, Next.js 에러 파일) |
| [15-deployment.md](./15-deployment.md) | 배포 가이드 (Vercel) |
---

View File

@@ -0,0 +1,142 @@
# 10. 모듈 분리 아키텍처
> 대상: 프론트엔드 개발자
> 최종 업데이트: 2026-03-20
---
## 목차
| 번호 | 항목 |
|------|------|
| 10.1 | [개요](#101-개요) |
| 10.2 | [모듈 구조](#102-모듈-구조) |
| 10.3 | [핵심 패턴: moduleAware](#103-핵심-패턴-moduleaware) |
| 10.4 | [라우트 가드](#104-라우트-가드) |
| 10.5 | [크로스 모듈 임포트 규칙](#105-크로스-모듈-임포트-규칙) |
| 10.6 | [적용 영역](#106-적용-영역) |
---
## 10.1 개요
SAM ERP는 멀티테넌트 시스템으로, 테넌트별 업종(`tenant.options.industry`)에 따라 필요한 모듈만 활성화합니다.
**핵심 원칙**: `industry` 미설정 시 모든 모듈 활성화 (기존 동작 100% 유지)
| 테넌트 | industry | 활성 모듈 |
|--------|----------|----------|
| 경동 | `manufacturing` | production, quality |
| 주일 | `construction` | construction |
| 미설정 | - | 전체 (기존 호환) |
---
## 10.2 모듈 구조
### 파일 구조
```
src/modules/
types.ts # ModuleId, TenantIndustry, MODULE_REGISTRY
tenant-config.ts # INDUSTRY_MODULE_MAP (업종별 활성 모듈 매핑)
index.ts # getModuleForRoute, getEnabledDashboardSections
src/hooks/useModules.ts # React Hook
```
### ModuleId 목록
| ModuleId | 설명 | 라우트 접두사 |
|----------|------|-------------|
| `common` | 공통 ERP | - |
| `production` | 생산관리 | `/production` |
| `quality` | 품질관리 | `/quality` |
| `construction` | 시공관리 | `/business/construction` |
| `vehicle-management` | 차량관리 | `/vehicle-management` |
### useModules Hook
```typescript
const { isEnabled, tenantIndustry, enabledModules } = useModules();
// 특정 모듈 활성화 여부 확인
if (isEnabled('production')) {
// 생산관리 관련 UI 표시
}
```
---
## 10.3 핵심 패턴: moduleAware
```typescript
const { isEnabled, tenantIndustry } = useModules();
const moduleAware = !!tenantIndustry; // industry 미설정 -> false -> 전부 허용
if (!moduleAware) return allData; // 기존과 동일
return filteredData; // 모듈 기반 필터링
```
- `moduleAware = false` -> 필터링 없음, 모든 기능 노출
- `moduleAware = true` -> 테넌트 업종에 맞는 모듈만 노출
---
## 10.4 라우트 가드
### 라우트 기반 가드 (ModuleGuard)
`/production/*`, `/quality/*` 등 전용 라우트는 layout.tsx에서 자동 차단:
```typescript
// src/app/[locale]/(protected)/production/layout.tsx
<ModuleGuard moduleId="production">
{children}
</ModuleGuard>
```
### 명시적 가드
공통 라우트 내 모듈 의존 페이지는 직접 체크:
```typescript
// /sales/*/production-orders 같은 공통 라우트 내 모듈 의존 페이지
if (tenantIndustry && !isEnabled('production')) {
return <div>생산관리 모듈이 활성화되어 있지 않습니다.</div>;
}
```
---
## 10.5 크로스 모듈 임포트 규칙
| 방향 | 허용 | 비고 |
|------|------|------|
| Common -> Tenant | 직접 import 금지 | `// MODULE_SEPARATION_OK` 주석 + 공유 래퍼만 허용 |
| Tenant -> Common | 자유 | - |
| Tenant -> Tenant | 금지 | dynamic import만 허용 |
검증 스크립트: `scripts/verify-module-separation.sh`
### MODULE.md 경계 마커
각 테넌트 모듈 디렉토리에 `MODULE.md` 파일로 모듈 경계 문서화:
```
src/components/production/MODULE.md
src/components/quality/MODULE.md
src/components/business/construction/MODULE.md
src/components/vehicle-management/MODULE.md
```
---
## 10.6 적용 영역
| 영역 | 적용 방식 |
|------|----------|
| CEO 대시보드 | `moduleAware` 패턴으로 섹션 필터링 |
| 사이드바 메뉴 | `isEnabled()` 체크로 메뉴 숨김 |
| 라우트 접근 | ModuleGuard 또는 명시적 가드 |
| API 호출 | 비활성 모듈 관련 API 호출 차단 |

View File

@@ -0,0 +1,202 @@
# 11. 페이지 패턴 가이드
> 대상: 프론트엔드 개발자
> 최종 업데이트: 2026-03-20
---
## 목차
| 번호 | 항목 |
|------|------|
| 11.1 | [리스트 페이지](#111-리스트-페이지) |
| 11.2 | [상세/폼 페이지](#112-상세폼-페이지) |
| 11.3 | [검색 모달](#113-검색-모달) |
| 11.4 | [공통 기능은 공통 컴포넌트에서](#114-공통-기능은-공통-컴포넌트에서) |
---
## 11.1 리스트 페이지
### UniversalListPage (권장)
대부분의 리스트 페이지는 `UniversalListPage`로 구성합니다.
```typescript
const config: UniversalListConfig<MyItem> = {
// 검색 (클라이언트 사이드 필터링)
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const q = searchValue.toLowerCase();
return item.name.toLowerCase().includes(q) || item.code.toLowerCase().includes(q);
},
searchPlaceholder: '이름, 코드 검색...',
// 컬럼 정의
columns: [...],
// 모바일 카드
renderMobileCard: (item) => <MobileCard item={item} />,
};
```
### IntegratedListTemplateV2 필수 적용 항목
IntegratedListTemplateV2 사용 시 다음 10가지를 반드시 확인:
| # | 항목 | 필수 |
|---|------|------|
| 1 | `useColumnSettings` + `ColumnSettingsPopover` | O |
| 2 | `searchValue` + `onSearchChange` (or clientSideFiltering) | O |
| 3 | 체크박스 `Set<string>` 패턴 | O |
| 4 | 페이지네이션 | O |
| 5 | `renderMobileCard` | O |
| 6 | 테이블 행 클릭 -> 상세 이동 | O |
| 7 | 헤더 레이아웃 (StatCards, 필터) | O |
| 8 | `tableHeaderActions` (테이블 내 필터) | 선택 |
| 9 | `filterConfig` (필터 설정) | 선택 |
| 10 | 탭 구성 | 선택 |
### 검색 패턴 (필수)
```typescript
// ✅ 올바른 패턴 -- UniversalListPage + clientSideFiltering
const config: UniversalListConfig<MyItem> = {
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const q = searchValue.toLowerCase();
return item.name.toLowerCase().includes(q);
},
};
// 데이터를 한 번 API로 로드 -> 검색은 메모리에서 즉시 필터링 -> 깜빡임 없음
```
```typescript
// ❌ 금지 패턴 -- IntegratedListTemplateV2에 onSearchChange 직접 연결
<IntegratedListTemplateV2
searchValue={searchTerm}
onSearchChange={(q) => { setSearchTerm(q); }}
/>
// 키입력마다 state 변경 -> re-render -> 화면 깜빡임, 한글 조합 불가
```
---
## 11.2 상세/폼 페이지
### 라우팅 규칙
- 별도 `/new` 경로 금지 -> `?mode=new` 쿼리파라미터 사용
- 별도 `/edit` 경로 금지 -> `?mode=edit` 쿼리파라미터 사용
```typescript
// ✅ 올바른 패턴
router.push('/some-page?mode=new');
router.push('/some-page/123?mode=edit');
// ❌ 금지 패턴
router.push('/some-page/new');
router.push('/some-page/123/edit');
```
### 페이지 구조
```typescript
export default function SomePage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') return <SomeForm />;
return <SomeList />;
}
```
### 헤더 표준
| 위치 | 요소 |
|------|------|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
| 상단 우측 | `<- 목록으로` 링크 |
### 하단 Sticky 액션 바 (필수)
```typescript
<div className="sticky bottom-0 bg-white border-t shadow-sm">
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
<Button variant="outline" onClick={() => router.push(listPath)}>
<X className="h-4 w-4 mr-1" /> 취소
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
<Save className="h-4 w-4 mr-1" /> 저장
</Button>
</div>
</div>
```
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | 취소 | 저장 |
| 상세 (view) | 취소 (목록으로) | 수정 |
| 수정 (edit) | 취소 | 저장 |
### 폼 레이아웃
- Card 내부에 버튼 넣지 않음 -> sticky 하단 바 사용
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
---
## 11.3 검색 모달
### SearchableSelectionModal (필수)
검색+선택 기능이 필요한 모달은 반드시 `SearchableSelectionModal<T>`을 사용합니다.
```typescript
// ✅ 올바른 패턴
<SearchableSelectionModal<VendorItem>
open={open}
onOpenChange={setOpen}
title="거래처 선택"
fetchData={async () => {
const result = await getVendors();
return result.success ? result.data : [];
}}
columns={[
{ key: 'vendorName', label: '거래처명' },
{ key: 'businessNumber', label: '사업자번호' },
]}
onSelect={(vendor) => {
setSelectedVendor(vendor);
}}
searchFilter={(item, query) =>
item.vendorName.includes(query) || item.businessNumber?.includes(query)
}
/>
```
```typescript
// ❌ 금지 패턴 -- Dialog + Table 직접 조합
<Dialog>
<Input onChange={...} />
<Table>...</Table>
</Dialog>
```
---
## 11.4 공통 기능은 공통 컴포넌트에서
리스트 페이지 전체에 적용해야 하는 기능은 개별 페이지 수정 금지. 반드시 공통 레이어에서 처리.
| 기능 | 수정 위치 | 개별 페이지 수정 |
|------|----------|----------------|
| 검색 상태 보존 | `UniversalListPage` | 금지 |
| 검색 X(클리어) 버튼 | `SearchFilter` + `IntegratedListTemplateV2` | 금지 |
| 검색 디바운스 | `UniversalListPage` 내부 300ms | 금지 |
| 체크박스 선택 | `IntegratedListTemplateV2` | 금지 |
| 페이지네이션 | `IntegratedListTemplateV2` | 금지 |
| 모바일 카드/인피니티 | `IntegratedListTemplateV2` | 금지 |
| 컬럼 설정 | `useColumnSettings` + `ColumnSettingsPopover` | 금지 |
**원칙**: "26개 페이지에 하나씩 적용" -> 잘못된 접근. "공통 1곳 수정 -> 전체 자동 적용" -> 올바른 접근.

View File

@@ -0,0 +1,153 @@
# 12. 폼 검증 (Zod)
> 대상: 프론트엔드 개발자
> 최종 업데이트: 2026-03-20
---
## 목차
| 번호 | 항목 |
|------|------|
| 12.1 | [적용 범위](#121-적용-범위) |
| 12.2 | [기본 패턴](#122-기본-패턴) |
| 12.3 | [트러블슈팅](#123-트러블슈팅) |
| 12.4 | [체크리스트](#124-체크리스트) |
---
## 12.1 적용 범위
| 대상 | Zod 적용 |
|------|---------|
| 신규 폼 | 필수 |
| 기존 폼 (정상 작동 중) | 건드리지 않음 |
| 단순 필드 1-2개 인라인 폼 | 불필요 (오버엔지니어링) |
| 신규 서버 액션 API 응답 | 선택적 |
---
## 12.2 기본 패턴
### 스키마 정의 + 타입 추출
```typescript
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. 스키마 정의 (타입 + 검증 한 번에)
const formSchema = z.object({
itemName: z.string().min(1, '품목명을 입력하세요'),
quantity: z.number().min(1, '1 이상 입력하세요'),
status: z.enum(['active', 'inactive']),
memo: z.string().optional(),
});
// 2. 스키마에서 타입 추출 (별도 interface 정의 불필요)
type FormData = z.infer<typeof formSchema>;
// 3. useForm에 zodResolver 연결
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { itemName: '', quantity: 1, status: 'active' },
});
```
### 규칙
- **스키마 위치**: 컴포넌트 파일 상단 또는 같은 디렉토리의 `schema.ts`
- **타입 추출**: `z.infer<typeof schema>` 사용, 별도 `interface` 중복 정의 금지
- **에러 메시지**: 한글로 작성 (사용자에게 직접 표시됨)
- **`as` 캐스트 지양**: Zod 스키마로 타입이 보장되므로 불필요
---
## 12.3 트러블슈팅
### 문제 1: 에러 메시지가 영어로 나옴
```typescript
// ❌ 기본 에러 메시지 (영어)
const schema = z.object({
name: z.string().min(1),
});
// -> "String must contain at least 1 character(s)"
// ✅ 한글 메시지 명시
const schema = z.object({
name: z.string().min(1, '이름을 입력하세요'),
});
```
### 문제 2: 불필요한 필드까지 검증됨
수정 폼에서 등록 전용 필드가 검증되는 경우:
```typescript
// ✅ .omit()으로 불필요 필드 제외
const editSchema = createSchema.omit({ password: true });
```
### 문제 3: 조건부 필수 필드
특정 값 선택 시에만 다른 필드가 필수인 경우:
```typescript
// ✅ .superRefine() 사용
const schema = z.object({
type: z.enum(['individual', 'company']),
companyName: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.type === 'company' && !data.companyName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '법인명을 입력하세요',
path: ['companyName'],
});
}
});
```
### 문제 4: .omit() 후 refinement 소실
`.omit()``.refine()`, `.superRefine()` 등 체이닝된 검증을 제거합니다.
```typescript
// ❌ refinement가 사라짐
const base = z.object({ a: z.string(), b: z.string() })
.refine((d) => d.a !== d.b, { message: 'a와 b는 달라야 합니다' });
const partial = base.omit({ b: true }); // refine 소실!
// ✅ .omit() 후 refinement 재적용
const partial = base.omit({ b: true });
const withRefine = partial.refine(...); // 필요 시 다시 추가
```
### 문제 5: 폼 필드명과 스키마 키 불일치
```typescript
// ❌ 폼에서는 camelCase, 스키마에서는 snake_case
const schema = z.object({ item_name: z.string() });
// useForm에서 register('itemName') -> 검증 안 됨
// ✅ 동일한 키 사용
const schema = z.object({ itemName: z.string() });
```
---
## 12.4 체크리스트
신규 폼 작성 시 확인:
| # | 항목 |
|---|------|
| 1 | 스키마 정의 완료 (모든 필드 포함) |
| 2 | `z.infer`로 타입 추출 (별도 interface 없음) |
| 3 | `zodResolver` 연결 |
| 4 | 에러 메시지 한글 작성 |
| 5 | 조건부 필수 -> `.superRefine()` 사용 |
| 6 | 수정 폼 -> `.omit()` 적용 시 refinement 재확인 |
| 7 | 스키마 키와 폼 필드명 일치 확인 |
| 8 | `as` 캐스트 제거 |

View File

@@ -0,0 +1,168 @@
# 13. 모바일 반응형 패턴
> 대상: 프론트엔드 개발자
> 최종 업데이트: 2026-03-20
---
## 목차
| 번호 | 항목 |
|------|------|
| 13.1 | [대상 디바이스](#131-대상-디바이스) |
| 13.2 | [그리드 레이아웃](#132-그리드-레이아웃) |
| 13.3 | [테이블 대응](#133-테이블-대응) |
| 13.4 | [모달/다이얼로그](#134-모달다이얼로그) |
| 13.5 | [버튼/액션](#135-버튼액션) |
| 13.6 | [텍스트 처리](#136-텍스트-처리) |
| 13.7 | [체크리스트](#137-체크리스트) |
---
## 13.1 대상 디바이스
| 디바이스 | 너비 | Tailwind 접두사 |
|---------|------|----------------|
| Galaxy Z Fold 5 (접힌) | 344px | 기본 (접두사 없음) |
| 일반 모바일 | 375-430px | 기본 |
| 커스텀 브레이크포인트 | 480px | `xs:` |
| 태블릿 | 768px | `md:` |
| 데스크탑 | 1024px+ | `lg:` |
커스텀 브레이크포인트 `xs` (480px)는 `tailwind.config.ts`에 정의:
```typescript
// tailwind.config.ts
theme: {
screens: {
xs: '480px',
// sm, md, lg, xl 기본값 유지
}
}
```
---
## 13.2 그리드 레이아웃
**모바일 퍼스트**: 항상 `grid-cols-1`에서 시작, 위로 확장
```typescript
// ✅ 올바른 패턴
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-4">
// ❌ 데스크탑 퍼스트
<div className="grid grid-cols-4 gap-4">
```
### 폼 레이아웃 예시
```typescript
// 기본 정보 폼 (4컬럼 -> 모바일 1컬럼)
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>견적번호</div>
<div>접수일</div>
<div>수주처</div>
<div>현장명</div>
</div>
```
---
## 13.3 테이블 대응
### 방법 1: IntegratedListTemplateV2 모바일 카드 (권장)
```typescript
<IntegratedListTemplateV2
renderMobileCard={(item) => (
<MobileCard
title={item.name}
subtitle={item.code}
fields={[
{ label: '수량', value: item.quantity },
{ label: '금액', value: formatNumber(item.amount) },
]}
/>
)}
/>
```
### 방법 2: 가로 스크롤
```typescript
<div className="overflow-x-auto">
<Table className="min-w-[600px]">
{/* 테이블 내용 */}
</Table>
</div>
```
### 방법 3: 컬럼 숨김
```typescript
<TableHead className="hidden md:table-cell">비고</TableHead>
<TableCell className="hidden md:table-cell">{item.memo}</TableCell>
```
---
## 13.4 모달/다이얼로그
```typescript
// ✅ 모바일에서 화면 벗어나지 않게
<DialogContent className="max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]">
```
모바일에서 모달 내 폼:
- 필드를 1컬럼으로 쌓기
- 하단 버튼은 `flex-col` 또는 `flex-wrap`
---
## 13.5 버튼/액션
```typescript
// ✅ 모바일: 아이콘만, 태블릿+: 아이콘+텍스트
<Button size="sm" className="md:size-default">
<Save className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">저장</span>
</Button>
```
버튼 그룹 래핑:
```typescript
<div className="flex items-center flex-wrap gap-1 md:gap-3">
<Button>...</Button>
<Button>...</Button>
</div>
```
---
## 13.6 텍스트 처리
| 상황 | 클래스 |
|------|--------|
| 한 줄 말줄임 | `truncate` |
| 여러 줄 말줄임 | `line-clamp-2` |
| 줄바꿈 방지 (한글) | `break-keep` |
| 긴 단어 강제 줄바꿈 | `break-all` |
| 반응형 폰트 | `text-sm md:text-base` |
---
## 13.7 체크리스트
새 페이지/컴포넌트 작성 시:
| # | 항목 |
|---|------|
| 1 | 그리드: `grid-cols-1`에서 시작하는가? |
| 2 | 테이블: 모바일 카드 또는 가로 스크롤 적용했는가? |
| 3 | 모달: `max-w-[calc(100vw-2rem)]` 적용했는가? |
| 4 | 버튼: 모바일 아이콘만/데스크탑 아이콘+텍스트 패턴인가? |
| 5 | 긴 텍스트: `truncate` 또는 `line-clamp` 처리했는가? |
| 6 | Galaxy Fold (344px) 너비에서 깨지지 않는가? |
| 7 | 줌 방지: input `font-size` 16px 이상인가? |

View File

@@ -0,0 +1,192 @@
# 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` 사용

View File

@@ -0,0 +1,172 @@
# 15. 배포 가이드 (Vercel)
> 대상: 프론트엔드 개발자, DevOps
> 최종 업데이트: 2026-03-20
---
## 목차
| 번호 | 항목 |
|------|------|
| 15.1 | [배포 환경](#151-배포-환경) |
| 15.2 | [환경 변수](#152-환경-변수) |
| 15.3 | [빌드 설정](#153-빌드-설정) |
| 15.4 | [Puppeteer 설정](#154-puppeteer-설정) |
| 15.5 | [배포 체크리스트](#155-배포-체크리스트) |
| 15.6 | [비용 관리](#156-비용-관리) |
---
## 15.1 배포 환경
| 환경 | 브랜치 | URL | 용도 |
|------|--------|-----|------|
| Development | `develop` | localhost:3000 | 로컬 개발 |
| Stage | `stage` | stage.xxx.com | QA/테스트 |
| Production | `main` | app.xxx.com | 실서비스 |
환경 구분은 `NEXT_PUBLIC_APP_ENV` 환경변수로 판별:
```typescript
const env = process.env.NEXT_PUBLIC_APP_ENV; // 'local' | 'development' | 'staging' | 'production'
```
---
## 15.2 환경 변수
### 필수 환경 변수
| 변수 | 설명 | 예시 |
|------|------|------|
| `NEXT_PUBLIC_API_URL` | 백엔드 API 주소 | `https://api.xxx.com` |
| `NEXT_PUBLIC_APP_ENV` | 실행 환경 | `production` |
| `JWT_SECRET` | JWT 토큰 시크릿 | (보안) |
### Vercel 설정 방법
1. Vercel Dashboard -> Settings -> Environment Variables
2. 환경별(Production/Preview/Development) 분리 설정
3. 민감 정보(JWT_SECRET 등)는 Encrypted로 저장
---
## 15.3 빌드 설정
### vercel.json
```json
{
"functions": {
"app/**/*.ts": {
"memory": 1024,
"maxDuration": 30
}
},
"regions": ["icn1"]
}
```
| 설정 | 값 | 설명 |
|------|-----|------|
| memory | 1024MB | PDF 생성 등 무거운 작업 대응 |
| maxDuration | 30초 | 함수 실행 타임아웃 |
| regions | icn1 | 서울 리전 (한국 서비스) |
### TypeScript strict mode
Vercel 빌드 시 `tsc --noEmit`이 실행되므로 타입 에러가 있으면 배포 실패:
```bash
# 배포 전 로컬에서 확인
npx tsc --noEmit
```
---
## 15.4 Puppeteer 설정
PDF 생성(견적서, 거래명세서 등)을 위한 Puppeteer 환경별 분기:
```typescript
// 로컬: 시스템 Chrome 사용
// Vercel: puppeteer-core + @sparticuz/chromium (경량)
const browser = await (isVercel
? puppeteerCore.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
})
: puppeteer.launch({ headless: true })
);
```
### 패키지 구성
```json
{
"dependencies": {
"puppeteer-core": "^x.x.x",
"@sparticuz/chromium": "^x.x.x"
},
"devDependencies": {
"puppeteer": "^x.x.x"
}
}
```
- `puppeteer`: 로컬 개발용 (devDependencies)
- `puppeteer-core` + `@sparticuz/chromium`: Vercel 배포용 (dependencies)
---
## 15.5 배포 체크리스트
### 배포 전
| # | 항목 |
|---|------|
| 1 | `npx tsc --noEmit` 에러 없음 |
| 2 | `npm run build` 성공 |
| 3 | 환경 변수 Vercel에 설정 완료 |
| 4 | 백엔드 CORS에 Vercel 도메인 추가 |
| 5 | API URL이 올바른 환경을 가리키는지 확인 |
### 배포 후
| # | 항목 |
|---|------|
| 1 | 로그인 정상 동작 (쿠키/토큰) |
| 2 | API 호출 정상 (CORS 에러 없음) |
| 3 | PDF 생성 정상 (Puppeteer) |
| 4 | 모바일 접속 확인 |
### 백엔드 CORS 설정
```php
// Laravel: config/cors.php
'allowed_origins' => [
'https://your-app.vercel.app',
'https://your-custom-domain.com',
],
```
---
## 15.6 비용 관리
### 예상 비용 (50개 회사 기준)
| 항목 | 월 비용 |
|------|--------|
| Serverless Functions | ~$15-20 |
| Bandwidth | ~$5-8 |
| 합계 | ~$22-28 |
### 비용 절감 전략
- **Edge Middleware**: 인증 체크 등 가벼운 로직을 Edge에서 처리 (Functions 비용 95% 절감 가능)
- **정적 페이지 최적화**: ISR(Incremental Static Regeneration) 적용 가능한 페이지 식별
- **이미지 최적화**: `next/image` 활용으로 Bandwidth 절감