React Hook Form, Zod로 Form 관리하기

React Hook Form, Zod로 Form 관리하기

들어가며

입력 항목이 매우 많은 다단계 폼은 그 자체로 유지보수가 까다로울 뿐만 아니라, 임시 저장, 초기값 설정 등의 기능이 조합되면 매우 빠른 속도로 복잡해집니다. 따라서 form을 다루는 데 있어서 효율적인 '전략'이 필요합니다. 본 글에서는 TypeScript, React 환경에서 react-hook-form(RHF), Zod, Jotai를 활용하여 복잡한 폼을 전략적으로 관리하는 방법을 정리해보겠습니다.

복잡한 폼은 언제부터 망가지기 시작하는가?

폼이 거대해질수록 아래와 같은 문제가 발생하게 됩니다.

1. 컴포넌트가 거대해진다.

- 입력 필드, 검증 로직, 조건부 렌더링, 에러 처리 로직이 증가하며 해당 폼을 관리하는 컴포넌트가 기하급수적으로 커지게 됩니다.

2. 값 유실 또는 불필요한 데이터가 생긴다.

- 조건부 필드가 있는 경우에는 현재 조건과 맞지 않는 필드의 값이 유실되거나 불필요하게 저장됩니다.

3. 시점이 다양할수록 구조가 급격히 복잡해진다.

- 임시 저장 기능이 있는 경우 또는 조건부 필드가 여러 개인 경우, 값이 저장되거나 변경되는 시점에 따라 매우 다양한 경우의 수를 처리해야 하므로 코드가 급격히 복잡해집니다.

관리 전략 설정

위와 같은 문제들을 단순히 일대일로 처리하는 것이 아닌, 효율적인 관리 원칙을 설정하여 이에 맞게 대응하고자 했습니다. 제가 설정한 전략은 아래와 같습니다.

1. 중복되는 로직을 최소화하여 전체 코드를 단순화한다

2. 조건이 변경되면 불필요한 조건부 필드의 값은 초기화한다.

3. 조건값과 시점을 기준으로 대응 로직을 구조화한다.

4. 단계별로 입력값 검증을 하되 최종 저장 시 전체 검증을 한 번 더 수행한다.

전략의 구체화

(1) 공통 타입 정의

// schemas/common.ts
import { z } from "zod";

export const MemberType = z.enum(["personal", "employee", "freelancer", "business"]);
export type MemberType = z.infer<typeof MemberType>;

export const basicProfileSchema = z.object({
  nickname: z.string().min(2, "닉네임은 2자 이상이어야 합니다.").max(20),
  ...
});

(2) 단계별 코드를 분리하고 RHF의 extend(), Zod의 discriminatedUnion을 활용

// schemas/steps/job.schema.ts
import { z } from "zod";
import { MemberType } from "../common";

const baseJob = z.object({
  memberType: MemberType,
  ...
});

const employeeJob = baseJob.extend({
  memberType: z.literal("employee"),
  ...
});

const freelancerJob = baseJob.extend({
  memberType: z.literal("freelancer"),
  ...
});

const businessJob = baseJob.extend({
  memberType: z.literal("business"),
  ...
});

const personalJob = baseJob.extend({
  memberType: z.literal("personal"),
  ...
});

export const jobStepSchema = z.discriminatedUnion("memberType", [
  personalJob,
  employeeJob,
  freelancerJob,
  businessJob,
]);

export type JobStepForm = z.infer<typeof jobStepSchema>;

(3) full 모델로 조합

// schemas/profile.schema.ts
import { z } from "zod";
import { basicProfileSchema } from "./common";
import { jobStepSchema } from "./steps/job.schema";

export const profileSchema = z.object({
  profile: basicProfileSchema,
  job: jobStepSchema,
  ...
});

export type ProfileForm = z.infer<typeof profileSchema>;

이때, 필요하면 Zod의 refine(), superRefine() 함수를 이용하여 값을 검증하며, Jotai로 현재 단계와 임시 저장값을 저장합니다.

컴포넌트 구현 예시

컴포넌트는 아래와 같이 구현합니다. 임시 저장값이 있는 경우 초기 세팅을 하고, 임시 저장 및 현재/완료 단계의 저장은 현재 단계의 form이 submit되는 경우에 실행합니다.

// steps/Job.tsx
import React, { useEffect } from "react";
import { useAtom } from "jotai";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { draftAtom, completedStepsAtom, currentStepAtom } from "../state/profileWizard.atoms";
import { mergeDraft } from "../state/mergeDraft";
import { jobStepSchema, type JobStepForm } from "../schemas/steps/Job.schema";

export function Job() {
  const [draft, setDraft] = useAtom(draftAtom); // 임시 저장값 atom
  const [completedSteps, setCompletedSteps] = useAtom(completedStepsAtom); // 완료된 단계를 저장하는 atom
  const [currentStep, setCurrentStep] = useAtom(currentStepAtom); // 현재 단계를 나타내는 atom

  const form = useForm<JobStepForm>({
    resolver: zodResolver(jobStepSchema),
    defaultValues: {
      ...
    },
  });
  const memberType = form.watch("memberType");

  const handleSubmit = (values) => {
    // 1) 현재 스텝 값을 draft에 저장
    setDraft(mergeDraft(draft, { job: values }));

    // 2) 스텝 완료 처리
    const nextCompleted = new Set(completedSteps);
    nextCompleted.add(4); setCompletedSteps(nextCompleted);

    // 3) 다음 스텝 이동
    setCurrentStep((currentStep + 1) as any);
  }

  // 스텝 진입 시 임시 저장값으로 초기화
  useEffect(() => {
    if (!!draft?.job) {
      form.reset(draft.job);
    }
  }, []);

  // 조건 변경 시 불필요한 조건부 필드의 값 초기화 (관리 전략 2)
  useEffect(() => {
    const base = form.getValues();

    if (memberType === "employee") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "freelancer") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "business") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }

    if (memberType === "personal") {
      form.reset({
        memberType,
        role: base.role,
        ...
      } as JobStepForm);
    }
  }, [memberType]);

  return (
    ...
  )
};

마치며

위와 같이 간략한 예시 코드를 통해 복잡한 폼을 다루기 위한 전략 설정과 적용에 대해 알아보았습니다. 이처럼 기본 원칙과 전략을 먼저 설정함으로써 폼을 더욱 효과적으로 컨트롤할 수 있을 뿐만 아니라 개발 및 유지보수 시간도 큰 폭으로 줄일 수 있었습니다. 좋은 품질의 코드는 적절한 정책을 통해서도 나올 수 있다는 것을 알게 된 경험이었습니다.

May

Site footer