zod로 유효성 검사를 선언적으로 관리하기 Thumbnail

복잡한 Form을 잘 작성하려면

9 min read
라이브러리Form

Zod 를 잘 작성하는 방법

zod로 유효성 검사를 선언적으로 관리하기zod로 유효성 검사를 선언적으로 관리하기

최근 10개 이상의 필드를 가진 Form 코드를 작성하게 되면서 zod를 활용할 수 있는 기회가 있었다. 복잡한 Form 코드를 작성할 기회가 많지 않았는데 최근 Form 코드를 구현하면서 복잡한 유효성 검사를 zod로 개선한 경험에 대해서 이야기해보고, 어떻게 유효성 검사를 분리하는 것이 중요할지 생각해보기로 했다.

Zod란?

zod는 일단 Form 라이브러리는 아니고 Validation 라이브러리이다. Form 자체의 입력과 상태 관리는 React Hook Form, Formik, Tanstack Form 등이 있다. zod는 이와는 달리 유효성검사를 선언적으로 할 수 있게 도와주는 라이브러리이다.

선언적으로? 유효성 검사를? 말로 들으면 이해가 잘 되지 않았다. 아래 코드를 보고 쉽게 이해해보자.

const schema = z.object({
  email: z.email("유효한 이메일이 아닙니다."),
  firstName: z.string().trim().min(1, "올바르지 않은 이름입니다."),
  lastName: z.string().trim().min(1, "올바르지 않은 이름입니다."),
  password: z.string().optional(),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "비밀번호가 일치하지 않습니다",
  path: ["confirmPassword"],
});

위 코드는 schema를 정의하는 코드이다. 각 필드명에 대해서 z.email(), z.string() 등 zod 메서드를 사용해서 타입이나 제약조건을 선언적으로 지정할 수 있다. 그리고 또 z.email().min() 형식으로 메서드 체이닝을 활용하면 제약조건을 추가하는 것도 가능하다.

zod에는 다양한 메서드가 있는데 자주 쓰이는 건 길이 검증하는 mim, max 그리고 email() 함수로 쉽게 이메일 형식 검증이 가능하다.

zod의 장점은 선언적이기 때문에 필드의 제약조건을 파악하기 쉽다.

선언적 비교선언적 비교

원래라면 이메일 형식을 검증하기 위해 ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$ 이런 복잡한 정규표현식을 사용하거나 했다. 하지만 이럴 경우 주석처리를 하거나 함수이름을 명확하게 정의하지 않으면 이메일을 검증하는 것인지 바로 인지하기 쉽지 않다고 생각한다. zod에서는 이런 정규표현식을 추상화한 메서드로 형식을 검증할 수 있다.

그리고 zod에 없는 형식을 검증하거나 각 필드 간의 관계를 검증하기 위해서는 .refine 함수를 사용하면 보다 복잡한 유효성 검사를 적용할 수 있다.

다양한 메서드 zod

zod 사용 예시

zod는 아래처럼 사용할 수 있다.

// 1. 스키마 정의
const userSchema = z.object({
  nickname: z.string()
    .trim()
    .min(2, "닉네임은 최소 2자 이상이어야 합니다")
    .max(10, "닉네임은 최대 10자까지 가능합니다"),
  email: z.string().email("올바른 이메일 형식이 아닙니다"),
});

// 2. TypeScript 타입 자동 추론
type UserFormData = z.infer<typeof userSchema>;

// 3. React 컴포넌트에서 사용
const UserRegistrationForm = () => {
  const [formData, setFormData] = useState<Partial<UserFormData>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const result = userSchema.safeParse(formData);
    
    if (!result.success) {
      const formattedErrors: Record<string, string> = {};
      result.error.errors.forEach((err) => {
        formattedErrors[err.path[0]] = err.message;
      });
      setErrors(formattedErrors);
      return;
    }
    
    console.log('검증된 데이터:', result.data);
    // API 호출 등...
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          name="nickname"
          onChange={(e) => setFormData(prev => ({ ...prev, nickname: e.target.value }))}
          placeholder="닉네임"
        />
        {errors.nickname && <span>{errors.nickname}</span>}
      </div>

      <div>
        <input
          name="email"
          onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
          placeholder="이메일"
        />
        {errors.email && <span>{errors.email}</span>}
      </div>

      <button type="submit">가입하기</button>
    </form>
  );
}

zod로 모든 유효성검사를 할 수 있을까?

zod로 모든 유효성 검사를 대체할 수 있을까? zod는 많이 사용되는 유효성검사들을 쉽게 재사용할 수 있다는 강점이 있지만 모든 유효성검사를 대체하기에는 한계가 있다.

zod가 적합한 경우는

  • type을 검증하거나
  • 입력 값의 형식, 길이, 패턴 등을 검증하거나
  • 필드 간 관계를 검증할 때

zod로 하지 않는게 좋은 것

  • 복잡한 비즈니스 로직에 연관된 유효성검사
  • 서버에서 검증해야 하는 것 (중복 이메일 검사 등)

form 코드로 알아보는 로직 분리에 대한 나의 생각

Form 코드를 어떻게 분리하는게 효율적일까? Form 코드는 작성하다보면 매우 쉽게 길어지는 것 같다. Form에 들어가는 여러가지 input 필드들, 복잡한 유효성 검사들, API 요청 코드, 타입 정의 등 많은 부분이 들어가기 때문이다.

일단 나는 Form의 필드 부분은 컴포넌트, 유효성 검사나 submit 핸들러 등 로직 부분을 커스텀 훅으로 분리하는 것을 기본으로 두고 했었던 것 같다.

zod를 쓰게 된다면 위 부분에서 필드 유효성 검사와 타입 정의를 zod schema 레벨로 분리를 할 수 있어서 가독성이 더 좋아지는 것 같다.

결론

Zod는 선언적 유효성 검사의 강력한 도구

TypeScript와의 완벽한 통합으로 타입 안정성 확보 메서드 체이닝으로 복잡한 제약조건도 읽기 쉽게 표현

모든 검증을 Zod로 해결하려 하지 말 것

비동기 검증, 복잡한 비즈니스 로직은 적절한 곳에서 처리 각 검증의 성격에 맞는 방법 선택이 중요

관심사의 분리는 Form에서도 중요

스키마 검증 / 비즈니스 로직 / 서버 검증을 명확히 구분 유지보수 가능하고 테스트하기 쉬운 코드 작성

참고자료

Table of Contents

0
추천 글