docs: [guide] 프론트엔드 개발 가이드 6개 챕터 추가 (모듈분리, 페이지패턴, 폼검증, 모바일, 에러처리, 배포)
This commit is contained in:
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
142
sam-docs/frontend/v1/10-module-separation.md
Normal file
142
sam-docs/frontend/v1/10-module-separation.md
Normal 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 호출 차단 |
|
||||
202
sam-docs/frontend/v1/11-page-patterns.md
Normal file
202
sam-docs/frontend/v1/11-page-patterns.md
Normal 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곳 수정 -> 전체 자동 적용" -> 올바른 접근.
|
||||
153
sam-docs/frontend/v1/12-form-validation.md
Normal file
153
sam-docs/frontend/v1/12-form-validation.md
Normal 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` 캐스트 제거 |
|
||||
168
sam-docs/frontend/v1/13-mobile-patterns.md
Normal file
168
sam-docs/frontend/v1/13-mobile-patterns.md
Normal 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 이상인가? |
|
||||
192
sam-docs/frontend/v1/14-error-handling.md
Normal file
192
sam-docs/frontend/v1/14-error-handling.md
Normal 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` 사용
|
||||
172
sam-docs/frontend/v1/15-deployment.md
Normal file
172
sam-docs/frontend/v1/15-deployment.md
Normal 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 절감
|
||||
Reference in New Issue
Block a user