본문으로 건너뛰기
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로 바꾸면 어떻게 될까? 통과했습니다. 브라우저 개발자 도구로 요청을 직접 만들어 보내면? 검증 코드는 아예 실행되지도 않았습니다.

검증에는 목적이 두 개 있다

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

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

둘 다 필요하지만 하는 일이 다릅니다. 클라이언트 검증은 사용자가 잘못 입력했을 때 즉시 알려 주는 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 };
};

정리

  • 클라이언트 검증은 사용자의 실수를, 서버 검증은 악의를 막습니다. 목적이 다르므로 둘 다 필요합니다.
  • 클라이언트에서 온 값은 전부 의심합니다. 파일명, 크기, 타입, 심지어 사용자 ID까지.
  • 파일은 이름이 아니라 내용(시그니처)으로 타입을 판정합니다.
  • 규칙은 한 벌만 씁니다. 스키마를 공유하고 양쪽에서 실행합니다.
  • "클라이언트에서 막았으니 괜찮겠지"는 검증이 아니라 희망입니다.

이 글이 나온 작업