- _index.md: 문서 목록 및 버전 관리 - 01~09: 아키텍처, API패턴, 컴포넌트, 폼, 스타일, 인증, 대시보드, 컨벤션 - 10: 문서 API 연동 스펙 (api-specs에서 이관) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4.8 KiB
4.8 KiB
05. 폼 패턴
대상: 프론트엔드 개발자 버전: 1.0.0 최종 수정: 2026-03-09
1. 폼 패턴 2가지
| 패턴 | 적용 대상 | 비고 |
|---|---|---|
| Zod + react-hook-form | 신규 폼 | 필수 |
| useState + 수동 검증 | 기존 폼 | 건드리지 않음 |
2. 신규 폼: Zod + react-hook-form
기본 패턴
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 사용 (신규 폼).
기본 사용법
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 등 특수 컴포넌트
- 복합 레이아웃 (주소 검색: 버튼+입력 조합)
비교
// ✅ 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. 기존 폼 패턴 (수정하지 않음)
// 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 에러를 폼에 표시:
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 사용:
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으로 해결:
// ✅ key prop으로 강제 리마운트
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
>
{/* ... */}
</Select>