본문으로 건너뛰기
DEVKBS
개발 노트
기초읽는 데 약 4분

클라이언트 검증과 서버 검증은 다른 일을 한다

파일 첨부 문의 폼을 만들다가 확장자만 바꾼 파일이 그대로 통과하는 걸 봤습니다. 폼 검증에는 목적이 두 가지라는 걸 그때 알았습니다.

#보안#Zod#폼#파일 업로드

그전까지 저에게 폼 검증은 사용자가 실수하지 않게 돕는 일이었습니다. 이메일 형식이 틀렸으면 빨간 글씨를 띄우고, 필수 항목이 비었으면 제출을 막습니다. 그게 전부라고 생각했습니다.

컨설팅 회사 홈페이지에 파일 첨부가 되는 문의 폼을 붙이면서 그 생각이 바뀌었습니다.

확장자만 바꾸면 통과했다

처음 만든 검증은 이랬습니다. 파일을 고르면 크기와 확장자를 확인하고, 조건에 맞지 않으면 경고를 띄웁니다.

ts클라이언트에서만 하는 검증. 우회할 수 있다
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로 바꾸면 어떻게 될까? 통과했습니다. 브라우저 개발자 도구로 요청을 직접 만들어 보내면? 검증 코드는 아예 실행되지도 않았습니다.

검증에는 목적이 두 개 있다

이때 정리한 구분이 지금까지 그대로 남아 있습니다.

클라이언트 검증서버 검증
목적사용자 편의시스템 보호
막는 대상실수악의
없으면사용자가 제출 후에야 실패를 안다시스템이 뚫린다
생략 가능한가가능 (경험이 나빠질 뿐)절대 불가
신뢰도없음, 참고용여기가 최종

둘 다 필요하지만 하는 일이 다릅니다. 클라이언트 검증은 사용자가 잘못 입력했을 때 즉시 알려 주는 UX 장치이고, 서버 검증은 시스템에 들어오는 모든 것을 의심하는 방어선입니다. 클라이언트 검증이 있다고 서버 검증을 생략할 수 없고, 서버 검증이 있다고 클라이언트 검증이 불필요한 것도 아닙니다.

서버에서는 이름이 아니라 내용을 본다

파일명은 사용자가 정하는 문자열일 뿐입니다. .pdf로 끝난다고 해서 그 파일이 PDF라는 증거가 되지는 못합니다. 실제 타입을 알려면 내용의 앞부분, 즉 파일 시그니처(매직 넘버)를 봐야 합니다.

ts서버에서 실제 크기와 실제 타입을 확인한다
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를 재야 합니다. 더 나아가면 요청 본문 크기 자체에 상한을 두어 애초에 큰 요청이 서버까지 도달하지 못하게 막는 것이 좋습니다.

그렇다고 규칙을 두 벌 쓰면 안 된다

여기서 다음 함정이 나옵니다. 클라이언트와 서버에서 각각 검증한다고 규칙을 두 벌 쓰면 어느 순간 둘이 어긋납니다. 저도 이전 프로젝트에서 정규식을 양쪽에 따로 써 두었다가 한쪽만 고치는 실수를 한 적이 있습니다.

해결은 간단합니다. 규칙은 한 벌만 두고 실행은 양쪽에서 합니다. 스키마를 하나 정의하고 클라이언트와 서버에서 같이 임포트해 씁니다.

tsschemas/contact.ts: 클라이언트와 서버가 같은 걸 본다
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>;
tsx클라이언트: 바로 피드백
const form = useForm<ContactInput>({
  resolver: zodResolver(contactSchema),
  mode: "onBlur",
});
ts서버: 여기가 최종 판정
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 };
};

코드가 짧아진 것보다 규칙이 어긋날 여지가 구조적으로 없어진 게 더 큽니다. 스키마에서 타입까지 뽑아 쓰니 폼 필드 이름을 오타 냈을 때도 컴파일 단계에서 걸립니다.

이 글이 나온 작업