React Hook Form과 Zod

장운서·2026년 3월 10일

이론

목록 보기
8/8
post-thumbnail

React Hook Form과 Zod를 함께 쓰는 이유

폼 상태와 검증을 분리하면 코드가 왜 편해질까

폼을 만들 때 처음에는 useState만으로도 충분해 보입니다.
하지만 필드가 늘어나기 시작하면 이야기가 달라지죠. 입력값 상태, 에러 상태, 제출 가능 여부, 숫자 변환, 공백 제거, 서버로 보낼 최종 데이터 정리까지 한 컴포넌트 안에서 다 처리해야 하기 때문입니다.

이 시점부터 폼은 단순한 UI가 아니라, “사용자 입력을 유효한 데이터로 바꾸는 경계”가 된다.
저는 이 경계를 더 명확하게 다루기 위해 어떠한 방법이 있을까 생각하다가 React Hook Form과 Zod를 함께 쓰는 방법을 찾아봤습니다. React Hook Form은 폼 상태와 제출 흐름을 관리하고, Zod는 데이터 구조와 검증 규칙을 담당하게 분리할 수 있기 때문입니다. React Hook Form은 공식적으로 resolver를 통해 Zod 같은 외부 검증 라이브러리를 연결할 수 있고, Zod는 스키마 기반 파싱과 타입 추론을 제공합니다.


왜 useState만으로 폼이 점점 버거워질까

예를 들어 회원가입 폼을 생각해봅시다.

  • 이름은 비어 있으면 안 된다.
  • 이메일은 형식이 맞아야 한다.
  • 나이는 숫자여야 한다.
  • 비밀번호는 최소 길이를 가져야 한다.
  • 제출 직전에는 공백 제거 같은 전처리가 필요하다.

이걸 전부 useState로 관리하면, 결국 “입력값 관리 로직”과 “검증 로직”과 “제출 데이터 가공 로직”이 서로 엉키기 쉽습니다.
문제는 이 구조가 커질수록 단순히 귀찮은 수준이 아니라, 어디서 어떤 규칙이 적용되는지 추적하기 어려워진다는 점이죠.

그래서 폼을 만들 때 역할을 둘로 나누면 어떨까하는 부분을 생각해봤습니다.

  • React Hook Form: 입력 등록, 제출, 에러 상태, 폼 상태 관리
  • Zod: 데이터 구조 정의, 검증, 변환, 최종 타입 보장

이 분리가 중요한 이유는 “폼 라이브러리”와 “검증 라이브러리”의 관심사가 애초에 다르기 때문입니다. React Hook Form 공식 문서는 resolver를 통해 외부 검증기를 연결하는 구조를 제공하고, resolvers 저장소는 Zod를 포함한 여러 검증기와의 연동을 안내한다.


React Hook Form과 Zod는 각각 무엇을 맡을까

React Hook Form은 입력 필드를 등록하고, 제출 시점을 관리하고, 에러를 폼 상태에 연결하는 역할에서 강력합니다.
반면 Zod는 “이 데이터가 어떤 구조여야 하는지”를 스키마로 정의하고, 실제 입력값을 검사한 뒤 안전한 결과를 반환하는 데 강합니다. Zod 공식 문서는 스키마를 정의한 뒤 .parse()로 검증하고, 실패를 예외 없이 처리하려면 .safeParse()를 사용할 수 있다고 설명한다.

핵심은 여기서 끝나지 않습니다.
Zod는 스키마로부터 타입을 추론할 수 있고, 특히 transform()이 들어가면 입력 타입과 출력 타입이 달라질 수 있어서 z.input<typeof schema>z.output<typeof schema>를 각각 분리해서 다룰 수 있다. React Hook Form resolvers 저장소도 useForm<Input, Context, Output>() 형태를 보여주며, 스키마로부터 출력 타입을 추론할 수 있다고 안내하고 있습니다.


설치

npm install react-hook-form zod @hookform/resolvers

@hookform/resolvers는 React Hook Form과 외부 검증 라이브러리를 연결해주는 패키지입니다. 공식 저장소에서는 Zod를 포함한 여러 검증기를 지원한다고 설명하고 있습니다.


가장 단순한 예제부터 시작해보기

먼저 이름, 이메일, 나이를 입력받는 폼을 만들어보겠습니다.

1) 스키마 정의

import { z } from "zod";

export const signupSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "이름을 입력해주세요."),

  email: z
    .string()
    .trim()
    .email("올바른 이메일 형식을 입력해주세요."),

  age: z.coerce
    .number()
    .int("나이는 정수여야 합니다.")
    .min(1, "나이는 1 이상이어야 합니다."),
});

export type SignupFormInput = z.input<typeof signupSchema>;
export type SignupFormOutput = z.output<typeof signupSchema>;

