- _index.md: 문서 목록 및 버전 관리 - 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션 - 10: 문서 API 연동 스펙 (api-specs에서 이관) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
4.8 KiB
Markdown
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>
|
|
```
|