그전까지 나에게 폼 검증은 '사용자가 실수하지 않게 돕는 일'이었습니다. 이메일 형식이 틀렸으면 빨간 글씨를 띄우고, 필수 항목이 비었으면 제출을 막습니다. 그게 전부라고 생각했습니다.
컨설팅 회사 홈페이지에 파일 첨부가 되는 문의 폼을 붙이면서 그 생각이 바뀌었습니다.
확장자만 바꾸면 통과했다
처음 만든 검증은 이랬습니다. 파일을 고르면 크기와 확장자를 확인하고, 조건에 맞지 않으면 경고를 띄웁니다.
const ALLOWED = [".pdf", ".png", ".jpg"];
const MAX = 5 * 1024 * 1024;
const validate = (file: File) => {
const ext = file.name.slice(file.name.lastIndexOf("."));
if (!ALLOWED.includes(ext.toLowerCase())) return "지원하지 않는 형식입니다";
if (file.size > MAX) return "5MB 이하만 첨부할 수 있습니다";
return null;
};테스트해 보니 잘 동작했습니다. 그런데 문득 궁금해졌습니다. 실행 파일 이름을 document.pdf로 바꾸면 어떻게 될까? 통과했습니다. 브라우저 개발자 도구로 요청을 직접 만들어 보내면? 검증 코드는 아예 실행되지도 않았습니다.
검증에는 목적이 두 개 있다
이때 정리한 구분이 지금까지 그대로 남아 있습니다.
| 클라이언트 검증 | 서버 검증 | |
|---|---|---|
| 목적 | 사용자 편의 | 시스템 보호 |
| 막는 대상 | 실수 | 악의 |
| 없으면 | 사용자가 제출 후에야 실패를 안다 | 시스템이 뚫린다 |
| 생략 가능한가 | 가능 (경험이 나빠질 뿐) | 절대 불가 |
| 신뢰도 | 0 — 참고용 | 여기가 최종 |
둘 다 필요하지만 하는 일이 다릅니다. 클라이언트 검증은 사용자가 잘못 입력했을 때 즉시 알려 주는 UX 장치이고, 서버 검증은 시스템에 들어오는 모든 것을 의심하는 방어선입니다. 클라이언트 검증이 있다고 서버 검증을 생략할 수 없고, 서버 검증이 있다고 클라이언트 검증이 불필요한 것도 아닙니다.
서버에서는 이름이 아니라 내용을 본다
파일 검증에서 핵심은 파일명은 사용자가 정하는 문자열일 뿐이라는 것입니다. .pdf로 끝난다는 것은 그 파일이 PDF라는 증거가 되지 못합니다. 실제 타입을 알려면 내용의 앞부분, 즉 파일 시그니처(매직 넘버)를 봐야 합니다.
const SIGNATURES: Record<string, number[]> = {
"application/pdf": [0x25, 0x50, 0x44, 0x46], // %PDF
"image/png": [0x89, 0x50, 0x4e, 0x47], // \x89PNG
"image/jpeg": [0xff, 0xd8, 0xff],
};
const detectType = (buf: Buffer) => {
for (const [type, sig] of Object.entries(SIGNATURES)) {
if (sig.every((byte, i) => buf[i] === byte)) return type;
}
return null;
};
export const verifyUpload = (buf: Buffer) => {
// 크기는 실제 바이트 길이로 — 클라이언트가 알려준 값이 아니라
if (buf.byteLength > 5 * 1024 * 1024) {
throw new Error("파일이 너무 큽니다");
}
const type = detectType(buf);
if (!type) throw new Error("허용되지 않은 파일 형식입니다");
return type;
};크기도 마찬가지입니다. 클라이언트가 보내준 size 값을 믿으면 안 되고, 실제로 받은 버퍼의 byteLength를 재야 합니다. 더 나아가면 요청 본문 크기 자체에 상한을 두어 애초에 큰 요청이 서버까지 도달하지 못하게 막는 것이 좋습니다.
그렇다고 규칙을 두 벌 쓰면 안 된다
여기서 다음 함정이 나옵니다. 클라이언트와 서버에서 각각 검증한다고 검증 규칙을 두 벌 쓰면, 어느 순간 둘이 어긋납니다. 실제로 나는 이전 프로젝트에서 정규식을 양쪽에 따로 써 두었다가 한쪽만 고치는 실수를 했습니다.
해결은 간단합니다. 규칙은 한 벌, 실행은 두 번. 스키마를 하나 정의하고 양쪽에서 임포트해 씁니다.
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(2, "이름을 2자 이상 입력해 주세요"),
email: z.email("이메일 형식이 올바르지 않습니다"),
message: z.string().min(10, "문의 내용을 10자 이상 입력해 주세요").max(2000),
});
export type ContactInput = z.infer<typeof contactSchema>;const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onBlur",
});export const submitContact = async (formData: FormData) => {
"use server";
const parsed = contactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: z.treeifyError(parsed.error) };
}
await sendMail(parsed.data);
return { ok: true };
};정리
- 클라이언트 검증은 사용자의 실수를, 서버 검증은 악의를 막습니다. 목적이 다르므로 둘 다 필요합니다.
- 클라이언트에서 온 값은 전부 의심합니다. 파일명, 크기, 타입, 심지어 사용자 ID까지.
- 파일은 이름이 아니라 내용(시그니처)으로 타입을 판정합니다.
- 규칙은 한 벌만 씁니다. 스키마를 공유하고 양쪽에서 실행합니다.
- "클라이언트에서 막았으니 괜찮겠지"는 검증이 아니라 희망입니다.