여기서 눈여겨볼 부분은 z.coerce.number()입니다.
HTML의 input 값은 대부분 문자열로 다뤄지기 쉽지만, 내가 최종적으로 원하는 값은 숫자일 수 있다. Zod 공식 문서는 z.coerce.* 를 사용해 입력값을 적절한 타입으로 변환할 수 있다고 설명하고 있습니다. 또한 coercion 스키마의 입력 타입은 기본적으로 unknown이며, z.input<typeof schema>로 입력 타입을 따로 다룰 수 있습니다.


2) React Hook Form에 연결

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  signupSchema,
  type SignupFormInput,
  type SignupFormOutput,
} from "./signupSchema";

export default function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isValid },
  } = useForm<SignupFormInput, unknown, SignupFormOutput>({
    resolver: zodResolver(signupSchema),
    mode: "onChange",
    criteriaMode: "all",
    defaultValues: {
      name: "",
      email: "",
      age: undefined,
    },
  });

  const onSubmit = async (data: SignupFormOutput) => {
    console.log("submit data", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">이름</label>
        <input id="name" {...register("name")} />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" type="email" {...register("email")} />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="age">나이</label>
        <input id="age" type="number" {...register("age")} />
        {errors.age && <p>{errors.age.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting || !isValid}>
        가입하기
      </button>
    </form>
  );
}

여기서 중요한 건 zodResolver(signupSchema)로 폼과 스키마를 연결했다는 점입니다. React Hook Form resolvers 저장소는 Zod가 스키마로부터 값을 추론할 수 있고, useForm<Input, Context, Output>() 형태로 입력과 출력 타입을 명시할 수 있다고 안내하고 있습니다. 또 criteriaModefirstError | all을 지원하며, React Hook Form 공식 문서는 기본값이 firstError라고 설명합니다.


이 조합의 핵심은 input과 output을 분리하는 데 있다

많은 글이 React Hook Form과 Zod를 “폼 검증 라이브러리 조합” 정도로 소개하는것같습니다.
하지만 실제로는 그보다 더 중요한 포인트가 있습니다.

바로 사용자 입력값의 타입과 최종적으로 안전하다고 보장된 제출값의 타입이 다를 수 있다는 점입니다.

예를 들어 사용자는 나이 입력창에 "30"을 입력합니다.
브라우저 입장에서는 문자열처럼 들어올 수 있지만, 내가 서버에 보내고 싶은 값은 30이라는 숫자죠.
이 경계를 애매하게 두면 결국 제출 직전에 여기저기서 Number(...)를 하게 되고, 검증과 변환 책임이 분산됩니다.

Zod 공식 문서는 .transform()이 들어가면 입력 타입과 출력 타입이 달라질 수 있다고 설명하며, z.input<typeof schema>z.output<typeof schema>를 따로 추출할 수 있다고 안내하고 있습니다. React Hook Form resolvers 문서 역시 useForm<Input, Context, Output>() 패턴을 제공합니다.

이걸 코드 기준으로 다시 보면 더 분명합니다.

type SignupFormInput = z.input<typeof signupSchema>;
type SignupFormOutput = z.output<typeof signupSchema>;

즉,
SignupFormInput은 “폼이 받는 입력의 관점”이고,
SignupFormOutput은 “검증과 변환을 통과한 뒤 제출 가능한 값의 관점”인것 같습니다.

저는 이 차이를 명시하는 것만으로도 폼 코드가 훨씬 덜 헷갈린다고 느꼇습니다.
특히 숫자, 날짜, trim, 서버 전송용 정규화가 들어가는 폼에서는 더 그렇다고 느껴집니다.


parse보다 safeParse가 유용한 순간

React Hook Form 안에서는 보통 resolver를 쓰기 때문에 직접 parse()를 자주 호출하지 않을 수 있습니다.
하지만 Zod는 폼 외부에서도 충분히 유용합니다.

예를 들어:

  • API 응답을 검증할 때
  • localStorage에서 복원한 값을 확인할 때
  • URL query를 파싱할 때
  • 서버로 보내기 전 데이터 정규화를 다시 확인할 때

이럴 때는 .safeParse()가 특히 편합니다.
Zod 공식 문서에 따르면 .parse()는 실패 시 예외를 던지고, .safeParse()는 성공 여부를 포함한 결과 객체를 반환한다. 실패 시 error, 성공 시 data에 접근할 수 있습니다.

예를 들면 이런 식입니다.

const result = signupSchema.safeParse({
  name: "  장서운  ",
  email: "test@example.com",
  age: "30",
});

if (!result.success) {
  console.error(result.error.issues);
} else {
  console.log(result.data);
  // { name: "장서운", email: "test@example.com", age: 30 }
}

이 코드는 “폼 검증”을 넘어 “입력 경계 검증”으로 Zod를 재사용하는 예시입니다.
개인적으로는 이 재사용성이 Zod의 가장 실용적인 장점 중 하나라고 생각합니다.


커스텀 UI 컴포넌트를 쓸 때는 Controller를 고려해야 한다

기본 input, select처럼 register만으로 연결 가능한 경우도 많지만,
React-Select, MUI, Ant Design 같은 controlled component를 쓰면 Controller가 더 자연스러운 경우가 많습니다.

React Hook Form 공식 문서는 Controller가 React-Select, AntD, MUI 같은 외부 controlled component를 다루기 쉽게 해주는 wrapper라고 설명해줍니다. 또 shouldUnregisteruseFieldArray와 함께 사용할 때 주의하라고 안내한다.

예를 들면 다음처럼 쓸 수 있을것같습니다.

import { Controller, useForm } from "react-hook-form";

type FormValues = {
  category: string;
};

export default function Example() {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: {
      category: "",
    },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="category"
        control={control}
        render={({ field, fieldState }) => (
          <div>
            <select {...field}>
              <option value="">선택해주세요</option>
              <option value="frontend">Frontend</option>
              <option value="backend">Backend</option>
            </select>
            {fieldState.error && <p>{fieldState.error.message}</p>}
          </div>
        )}
      />
      <button type="submit">제출</button>
    </form>
  );
}

