폼 & 데이터 입력
검증 패턴, 에러 시스템, 복잡한 폼 합성의 표준화
폼은 사용자와 시스템 간의 핵심 인터페이스입니다. AI가 일관된 폼을 생성하려면, 검증 패턴과 에러 처리가 체계적으로 정의되어야 합니다.
폼 아키텍처
폼 필드 컴포넌트
FormField 구조
// components/form/form-field.tsx
interface FormFieldProps {
children: React.ReactNode
/** 필드 이름 (form 상태 키) */
name: string
/** 필수 여부 */
required?: boolean
}
interface FormFieldContextValue {
name: string
id: string
error?: string
required?: boolean
}
const FormFieldContext = createContext<FormFieldContextValue | null>(null)
/**
* 폼 필드 래퍼
* @ai-hint 모든 입력 필드는 FormField로 감싸야 함
* Label, Input, Description, Error 자동 연결
*/
export function FormField({ children, name, required }: FormFieldProps) {
const id = useId()
const { errors } = useFormContext()
const error = errors[name]?.message
return (
<FormFieldContext.Provider value={{ name, id, error, required }}>
<div className="space-y-2">{children}</div>
</FormFieldContext.Provider>
)
}
// 하위 컴포넌트
FormField.Label = FormLabel
FormField.Control = FormControl
FormField.Description = FormDescription
FormField.Error = FormError완성된 폼 필드 예시
// 사용 예시
<FormField name="email" required>
<FormField.Label>이메일</FormField.Label>
<FormField.Control>
<Input type="email" placeholder="name@example.com" />
</FormField.Control>
<FormField.Description>비즈니스 이메일을 입력하세요.</FormField.Description>
<FormField.Error />
</FormField>컴포넌트 구현
// components/form/form-label.tsx
function FormLabel({ children }: { children: React.ReactNode }) {
const { id, required, error } = useFormFieldContext()
return (
<Label
htmlFor={id}
className={cn(error && 'text-destructive')}
>
{children}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
)
}
// components/form/form-control.tsx
function FormControl({ children }: { children: React.ReactElement }) {
const { id, name, error } = useFormFieldContext()
const { register } = useFormContext()
return cloneElement(children, {
id,
'aria-describedby': error ? `${id}-error` : undefined,
'aria-invalid': !!error,
...register(name),
})
}
// components/form/form-error.tsx
function FormError() {
const { id, error } = useFormFieldContext()
if (!error) return null
return (
<p
id={`${id}-error`}
role="alert"
className="text-sm text-destructive"
>
{error}
</p>
)
}검증 패턴
검증 스키마 (Zod)
// schemas/user.ts
import { z } from 'zod'
export const userSchema = z
.object({
email: z.string().min(1, '이메일을 입력하세요.').email('올바른 이메일 형식이 아닙니다.'),
password: z
.string()
.min(1, '비밀번호를 입력하세요.')
.min(8, '비밀번호는 8자 이상이어야 합니다.')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, '영문 대/소문자와 숫자를 포함해야 합니다.'),
confirmPassword: z.string().min(1, '비밀번호 확인을 입력하세요.'),
name: z
.string()
.min(1, '이름을 입력하세요.')
.min(2, '이름은 2자 이상이어야 합니다.')
.max(50, '이름은 50자 이하여야 합니다.'),
phone: z
.string()
.optional()
.refine(
(val) => !val || /^01[0-9]-?\d{4}-?\d{4}$/.test(val),
'올바른 전화번호 형식이 아닙니다.'
),
birthDate: z
.date()
.optional()
.refine((val) => !val || val <= new Date(), '미래 날짜는 선택할 수 없습니다.'),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: '약관에 동의해주세요.' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: '비밀번호가 일치하지 않습니다.',
path: ['confirmPassword'],
})
export type UserFormData = z.infer<typeof userSchema>검증 타이밍
// hooks/use-form-validation.ts
type ValidationMode = 'onChange' | 'onBlur' | 'onSubmit' | 'all'
interface UseFormValidationOptions<T extends FieldValues> {
schema: z.ZodSchema<T>
mode?: ValidationMode
reValidateMode?: 'onChange' | 'onBlur'
}
/**
* 폼 검증 훅
* @ai-hint 기본 mode는 'onBlur' 권장
* - 회원가입/복잡한 폼: mode='onBlur'
* - 검색/필터: mode='onChange' + debounce
* - 간단한 폼: mode='onSubmit'
*/
export function useFormValidation<T extends FieldValues>({
schema,
mode = 'onBlur',
reValidateMode = 'onChange',
}: UseFormValidationOptions<T>) {
return useForm<T>({
resolver: zodResolver(schema),
mode,
reValidateMode,
})
}에러 메시지 시스템
에러 메시지 가이드라인
// constants/error-messages.ts
/**
* 에러 메시지 작성 규칙
* 1. 무엇이 잘못되었는지 명확히
* 2. 어떻게 수정하는지 안내
* 3. 사용자 탓하지 않기
* 4. 기술 용어 피하기
*/
export const errorMessages = {
required: (field: string) => `${field}을(를) 입력하세요.`,
minLength: (field: string, min: number) => `${field}은(는) ${min}자 이상이어야 합니다.`,
maxLength: (field: string, max: number) => `${field}은(는) ${max}자 이하여야 합니다.`,
email: '올바른 이메일 형식이 아닙니다. (예: name@example.com)',
phone: '올바른 전화번호 형식이 아닙니다. (예: 010-1234-5678)',
password: {
weak: '비밀번호가 너무 약합니다. 영문 대/소문자, 숫자를 포함하세요.',
mismatch: '비밀번호가 일치하지 않습니다.',
},
date: {
future: '미래 날짜는 선택할 수 없습니다.',
past: '과거 날짜는 선택할 수 없습니다.',
invalid: '올바른 날짜 형식이 아닙니다.',
},
file: {
size: (max: string) => `파일 크기는 ${max} 이하여야 합니다.`,
type: (types: string[]) => `${types.join(', ')} 파일만 업로드할 수 있습니다.`,
},
network: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도하세요.',
server: '서버 오류가 발생했습니다. 문제가 지속되면 고객센터에 문의하세요.',
unauthorized: '세션이 만료되었습니다. 다시 로그인하세요.',
} as const에러 표시 패턴
// 폼 레벨 에러 표시
function FormError({ error }: { error?: string }) {
if (!error) return null
return (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>오류</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)
}
// 사용 예시
;<form onSubmit={handleSubmit}>
<FormError error={formError} />
<FormField name="email">{/* ... */}</FormField>
<Button type="submit" loading={isSubmitting}>
제출
</Button>
</form>마이크로카피 (콘텐츠 시스템)
AI가 UI를 만들 때 가장 빠르게 무너지는 것 중 하나가 “텍스트의 일관성”입니다. 버튼/레이블/플레이스홀더/에러 문구는 디자인 토큰처럼 표준화할수록 품질이 올라갑니다.
원칙 (최소 규칙)
- 동사 + 목적어로 시작:
저장,추가,삭제,업데이트 - 같은 의미는 같은 단어:
확인/완료/저장을 섞어 쓰지 않기 - 문제 + 해결 형태의 에러:
비밀번호가 일치하지 않습니다. 다시 확인해주세요. - 사용자 탓 금지:
잘못 입력했습니다❌ →입력을 확인해주세요✅
표준 레이블(예시)
| 의도 | 권장 | 피하기 |
|---|---|---|
| 기본 확인 | 확인 | OK |
| 취소 | 취소 | 닫기(의미가 다름) |
| 저장 | 저장 | 완료(모호) |
| 삭제 | 삭제 | 제거(혼용 금지) |
| 재시도 | 다시 시도 | Retry |
Copy Tokens (선택: 코드로 관리)
문구를 데이터로 관리하면, AI/도구가 “새 문장 생성” 대신 “키 선택”을 하게 만들 수 있습니다.
// copy/ko.ts
export const copy = {
actions: {
save: '저장',
cancel: '취소',
confirm: '확인',
delete: '삭제',
retry: '다시 시도',
},
labels: {
email: '이메일',
password: '비밀번호',
phone: '전화번호',
},
placeholders: {
email: 'name@example.com',
search: '검색어를 입력하세요',
},
errors: {
required: (field: string) => `${field}을(를) 입력하세요.`,
invalidEmail: '올바른 이메일 형식이 아닙니다. (예: name@example.com)',
},
} as constAI 컨텍스트 주입 팁
CLAUDE.md에 “마이크로카피” 섹션을 넣고 표준 문구 우선 사용을 강제- 에러 메시지는 가능한 한
errorMessages/copy.errors같은 중앙 정의를 사용 - 신규 문구가 필요하면 PR에서 “키 추가”로 리뷰 포인트를 만들기 (문구가 drift 되지 않게)
복잡한 폼 합성
다단계 폼 (Wizard)
// components/form/multi-step-form.tsx
interface Step {
id: string
title: string
description?: string
schema: z.ZodSchema
component: React.ComponentType<{ control: Control }>
}
interface MultiStepFormProps {
steps: Step[]
onComplete: (data: Record<string, any>) => Promise<void>
}
/**
* 다단계 폼
* @ai-hint 긴 폼을 논리적 단계로 분할할 때 사용
*/
export function MultiStepForm({ steps, onComplete }: MultiStepFormProps) {
const [currentStep, setCurrentStep] = useState(0)
const [formData, setFormData] = useState<Record<string, any>>({})
const currentStepConfig = steps[currentStep]
const isLastStep = currentStep === steps.length - 1
const form = useForm({
resolver: zodResolver(currentStepConfig.schema),
defaultValues: formData,
})
const handleNext = async (data: Record<string, any>) => {
const newData = { ...formData, ...data }
setFormData(newData)
if (isLastStep) {
await onComplete(newData)
} else {
setCurrentStep((prev) => prev + 1)
}
}
const handleBack = () => {
setCurrentStep((prev) => prev - 1)
}
const StepComponent = currentStepConfig.component
return (
<div>
{/* Progress indicator */}
<nav aria-label="Progress">
<ol className="flex items-center gap-4">
{steps.map((step, index) => (
<li key={step.id} className="flex items-center">
<span
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
index < currentStep && 'bg-primary text-primary-foreground',
index === currentStep && 'border-2 border-primary',
index > currentStep && 'border-2 border-muted'
)}
>
{index < currentStep ? (
<CheckIcon className="h-4 w-4" />
) : (
index + 1
)}
</span>
<span className="ml-2 text-sm">{step.title}</span>
</li>
))}
</ol>
</nav>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(handleNext)} className="mt-8">
<StepComponent control={form.control} />
<div className="mt-8 flex justify-between">
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={currentStep === 0}
>
이전
</Button>
<Button type="submit">
{isLastStep ? '완료' : '다음'}
</Button>
</div>
</form>
</Form>
</div>
)
}동적 필드 (Field Array)
// components/form/dynamic-fields.tsx
interface DynamicFieldsProps {
name: string
maxItems?: number
minItems?: number
renderField: (index: number, remove: () => void) => React.ReactNode
addLabel?: string
}
/**
* 동적 필드 배열
* @ai-hint 반복 가능한 입력 그룹에 사용 (연락처, 주소 등)
*/
export function DynamicFields({
name,
maxItems = 10,
minItems = 1,
renderField,
addLabel = '추가',
}: DynamicFieldsProps) {
const { fields, append, remove } = useFieldArray({ name })
const canAdd = fields.length < maxItems
const canRemove = fields.length > minItems
return (
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4">
{renderField(index, () => canRemove && remove(index))}
</div>
))}
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({})}
>
<PlusIcon className="mr-2 h-4 w-4" />
{addLabel}
</Button>
)}
</div>
)
}조건부 필드
// 조건부 필드 패턴
function ConditionalFieldExample() {
const { watch } = useFormContext()
const contactMethod = watch('contactMethod')
return (
<>
<FormField name="contactMethod">
<FormField.Label>연락 방법</FormField.Label>
<FormField.Control>
<Select>
<SelectItem value="email">이메일</SelectItem>
<SelectItem value="phone">전화</SelectItem>
<SelectItem value="both">둘 다</SelectItem>
</Select>
</FormField.Control>
</FormField>
{(contactMethod === 'email' || contactMethod === 'both') && (
<FormField name="email">
<FormField.Label>이메일</FormField.Label>
<FormField.Control>
<Input type="email" />
</FormField.Control>
</FormField>
)}
{(contactMethod === 'phone' || contactMethod === 'both') && (
<FormField name="phone">
<FormField.Label>전화번호</FormField.Label>
<FormField.Control>
<Input type="tel" />
</FormField.Control>
</FormField>
)}
</>
)
}