ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React hook form과 zod로 form validation 처리
    Dev 2025. 6. 16. 03:27

    react hook form과 zod

    프론트엔드 개발에서 '폼'은 언제나 핵심적인 UI 컴포넌트 중 하나입니다.

    유효성 검사와 상태 관리를 현대적으로, 깔끔하게 해결해주는 도구 두 가지가 바로 React Hook FormZod입니다.

     

    React Hook Form(이하 RHF)은 React에서 성능 중심의 폼 상태 관리를 가능하게 해주는 라이브러리입니다.

    주요 특징은 다음과 같습니다.

    - *Uncontrolled 컴포넌트 기반: 기본적으로 uncontrolled 방식(Form DOM API 중심)을 사용하여 리렌더링을 최소화.

    - *성능 최적화: 입력 필드의 개수가 많아져도 빠른 렌더링 속도를 유지.

    - 작은 번들 크기: react-hook-form의 적은 의존성.

    - 심플한 API: useForm, register, handleSubmit, formState 등의 훅 기반 API를 통해 선언적인 코드 작성 가능.

    - 유효성 검사 유연성: 자체 유효성 검사도 제공하지만, 외부 스키마 기반 유효성 검사 라이브러리와의 손쉬운 통합.

    - Controlled Component
    인풋의 값(value)이 React state에 의해 제어되는 컴포넌트.
    const [name, setName] = useState('');
    
    return <input value={name} onChange={(e) => setName(e.target.value)} />

     

    value가 상태로 관리되며 변경마다 리렌더 발생.
    상태의 추적과 제어가 완전할 수 있지만 고빈도 입력 상황에서 성능 저하가 우려된다.


    - Uncontrolled Component
    인풋의 값이 DOM에 의해 자체적으로 관리되며, 필요할 때만 React가 접근하는 방식.
    const inputRef = useRef();
    
    return <input ref={inputRef} />​

     

    입력마다 리렌더가 발생하지 않아 성능이 다소 우수하지만, 상태 추적이 어렵고 동적 제어의 한계가 존재한다.

    RHF는 기본적으로 Uncontrolled 접근을 기반으로 설계되었습니다.
    RHF는 register()로 input을 DOM에 직접 연결하고, ref를 통해 native input의 상태를 추적합니다.
    이를 통해 폼을 대규모로 쓰는 상황에서도 퍼포먼스를 안정적으로 유지할 수 있게 해줍니다.

     

     

    Zod타입스크립트 친화적인 스키마 기반의 유효성 검사 라이브러리입니다.

    - TypeScript 우선 철학: 스키마를 정의하면, 그에 따른 타입이 자동으로 생성.

    - *런타임 타입 체크: TS는 컴파일 타임에만 타입 체크를 하지만, Zod는 런타임에서도 실제로 타입이 맞는지 검증 가능.

    - 구조화된 에러 메시지: 에러 메시지가 구조화되어 있어 폼 UI에 연결이 용이.

    - 체이닝 방식의 API: .min(), .max(), .email() 같은 메서드를 체이닝으로 연결할 수 있어 직관적.

    런타입 타입 체크
    런타임(브라우저 또는 Node.js에서 실행될 때)에는 타입 정보가 사라지므로 API로부터 받는 응답 객체, localStorage, form input, query string, external JSON 파일 등의 구조를 파악할 수 없다.

    Zod는 TypeScript의 타입을 대신하는 게 아니라 런타임에서 실제 값이 타입 구조에 맞는지 검증하는 도구.
    import { z } from 'zod';
    
    const User = z.object({
      name: z.string(),
      age: z.number(),
    });
    
    const json = '{"name": "Alice", "age": "not-a-number"}';
    const data = JSON.parse(json);
    
    const result = User.safeParse(data);
    
    if (!result.success) {
      console.error(result.error);  // 오류를 구조화된 형태로 확인 가능
    } else {
      const user = result.data;
      console.log(user.age.toFixed(1));  // ✅
    }​

    - User.safeParse(data)를 통해 JSON 데이터의 타입 구조를 직접 검사
    - age가 number 타입인지 실제로 체크
    - 실패할 경우 구조화된 에러 리포트 반환 (ZodError)
    - 성공할 경우 타입이 보장된 상태로 접근 가능 (result.data는 타입 추론도 자동 적용)

    Zod는 “들어오는 값”이 우리가 생각하는 타입에 맞는지를 런타임에서 직접 확인할 수 있게 해주는 툴입니다.

     

    이처럼 RHF는 폼 상태 관리에 강점이 있고, Zod는 데이터 구조와 유효성 검증에 특화되어 있어 시너지를 얻을 수 있습니다.

     

     

    사용 예시

    import { useForm } from "react-hook-form";
    import { z } from "zod";
    import { zodResolver } from "@hookform/resolvers/zod";
    
    // ⚠️ 유효성 검사 스키마
    const schema = z.object({
      email: z.string().email(),
      password: z.string().min(6),
    });
    
    type FormData = z.infer<typeof schema>;
    
    export default function LoginForm() {
      const {
        register,
        handleSubmit,
        formState: { errors },
      } = useForm<FormData>({
        resolver: zodResolver(schema),  // ⚠️ Zod 스키마를 RHF에 연결
      });
    
      const onSubmit = (data: FormData) => {
        console.log("✅ 성공", data);
      };
    
      return (
        <form onSubmit={handleSubmit(onSubmit)}>
          <input type="email" placeholder="Email" {...register("email")} />  // ⚠️ 폼 필드를 RHF에 등록
          {errors.email && <p>{errors.email.message}</p>}
    
          <input type="password" placeholder="Password" {...register("password")} />
          {errors.password && <p>{errors.password.message}</p>}
    
          <button type="submit">Login</button>
        </form>
      );
    }

     

     

    '회원가입 페이지'를 예시로 하는 실질적인 form 제어

     

    회원가입 페이지를 예시로하여 7개의 RHF+zod를 사용해 제어합니다.

    다만, form 제어의 특수성을 확인 자체를 주 목적으로 하기 때문에 필요하다고 여겨질 수 있는 기능의 부재가 존재할 수 있습니다.

    (ex. 이메일 인증을 통한 유효성 확인 or 전화번호인증)

     

    1. 이름

    - 불완전 한글 문자 제어

    name: z
      .string()
      .min(2, "이름을 입력해주세요")  // 한글자 이름은 존재하지 않음.
      .regex(/^[가-힣\s]*$/, {  // 정규식 한글 범위 제한으로 조합되지 않은 불완전한 한글(ㄱ, ㅏ 등)은 입력되지 않도록 처리
        message: "완전한 문자를 입력해주세요",
      }),

     

    - 영문 입력 제어

    <CustomInput
      maxLength={7}  // 1993년 2월 25일 이후 출생자가 가질 수 있는 가장 긴 이름은 7자
      // ...
      {...register("name", {
        onChange: formatKoreanHandler("name", setValue),
      })}
      errorMessage={errors.name?.message}
    />
    import { debounce } from "./debounce";
    import { UseFormSetValue } from "react-hook-form";
    
    export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
      let timeout: ReturnType<typeof setTimeout>;
      return (...args: Parameters<T>) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => fn(...args), delay);
      };
    }
    
    export const formatKoreanHandler = (
      fieldName: string,
      setValue: UseFormSetValue<any>
    ) => {
      const debouncedSetValue = debounce((value: string) => {
        setValue(fieldName, value);
      }, 200);
    
      return (e: React.ChangeEvent<HTMLInputElement>) => {
        const currentValue = e.target.value;
        const koreanOnlyValue = currentValue.replace(/[^ㄱ-ㅎㅏ-ㅣ가-힣\s]/g, "");
    
        if (currentValue !== koreanOnlyValue) debouncedSetValue(koreanOnlyValue);
        if (currentValue === koreanOnlyValue) setValue(fieldName, koreanOnlyValue);
      };
    };

    영문, 숫자, 특수문자의 입력에 대해서 0.2초의 딜레이를 두어 부드러운 UX로 input의 입력을 제어.

     

    ⚠️ RHF의 setValue로 폼 상태를 업데이트 하여 유효성 검사 및 리렌더링을 관리할 수 있게 함. (react-hook-form: setValue)

     

    2. 닉네임

    - 중복 확인

    .superRefine((data, ctx) => {
      // nickname 중복확인 체크
      if (!isNicknameVerified) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "중복 확인을 해주세요",
          path: ["nickname"],
        });
      }
    })
    const handleVerifyNickname = async (nickname: string) => {
      const response = await AuthApi.verifyNickname(nickname);
    
      if (response.success && /^[가-힣a-zA-Z0-9]+$/.test(nickname)) {
        setIsNicknameVerified(true);
        setError("nickname", {
          message: "",
        });
      } else {
        setIsNicknameVerified(false);
        setError("nickname", {
          message: "사용 불가능한 별명입니다.",
        });
      }
    };

    isNicknameVerified는 동일한 파일 스코프에 존재하는 react state.

    임의의 버튼에 종속된 handleVerifyNickname을 통해 setIsNicknameVerified가 수행.

     

    ⚠️ superRefine()은 가장 마지막에 호출되는 커스텀 유효성 검사.
    따라서 보다 이전에 정의된 폼 제어에 필터링되어 버렸다면 superRefine에 정의된 에러 메시지는 검출될 수 없다.

     

    3. 프로필 사진

    - 확장자, 용량 제어

    profileImage: z
      .instanceof(File, {
        message: "프로필 사진을 선택해주세요",
      })
      .refine(
        (file) => file.type === "image/png" || file.type === "image/jpeg",
        {
          message: "jpg, png 파일만 업로드 가능합니다.",
        }
      )
      .refine((file) => file.size <= 1024 * 1024 * 5, {
        message: "5MB 이하의 파일만 업로드 가능합니다.",
      }),

     

    4. 이메일

    - 이메일 유효성

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

    zod 내장 메서드 체이닝 API

     

    5. 전화번호

    - 전화번호 유효성

    phone: z
      .string()
      .min(13, "전화번호를 입력해주세요")
      .regex(/^01([0|1|6|7|8|9])-?([0-9]{4})-?([0-9]{4})$/, {
        message: "올바른 전화번호를 입력해주세요",
      }),

     

    - 전화번호 구분자

    <CustomInput
      // ...
      {...register("phone", {
        onChange: (e) => {
          e.target.value = formatPhoneNumberHandler(e.target.value);
        },
      })}
      errorMessage={errors.phone?.message}
    />
    export const formatPhoneNumberHandler = (value: string) => {
      const numbers = value.replace(/[^0-9]/g, "");
      const limitedNumbers = numbers.slice(0, 11);
    
      if (limitedNumbers.length <= 3) return limitedNumbers;
      if (limitedNumbers.length <= 7)
        return `${limitedNumbers.slice(0, 3)}-${limitedNumbers.slice(3)}`;
      return `${limitedNumbers.slice(0, 3)}-${limitedNumbers.slice(
        3,
        7
      )}-${limitedNumbers.slice(7)}`;
    };

     

    6. 비밀번호 / 비밀번호 확인

    - 비밀번호 유효성

     

    password: z
      .string()
      .min(8, "8자 이상의 비밀번호를 입력해주세요")
      .max(16, "16자 이하의 비밀번호를 입력해주세요")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,16}$/,
        {
          message: "영문, 숫자, 특수문자를 포함하여 8-16자로 입력해주세요",
        }
      ),
    passwordConfirm: z.string().min(1, "비밀번호 확인을 입력해주세요"),
    .superRefine((data, ctx) => {
      if (data.password !== data.passwordConfirm) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "비밀번호가 일치하지 않습니다",
          path: ["passwordConfirm"],
        });
      }
    })
    <CustomInput
      // ...
      maxLength={20}
      {...register("password")}
      errorMessage={errors.password?.message}
    />

     

     

    ⚠️ 16자 이내의 제한이지만, type이 password인 input의 입력은 사용자가 입력해온 길이를 가늠하기 힘들어 보다 여유롭게 설정.

     

    7. 약관 동의

    - 약관 유효성

    isAgree: z.boolean().refine((isAgree) => isAgree, {
      message: "약관 동의를 해주세요",
    }),

     

    작성된 코드는 모두 https://github.com/dltkdals224/boilerplate-code에서 확인 가능합니다.

    'Dev' 카테고리의 다른 글

    프론트엔드 깃허브 액션 CI 시스템을 이용한 버전관리  (3) 2025.07.17
    tabIndex의 진실  (0) 2024.07.25
    이전 페이지의 URL  (0) 2023.03.27
    json-server 라이브러리  (0) 2022.12.30
    SEO와 Next.js  (0) 2022.11.15
Designed by Tistory.