모든 필드에 Controller가 필요한 것은 아닙니다.
오히려 기본 HTML input이면 register가 더 단순합니다.
중요한 건 “이 컴포넌트가 uncontrolled하게 연결 가능한가, 아니면 controlled한 래핑이 필요한가”를 구분하는 것입니다.


Zod 4에서 특히 눈여겨볼 점

현재 Zod 공식 문서는 Zod 4를 기준으로 제공되고 있습니다.
Zod 4 릴리즈 노트에 따르면 .transform()은 여전히 유용하지만 출력 타입을 바꿀 수 있고, .overwrite()는 추론 타입을 바꾸지 않는 변환을 표현하기 위해 도입됐습니다. 또 기존의 .trim(), .toLowerCase(), .toUpperCase().overwrite() 기반으로 다시 구현되었다.

이 차이는 실무에서 꽤 중요하다고생각됩니다.

  • 출력 타입이 바뀌는 변환이 필요하면 transform()
  • 타입은 유지한 채 값만 정리하고 싶으면 trim(), toLowerCase() 같은 내장 메서드 혹은 overwrite() 계열 사고방식

예를 들어 문자열 길이로 바꾸면 출력 타입이 숫자가 되므로 transform()이 맞습니다.

const lengthSchema = z.string().transform((value) => value.length);

type LengthInput = z.input<typeof lengthSchema>;   // string
type LengthOutput = z.output<typeof lengthSchema>; // number

이처럼 Zod는 “검증”뿐 아니라 “검증 이후 어떤 타입으로 다뤄질 것인가”까지 함께 표현할 수 있습니다.
그래서 단순 필수값 체크보다, 입력과 도메인 경계를 선명하게 만드는 데 더 큰 의미가 있죠. Zod 공식 문서도 transform() 사용 시 input/output 타입이 달라질 수 있다고 설명하고있습니다.


내가 이 조합을 선호하는 이유

React Hook Form과 Zod를 같이 쓰면 좋은 점은 생각보다 단순합니다.

첫째, 폼 상태와 검증 규칙이 분리된다.
둘째, 제출 데이터의 타입을 더 신뢰할 수 있다.
셋째, 폼 밖에서도 같은 스키마를 재사용할 수 있다.
넷째, 입력값과 최종값이 다른 경우를 코드로 명확하게 표현할 수 있다.

특히 마지막 포인트가 중요합니다.
폼은 결국 “사용자가 막 입력한 값”을 다루는 동시에 “시스템이 신뢰할 수 있는 값”으로 바꾸는 과정이죠.
React Hook Form은 그 흐름을 관리하고, Zod는 그 결과를 정의한다. 이 조합은 공식 문서 기준으로도 자연스러운 연결 방식이라고 생각합니다.


마무리

예전에는 폼을 만들 때 상태 관리와 검증을 한 덩어리로 생각했습니다.
하지만 프로젝트가 커질수록 둘은 분리해서 보는 편이 훨씬 낫다는 걸 느꼈습니다.

React Hook Form은 폼을 다루는 도구고
Zod는 데이터를 다루는 도구라고 생각하시면 편할것 같습니다.

이 둘을 함께 쓰면 단순히 “검증이 편해진다”에서 끝나지 않죠.
입력값이 최종 데이터로 변하는 경계를 더 명확하게 설계할 수 있습니다.

그리고 그 차이가 폼 코드를 오래 버티게 만든다고생각합니다.


참고한 공식 문서

profile
성공을 위해선 과정만 있을 뿐이다

0개의 댓글