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

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>