React Hook Form과 Zod를 통한 Form Validation 처리

기운찬곰·2023년 6월 16일
23

프론트개발이모저모

목록 보기
14/20
post-thumbnail

Overview

이번에는 어떤 블로그 글을 써볼까 하다가 Zod가 눈에 들어왔습니다. 해외 개발 블로그 보다보면 Zod에 대한 영상이 꽤나 보이는데 한번 해보면 재밌을거 같다는 생각이 들었습니다. 이전 시간에 했던 React Hook Form과 연계해서 Zod를 통한 Form Validation 처리를 하면 좋을 거 같았습니다.


Zod는 무엇이고 왜 사용하는가

Zod 란?

Zod에 대한 공식문서 소개를 보면 다음과 같습니다.

TypeScript-first schema validation with static type inference. - 공식문서

여기서 schema란 단순 문자열에서 복잡한 중첩 객체에 이르기까지 모든 데이터 유형을 광범위하게 지칭하기 위해 사용된 용어라고 합니다.

Zod는 최대한 개발자 친화적으로 설계되었습니다. 중복 타입 선언을 제거하는 것이 목표입니다. Zod를 사용하여 validator를 한 번만 선언하면 Zod가 정적 TypeScript 유형을 자동으로 추론(infer)합니다.

또한, 몇가지 훌륭한 특성들을 나열해보면 다음과 같습니다.

  • Zero dependencies
  • Node.js와 브라우저 환경에서 동작 가능하다.
  • Tiny size : 8kb minified + zipped
  • Immutable : methods (ex. optional())은 새 인스턴스를 리턴한다
  • 간결하고 chainable interface이다.
  • 그냥 자바스크립트에서도 작동한다. 타입스크립트를 사용할 필요없다.

사용하기 위한 필수 조건은 다음과 같습니다.

  • 타입스크립트 4.5 이상
  • tsconfig.json에서 strict 는 항상 true여야 한다.

간단 사용법

예시를 보면 사용하기 쉬운편인 것을 알 수 있습니다.

형식에 맞는 스크마 인스턴스를 생성하고, parse를 통해 검증할 수 있고, infer를 통해 자동 타입 추론이 가능합니다.

import { z } from "zod";

// creating a schema for strings
const mySchema = z.string();

// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError

// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }

또한, 복잡한 데이터 형식을 객체로 묶어서 스키마를 생성할 수 있습니다.

import { z } from "zod";

const User = z.object({
  username: z.string(),
});

User.parse({ username: "Ludwig" });

// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }

왜 사용하는가

일단, 가장 큰 사용 이유로는 validation이 편하다는 겁니다. Zod를 사용하지 않았을 경우, form control 마다 하나씩 검사하고, if 문으로 체크해야 할 것입니다.

참고 : https://youtu.be/9UVPk0Ulm6U

Zod를 사용하면 간단하고 가독성이 좋아집니다.


TypeScript와 Zod 와의 관계

참고 : Fixing TypeScript's Blindspot: Runtime Typechecking

TypeScript는 런타임 타입 체킹은 못한다.

타입스크립는 만능일까요? super safe 할까요? 그렇지 않습니다. TypeScript는 컴파일 시에만 타입을 검사합니다. 따라서 데이터 요청 시에 어떤 형식이 넘어올 거다 라고 명시는 해놓고 사용하지만 실제 그 형식대로 넘어오지 않을 수도 있습니다. 타입스크립트는 런타입 타입 체킹이 아니기 때문입니다.

예를 들어, data.json에 아무런 데이터가 없습니다. 하지만 타입스크립트는 에러를 발생시키지 않습니다.

// data.json
{}
// index.ts
import fs from "fs";

interface Result {
  results: {
    id: number;
    name: string;
    job: string;
  }[];
}

const printJobs = (results: Result) => {
  results.results.forEach(({ job }) => {
    console.log(job);
  });
};

const data: Result = JSON.parse(fs.readFileSync("data.json", "utf-8"));
printJobs(data);

물론, 에러를 피하기 위해 ?(옵셔널 체이닝) 를 사용해서 해결할 수 있습니다.

