07. Zod 통합 (Validation)

2026년 4월 30일 수정됨

📋 개요

API의 안정성을 책임지는 스키마 검증 심화 가이드입니다. 에러 메시지 커스터마이징부터 복합 검증 로직까지 다룹니다.

📋 목차

API의 안정성을 책임지는 스키마 검증 심화 가이드입니다. 에러 메시지 커스터마이징부터 복합 검증 로직까지 다룹니다.


🛠️ 1. 스키마 디테일 설정 (Custom Messages)

자동 생성된 스키마에 "친절한 에러 메시지" 를 심어야 합니다. 사용자에게 "Invalid Type"이라고 보여줄 순 없으니까요.

import { createInsertSchema } from 'drizzle-zod';
 
export const insertUserSchema = createInsertSchema(users, {
  // 이메일 필드 커스터마이징
  email: (schema) => schema.email({ message: "올바른 이메일 형식이 아닙니다." }),
 
  // 패스워드: 길이 제한 + 메시지
  password: (schema) => schema.min(8, { message: "비밀번호는 8자 이상이어야 합니다." }),
 
  // 선택적 필드 처리 (빈 문자열 -> null 처리 등)
  bio: (schema) => schema.max(500).optional().or(z.literal('')),
});

🛡️ 2. 복합 검증 로직 (Refine)

단일 필드를 넘어, 여러 필드 간의 관계를 검증할 때 refine이나 superRefine을 사용합니다.

// 비밀번호 확인 로직 추가
export const signupSchema = insertUserSchema
  .extend({
    confirmPassword: z.string(), // DB에는 없지만 폼에는 있는 필드
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "비밀번호가 서로 일치하지 않습니다.",
    path: ["confirmPassword"], // 에러를 표시할 필드 위치
  });

⚙️ 3. 형변환 (Coercion)

Query String이나 Form Data는 모든 게 문자열로 들어옵니다. 이를 알맞은 타입으로 변환합니다.

// GET /posts?page=1&limit=10
// -> { page: "1", limit: "10" } (문자열임)
 
export const paginationSchema = z.object({
  // 문자열 "1"을 숫자 1로 변환 후 검증
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(10),
});

drizzle-zod에서도 팩토리 옵션으로 전역 적용이 가능하지만, 보통은 개별 필드에서 z.coerce를 쓰는 게 명확합니다.


🎨 4. 프론트엔드 연동 (React Hook Form)

프론트엔드 개발자가 가장 좋아하는 부분입니다. 백엔드와 스키마를 공유하면 중복 코드가 0이 됩니다.

// apps/client/src/features/auth/SignUpForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { type NewUser, insertUserSchema } from '@repo/schema'; // 공유 패키지
 
export const SignUpForm = () => {
  const { register, handleSubmit, formState: { errors } } = useForm<NewUser>({
    resolver: zodResolver(insertUserSchema), // Zod 스키마 연결
  });
 
  const onSubmit = (data: NewUser) => {
    // data는 이미 검증 완료됨
    api.post('/users', data);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      <p>{errors.email?.message}</p> {/* "올바른 이메일 형식이 아닙니다" 출력 */}
      <button>가입</button>
    </form>
  );
};

📝 마무리 퀴즈

Q1. drizzle-zod로 insert schema를 만들 때 그대로 쓰지 않고 필드별 메시지나 규칙을 보강하는 이유는 무엇인가요?

정답: DB 저장 규칙과 사용자 입력 UX가 완전히 같지 않기 때문입니다.

💡 상세 해설: DB는 not null, 길이, 타입을 강제하지만 사용자는 왜 실패했는지 이해해야 합니다. 이메일 형식, 비밀번호 길이, 빈 문자열 처리처럼 폼에 맞는 메시지와 전처리가 필요합니다.

Q2. Query String의 page=1을 검증할 때 z.coerce.number()가 필요한 이유는 무엇인가요?

정답: HTTP로 들어온 값은 문자열이므로 숫자로 변환한 뒤 범위를 검증해야 하기 때문입니다.

💡 상세 해설: ?page=1은 JavaScript에서 처음부터 number가 아닙니다. z.coerce.number().min(1).default(1)처럼 변환과 검증을 함께 두면 API 경계가 명확해집니다.

Q3. 영철이의 테스트 타임: 회원가입 폼에 confirmPassword가 필요한데 DB 테이블에는 이 컬럼이 없습니다. 어떻게 검증해야 하나요?

정답: Drizzle에서 생성한 insert schema를 extend한 뒤 refine으로 두 비밀번호의 관계를 검증합니다.

💡 상세 해설: confirmPassword는 저장 데이터가 아니라 입력 경험을 위한 필드입니다. 영철은 이제 DB 스키마와 폼 스키마를 같게 복붙하지 않고, 경계별 책임을 나눠 봐야 합니다.

🐣 영철이의 퇴근 일기

오늘은 Zod 통합을 보면서 "스키마를 공유하면 중복이 줄어든다"에서 한 단계 더 배웠다. 공유한다고 해서 모든 입력 화면이 DB와 완전히 같아지는 건 아니었다.

영숙 님이 "에러 메시지를 보고 사용자가 뭘 고쳐야 하는지 알아야 한다"고 한 말이 계속 남았다. 백엔드가 Invalid type만 던지면 기술적으로는 맞아도 제품으로는 불친절하다.

💡 "검증은 거절이 아니라 안내다. DB 규칙을 지키면서도 사용자가 고칠 수 있는 문장으로 실패를 돌려줘야 한다."

내일부터는 createInsertSchema를 보면 그대로 끝내지 않고, 폼 전용 필드, 형변환, 에러 메시지를 어디에서 책임지는지 같이 확인해야겠다.

🔗 레퍼런스

다음 장: 08. 타입 추론 (Type Inference)