회원가입 폼이 3단계로 나뉘어 있고, 각 단계에서 필드가 동적으로 추가되며, 서버에서도 검증을 해야 한다면 어떻게 설계할까요?

단순한 로그인 폼은 input 2개면 끝이지만, 실무에서는 훨씬 복잡한 폼을 만들어야 할 때가 많습니다. 다단계 위저드, 동적으로 추가/삭제되는 필드, 클라이언트와 서버 양쪽의 검증, 새로고침 시 데이터 보존까지. 이런 복잡한 폼을 어떻게 설계할 수 있는지 패턴별로 정리합니다.

다단계 폼 (Multi-step Form)

기본 구조

JSX
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이 내부에 값을 유지하므로 별도 처리가 불필요합니다

단계별 컴포넌트 분리

JSX
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 활용

JSX
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를 쓰면 안 되는 이유

JSX
// 잘못된 방법
{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을 재사용하여 입력값이 다른 항목에 표시될 수 있습니다.

중첩 동적 필드

JSX
// 프로젝트 → 각 프로젝트의 기술 스택 (중첩 배열)
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로 서버 에러 반영

JSX
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>
  );
}

비동기 필드 검증

JSX
<input
  {...register('username', {
    required: '사용자명을 입력해주세요',
    validate: {
      // 비동기 검증 — onBlur 모드에서 효과적
      checkDuplicate: async (value) => {
        const { available } = await api.checkUsername(value);
        return available || '이미 사용 중인 사용자명입니다';
      },
    },
  })}
/>

비동기 validate는 매 입력마다 API를 호출할 수 있으니, mode: 'onBlur'와 함께 사용하거나 디바운싱을 적용하는 것이 좋습니다.

폼 상태 영속화

사용자가 긴 폼을 작성하다 실수로 새로고침하면 모든 데이터가 사라집니다. 이를 방지하는 방법입니다.

localStorage 연동

JSX
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에 저장하면 안 됩니다
  • ** 데이터 만료 **: 오래된 임시 저장 데이터는 자동 삭제 로직이 필요합니다
  • ** 스키마 변경 **: 폼 구조가 바뀌면 이전 저장 데이터가 호환되지 않을 수 있습니다
JSX
// 만료 처리 예시
const saved = JSON.parse(localStorage.getItem(key));
if (saved && Date.now() - saved.timestamp < 24 * 60 * 60 * 1000) {
  reset(saved.data);
} else {
  localStorage.removeItem(key);
}

다단계 폼 + 동적 필드 + 서버 검증 통합 예제

JSX
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에 디바운스로 저장하되, 민감 정보와 만료 처리에 주의합니다
  • FormProvideruseFormContext를 사용하면 깊은 컴포넌트 트리에서도 props drilling 없이 폼에 접근할 수 있습니다

주의할 점

useFieldArray에서 index를 key로 사용하면 입력값이 꼬임

동적 필드에서 key={index}를 사용하면, 항목을 삭제/삽입할 때 React가 잘못된 DOM과 매칭하여 입력값이 뒤섞입니다. 반드시 key={field.id}를 사용해야 합니다.

서버 검증 에러를 무시하고 클라이언트 검증만 의존

이메일 중복 확인, 쿠폰 유효성 같은 검증은 클라이언트에서 완전히 처리할 수 없습니다. setError로 서버 에러를 특정 필드에 반영하는 패턴을 반드시 구현해야 합니다.

복잡한 폼일수록 미리 전체 구조를 설계하고 시작하는 것이 중요합니다. 필드 목록, 검증 규칙, 단계 흐름을 먼저 정리한 후 코드를 작성해야 합니다.

댓글 로딩 중...