const printJobs = (results: Result) => {
  results?.results?.forEach(({ job }) => {
    console.log(job);
  });
};

Zod가 해당 부분을 메워줄 수 있다.

Zod를 사용해서 런타임 체킹이 가능합니다. 여기서는 safeParse를 사용했습니다.

import fs from "fs";
import { z } from "zod";

const ResultSchema = z.object({
  results: z.array(
    z.object({
      id: z.number().min(100),
      name: z.string(),
      job: z.string(),
    })
  ),
});

type Result = z.infer<typeof ResultSchema>;

const printJobs = (results: Result) => {
  if (ResultSchema.safeParse(results).success) {
    results?.results?.forEach(({ job }) => {
      console.log(job);
    });
  } else {
    console.log("Bad data");
  }
};

const data: Result = JSON.parse(fs.readFileSync("data.json", "utf-8"));
printJobs(data);

사실 Zod 말고도 Yup, Joi 도 있습니다. 특이하게도 다 3글자이네요. 사용법은 조금씩 다릅니다. 근데 저는 그중에서 Zod가 가장 편한거 같습니다.


React Hook Form과 Zod를 통한 Form Validation 처리

요구사항 정의

참고 : https://dealicious-inc.github.io/2022/07/25/ss-studio.html
디자인 참고 : https://sinsangstudio.com/auth/sign-up

참고로, 저는 디자인을 잘 못해서... 마침 좋은 '신상스튜디오' 라는 서비스 회원가입 폼이 있길래 따라해봤습니다..ㅎ..ㅎ

간단하게 회원가입 폼을 만들건데, 이메일, 아이디, 비밀번호, 비밀번호 확인, 추천코드, 마지막으로 약관 동의까지 해보도록 하겠습니다. 각 form control에 대한 유효성 조건은 다음과 같습니다.

  • 이메일 : 이메일 형식일것 (필수)
  • 아이디 : 영문 소문자 또는 영문+숫자 조합 4~30자리 일 것(필수)
  • 비밀번호 : 영문+숫자+특수문자(! @ # $ % & * ?) 조합 8~15자리 (필수)
  • 비밀번호 확인 : 앞선 비밀번호와 동일하게 입력할 것 (필수)
  • 추천 코드 : 입력하지 않아도 됨. 근데 입력할 경우 소문자로만 입력할 것

약관 동의는 필수 3가지에 동의해야 합니다. 나머지 마케팅 수신 동의는 선택이며 SMS, 이메일 선택할 수 있습니다. 약관 동의 내용도 submit 이벤트 시 서버로 데이터 전송을 한다고 가정하겠습니다.

react-hook-form resolvers

참고 : https://github.com/react-hook-form/resolvers

다양한 Validation resolvers가 있는데 Zod를 설치할 때 같이 설치해줍니다.

pnpm add zod @hookform/resolvers

그리고 이런식으로 useForm에 resolver를 넣어서 사용하면 됩니다.

  const {
    register,
    handleSubmit,
    setValue,
    formState: { errors },
  } = useForm<RegisterSchemaType>({
    resolver: zodResolver(registerSchema),
  });

registerSchema 스키마 정의

registerSchema는 아래처럼 정의했습니다.

import { z } from "zod";

export type RegisterSchemaType = z.infer<typeof registerSchema>; // 타입 추론 자동

export const registerSchema = z
  .object({
    email: z
      .string()
      .nonempty("이메일을 입력해주세요.")
      .email("이메일 형식을 입력해주세요."),
    userId: z
      .string()
      .nonempty("아이디를 입력해주세요.")
      .regex(
        /^[a-z0-9]{4,30}$/,
        "영문 소문자 또는 영문+숫자 조합 4~30자리를 입력해주세요."
      ),
    password: z
      .string()
      .nonempty("비밀번호를 입력해주세요.")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$/,
        "영문+숫자+특수문자(! @ # $ % & * ?) 조합 8~15자리를 입력해주세요."
      ),
    passwordCheck: z.string().nonempty("비밀번호를 다시 입력해주세요."),
    recommendationCode: z
      .string()
      .regex(/^[a-z]{0,}$/, "추천코드는 소문자로 입력 가능합니다")
      .optional(),
    agree: z.string(),
  })
  .refine((data) => data.password === data.passwordCheck, {
    path: ["passwordCheck"],
    message: "비밀번호가 일치하지 않습니다.",
  });

