4단계
입력 검증 + 길이 상한
25 분
입력 검증 + 길이 상한
클라이언트가 보낸 모든 값은 의심. zod · Valibot 같은 스키마 라이브러리로 서버에서 다시 검증.
1. 왜 서버에서 또 검증?
- 브라우저 검증은 우회 가능 (DevTools · curl)
- 프론트 버전 불일치로 검증 누락 가능
- 악의적 클라이언트는 정상 UI 를 쓰지 않음
프론트 검증은 UX, 서버 검증은 보안 경계.
2. zod 최소 예
pnpm add zod
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
tags: z.array(z.string().max(30)).max(10).optional(),
published: z.boolean().default(false),
});
export async function POST(req: Request) {
const json = await req.json();
const parsed = CreatePostSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const post = parsed.data;
// 여기서부터 타입 안전 · 내용 신뢰
}
3. 길이 상한이 중요한 이유
- DoS 방어 — 메가바이트 문자열 수천 개가 DB 메모리 날림
- 저장 비용 — 자릿수 제한이 없으면 한 사용자가 GB 단위 기록
- 인덱스 성능 — PostgreSQL text 인덱스는 길어질수록 느려짐
모든 string 필드에 .max(...) 습관화.
4. 공통 스키마 재사용
// schemas/common.ts
export const EmailSchema = z.string().email().max(120).toLowerCase();
export const SlugSchema = z.string().regex(/^[a-z0-9-]+$/).min(1).max(80);
export const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
// schemas/user.ts
export const CreateUserSchema = z.object({
email: EmailSchema,
nickname: z.string().min(2).max(30),
birthdate: IsoDateSchema.optional(),
});
재사용으로 "email 정규식을 여러 곳에서 다르게" 문제 방지.
5. 중첩 · 유니온
const NotificationSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("comment"),
postId: z.number().int().positive(),
commentId: z.number().int().positive(),
}),
z.object({
type: z.literal("like"),
postId: z.number().int().positive(),
}),
z.object({
type: z.literal("follow"),
userId: z.string().uuid(),
}),
]);
type 에 따라 다른 필드 요구. 런타임에 typescript-safe.
6. transform — 파싱 + 정규화
const TrimmedStringSchema = z.string().transform((s) => s.trim()).pipe(z.string().min(1));
const PhoneSchema = z.string()
.transform((s) => s.replace(/[\s-]/g, ""))
.pipe(z.string().regex(/^010\d{8}$/));
사용자 입력 다양성을 흡수한 뒤 정규 패턴 검증.
7. 에러 응답 포맷
if (!parsed.success) {
return NextResponse.json({
error: "validation_failed",
issues: parsed.error.issues.map(i => ({
path: i.path.join("."),
message: i.message,
code: i.code,
})),
}, { status: 400 });
}
프론트가 필드별 에러 표시 가능. 일관된 에러 스키마 SSOT 유지.
8. Query string · URL params
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).max(10000).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().max(100).optional(),
});
const params = QuerySchema.parse(Object.fromEntries(url.searchParams));
z.coerce.number() 로 string → number 자동 변환.
9. 파일 업로드
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
if (file.size > MAX_SIZE) return NextResponse.json({ error: "too_large" }, { status: 413 });
if (!ALLOWED_MIME.has(file.type)) return NextResponse.json({ error: "mime" }, { status: 415 });
// MIME sniffing — 실제 매직 바이트도 확인
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
// ... 실제 시그니처 검증
클라이언트 file.type 은 변조 가능. 매직 바이트 · file-type 라이브러리 권장.
10. 자주 걸리는 자리
- max 누락 — string
z.string()만 쓰면 무한 길이 coerce없이 query parsing — string vs number 타입 혼동- 에러 메시지에 내부 정보 포함 — DB 구조 힌트가 공격자에게
- 검증 후 재변형 — parse 결과를 그대로 써야 안전
하고픈 말
z.string().min(1).max(N) 한 줄이 XSS · DoS · 메모리 공격을 동시에 줄입니다. 스키마 파일을 만들고 초반부터 훈련하는 편이 쉬움.
Next
- 05-rate-limit-cors