# 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>
```
