07. Zod 통합 (Validation)
📋 개요
API의 안정성을 책임지는 스키마 검증 심화 가이드입니다. 에러 메시지 커스터마이징부터 복합 검증 로직까지 다룹니다.
📋 목차
- 🛠️ 1. 스키마 디테일 설정 (Custom Messages)
- 🛡️ 2. 복합 검증 로직 (Refine)
- ⚙️ 3. 형변환 (Coercion)
- 🎨 4. 프론트엔드 연동 (React Hook Form)
- 🔗 레퍼런스
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를 보면 그대로 끝내지 않고, 폼 전용 필드, 형변환, 에러 메시지를 어디에서 책임지는지 같이 확인해야겠다.