복잡한 폼 설계 — 다단계 폼, 동적 필드, 서버 검증
회원가입 폼이 3단계로 나뉘어 있고, 각 단계에서 필드가 동적으로 추가되며, 서버에서도 검증을 해야 한다면 어떻게 설계할까요?
단순한 로그인 폼은 input 2개면 끝이지만, 실무에서는 훨씬 복잡한 폼을 만들어야 할 때가 많습니다. 다단계 위저드, 동적으로 추가/삭제되는 필드, 클라이언트와 서버 양쪽의 검증, 새로고침 시 데이터 보존까지. 이런 복잡한 폼을 어떻게 설계할 수 있는지 패턴별로 정리합니다.
다단계 폼 (Multi-step Form)
기본 구조
function MultiStepForm() {
const [step, setStep] = useState(0);
const { register, handleSubmit, trigger, getValues, formState: { errors } } = useForm({
defaultValues: {
// 모든 단계의 기본값
name: '',
email: '',
address: '',
cardNumber: '',
},
});
const steps = [
<PersonalInfo register={register} errors={errors} />,
<AddressInfo register={register} errors={errors} />,
<PaymentInfo register={register} errors={errors} />,
];
// 다음 단계로 가기 전에 현재 단계만 검증
const handleNext = async () => {
const fieldsToValidate = {
0: ['name', 'email'],
1: ['address'],
2: ['cardNumber'],
};
const isValid = await trigger(fieldsToValidate[step]);
if (isValid) setStep((prev) => prev + 1);
};
const handlePrev = () => {
setStep((prev) => prev - 1);
};
const onSubmit = (data) => {
console.log('전체 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 진행 표시 */}
<StepIndicator current={step} total={steps.length} />
{/* 현재 단계의 필드만 표시 */}
{steps[step]}
<div className="buttons">
{step > 0 && (
<button type="button" onClick={handlePrev}>이전</button>
)}
{step < steps.length - 1 ? (
<button type="button" onClick={handleNext}>다음</button>
) : (
<button type="submit">완료</button>
)}
</div>
</form>
);
}
핵심 포인트
- **하나의 useForm으로 전체 관리 **: 각 단계마다 useForm을 쓰면 데이터 동기화가 복잡해집니다
- **trigger로 단계별 검증 **:
trigger(['name', 'email'])처럼 특정 필드만 검증합니다 - ** 이전 버튼 시 데이터 보존 **: useForm이 내부에 값을 유지하므로 별도 처리가 불필요합니다
단계별 컴포넌트 분리
function PersonalInfo({ register, errors }) {
return (
<div>
<h2>개인 정보</h2>
<input
{...register('name', { required: '이름을 입력해주세요' })}
placeholder="이름"
/>
{errors.name && <span>{errors.name.message}</span>}
<input
{...register('email', {
required: '이메일을 입력해주세요',
pattern: { value: /\S+@\S+\.\S+/, message: '올바른 이메일 형식이 아닙니다' },
})}
placeholder="이메일"
/>
{errors.email && <span>{errors.email.message}</span>}
</div>
);
}
동적 필드 추가/삭제
useFieldArray 활용
function ExperienceForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
experiences: [{ company: '', position: '', duration: '' }],
},
});
const { fields, append, remove, swap } = useFieldArray({
control,
name: 'experiences',
});
return (
<form onSubmit={handleSubmit(console.log)}>
<h2>경력 사항</h2>
{fields.map((field, index) => (
<div key={field.id} className="experience-row">
<input
{...register(`experiences.${index}.company`, {
required: '회사명을 입력해주세요',
})}
placeholder="회사"
/>
<input
{...register(`experiences.${index}.position`)}
placeholder="직책"
/>
<input
{...register(`experiences.${index}.duration`)}
placeholder="기간"
/>
{fields.length > 1 && (
<button type="button" onClick={() => remove(index)}>삭제</button>
)}
{index > 0 && (
<button type="button" onClick={() => swap(index, index - 1)}>위로</button>
)}
</div>
))}
<button
type="button"
onClick={() => append({ company: '', position: '', duration: '' })}
>
경력 추가
</button>
<button type="submit">저장</button>
</form>
);
}
key에 index를 쓰면 안 되는 이유
// 잘못된 방법
{fields.map((field, index) => (
<div key={index}> {/* 항목 삭제/추가 시 값이 꼬임 */}
<input {...register(`items.${index}.name`)} />
</div>
))}
// 올바른 방법
{fields.map((field, index) => (
<div key={field.id}> {/* useFieldArray가 제공하는 고유 ID */}
<input {...register(`items.${index}.name`)} />
</div>
))}
배열 중간 항목을 삭제하면 인덱스가 밀립니다. key={index}를 사용하면 React가 잘못된 DOM을 재사용하여 입력값이 다른 항목에 표시될 수 있습니다.
중첩 동적 필드
// 프로젝트 → 각 프로젝트의 기술 스택 (중첩 배열)
function ProjectsForm() {
const { register, control } = useForm({
defaultValues: {
projects: [{ name: '', techStack: [{ name: '' }] }],
},
});
const { fields: projects, append: addProject } = useFieldArray({
control,
name: 'projects',
});
return (
<div>
{projects.map((project, projectIndex) => (
<div key={project.id}>
<input {...register(`projects.${projectIndex}.name`)} />
{/* 중첩된 useFieldArray */}
<TechStackFields
control={control}
nestIndex={projectIndex}
register={register}
/>
</div>
))}
</div>
);
}
function TechStackFields({ control, nestIndex, register }) {
const { fields, append, remove } = useFieldArray({
control,
name: `projects.${nestIndex}.techStack`,
});
return (
<div>
{fields.map((field, index) => (
<input
key={field.id}
{...register(`projects.${nestIndex}.techStack.${index}.name`)}
/>
))}
<button type="button" onClick={() => append({ name: '' })}>
기술 추가
</button>
</div>
);
}
서버 검증 통합
클라이언트 검증만으로는 부족합니다. 이메일 중복 체크, 초대 코드 유효성 등은 서버에서 확인해야 합니다.
setError로 서버 에러 반영
function SignupForm() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm();
const onSubmit = async (data) => {
try {
await api.signup(data);
} catch (err) {
if (err.response?.status === 409) {
// 서버에서 반환한 에러를 특정 필드에 설정
setError('email', {
type: 'server',
message: '이미 사용 중인 이메일입니다',
});
} else if (err.response?.data?.errors) {
// 여러 필드 에러를 한 번에 설정
const serverErrors = err.response.data.errors;
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, { type: 'server', message });
});
} else {
// 전역 에러 (특정 필드와 관련 없는 에러)
setError('root', {
type: 'server',
message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
});
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div className="global-error">{errors.root.message}</div>}
<input {...register('email', { required: '이메일을 입력해주세요' })} />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">가입</button>
</form>
);
}
비동기 필드 검증
<input
{...register('username', {
required: '사용자명을 입력해주세요',
validate: {
// 비동기 검증 — onBlur 모드에서 효과적
checkDuplicate: async (value) => {
const { available } = await api.checkUsername(value);
return available || '이미 사용 중인 사용자명입니다';
},
},
})}
/>
비동기 validate는 매 입력마다 API를 호출할 수 있으니, mode: 'onBlur'와 함께 사용하거나 디바운싱을 적용하는 것이 좋습니다.
폼 상태 영속화
사용자가 긴 폼을 작성하다 실수로 새로고침하면 모든 데이터가 사라집니다. 이를 방지하는 방법입니다.
localStorage 연동
function usePersistForm(key, useFormReturn) {
const { watch, reset } = useFormReturn;
// 저장된 데이터로 폼 초기화
useEffect(() => {
const saved = localStorage.getItem(key);
if (saved) {
try {
reset(JSON.parse(saved));
} catch {
localStorage.removeItem(key);
}
}
}, [key, reset]);
// 값 변경 시 저장 (디바운스 적용)
useEffect(() => {
const subscription = watch((data) => {
const timeoutId = setTimeout(() => {
localStorage.setItem(key, JSON.stringify(data));
}, 500);
return () => clearTimeout(timeoutId);
});
return () => subscription.unsubscribe();
}, [watch, key]);
// 폼 제출 성공 시 저장 데이터 삭제
const clearSaved = () => localStorage.removeItem(key);
return { clearSaved };
}
// 사용
function LongForm() {
const formMethods = useForm({ defaultValues: { /* ... */ } });
const { clearSaved } = usePersistForm('long-form-draft', formMethods);
const onSubmit = async (data) => {
await api.submit(data);
clearSaved(); // 성공 시 임시 저장 삭제
};
return <form onSubmit={formMethods.handleSubmit(onSubmit)}>{/* ... */}</form>;
}
주의할 점
- **민감한 정보 **: 비밀번호, 카드 번호 등은 localStorage에 저장하면 안 됩니다
- ** 데이터 만료 **: 오래된 임시 저장 데이터는 자동 삭제 로직이 필요합니다
- ** 스키마 변경 **: 폼 구조가 바뀌면 이전 저장 데이터가 호환되지 않을 수 있습니다
// 만료 처리 예시
const saved = JSON.parse(localStorage.getItem(key));
if (saved && Date.now() - saved.timestamp < 24 * 60 * 60 * 1000) {
reset(saved.data);
} else {
localStorage.removeItem(key);
}
다단계 폼 + 동적 필드 + 서버 검증 통합 예제
function CompleteSignupForm() {
const [step, setStep] = useState(0);
const methods = useForm({
mode: 'onTouched',
defaultValues: {
email: '',
password: '',
skills: [{ name: '', level: 'beginner' }],
agreeTerms: false,
},
});
const { handleSubmit, trigger, setError, formState: { isSubmitting } } = methods;
const stepsConfig = [
{ fields: ['email', 'password'], component: AccountStep },
{ fields: ['skills'], component: SkillsStep },
{ fields: ['agreeTerms'], component: ConfirmStep },
];
const handleNext = async () => {
const isValid = await trigger(stepsConfig[step].fields);
if (isValid) setStep((s) => s + 1);
};
const onSubmit = async (data) => {
try {
await api.signup(data);
// 성공 처리
} catch (err) {
if (err.response?.data?.field === 'email') {
setStep(0); // 이메일 에러면 첫 단계로 이동
setError('email', { message: err.response.data.message });
}
}
};
const CurrentStep = stepsConfig[step].component;
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<ProgressBar current={step} total={stepsConfig.length} />
<CurrentStep />
<FormNavigation
step={step}
totalSteps={stepsConfig.length}
onPrev={() => setStep((s) => s - 1)}
onNext={handleNext}
isSubmitting={isSubmitting}
/>
</form>
</FormProvider>
);
}
정리
복잡한 폼을 설계할 때 기억할 원칙들입니다.
- **다단계 폼 **: 하나의 useForm으로 전체를 관리하고,
trigger로 단계별 검증합니다 - ** 동적 필드 **:
useFieldArray를 사용하고, key에 반드시field.id를 씁니다 - ** 서버 검증 **:
setError로 서버 에러를 특정 필드에 반영하고,root에러로 전역 에러를 처리합니다 - ** 상태 영속화 **: localStorage에 디바운스로 저장하되, 민감 정보와 만료 처리에 주의합니다
FormProvider와useFormContext를 사용하면 깊은 컴포넌트 트리에서도 props drilling 없이 폼에 접근할 수 있습니다
주의할 점
useFieldArray에서 index를 key로 사용하면 입력값이 꼬임
동적 필드에서 key={index}를 사용하면, 항목을 삭제/삽입할 때 React가 잘못된 DOM과 매칭하여 입력값이 뒤섞입니다. 반드시 key={field.id}를 사용해야 합니다.
서버 검증 에러를 무시하고 클라이언트 검증만 의존
이메일 중복 확인, 쿠폰 유효성 같은 검증은 클라이언트에서 완전히 처리할 수 없습니다. setError로 서버 에러를 특정 필드에 반영하는 패턴을 반드시 구현해야 합니다.
복잡한 폼일수록 미리 전체 구조를 설계하고 시작하는 것이 중요합니다. 필드 목록, 검증 규칙, 단계 흐름을 먼저 정리한 후 코드를 작성해야 합니다.