Files
sam-docs/frontend/v1/05-form-pattern.md
유병철 8f939d3609 docs: [frontend] 프론트엔드 아키텍처/가이드 문서 v1 작성
- _index.md: 문서 목록 및 버전 관리
- 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션
- 10: 문서 API 연동 스펙 (api-specs에서 이관)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:24:25 +09:00

194 lines
4.8 KiB
Markdown

# 05. 폼 패턴
> **대상**: 프론트엔드 개발자
> **버전**: 1.0.0
> **최종 수정**: 2026-03-09
---
## 1. 폼 패턴 2가지
| 패턴 | 적용 대상 | 비고 |
|------|----------|------|
| Zod + react-hook-form | **신규 폼** | 필수 |
| useState + 수동 검증 | 기존 폼 | 건드리지 않음 |
---
## 2. 신규 폼: Zod + react-hook-form
### 기본 패턴
```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 연결
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
itemName: '',
quantity: 1,
status: 'active',
},
});
```
### 규칙
| 항목 | 규칙 |
|------|------|
| 스키마 위치 | 컴포넌트 파일 상단 또는 같은 폴더의 `schema.ts` |
| 타입 추출 | `z.infer<typeof schema>` 사용, 별도 interface 중복 금지 |
| 에러 메시지 | **한글** (사용자에게 직접 표시) |
| `as` 캐스트 | 지양 (Zod가 타입 보장) |
### Zod 사용하지 않는 경우
- 기존 `rules={{ required: true }}` 패턴으로 작동 중인 폼
- 필드 1~2개짜리 인라인 폼 (오버엔지니어링)
---
## 3. FormField molecule
Label + Input 수동 조합 대신 `FormField` 사용 (신규 폼).
### 기본 사용법
```typescript
import { FormField } from '@/components/molecules/FormField';
<FormField
label="회사명"
value={formData.companyName}
onChange={(value) => handleChange('companyName', value)}
placeholder="회사명을 입력하세요"
disabled={!isEditMode}
/>
```
### 지원 타입
| type | 설명 | 비고 |
|------|------|------|
| `text` | 일반 텍스트 (기본값) | |
| `number` | 숫자 입력 | |
| `email` | 이메일 | |
| `tel` | 전화번호 | 자동 포맷 (010-1234-5678) |
| `businessNumber` | 사업자등록번호 | 자동 포맷 (123-45-67890) |
| `textarea` | 여러 줄 텍스트 | |
| `currency` | 금액 입력 | 콤마 자동 포맷 |
| `select` | 드롭다운 선택 | options prop 필요 |
| `date` | 날짜 선택 | DatePicker 연동 |
### FormField로 대체하지 않는 경우
- Select, DatePicker 단독 사용 (이미 Label 포함인 경우)
- ImageUpload, FileInput 등 특수 컴포넌트
- 복합 레이아웃 (주소 검색: 버튼+입력 조합)
### 비교
```typescript
// ✅ FormField 사용 (신규 폼)
<FormField
label="회사명"
value={formData.companyName}
onChange={(value) => handleChange('companyName', value)}
/>
// ❌ 수동 조합 (신규 폼에서 금지)
<div className="space-y-2">
<Label>회사명</Label>
<Input
value={formData.companyName}
onChange={(e) => handleChange('companyName', e.target.value)}
/>
</div>
```
---
## 4. 기존 폼 패턴 (수정하지 않음)
```typescript
// useState 기반 — 작동 중이면 건드리지 않음
const [formData, setFormData] = useState<FormData>(initialData);
const handleChange = (field: keyof FormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// react-hook-form rules 기반
<Input {...register('name', { required: true })} />
```
---
## 5. 서버 검증 에러 처리
Laravel에서 반환하는 validation 에러를 폼에 표시:
```typescript
const result = await saveItem(formData);
if (!result.success && result.fieldErrors) {
// fieldErrors: { name: ['이름은 필수입니다.'], amount: ['0보다 커야 합니다.'] }
Object.entries(result.fieldErrors).forEach(([field, messages]) => {
setError(field as keyof FormData, {
type: 'server',
message: messages[0],
});
});
}
```
---
## 6. DatePicker 사용 규칙
`input type="date"` 대신 커스텀 DatePicker 사용:
```typescript
import { DatePicker } from '@/components/ui/date-picker';
<DatePicker
value={formData.startDate} // 'yyyy-MM-dd' 문자열
onChange={(date) => setFormData(prev => ({ ...prev, startDate: date }))}
placeholder="날짜 선택"
/>
```
- `value`/`onChange`: string (`yyyy-MM-dd`)
- `minDate`/`maxDate`: Date 객체 (`new Date('2026-01-01')`)
- 테이블 셀: `size="sm"` 사용
- 한글 로케일, 주말/공휴일 색상 구분
---
## 7. Radix UI Select 주의사항
빈 값('')으로 시작 후 값 변경이 안 되는 버그 → `key` prop으로 해결:
```typescript
// ✅ key prop으로 강제 리마운트
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
>
{/* ... */}
</Select>
```