위와같이 체이닝을 통해 검증을 추가할 수 있고, 검증함수마다 에러 메시지를 정의해줄 수 있었습니다. nonempty를 통해 필수값에 대한 처리를 해줬고, 필수가 아니면 optional을 사용해줬습니다.

Ref 에러 상황

⚠️ Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

중간에 Ref 에러 상황을 마주하게 되었습니다. 이에 대해 찾아보니 저는 단순히 input을 컴포넌트로 분리하고 register를 넘겨주려고 했습니다. 하지만 {...register("~")} 에 대해서 살펴보니 4가지 속성을 가지고 있었고, 그 중 하나가 ref였습니다.

<input {...register("firstName"}/>

또는...

const {onChange, onBlur, name, ref} = register("firstName");

<input 
 onChange={onChange}
 onBlur={onBlur}
 name={name}
 ref={ref}
/>

결국에 그냥 ref를 넘겨주려다가 에러가 발생한거였고, 그래서 forwardRef를 사용해주면 되었습니다.

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ id, onChange, ...rest }, ref) => {
    const [value, setValue] = React.useState("");

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      setValue(e.target.value);
    };

    return (
      <div className="relative">
        <input
          type="text"
          id={id}
          className="w-full h-[44px] border-[1px] border-gray-40 rounded-[4px] px-[16px]"
          value={value}
          onChange={(e) => {
            onChange!(e);
            handleChange(e);
          }}
          ref={ref}
          {...rest}
        />

        {value && (
          <button type="button">
            <AiFillCloseCircle className="text-gray-300 absolute right-[15px] top-[50%] -translate-y-[50%]" />
          </button>
        )}
      </div>
    );
  }
);

Input.displayName = "Input";

export default Input;

아, 그리고 onChange에 대해서도 할 말이 있는데 처음에 그냥 input 내부적으로 state와 onChange를 사용하려고 했을때 이상하게 input이 아무 반응이 없더군요. 이게 알고보니 register에서 넘어온 onChange와 내부 정의 onChange를 같이 사용해야 하더군요. 그래서 위에 처럼 handleChange와 onChange를 같이 사용했습니다. - 스택오버플오우 참고

약관 동의 처리

제일 문제는 약관 동의 처리... 그냥 내부적으로 상태 변경만 해줄지 아니면 react hook form과 연동해서 서버로 데이터 전송까지 할지.. 고민이 되었습니다. 그러다가 그냥 내부적으로 상태 변경하고, 마지막 전송 시에 react hook form과 연동하는걸로 했습니다.

그래서 저는 agree 상태 변화 시에 react hook form에 setValue를 사용해서 agree 데이터를 추가해주었습니다.

// 필수 동의 체크 되면 버튼 활성화
  React.useEffect(() => {
    setValue("agree", JSON.stringify(agree)); // 추가

    if (agree.personalInfo && agree.chanTerms && agree.chanPolicy) {
      setSubmitBtnDisabled(false);
    } else {
      setSubmitBtnDisabled(true);
    }
  }, [agree, setSubmitBtnDisabled, setValue]);

이렇게 하면 form submit 시에 agree 데이터도 잘 들어가게 될 것입니다.

소스 코드 참고 : https://github.com/ckstn0777/zod-form-practice

최종 결과 : https://zod-form-practice.vercel.app/


마치면서

오랜만에 뭔가 제대로 만들어보는 느낌이 들어서 재밌었던거 같습니다. 이번 시간에서 가장 중요한 건 Zod가 왜 사용되는지, 그리고 타입스크립트는 만능이 아니라는 점입니다. 이걸 제대로 알고 Zod를 사용하면 좋겠습니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

2개의 댓글

comment-user-thumbnail
2024년 9월 25일

좋은글 감사합니다.

답글 달기
comment-user-thumbnail
2024년 11월 9일

useEffect를 사용하지 않는 방법도 소개해주시면 좋을 것 같습니다!

답글 달기