클라이언트의 폼 검증은 사용자가 빠르게 피드백을 받고 입력 실수를 줄이는 데 도움이 됩니다.
반면 서버 검증은 요청 경로에 관계없이 동일한 기준을 적용하는 일입니다. 요청은 브라우저 폼만 거쳐 들어오지 않고, 모바일 앱/외부 파트너/스크립트/직접 호출 등 어떤 형태로든 들어올 수 있기 때문입니다.
다만 모든 필드가 정규화 대상은 아닙니다. 예를 들어 비밀번호는 “값 자체”가 의미를 가지므로, 서버에서 조용히 trim()으로 바꿔버리면 사용자가 의도한 값과 달라질 수 있습니다. 이런 경우는 정규화로 바꾸기보다, 앞/뒤 공백을 허용하지 않겠다는 검증(정책)으로 막는 방식이 더 안전합니다.
아래 스키마는 입력 제약 조건을 모아두고, 동시에 타입을 추출해 서비스 코드에서도 재사용할 수 있게 합니다.
email은 trim + 소문자화로 정규화해서 저장/비교의 기준을 하나로 맞춥니다.
password는 정규화로 값을 바꾸지 않고, 앞/뒤 공백을 허용하지 않는 검증으로 처리합니다.
age는 z.coerce.number()로 정책을 적용해 "14" 같은 문자열 입력도 받아서 number로 통일합니다.
schemas/signup.ts
import * as z from "zod";// 이 글은 Zod 4 기준입니다.// Zod 3를 쓰는 경우:// - import 경로를 "zod/v3"로 바꾸거나(zod@3), // - email 스키마의 `.pipe(z.email(...))` 부분을 `.email(...)` 체인으로 바꿔주세요.export const SignupBodySchema = z .object({ // 정규화: 입력 단계에서 표준 형태로 맞춰두면 저장/비교가 단순해집니다. email: z .string() .trim() .toLowerCase() .max(255) // Zod 4: 포맷 검증은 `z.email()` 같은 top-level 스키마로도 제공됩니다. // 정규화/길이 제한을 먼저 적용하고, 마지막에 email 포맷 검증을 파이프라인으로 붙입니다. .pipe(z.email("유효한 이메일 형식이 아닙니다.")), // 비밀번호는 "값" 자체가 의미가 있어서 서버에서 trim으로 조용히 바꾸지 않습니다. // 대신, 앞/뒤 공백을 허용하지 않겠다는 정책이라면 검증으로 막습니다. password: z .string() .min(8, { error: "비밀번호는 8자 이상이어야 합니다." }) .max(72) .refine((v) => v.trim() === v, { error: "비밀번호의 앞/뒤 공백은 허용되지 않습니다." }), // 정책(coerce): query string이나 form에서 "14" 같은 문자열이 올 수 있어서, // 숫자로 변환 가능한 입력은 number로 받아주는 정책을 적용합니다. age: z.coerce.number().int().min(14).max(120), marketingOptIn: z.boolean().optional().default(false), }) .strict();export type SignupBody = z.infer<typeof SignupBodySchema>;
Express는 기본 타입에서 req.body가 any로 잡히는 경우가 많습니다.
아래 헬퍼는 스키마를 넘기면 Request 제네릭이 따라오도록 만들고, 검증 후 정제된 데이터를 req.body / req.query / req.params에 다시 넣어 컨트롤러에서 그대로 쓰게 하는 패턴입니다.
부분 업데이트는 필수 조건이 다르고, “값을 바꾸지 않음”을 표현해야 합니다. 그래서 PATCH는 스키마를 분리하거나 .partial()을 활용합니다.
여기서 한 번 더 주의할 점이 있습니다. Create 스키마에 .default()가 들어가 있으면, .partial()을 해도 기본값이 그대로 적용됩니다.
예를 들어 아래처럼 marketingOptIn에 .default(false)가 있다면, 클라이언트가 해당 필드를 보내지 않아도 파싱 결과에 false가 채워집니다. 이러면 “변경 의도가 없음”과 “false로 변경”을 구분할 수 없습니다.
그래서 PATCH 스키마에서는 .default()가 있는 필드를 제거하고 다시 정의합니다.
schemas/patch-user.ts
import * as z from "zod";import { SignupBodySchema } from "./signup.js";export const PatchUserBodySchema = SignupBodySchema .omit({ marketingOptIn: true }) .partial() .extend({ // PATCH에서는 "안 보냄 = 변경하지 않음"이어야 하므로 default를 넣지 않습니다. marketingOptIn: z.boolean().optional(), }) .strict();export type PatchUserBody = z.infer<typeof PatchUserBodySchema>;
// 이렇게 하면 안 되는 이유const Broken = SignupBodySchema.partial();Broken.parse({ email: "a@b.com" });// → { marketingOptIn: false } ← 안 보냈는데 false가 채워짐// PatchUserBodySchema는 이걸 방지합니다PatchUserBodySchema.parse({ email: "a@b.com" });// → { marketingOptIn: undefined } ← "변경 의도 없음"이 보존됨